diff --git a/frontend/editor/src-tauri/src/lib.rs b/frontend/editor/src-tauri/src/lib.rs index 8855604624..f87c77b11d 100644 --- a/frontend/editor/src-tauri/src/lib.rs +++ b/frontend/editor/src-tauri/src/lib.rs @@ -2,6 +2,7 @@ use tauri::{AppHandle, Emitter, Manager, RunEvent, WindowEvent}; mod utils; pub mod commands; +mod menu; mod state; use commands::{ @@ -74,6 +75,12 @@ fn parse_launch_files(args: &[String]) -> Vec { #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() + .menu(menu::build_app_menu) + .on_menu_event(|app, event| { + if event.id() == menu::GRACEFUL_QUIT_MENU_ID { + menu::request_graceful_quit(app); + } + }) .plugin( tauri_plugin_log::Builder::new() .level(log::LevelFilter::Info) @@ -209,6 +216,10 @@ pub fn run() { // Use Tauri's built-in cleanup app_handle.cleanup_before_exit(); } + RunEvent::Exit => { + add_log("🔄 App exiting, ensuring backend cleanup...".to_string()); + cleanup_backend(); + } RunEvent::WindowEvent { event: WindowEvent::CloseRequested {.. }, .. } => { add_log("🔄 Window close requested (will cleanup on actual exit)...".to_string()); // Don't cleanup here - let JavaScript handler prevent close if needed diff --git a/frontend/editor/src-tauri/src/menu.rs b/frontend/editor/src-tauri/src/menu.rs new file mode 100644 index 0000000000..d250cb181e --- /dev/null +++ b/frontend/editor/src-tauri/src/menu.rs @@ -0,0 +1,139 @@ +use tauri::menu::Menu; +use tauri::{AppHandle, Manager, Runtime}; + +#[cfg(target_os = "macos")] +use tauri::menu::{ + AboutMetadata, MenuItem, PredefinedMenuItem, Submenu, HELP_SUBMENU_ID, WINDOW_SUBMENU_ID, +}; + +use crate::utils::add_log; + +// Id of our custom Quit item. macOS routes Cmd+Q / the app-menu Quit through +// [NSApp terminate:], which fires RunEvent::Exit (not ExitRequested) and never +// asks windows to close - so the frontend's unsaved-changes guard is bypassed. +// We intercept the shortcut with this item and route it through each window's +// close flow instead. +pub const GRACEFUL_QUIT_MENU_ID: &str = "graceful-quit"; + +// macOS: mirror Tauri's default menu (Menu::default) but replace the standard +// Quit item with a custom one so Cmd+Q goes through request_graceful_quit(). +#[cfg(target_os = "macos")] +pub fn build_app_menu(app: &AppHandle) -> tauri::Result> { + let pkg = app.package_info(); + let config = app.config(); + let about = AboutMetadata { + name: Some(pkg.name.clone()), + version: Some(pkg.version.to_string()), + copyright: config.bundle.copyright.clone(), + authors: config.bundle.publisher.clone().map(|p| vec![p]), + ..Default::default() + }; + + let quit = MenuItem::with_id( + app, + GRACEFUL_QUIT_MENU_ID, + format!("Quit {}", pkg.name), + true, + Some("CmdOrCtrl+Q"), + )?; + + let app_menu = Submenu::with_items( + app, + pkg.name.clone(), + true, + &[ + &PredefinedMenuItem::about(app, None, Some(about))?, + &PredefinedMenuItem::separator(app)?, + &PredefinedMenuItem::services(app, None)?, + &PredefinedMenuItem::separator(app)?, + &PredefinedMenuItem::hide(app, None)?, + &PredefinedMenuItem::hide_others(app, None)?, + &PredefinedMenuItem::separator(app)?, + &quit, + ], + )?; + + let file_menu = Submenu::with_items( + app, + "File", + true, + &[&PredefinedMenuItem::close_window(app, None)?], + )?; + + let edit_menu = Submenu::with_items( + app, + "Edit", + true, + &[ + &PredefinedMenuItem::undo(app, None)?, + &PredefinedMenuItem::redo(app, None)?, + &PredefinedMenuItem::separator(app)?, + &PredefinedMenuItem::cut(app, None)?, + &PredefinedMenuItem::copy(app, None)?, + &PredefinedMenuItem::paste(app, None)?, + &PredefinedMenuItem::select_all(app, None)?, + ], + )?; + + let view_menu = Submenu::with_items( + app, + "View", + true, + &[&PredefinedMenuItem::fullscreen(app, None)?], + )?; + + let window_menu = Submenu::with_id_and_items( + app, + WINDOW_SUBMENU_ID, + "Window", + true, + &[ + &PredefinedMenuItem::minimize(app, None)?, + &PredefinedMenuItem::maximize(app, None)?, + &PredefinedMenuItem::separator(app)?, + &PredefinedMenuItem::close_window(app, None)?, + ], + )?; + + let help_menu = Submenu::with_id_and_items(app, HELP_SUBMENU_ID, "Help", true, &[])?; + + Menu::with_items( + app, + &[ + &app_menu, + &file_menu, + &edit_menu, + &view_menu, + &window_menu, + &help_menu, + ], + ) +} + +// Other platforms keep Tauri's default menu unchanged; the RunEvent::Exit +// cleanup path in lib.rs already guards against orphaned backends there. +#[cfg(not(target_os = "macos"))] +pub fn build_app_menu(app: &AppHandle) -> tauri::Result> { + Menu::default(app) +} + +// Route a quit request through each window's close flow so the frontend's +// unsaved-changes guard (useExitWarning) runs. Closing the last window causes +// the runtime to emit RunEvent::ExitRequested, which kills the bundled backend. +// If somehow no windows exist, exit directly. +pub fn request_graceful_quit(app: &AppHandle) { + let windows = app.webview_windows(); + if windows.is_empty() { + add_log("Quit requested with no open windows; exiting.".to_string()); + app.exit(0); + return; + } + + add_log(format!( + "Quit requested; closing {} window(s) via close flow", + windows.len() + )); + for (_, window) in windows { + let _ = window.close(); + } +}