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 <noreply@anthropic.com>
This commit is contained in:
Mara JohnsonandClaude Opus 5.5 committed 2026-10-11 18:47:11 -04:00
1 parent d26682a58a
commit 0549f672a3
7 files changed
+210

No files matched your search

+1
View File
@@ -4,3 +4,4 @@ plans/
/target
.DS_Store
/.claude/
.idea/
Generated
+10
View File
@@ -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"
+1
View File
@@ -12,6 +12,7 @@ repository = "https://github.com/storytold/craft-libs"
[workspace.dependencies]
# internal: add each crate here as `craft-<name> = { path = "crates/<name>" }`
craft-print = { path = "crates/print" }
craft-quit = { path = "crates/quit" }
craft-ui = { path = "crates/ui" }
egui = { version = "0.36", default-features = false }
+24
View File
@@ -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
+97
View File
@@ -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<ViewportCommand>) {
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");
}
}
+58
View File
@@ -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
}
}
+19
View File
@@ -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);
}
});
}