From faa31040450517f7390149ce8abe940ad0169a56 Mon Sep 17 00:00:00 2001 From: Srinivas Vaddi <38348871+vaddisrinivas@users.noreply.github.com> Date: Fri, 15 May 2026 16:49:54 -0400 Subject: [PATCH] fix: route macOS quit through safe teardown (#1841) --- app/src-tauri/src/lib.rs | 101 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 99 insertions(+), 2 deletions(-) diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index f59a155df..e1950e3b4 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -31,6 +31,8 @@ mod webview_apis; mod whatsapp_scanner; mod window_state; +#[cfg(target_os = "macos")] +use tauri::menu::{PredefinedMenuItem, Submenu}; #[cfg(any(target_os = "macos", target_os = "windows"))] use tauri::WindowEvent; #[cfg(not(target_os = "linux"))] @@ -54,6 +56,12 @@ use objc2_app_kit::{NSPanel, NSWindowCollectionBehavior, NSWindowStyleMask}; // CEF is the only runtime; alias kept so command handlers thread the runtime generic uniformly. pub(crate) type AppRuntime = tauri::Cef; +static EARLY_TEARDOWN_RAN: std::sync::atomic::AtomicBool = + std::sync::atomic::AtomicBool::new(false); + +#[cfg(target_os = "macos")] +const APP_QUIT_MENU_ID: &str = "app_quit"; + #[tauri::command] fn core_rpc_url() -> String { crate::core_rpc::core_rpc_url_value() @@ -972,6 +980,69 @@ fn show_main_window(app: &AppHandle) -> Result<(), String> { } Ok(()) } + +#[cfg(target_os = "macos")] +fn macos_app_menu(app: &AppHandle) -> tauri::Result> { + let about = PredefinedMenuItem::about(app, None, None)?; + let hide = PredefinedMenuItem::hide(app, None)?; + let hide_others = PredefinedMenuItem::hide_others(app, None)?; + let show_all = PredefinedMenuItem::show_all(app, None)?; + let quit = MenuItem::with_id( + app, + APP_QUIT_MENU_ID, + "Quit OpenHuman", + true, + Some("CmdOrCtrl+Q"), + )?; + let app_sep_1 = PredefinedMenuItem::separator(app)?; + let app_sep_2 = PredefinedMenuItem::separator(app)?; + let app_menu = Submenu::with_items( + app, + "OpenHuman", + true, + &[ + &about, + &app_sep_1, + &hide, + &hide_others, + &show_all, + &app_sep_2, + &quit, + ], + )?; + + let undo = PredefinedMenuItem::undo(app, None)?; + let redo = PredefinedMenuItem::redo(app, None)?; + let cut = PredefinedMenuItem::cut(app, None)?; + let copy = PredefinedMenuItem::copy(app, None)?; + let paste = PredefinedMenuItem::paste(app, None)?; + let select_all = PredefinedMenuItem::select_all(app, None)?; + let edit_sep_1 = PredefinedMenuItem::separator(app)?; + let edit_sep_2 = PredefinedMenuItem::separator(app)?; + let edit_menu = Submenu::with_items( + app, + "Edit", + true, + &[ + &undo, + &redo, + &edit_sep_1, + &cut, + ©, + &paste, + &edit_sep_2, + &select_all, + ], + )?; + + let close = PredefinedMenuItem::close_window(app, None)?; + let minimize = PredefinedMenuItem::minimize(app, None)?; + let fullscreen = PredefinedMenuItem::fullscreen(app, None)?; + let window_menu = Submenu::with_items(app, "Window", true, &[&close, &minimize, &fullscreen])?; + + Menu::with_items(app, &[&app_menu, &edit_menu, &window_menu]) +} + #[cfg(target_os = "linux")] fn setup_tray(app: &AppHandle) -> tauri::Result<()> { let _ = app; @@ -1206,6 +1277,18 @@ fn perform_early_teardown_sync(app_handle: &AppHandle) { log::info!("[app] perform_early_teardown_sync — early teardown complete"); } +fn perform_early_teardown_sync_once(app_handle: &AppHandle, reason: &str) { + if EARLY_TEARDOWN_RAN.swap(true, std::sync::atomic::Ordering::SeqCst) { + log::info!( + "[app] perform_early_teardown_sync_once — already ran, skipping reason={reason}" + ); + return; + } + + log::info!("[app] perform_early_teardown_sync_once — reason={reason}"); + perform_early_teardown_sync(app_handle); +} + /// Shared early teardown logic before CEF's shutdown to prevent races and zombie processes. /// Asynchronous version to be called from async Tauri commands (e.g. `restart_app`, updates). async fn perform_early_teardown_async(app_handle: &AppHandle) { @@ -1229,7 +1312,7 @@ async fn perform_early_teardown_async(app_handle: &AppHandle) { /// Explicitly winds down CEF and Tauri before an app.exit(0) fn shutdown_app_sync(app_handle: &AppHandle, exit_code: i32) { log::info!("[app] shutdown_app_sync — starting early teardown"); - perform_early_teardown_sync(app_handle); + perform_early_teardown_sync_once(app_handle, "shutdown_app_sync"); log::info!("[app] shutdown_app_sync — early teardown complete, exiting"); app_handle.exit(exit_code); } @@ -1705,6 +1788,20 @@ pub fn run() { tauri::Builder::::new().command_line_args::<&str, &str>(args) }; + #[cfg(target_os = "macos")] + let builder = builder + // Use an app-owned Quit item for Cmd+Q instead of the native + // predefined Quit action. The predefined path calls + // NSApplication::terminate, which reaches CEF shutdown before + // OpenHuman's child-webview/core teardown can run. + .menu(macos_app_menu) + .on_menu_event(|app, event| { + if event.id().as_ref() == APP_QUIT_MENU_ID { + log::info!("[app-menu] action=quit"); + shutdown_app_sync(app, 0); + } + }); + let builder = builder // Single-instance guard — MUST be the first plugin registered so the // secondary-process exit path triggers before any other plugin setup @@ -2518,7 +2615,7 @@ pub fn run() { // do not wait — that would block the main thread // and starve CEF's UI loop. The kernel reaps the // child after Tauri exits. - perform_early_teardown_sync(app_handle); + perform_early_teardown_sync_once(app_handle, "exit_requested"); } RunEvent::Exit => { log::info!("[app] RunEvent::Exit — cef::shutdown follows");