mirror of
https://github.com/storytold/craft-libs.git
synced 2026-10-13 15:09:09 +00:00
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:
1 parent
d26682a58a
commit
0549f672a3
7 files changed
+210
No files matched your search
@@ -4,3 +4,4 @@ plans/
|
||||
/target
|
||||
.DS_Store
|
||||
/.claude/
|
||||
.idea/
|
||||
Generated
+10
@@ -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"
|
||||
|
||||
@@ -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 }
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user