Merge pull request #5 from mjohnson7cd/macos-quit-terminate

Add craft-quit: quit macOS apps through NSApplication terminate:
This commit is contained in:
skylar taylor-barrick
2026-10-12 12:17:38 -06:00
committed by GitHub
7 changed files with 268 additions and 0 deletions
+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
+140
View File
@@ -0,0 +1,140 @@
//! Route an egui window close through the platform quit.
use egui::{Context, ViewportCommand, ViewportId};
/// 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.
///
/// 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");
}
/// eframe uses only the logic callback while minimized; no UI callback runs here.
fn minimized_logic_frame(unsaved: bool) -> (bool, u32, bool, Vec<ViewportCommand>) {
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");
}
}
+73
View File
@@ -0,0 +1,73 @@
#![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. 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 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.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`.
#[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);
}
});
}