fix(macos): make Dock Quit cancelable

Route AppKit termination through the existing unsaved-document close transaction using the immutable winit patch. Restore pending prompts from hidden or minimized windows and preserve answers across repeated requests.

Validation: all six local gates, native cancellation/retry/save smoke, and hosted platform checks.

Fixes #544.

PR #623.
This commit is contained in:
skylar taylor-barrick
2026-10-12 14:52:12 -06:00
committed by GitHub
parent 17cf5b4e91
commit 64f0ca8fa6
9 changed files with 134 additions and 8 deletions
Generated
+2 -2
View File
@@ -1156,6 +1156,7 @@ dependencies = [
"regex", "regex",
"serde", "serde",
"serde_json", "serde_json",
"tempfile",
"toml", "toml",
"unicode-bidi", "unicode-bidi",
"unicode-bidi-mirroring", "unicode-bidi-mirroring",
@@ -5516,8 +5517,7 @@ checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
[[package]] [[package]]
name = "winit" name = "winit"
version = "0.30.13" version = "0.30.13"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "git+https://github.com/skylartaylor/winit.git?rev=3d51077fd1a966f84df9ef287839dc84fd5b4ca7#3d51077fd1a966f84df9ef287839dc84fd5b4ca7"
checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d"
dependencies = [ dependencies = [
"ahash", "ahash",
"android-activity", "android-activity",
+4
View File
@@ -97,3 +97,7 @@ opt-level = 3
lto = "thin" lto = "thin"
codegen-units = 16 codegen-units = 16
debug = 0 debug = 0
# Opt-in cancelable macOS termination on the pinned winit 0.30 line.
[patch.crates-io]
winit = { git = "https://github.com/skylartaylor/winit.git", rev = "3d51077fd1a966f84df9ef287839dc84fd5b4ca7" }
+2 -1
View File
@@ -2,7 +2,7 @@
**Stage: pre-alpha** · next: alpha, ~0% and ~15–25 h away; beta ~28 points and ~420–750 h beyond that (the core-workflow gate, not the percentage: ready for real work is already ~47%) **Stage: pre-alpha** · next: alpha, ~0% and ~15–25 h away; beta ~28 points and ~420–750 h beyond that (the core-workflow gate, not the percentage: ready for real work is already ~47%)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (orderly native exit recovery) · **Target:** Adobe InDesign 2026 > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (cancelable macOS Dock Quit) · **Target:** Adobe InDesign 2026
DesignCraft aims at full Adobe InDesign parity, and to be better: faster, open (documented JSON DesignCraft aims at full Adobe InDesign parity, and to be better: faster, open (documented JSON
format + IDML), scriptable by agents (MCP), and available on the web. format + IDML), scriptable by agents (MCP), and available on the web.
@@ -129,6 +129,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m
Newest first. Newest first.
- **2026-10-12** · macOS Dock Quit uses the same unsaved-document prompts as window and menu Quit. Repeated requests keep the current answers; Cancel retains every tab and unsaved recovery checkpoint, and completed Save/Don't Save answers exit through the ordinary event loop. No readiness percentage change.
- **2026-10-12** · Orderly native exit writes the latest recovery state after finalizing current quit approvals; incomplete or stale plans keep their documents, and Undo to the saved state clears stale recovery. Unavailable or failed recovery is reported. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change. - **2026-10-12** · Orderly native exit writes the latest recovery state after finalizing current quit approvals; incomplete or stale plans keep their documents, and Undo to the saved state clears stale recovery. Unavailable or failed recovery is reported. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change.
- **2026-10-12** · Recovered documents keep a checkpoint until saved, closed or reverted, including a second crash before autosave. Restarted document IDs and explicit recovery folders preserve other snapshots; failed republishing retains the original. No readiness percentage change. - **2026-10-12** · Recovered documents keep a checkpoint until saved, closed or reverted, including a second crash before autosave. Restarted document IDs and explicit recovery folders preserve other snapshots; failed republishing retains the original. No readiness percentage change.
- **2026-10-12** · Book numbering and style updates prepare every chapter before saving. Only closed native chapters are changed; imported IDML remains a read-only style/PDF source. If a later disk write fails, the error names the completed chapters; the batch is not a whole-book transaction. No readiness percentage change. - **2026-10-12** · Book numbering and style updates prepare every chapter before saving. Only closed native chapters are changed; imported IDML remains a read-only style/PDF source. If a later disk write fails, the error names the completed chapters; the batch is not a whole-book transaction. No readiness percentage change.
+2
View File
@@ -31,4 +31,6 @@ rfd = { workspace = true }
winit = { version = "0.30", default-features = false, features = ["x11"] } winit = { version = "0.30", default-features = false, features = ["x11"] }
[target.'cfg(target_os = "macos")'.dependencies] [target.'cfg(target_os = "macos")'.dependencies]
# The same winit as eframe; only the native termination opt-in is used.
winit = { version = "0.30", default-features = false }
muda = { version = "0.21", default-features = false } muda = { version = "0.21", default-features = false }
+9
View File
@@ -299,6 +299,15 @@ fn main() -> eframe::Result {
b.with_x11(); b.with_x11();
})); }));
} }
// AppKit Dock Quit otherwise terminates before egui can cancel the close. Route it through
// ordinary CloseRequested events, preserving the same transactional Save/Don't Save flow.
#[cfg(target_os = "macos")]
{
options.event_loop_builder = Some(Box::new(|builder| {
use winit::platform::macos::EventLoopBuilderExtMacOS;
builder.with_close_requested_on_terminate(true);
}));
}
eframe::run_native( eframe::run_native(
"DesignCraft", "DesignCraft",
options, options,
+1
View File
@@ -29,6 +29,7 @@ serde_json = { workspace = true }
log = { workspace = true } log = { workspace = true }
[dev-dependencies] [dev-dependencies]
tempfile = { workspace = true }
designcraft-idml = { workspace = true } designcraft-idml = { workspace = true }
zip = { workspace = true } zip = { workspace = true }
# Headless (offscreen wgpu) rendering of the whole window for UI screenshots: `examples/ui_shot.rs`. # Headless (offscreen wgpu) rendering of the whole window for UI screenshots: `examples/ui_shot.rs`.
+104
View File
@@ -852,6 +852,11 @@ impl DesignApp {
return; return;
} }
ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
// Dock Quit can arrive while the app is hidden or minimized. Make its question
// visible through the normal safe window commands before asking about documents.
ctx.send_viewport_cmd(egui::ViewportCommand::Visible(true));
ctx.send_viewport_cmd(egui::ViewportCommand::Minimized(false));
ctx.send_viewport_cmd(egui::ViewportCommand::Focus);
if self.quitting.is_none() { if self.quitting.is_none() {
self.quitting = Some(QuitState { original_active: self.session.active().map(|d| d.uid), accepted: HashMap::new(), pending: None }); self.quitting = Some(QuitState { original_active: self.session.active().map(|d| d.uid), accepted: HashMap::new(), pending: None });
} }
@@ -1427,6 +1432,105 @@ mod tests {
dialogs::confirm(app).unwrap(); dialogs::confirm(app).unwrap();
} }
#[test]
fn native_quit_prompts_restore_window_visibility_without_focusing_completed_quits() {
let ctx = egui::Context::default();
let mut app = app_with_unsaved_document();
let commands = quit_frame(&mut app, &ctx, true);
assert!(commands.contains(&egui::ViewportCommand::CancelClose));
for command in [egui::ViewportCommand::Visible(true), egui::ViewportCommand::Minimized(false), egui::ViewportCommand::Focus] {
assert!(commands.contains(&command), "unsaved prompt must restore the window: {commands:?}");
}
discard_quit_dialog(&mut app);
let approved = quit_frame(&mut app, &ctx, false);
assert!(approved.contains(&egui::ViewportCommand::Close));
let completed = quit_frame(&mut app, &ctx, true);
assert!(app.session.documents().is_empty());
let mut clean = DesignApp::new(designcraft_engine::Session::new(), Services::default());
clean.run("file.new", json!({})).unwrap();
let clean_close = quit_frame(&mut clean, &ctx, true);
let mut control = app_with_unsaved_document();
control.quit_without_asking = true;
let control_close = quit_frame(&mut control, &ctx, true);
for commands in [&approved, &completed, &clean_close, &control_close] {
assert!(
!commands.iter().any(|command| matches!(
command,
egui::ViewportCommand::Visible(true) | egui::ViewportCommand::Minimized(false) | egui::ViewportCommand::Focus
)),
"completed and immediate quits must not activate the window: {commands:?}"
);
}
}
/// Dock Quit may arrive again while the first request's dialogs are still open.
#[test]
fn repeated_native_quit_requests_preserve_partial_approvals_and_cancel() {
let ctx = egui::Context::default();
let mut app = app_with_unsaved_document();
second_unsaved_document(&mut app);
let original_active = app.session.active().unwrap().uid;
let uids: Vec<_> = app.session.documents().iter().map(|d| d.uid).collect();
let docs: Vec<_> = app.session.documents().iter().map(|d| d.doc.clone()).collect();
let directory = tempfile::tempdir().unwrap();
app.session.recovery_dir = Some(directory.path().to_path_buf());
app.run("file.recovery.save", json!({})).unwrap();
assert!(quit_frame(&mut app, &ctx, true).contains(&egui::ViewportCommand::CancelClose));
assert_eq!(dialog_uid(&app), Some(uids[0]));
discard_quit_dialog(&mut app);
quit_frame(&mut app, &ctx, false);
assert_eq!(dialog_uid(&app), Some(uids[1]));
for _ in 0..3 {
assert!(quit_frame(&mut app, &ctx, true).contains(&egui::ViewportCommand::CancelClose));
assert_eq!(dialog_uid(&app), Some(uids[1]));
}
app.ui.dialog = None; // Cancel the second document, retaining the first Don't Save answer.
assert!(!quit_frame(&mut app, &ctx, false).contains(&egui::ViewportCommand::Close));
assert_eq!(app.session.active().unwrap().uid, original_active);
assert_eq!(app.session.documents().iter().map(|d| d.uid).collect::<Vec<_>>(), uids);
for (document, original) in app.session.documents().iter().zip(docs) {
assert!(std::sync::Arc::ptr_eq(&document.doc, &original));
assert!(document.is_dirty());
assert!(!document.history.undo.is_empty());
assert!(directory.path().join(format!("{}.designcraft", document.uid)).exists());
}
// A fresh Dock Quit must ask about the first document again after cancellation.
assert!(quit_frame(&mut app, &ctx, true).contains(&egui::ViewportCommand::CancelClose));
assert_eq!(dialog_uid(&app), Some(uids[0]));
}
#[test]
fn repeated_native_quit_requests_save_and_discard_only_after_all_answers() {
let ctx = egui::Context::default();
let mut app = app_with_unsaved_document();
second_unsaved_document(&mut app);
let uids: Vec<_> = app.session.documents().iter().map(|d| d.uid).collect();
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("saved.designcraft");
let chosen = path.to_string_lossy().to_string();
app.services.pick_save = Some(Box::new(move |_| Some(chosen.clone())));
app.session.recovery_dir = Some(directory.path().join("recovery"));
app.run("file.recovery.save", json!({})).unwrap();
quit_frame(&mut app, &ctx, true);
assert_eq!(dialog_uid(&app), Some(uids[0]));
dialogs::confirm(&mut app).unwrap(); // Save the first document.
assert!(!app.session.documents()[0].is_dirty());
quit_frame(&mut app, &ctx, false);
assert_eq!(dialog_uid(&app), Some(uids[1]));
assert!(quit_frame(&mut app, &ctx, true).contains(&egui::ViewportCommand::CancelClose));
assert_eq!(dialog_uid(&app), Some(uids[1]));
discard_quit_dialog(&mut app);
assert!(quit_frame(&mut app, &ctx, false).contains(&egui::ViewportCommand::Close));
assert_eq!(app.session.documents().len(), 2);
assert!(app.session.recovery_dir.as_ref().unwrap().join(format!("{}.designcraft", uids[1])).exists());
assert!(quit_frame(&mut app, &ctx, true).is_empty());
assert!(app.session.documents().is_empty());
assert!(path.exists());
assert_eq!(designcraft_engine::Session::new().execute("file.open", &json!({"path": path})).unwrap()["index"], 0);
assert_eq!(std::fs::read_dir(app.session.recovery_dir.as_ref().unwrap()).unwrap().count(), 0);
}
#[test] #[test]
fn quitting_first_cancel_restores_original_clean_active_document() { fn quitting_first_cancel_restores_original_clean_active_document() {
let ctx = egui::Context::default(); let ctx = egui::Context::default();
+8 -3
View File
@@ -1,6 +1,6 @@
# DesignCraft architecture # DesignCraft architecture
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (orderly native exit recovery) · **Target:** Adobe InDesign 2026 > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (cancelable macOS Dock Quit) · **Target:** Adobe InDesign 2026
How DesignCraft is built today. The design notes behind it are in `plan/architecture.md` How DesignCraft is built today. The design notes behind it are in `plan/architecture.md`
(local, gitignored); this file describes what the code on `main` does. Agents: AGENTS.md has the (local, gitignored); this file describes what the code on `main` does. Agents: AGENTS.md has the
@@ -60,8 +60,12 @@ command is replayable as a script. Escaped panics during a command or an import/
error (`engine/src/guard.rs`), never a lost document; native recovery is written periodically error (`engine/src/guard.rs`), never a lost document; native recovery is written periodically
into each running app's locked folder. Orderly native shutdown finalizes complete, current quit into each running app's locked folder. Orderly native shutdown finalizes complete, current quit
approvals and writes the remaining documents' latest recovery state, including clean-document approvals and writes the remaining documents' latest recovery state, including clean-document
cleanup after Undo. Unavailable or failed recovery is reported. Cancelable macOS Dock Quit cleanup after Undo. Unavailable or failed recovery is reported. The desktop opts into a pinned
remains open (#544). winit 0.30 patch that cancels AppKit termination and queues ordinary window close requests,
including Quit from the macOS Dock. The existing unsaved-document transaction can cancel
those requests or exit after current approvals are complete. A pending prompt restores and
focuses a hidden or minimized window; completed and immediate quits do not activate it. The
AppKit callback never runs the confirmation dialogs inside a modal termination loop.
## Composition and rendering ## Composition and rendering
@@ -98,5 +102,6 @@ builds (on manifest changes), packaging lint and the release workflow; `xtask ci
| Date | Change | Summary | | Date | Change | Summary |
|---|---|---| |---|---|---|
| 2026-10-12 | minor | Documented cancelable Dock Quit through the native event-loop boundary |
| 2026-10-12 | minor | Documented orderly native exit recovery and the remaining cancelable Dock Quit gap | | 2026-10-12 | minor | Documented orderly native exit recovery and the remaining cancelable Dock Quit gap |
| 2026-10-10 | major | Created from the code on main (92649ff) and `plan/architecture.md` | | 2026-10-10 | major | Created from the code on main (92649ff) and `plan/architecture.md` |
+2 -2
View File
@@ -1,6 +1,6 @@
# DesignCraft parity with Adobe InDesign # DesignCraft parity with Adobe InDesign
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (recovery checkpoint retention) · **Target:** Adobe InDesign 2026 (21.6.0.57, macOS) > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (cancelable macOS Dock Quit) · **Target:** Adobe InDesign 2026 (21.6.0.57, macOS)
The authoritative assessment of how close DesignCraft is to Adobe InDesign. [ROADMAP.md](../ROADMAP.md) The authoritative assessment of how close DesignCraft is to Adobe InDesign. [ROADMAP.md](../ROADMAP.md)
summarizes it; [gaps.md](gaps.md) lists every known shortfall one at a time; the feature-by-feature summarizes it; [gaps.md](gaps.md) lists every known shortfall one at a time; the feature-by-feature
@@ -291,7 +291,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level
- Native engine saves, Save a Copy, snippets, books, libraries, swatches and engine/MCP exports use same-folder atomic replacement, as do desktop Services writes and CLI raster exports. Failed writes before replacement retain the original; existing Unix permissions apply before staging document bytes, and already-open readers retain the old bytes. Parent-folder flushing is best effort, so this is not a power-loss durability guarantee. - Native engine saves, Save a Copy, snippets, books, libraries, swatches and engine/MCP exports use same-folder atomic replacement, as do desktop Services writes and CLI raster exports. Failed writes before replacement retain the original; existing Unix permissions apply before staging document bytes, and already-open readers retain the old bytes. Parent-folder flushing is best effort, so this is not a power-loss durability guarantee.
- Save As prepares destination-relative data-source paths before writing and installs them only after a successful save. Failed Save As retains the document, saved state and history; Save a Copy rebases only the saved copy. Library and book collection edits likewise update live values only after persistence succeeds. - Save As prepares destination-relative data-source paths before writing and installs them only after a successful save. Failed Save As retains the document, saved state and history; Save a Copy rebases only the saved copy. Library and book collection edits likewise update live values only after persistence succeeds.
- Crash recovery: unsaved documents are written every 30 s into a locked folder owned by that running app and reopened (unsaved, remembering their files) after a crash. Startup takes entries from ended runs and leaves live runs alone; writes and cleanup validate current folder and lock ownership. Failed isolation disables recovery with a warning. Recovered checkpoints remain available through another crash before autosave; restarting IDs and explicit recovery from another folder preserve unopened snapshots. Successful Save, Close or Revert clears the document's checkpoints, including manual recovery while autosave is disabled; failed republishing keeps the original. Concurrent older versions still use unlocked root entries; mixed-version isolation remains open. File › Revert and Save a Copy…; `file.recovery.*`, `file.revert`, `file.saveACopy`. - Crash recovery: unsaved documents are written every 30 s into a locked folder owned by that running app and reopened (unsaved, remembering their files) after a crash. Startup takes entries from ended runs and leaves live runs alone; writes and cleanup validate current folder and lock ownership. Failed isolation disables recovery with a warning. Recovered checkpoints remain available through another crash before autosave; restarting IDs and explicit recovery from another folder preserve unopened snapshots. Successful Save, Close or Revert clears the document's checkpoints, including manual recovery while autosave is disabled; failed republishing keeps the original. Concurrent older versions still use unlocked root entries; mixed-version isolation remains open. File › Revert and Save a Copy…; `file.recovery.*`, `file.revert`, `file.saveACopy`.
- Window close and application/menu Quit ask about every unsaved document. Cancel or a cancelled/failed save keeps all tabs open; Don't Save approvals discard nothing until the complete plan is current. Later edits and new documents are checked before closing. Control-channel quit remains immediate; a cancelable macOS Dock Quit prompt is still missing (#544). - Window close, application/menu Quit and macOS Dock Quit ask about every unsaved document. Cancel or a cancelled/failed save keeps all tabs open; Don't Save approvals discard nothing until the complete plan is current. Later edits and new documents are checked before closing. Repeated native requests preserve an active dialog and partial approvals. Control-channel quit remains immediate.
- Graphics backend chosen before the window exists (DirectX 12 on Windows, Vulkan on Linux, Metal on macOS, OpenGL as the fallback; `WGPU_BACKEND` overrides) with a start-up fallback: a backend whose start never showed a frame is skipped at the next start and the status bar says so (`gpu.json`). Found on an AMD hybrid-graphics laptop whose Vulkan driver faulted at the first present. - Graphics backend chosen before the window exists (DirectX 12 on Windows, Vulkan on Linux, Metal on macOS, OpenGL as the fallback; `WGPU_BACKEND` overrides) with a start-up fallback: a backend whose start never showed a frame is skipped at the next start and the status bar says so (`gpu.json`). Found on an AMD hybrid-graphics laptop whose Vulkan driver faulted at the first present.
- Edit › Paste Into (copied objects become a frame's content, clipped by it; rendered, PDF and IDML both ways — verified in InDesign 2026); Polygon Settings (sides, star inset; double-click the Polygon tool). - Edit › Paste Into (copied objects become a frame's content, clipped by it; rendered, PDF and IDML both ways — verified in InDesign 2026); Polygon Settings (sides, star inset; double-click the Polygon tool).
- Color panel (fill/stroke proxy, CMYK/RGB/Lab sliders with channel ramps, tint slider for swatches, spectrum ramp, Add to Swatches) and Color Picker (double-click the proxy); mixed colours are unnamed (not listed in Swatches until added); `object.color`, `swatch.addToSwatches`, `swatch.addUnnamed`. - Color panel (fill/stroke proxy, CMYK/RGB/Lab sliders with channel ramps, tint slider for swatches, spectrum ramp, Add to Swatches) and Color Picker (double-click the proxy); mixed colours are unnamed (not listed in Swatches until added); `object.color`, `swatch.addToSwatches`, `swatch.addUnnamed`.