diff --git a/Cargo.lock b/Cargo.lock index 853b8a797..ace8c71e1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5001,11 +5001,14 @@ name = "wordcraft" version = "0.5.0" dependencies = [ "arboard", + "dispatch2", "eframe", "egui", "fmv-macos-events", "getrandom 0.3.4", "log", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", "open", "pollster 0.4.0", "rfd", diff --git a/apps/wordcraft/Cargo.toml b/apps/wordcraft/Cargo.toml index bd587a975..36e490475 100644 --- a/apps/wordcraft/Cargo.toml +++ b/apps/wordcraft/Cargo.toml @@ -33,6 +33,11 @@ winresource = "0.1" # by PdfCraft and PhotoCraft) registers the handler (Objective-C interop needs unsafe, which our # workspace forbids) behind a safe API. Pinned exactly: review the diff before bumping. fmv-macos-events = "=0.1.0" +# Closing the window must quit through NSApplication rather than by dropping the NSWindow +# inside the AppKit run loop (see `schedule_macos_terminate`). +dispatch2 = "0.3" +objc2 = "0.6" +objc2-app-kit = { version = "0.3", default-features = false, features = ["NSApplication"] } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] rfd = { workspace = true } diff --git a/apps/wordcraft/src/main.rs b/apps/wordcraft/src/main.rs index b8702a450..c5e73eeb2 100644 --- a/apps/wordcraft/src/main.rs +++ b/apps/wordcraft/src/main.rs @@ -48,11 +48,23 @@ impl eframe::App for App { ctx.send_viewport_cmd(egui::ViewportCommand::OuterPosition(pos)); } // The window's close button: with unsaved changes, ask first (`WordApp::close_requested`). - if ctx.input(|i| i.viewport().close_requested()) && !self.0.close_requested() { + let closing = ctx.input(|i| i.viewport().close_requested()); + if closing && !self.0.close_requested() { ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); - } - if self.0.quit_requested { - ctx.send_viewport_cmd(egui::ViewportCommand::Close); + } else if closing || self.0.quit_requested { + // On macOS, letting eframe drop the window inside `-[NSApplication run]` aborts in + // `-[_NSTouchBarFinderObservation invalidate]`. Command-Q calls `terminate:`. Once + // the close is allowed, take that path instead of dropping the window on this turn. + #[cfg(target_os = "macos")] + { + ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); + self.0.quit_requested = false; + schedule_macos_terminate(&self.0, self.2.as_ref()); + } + #[cfg(not(target_os = "macos"))] + if self.0.quit_requested { + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + } } } fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) { @@ -70,6 +82,35 @@ impl eframe::App for App { } } +/// Quit the way Command-Q does, on the next turn of the main queue. +/// +/// Called when the window is closing. Doing it on the next turn lets the close +/// button's display cycle finish while the window still exists; `terminate:` +/// then ends the process. Preferences are written and the control key file is +/// removed first, as `on_exit` does, because a fallback [`std::process::exit`] +/// skips `on_exit`. A second close while a quit is already scheduled is ignored. +#[cfg(target_os = "macos")] +fn schedule_macos_terminate(app: &WordApp, key_file: Option<&control_server::KeyFile>) { + use std::sync::atomic::{AtomicBool, Ordering}; + static QUIT: AtomicBool = AtomicBool::new(false); + if QUIT.swap(true, Ordering::Relaxed) { + return; + } + log::info!("window close: quitting"); + save_prefs(app); + if let Some(key_file) = key_file { + key_file.remove(); + } + dispatch2::DispatchQueue::main().exec_async(|| { + if let Some(mtm) = objc2::MainThreadMarker::new() { + objc2_app_kit::NSApplication::sharedApplication(mtm).terminate(None); + } + // `terminate:` does not return when it succeeds. If it did, leave anyway: + // staying up after the close was cancelled would trap the user in the window. + std::process::exit(0); + }); +} + /// `ui.json` in the settings folder, which the MCP bridge also uses to find the control key. fn prefs_path() -> Option { wordcraft_control_key::settings_dir().map(|b| b.join("ui.json"))