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.
This commit is contained in:
Shreyas 2025-07-23 19:26:34 +05:30 committed by GitHub
parent 636d350c0f
commit 1f158a19ff
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 75 additions and 6 deletions

View file

@ -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']

View file

@ -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<A extends HoppAction>(
)
}
}
/**
* 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()

View file

@ -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",
}
/**

View file

@ -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");

View file

@ -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 &&