From 1f158a19ff7f1eec818a75ec462c719eeda98839 Mon Sep 17 00:00:00 2001 From: Shreyas Date: Wed, 23 Jul 2025 19:26:34 +0530 Subject: [PATCH] feat(desktop): cross-platform `quit` action (#5266) This implements consistent cross-platform quit functionality that triggers graceful application shutdown through native native commands. Closes FE-919 The Cmd + Q quit shortcut was previously broken across platforms with inconsistent behavior, working on Windows 11 but failing on macOS, AppImage Linux, and Windows 10. The implementation was mixed and unreliable. --- .../hoppscotch-common/src/components.d.ts | 11 +++--- .../hoppscotch-common/src/helpers/actions.ts | 36 +++++++++++++++++++ .../src/helpers/keybindings.ts | 1 + .../hoppscotch-desktop/src-tauri/src/lib.rs | 22 +++++++++++- packages/hoppscotch-selfhost-web/src/main.ts | 11 ++++++ 5 files changed, 75 insertions(+), 6 deletions(-) diff --git a/packages/hoppscotch-common/src/components.d.ts b/packages/hoppscotch-common/src/components.d.ts index 02e6e52a..be3cfcb7 100644 --- a/packages/hoppscotch-common/src/components.d.ts +++ b/packages/hoppscotch-common/src/components.d.ts @@ -208,8 +208,10 @@ declare module 'vue' { IconLucideAlertTriangle: typeof import('~icons/lucide/alert-triangle')['default'] IconLucideArrowLeft: typeof import('~icons/lucide/arrow-left')['default'] IconLucideArrowUpRight: typeof import('~icons/lucide/arrow-up-right')['default'] + IconLucideBrush: typeof import('~icons/lucide/brush')['default'] IconLucideCheckCircle: typeof import('~icons/lucide/check-circle')['default'] IconLucideChevronRight: typeof import('~icons/lucide/chevron-right')['default'] + IconLucideCircleCheck: typeof import('~icons/lucide/circle-check')['default'] IconLucideGlobe: typeof import('~icons/lucide/globe')['default'] IconLucideHelpCircle: typeof import('~icons/lucide/help-circle')['default'] IconLucideInbox: typeof import('~icons/lucide/inbox')['default'] @@ -217,11 +219,14 @@ declare module 'vue' { IconLucideLayers: typeof import('~icons/lucide/layers')['default'] IconLucideListEnd: typeof import('~icons/lucide/list-end')['default'] IconLucideMinus: typeof import('~icons/lucide/minus')['default'] + IconLucidePlusCircle: typeof import('~icons/lucide/plus-circle')['default'] + IconLucideRss: typeof import('~icons/lucide/rss')['default'] IconLucideSearch: typeof import('~icons/lucide/search')['default'] + IconLucideTriangleAlert: typeof import('~icons/lucide/triangle-alert')['default'] IconLucideUsers: typeof import('~icons/lucide/users')['default'] + IconLucideVerified: typeof import('~icons/lucide/verified')['default'] IconLucideX: typeof import('~icons/lucide/x')['default'] ImportExportBase: typeof import('./components/importExport/Base.vue')['default'] - ImportExportCorsErrorModal: typeof import('./components/importExport/CorsErrorModal.vue')['default'] ImportExportImportExportList: typeof import('./components/importExport/ImportExportList.vue')['default'] ImportExportImportExportSourcesList: typeof import('./components/importExport/ImportExportSourcesList.vue')['default'] ImportExportImportExportStepsAllCollectionImport: typeof import('./components/importExport/ImportExportSteps/AllCollectionImport.vue')['default'] @@ -248,9 +253,6 @@ declare module 'vue' { LensesRenderersVideoLensRenderer: typeof import('./components/lenses/renderers/VideoLensRenderer.vue')['default'] LensesRenderersXMLLensRenderer: typeof import('./components/lenses/renderers/XMLLensRenderer.vue')['default'] LensesResponseBodyRenderer: typeof import('./components/lenses/ResponseBodyRenderer.vue')['default'] - ModalsNativeCACertificates: typeof import('./../../hoppscotch-selfhost-desktop/src/components/modals/NativeCACertificates.vue')['default'] - ModalsNativeClientCertificates: typeof import('./../../hoppscotch-selfhost-desktop/src/components/modals/NativeClientCertificates.vue')['default'] - ModalsNativeClientCertsAdd: typeof import('./../../hoppscotch-selfhost-desktop/src/components/modals/NativeClientCertsAdd.vue')['default'] ProfileUserDelete: typeof import('./components/profile/UserDelete.vue')['default'] RealtimeCommunication: typeof import('./components/realtime/Communication.vue')['default'] RealtimeConnectionConfig: typeof import('./components/realtime/ConnectionConfig.vue')['default'] @@ -262,7 +264,6 @@ declare module 'vue' { SettingsExtension: typeof import('./components/settings/Extension.vue')['default'] SettingsExtensionSubtitle: typeof import('./components/settings/ExtensionSubtitle.vue')['default'] SettingsNative: typeof import('./components/settings/Native.vue')['default'] - SettingsNativeInterceptor: typeof import('./../../hoppscotch-selfhost-desktop/src/components/settings/NativeInterceptor.vue')['default'] SettingsProxy: typeof import('./components/settings/Proxy.vue')['default'] Share: typeof import('./components/share/index.vue')['default'] ShareCreateModal: typeof import('./components/share/CreateModal.vue')['default'] diff --git a/packages/hoppscotch-common/src/helpers/actions.ts b/packages/hoppscotch-common/src/helpers/actions.ts index b21f9b30..25d0f388 100644 --- a/packages/hoppscotch-common/src/helpers/actions.ts +++ b/packages/hoppscotch-common/src/helpers/actions.ts @@ -10,6 +10,8 @@ import { RESTOptionTabs } from "~/components/http/RequestOptions.vue" import { HoppGQLSaveContext } from "./graphql/document" import { GQLOptionTabs } from "~/components/graphql/RequestOptions.vue" import { computed } from "vue" +import { getKernelMode } from "@hoppscotch/kernel" +import { invoke } from "@tauri-apps/api/core" export type HoppAction = | "contextmenu.open" // Send/Cancel a Hoppscotch Request @@ -85,6 +87,7 @@ export type HoppAction = | "tab.duplicate-tab" // Duplicate REST request | "gql.request.open" // Open GraphQL request | "agent.open-registration-modal" // Open Hoppscotch Agent registration modal + | "app.quit" // Quit app /** * Defines the arguments, if present for a given type that is required to be passed on @@ -320,3 +323,36 @@ export function defineActionHandler( ) } } + +/** + * NOTE: This Sets up core app-level action handlers that + * should be available throughout the app's lifecycle. + * These handlers are bound immediately when the actions module + * is imported and don't depend on some component lifecycle. + */ +function setupCoreActionHandlers() { + // + // This action is triggered by either by + // keyboard shortcut: Cmd+Q (macOS) / Ctrl+Q (Windows/Linux) + // or `invokeAction("app.quit")` + // + // Desktop calls native `quit_app` command to close the app, + // and it's a no-op on web app. + // + // @see {@link https://docs.rs/tauri/latest/tauri/struct.AppHandle.html#method.exit} + // for native `app.exit` docs. + bindAction("app.quit", async () => { + if (getKernelMode() === "desktop") { + try { + await invoke("quit_app") + } catch (error) { + console.error("Failed to quit application:", error) + // NOTE: Don't call window.close() here as a fallback because + // if the native command fails, it likely means this not in + // the proper context, and window.close() would close the wrong thing. + } + } + }) +} + +setupCoreActionHandlers() diff --git a/packages/hoppscotch-common/src/helpers/keybindings.ts b/packages/hoppscotch-common/src/helpers/keybindings.ts index 9a0528d6..28b9d141 100644 --- a/packages/hoppscotch-common/src/helpers/keybindings.ts +++ b/packages/hoppscotch-common/src/helpers/keybindings.ts @@ -89,6 +89,7 @@ const desktopBindings: { "ctrl-alt-right": "tab.next", "ctrl-alt-0": "tab.switch-to-last", "ctrl-alt-9": "tab.switch-to-first", + "ctrl-q": "app.quit", } /** diff --git a/packages/hoppscotch-desktop/src-tauri/src/lib.rs b/packages/hoppscotch-desktop/src-tauri/src/lib.rs index e6378cec..00f9f18e 100644 --- a/packages/hoppscotch-desktop/src-tauri/src/lib.rs +++ b/packages/hoppscotch-desktop/src-tauri/src/lib.rs @@ -17,6 +17,25 @@ fn hopp_auth_port() -> u16 { *SERVER_PORT.get().expect("Server port not initialized") } +/// Gracefully quit the Hoppscotch Desktop +/// +/// This command is invoked from the frontend when the user triggers +/// the quit action (typically via Cmd+Q/Ctrl+Q keyboard shortcut). +/// +/// It performs a clean shutdown by logging the quit request +/// for debugging and then calling `app.exit(0)` which triggers +/// v2's graceful shutdown. +/// +/// It basically trigger `RunEvent::ExitRequested` +/// followed by `RunEvent::Exit` events. +/// See https://docs.rs/tauri/latest/tauri/struct.AppHandle.html#method.exit +#[tauri::command] +fn quit_app(app: tauri::AppHandle) -> Result<(), String> { + tracing::info!("Quit command received, shutting down application"); + app.exit(0); + Ok(()) +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() @@ -83,7 +102,8 @@ pub fn run() { .invoke_handler(tauri::generate_handler![ hopp_auth_port, updater::check_updates_available, - updater::install_updates_and_restart + updater::install_updates_and_restart, + quit_app, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/packages/hoppscotch-selfhost-web/src/main.ts b/packages/hoppscotch-selfhost-web/src/main.ts index f5dffca1..d3127e4c 100644 --- a/packages/hoppscotch-selfhost-web/src/main.ts +++ b/packages/hoppscotch-selfhost-web/src/main.ts @@ -221,6 +221,17 @@ async function initApp() { let shortcutEvent: string | null = null if ( + isCtrlOrCmd && + !e.shiftKey && + !e.altKey && + e.key.toLowerCase() === "q" + ) { + // Ctrl/Cmd + Q - Quit Application + e.preventDefault() + e.stopPropagation() + e.stopImmediatePropagation() + shortcutEvent = "ctrl-q" + } else if ( isCtrlOrCmd && !e.shiftKey && !e.altKey &&