From 0549f672a32aabb24a64c79136f5c1fc31053d52 Mon Sep 17 00:00:00 2001 From: Mara Johnson Date: Sat, 10 Oct 2026 17:46:59 -0400 Subject: [PATCH 1/3] Add craft-quit: quit macOS apps through NSApplication terminate: Closing an eframe window on macOS can crash in AppKit's Touch Bar finder after eframe drops the window while the run loop is still running (#3). craft-quit routes a window close through -[NSApplication terminate:], the path Cmd+Q already takes, so the process ends before another display cycle runs. - quit(): schedules terminate: on the main queue on macOS, no-op elsewhere - on_close_request() (feature "egui"): cancels the root window's close and quits through the platform path instead Also commits Cargo.lock and ignores .idea/. Co-Authored-By: Claude Opus 5.5 --- .gitignore | 1 + Cargo.lock | 10 +++++ Cargo.toml | 1 + crates/quit/Cargo.toml | 24 ++++++++++ crates/quit/src/close.rs | 97 ++++++++++++++++++++++++++++++++++++++++ crates/quit/src/lib.rs | 58 ++++++++++++++++++++++++ crates/quit/src/macos.rs | 19 ++++++++ 7 files changed, 210 insertions(+) create mode 100644 crates/quit/Cargo.toml create mode 100644 crates/quit/src/close.rs create mode 100644 crates/quit/src/lib.rs create mode 100644 crates/quit/src/macos.rs diff --git a/.gitignore b/.gitignore index 3eef7b937..9108c660c 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ plans/ /target .DS_Store /.claude/ +.idea/ diff --git a/Cargo.lock b/Cargo.lock index 97fa16bed..3bd7e713c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -379,6 +379,16 @@ dependencies = [ "tempfile", ] +[[package]] +name = "craft-quit" +version = "0.1.0" +dependencies = [ + "dispatch2", + "egui", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", +] + [[package]] name = "craft-ui" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 6880b4f8d..f17a3a252 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,7 @@ repository = "https://github.com/storytold/craft-libs" [workspace.dependencies] # internal: add each crate here as `craft- = { path = "crates/" }` craft-print = { path = "crates/print" } +craft-quit = { path = "crates/quit" } craft-ui = { path = "crates/ui" } egui = { version = "0.36", default-features = false } diff --git a/crates/quit/Cargo.toml b/crates/quit/Cargo.toml new file mode 100644 index 000000000..0c7729419 --- /dev/null +++ b/crates/quit/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "craft-quit" +description = "Quit a Craft app the platform's own way: on macOS through `-[NSApplication terminate:]`." +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true +repository.workspace = true + +[features] +# `on_close_request`: route an egui/eframe window close through the platform quit. +egui = ["dep:egui"] + +[dependencies] +egui = { workspace = true, optional = true } + +# Already in the apps' Cargo.lock: objc2 and objc2-app-kit through eframe, dispatch2 through its other dependencies. +[target.'cfg(target_os = "macos")'.dependencies] +dispatch2 = { version = "0.3.1", default-features = false, features = ["std"] } +objc2 = { version = "0.6.4", default-features = false, features = ["std"] } +objc2-app-kit = { version = "0.3.2", default-features = false, features = ["std", "NSApplication", "NSResponder"] } + +[lints] +workspace = true diff --git a/crates/quit/src/close.rs b/crates/quit/src/close.rs new file mode 100644 index 000000000..c3cb6d22b --- /dev/null +++ b/crates/quit/src/close.rs @@ -0,0 +1,97 @@ +//! Route an egui window close through the platform quit. + +use egui::{Context, ViewportCommand, ViewportId}; + +/// Call once per frame, after the app has dealt with unsaved work: when the root window was asked +/// to close (close button, the app's own `ViewportCommand::Close`, the OS) and this platform has +/// its own quit (see [`crate::quit`]), cancels the close and quits that way instead. The window +/// stays for the quit to close. +/// +/// Returns whether it took the close over. `false` means there was no close request, the app had +/// already cancelled it this frame, or the platform has no quit of its own, and eframe goes on as +/// it would without this call. +pub fn on_close_request(ctx: &Context) -> bool { + take_over_close(ctx, crate::quit) +} + +/// [`on_close_request`] with the platform quit passed in. `quit` returns whether it quits. +fn take_over_close(ctx: &Context, quit: impl FnOnce() -> bool) -> bool { + if ctx.viewport_id() != ViewportId::ROOT || !ctx.input(|i| i.viewport().close_requested()) { + return false; + } + // The app's unsaved-changes prompt (or anything else) is holding the window open. + if ctx.viewport(|v| v.commands.contains(&ViewportCommand::CancelClose)) { + return false; + } + if !quit() { + return false; + } + ctx.send_viewport_cmd(ViewportCommand::CancelClose); + true +} + +#[cfg(test)] +mod tests { + use std::cell::Cell; + + use egui::{RawInput, ViewportEvent}; + + use super::*; + + /// One frame of the root window, with its close button pressed or not. `app` runs before the + /// take-over, like the app's own unsaved-changes check. Returns whether the close was taken + /// over, how often the quit ran and the frame's commands for the root window. + fn frame(close: bool, quits: bool, app: impl Fn(&Context)) -> (bool, u32, Vec) { + let ctx = Context::default(); + let mut input = RawInput::default(); + if close { + input.viewports.entry(ViewportId::ROOT).or_default().events.push(ViewportEvent::Close); + } + let (taken, quit_calls) = (Cell::new(false), Cell::new(0)); + let mut out = ctx.run_ui(input, |ui| { + app(ui.ctx()); + taken.set(take_over_close(ui.ctx(), || { + quit_calls.set(quit_calls.get() + 1); + quits + })); + }); + out.textures_delta.clear(); // not painted + let commands = out.viewport_output.remove(&ViewportId::ROOT).map(|o| o.commands).unwrap_or_default(); + (taken.get(), quit_calls.get(), commands) + } + + fn cancels(commands: &[ViewportCommand]) -> bool { + commands.contains(&ViewportCommand::CancelClose) + } + + #[test] + fn a_close_request_quits_and_keeps_the_window() { + let (taken, quit_calls, commands) = frame(true, true, |_| {}); + assert!(taken); + assert_eq!(quit_calls, 1); + assert!(cancels(&commands), "the window stays for the quit to close"); + } + + #[test] + fn nothing_happens_without_a_close_request() { + let (taken, quit_calls, commands) = frame(false, true, |_| {}); + assert!(!taken); + assert_eq!(quit_calls, 0); + assert!(!cancels(&commands)); + } + + #[test] + fn a_close_the_app_cancelled_does_not_quit() { + let (taken, quit_calls, _) = frame(true, true, |ctx| ctx.send_viewport_cmd(ViewportCommand::CancelClose)); + assert!(!taken); + assert_eq!(quit_calls, 0, "unsaved work asks first"); + } + + #[test] + fn without_a_platform_quit_the_window_closes_as_usual() { + let (taken, quit_calls, commands) = frame(true, false, |_| {}); + assert!(!taken); + assert_eq!(quit_calls, 1); + assert!(!cancels(&commands), "eframe closes the window"); + } +} diff --git a/crates/quit/src/lib.rs b/crates/quit/src/lib.rs new file mode 100644 index 000000000..e04bbed48 --- /dev/null +++ b/crates/quit/src/lib.rs @@ -0,0 +1,58 @@ +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] + +//! Quit a Craft app the platform's own way. +//! +//! On macOS an eframe app must not leave by closing its window. eframe drops the window while +//! AppKit's run loop is still running, and in the next display cycle AppKit's Touch Bar finder +//! tries to remove a KVO observer from the view that is gone +//! (`-[_NSTouchBarFinderObservation invalidate]`). That is an uncaught Objective-C exception and +//! the app crashes (seen on macOS 27 on a Touch Bar Mac). +//! `-[NSApplication terminate:]`, what ⌘Q does in winit's default menu, ends the process before +//! another display cycle runs. +//! +//! [`quit`] is that quit. With the `egui` feature, [`on_close_request`] routes a window close +//! through it; call it once per frame after the app's own unsaved-changes check: +//! +//! ```ignore +//! fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { +//! if ctx.input(|i| i.viewport().close_requested()) && self.has_unsaved_changes() { +//! ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); +//! self.ask_to_save(); // sends `ViewportCommand::Close` once answered +//! } else { +//! craft_quit::on_close_request(ctx); +//! } +//! } +//! ``` +//! +//! The app still saves what it keeps in `eframe::App::on_exit`: winit's +//! `applicationWillTerminate:` runs eframe's exit handling before the process ends. The process +//! ends there, so code after `eframe::run_native` in `main` does not run; move it to `on_exit`. + +#[cfg(feature = "egui")] +mod close; +#[cfg(target_os = "macos")] +mod macos; + +#[cfg(feature = "egui")] +pub use close::on_close_request; + +/// Quit through the platform's own path, where leaving by closing the window is not safe. +/// +/// macOS: schedules `-[NSApplication terminate:]` and returns `true`; the caller keeps its window +/// open and the process ends shortly after, once the current event has been handled. Must be +/// called while the app's event loop is running (any eframe callback is). Return to the event loop +/// before blocking: a blocking native dialog opened in the same callback would run the quit while +/// winit is still handling the event, which panics. +/// +/// Other platforms: does nothing and returns `false`; the caller closes its window as usual. +pub fn quit() -> bool { + #[cfg(target_os = "macos")] + { + macos::terminate_later(); + true + } + #[cfg(not(target_os = "macos"))] + { + false + } +} diff --git a/crates/quit/src/macos.rs b/crates/quit/src/macos.rs new file mode 100644 index 000000000..3a61976d0 --- /dev/null +++ b/crates/quit/src/macos.rs @@ -0,0 +1,19 @@ +//! `-[NSApplication terminate:]`, scheduled on the main queue. All safe objc2 calls. + +use dispatch2::DispatchQueue; +use objc2::MainThreadMarker; +use objc2_app_kit::NSApplication; + +/// Send `terminate:` from the main queue rather than from the caller. The caller is inside winit's +/// event handler, and `terminate:` makes winit deliver its exit event, which it can't do while +/// another event is being handled. From the main queue winit is idle: its +/// `applicationWillTerminate:` closes the windows and runs the app's exit handling (eframe saves +/// and calls `on_exit`), and AppKit ends the process. +pub(crate) fn terminate_later() { + DispatchQueue::main().exec_async(|| { + // The main queue runs on the main thread, so there is always a marker. + if let Some(mtm) = MainThreadMarker::new() { + NSApplication::sharedApplication(mtm).terminate(None); + } + }); +} From 9d6610afd1ff179af353ad1f90c81d859b901ab7 Mon Sep 17 00:00:00 2001 From: Mara Johnson Date: Sun, 11 Oct 2026 11:32:00 -0400 Subject: [PATCH 2/3] craft-quit: document calling on_close_request last in the frame eframe calls logic before ui, so on_close_request goes after all of the app's own close handling, at the end of ui when the app handles closes there. Co-Authored-By: Claude Opus 5.5 --- crates/quit/src/close.rs | 3 ++- crates/quit/src/lib.rs | 11 +++++++---- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/crates/quit/src/close.rs b/crates/quit/src/close.rs index c3cb6d22b..a19c1bec6 100644 --- a/crates/quit/src/close.rs +++ b/crates/quit/src/close.rs @@ -2,7 +2,8 @@ use egui::{Context, ViewportCommand, ViewportId}; -/// Call once per frame, after the app has dealt with unsaved work: when the root window was asked +/// Call once per frame, last, after all of the app's own close handling (eframe calls `logic` +/// before `ui`, so that includes anything the app does in `ui`): when the root window was asked /// to close (close button, the app's own `ViewportCommand::Close`, the OS) and this platform has /// its own quit (see [`crate::quit`]), cancels the close and quits that way instead. The window /// stays for the quit to close. diff --git a/crates/quit/src/lib.rs b/crates/quit/src/lib.rs index e04bbed48..36002895e 100644 --- a/crates/quit/src/lib.rs +++ b/crates/quit/src/lib.rs @@ -11,16 +11,19 @@ //! another display cycle runs. //! //! [`quit`] is that quit. With the `egui` feature, [`on_close_request`] routes a window close -//! through it; call it once per frame after the app's own unsaved-changes check: +//! through it. Call it last in the frame, after all of the app's own close handling: eframe calls +//! `logic` before `ui`, so an app that handles closes in `ui` calls it at the end of `ui`, not in +//! `logic`, or it quits before the app's unsaved-changes check has run: //! //! ```ignore -//! fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { +//! fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { +//! // ... the app's UI ... +//! let ctx = ui.ctx(); //! if ctx.input(|i| i.viewport().close_requested()) && self.has_unsaved_changes() { //! ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); //! self.ask_to_save(); // sends `ViewportCommand::Close` once answered -//! } else { -//! craft_quit::on_close_request(ctx); //! } +//! craft_quit::on_close_request(ctx); // last, so it sees the app's `CancelClose` //! } //! ``` //! From c6a847b7d05376a9beaadda72fe611b7eadf7a44 Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Mon, 12 Oct 2026 20:11:55 +0200 Subject: [PATCH 3/3] craft-quit: document logic-based close handling for hidden windows --- crates/quit/src/close.rs | 46 ++++++++++++++++++++++++++++++++++++++-- crates/quit/src/lib.rs | 28 +++++++++++++++++------- 2 files changed, 64 insertions(+), 10 deletions(-) diff --git a/crates/quit/src/close.rs b/crates/quit/src/close.rs index a19c1bec6..f22b73ae9 100644 --- a/crates/quit/src/close.rs +++ b/crates/quit/src/close.rs @@ -2,8 +2,10 @@ use egui::{Context, ViewportCommand, ViewportId}; -/// Call once per frame, last, after all of the app's own close handling (eframe calls `logic` -/// before `ui`, so that includes anything the app does in `ui`): when the root window was asked +/// Call once per frame at the end of `eframe::App::logic`, after the app's own close decision. +/// Move that decision out of `ui` as well: `logic` runs first, and `ui` does not run while the +/// window is minimized or hidden. See the crate example for keeping a save prompt in app state. +/// When the root window was asked /// to close (close button, the app's own `ViewportCommand::Close`, the OS) and this platform has /// its own quit (see [`crate::quit`]), cancels the close and quits that way instead. The window /// stays for the quit to close. @@ -95,4 +97,44 @@ mod tests { assert_eq!(quit_calls, 1); assert!(!cancels(&commands), "eframe closes the window"); } + + /// eframe uses only the logic callback while minimized; no UI callback runs here. + fn minimized_logic_frame(unsaved: bool) -> (bool, u32, bool, Vec) { + let ctx = Context::default(); + let mut input = RawInput::default(); + let root = input.viewports.entry(ViewportId::ROOT).or_default(); + root.minimized = Some(true); + root.events.push(ViewportEvent::Close); + let (taken, quit_calls, show_save_prompt) = (Cell::new(false), Cell::new(0), Cell::new(false)); + let mut out = ctx.run_logic(&input, |ctx| { + if ctx.input(|i| i.viewport().close_requested()) && unsaved { + ctx.send_viewport_cmd(ViewportCommand::CancelClose); + show_save_prompt.set(true); + } + taken.set(take_over_close(ctx, || { + quit_calls.set(quit_calls.get() + 1); + true + })); + }); + let commands = out.viewport_commands.remove(&ViewportId::ROOT).unwrap_or_default(); + (taken.get(), quit_calls.get(), show_save_prompt.get(), commands) + } + + #[test] + fn minimized_unsaved_close_waits_for_the_prompt_without_running_ui() { + let (taken, quit_calls, show_save_prompt, commands) = minimized_logic_frame(true); + assert!(!taken); + assert_eq!(quit_calls, 0); + assert!(show_save_prompt, "UI can render the decision stored by logic"); + assert!(cancels(&commands)); + } + + #[test] + fn minimized_clean_close_takes_over_without_running_ui() { + let (taken, quit_calls, show_save_prompt, commands) = minimized_logic_frame(false); + assert!(taken); + assert_eq!(quit_calls, 1); + assert!(!show_save_prompt); + assert!(cancels(&commands), "eframe must not tear down the window before platform quit"); + } } diff --git a/crates/quit/src/lib.rs b/crates/quit/src/lib.rs index 36002895e..b9cbab7b2 100644 --- a/crates/quit/src/lib.rs +++ b/crates/quit/src/lib.rs @@ -11,22 +11,34 @@ //! another display cycle runs. //! //! [`quit`] is that quit. With the `egui` feature, [`on_close_request`] routes a window close -//! through it. Call it last in the frame, after all of the app's own close handling: eframe calls -//! `logic` before `ui`, so an app that handles closes in `ui` calls it at the end of `ui`, not in -//! `logic`, or it quits before the app's unsaved-changes check has run: +//! through it. Put both the app's close decision and this hook in `eframe::App::logic`, with the +//! hook last. eframe calls `logic` even while the window is minimized or hidden, when `ui` does +//! not run. Move any unsaved-changes check out of `ui` too: `logic` runs before `ui`, so a check +//! left in `ui` would happen after the quit. Keep prompt state in the app and render it in `ui`: //! //! ```ignore -//! fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { -//! // ... the app's UI ... -//! let ctx = ui.ctx(); -//! if ctx.input(|i| i.viewport().close_requested()) && self.has_unsaved_changes() { +//! fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { +//! if ctx.input(|i| i.viewport().close_requested()) +//! && self.has_unsaved_changes() +//! && !self.discard_confirmed +//! { //! ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); -//! self.ask_to_save(); // sends `ViewportCommand::Close` once answered +//! self.show_save_prompt = true; //! } //! craft_quit::on_close_request(ctx); // last, so it sees the app's `CancelClose` //! } +//! +//! fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { +//! // ... the app's UI, including the prompt when show_save_prompt is true ... +//! } //! ``` //! +//! The prompt sends `ViewportCommand::Close` only after Save has succeeded (and cleared the +//! dirty state), or Don't Save has set `discard_confirmed`. Cancel, a cancelled file picker and +//! a failed save keep the app open; reset prompt/confirmation state when the decision is cancelled +//! or a new document is opened. An asynchronous save must finish before requesting Close. If the +//! window is hidden, bring it back before showing the prompt so the user can answer it. +//! //! The app still saves what it keeps in `eframe::App::on_exit`: winit's //! `applicationWillTerminate:` runs eframe's exit handling before the process ends. The process //! ends there, so code after `eframe::run_native` in `main` does not run; move it to `on_exit`.