diff --git a/Cargo.lock b/Cargo.lock index 5e4dc89..ef525c8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1156,6 +1156,7 @@ dependencies = [ "regex", "serde", "serde_json", + "tempfile", "toml", "unicode-bidi", "unicode-bidi-mirroring", @@ -5516,8 +5517,7 @@ checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" [[package]] name = "winit" version = "0.30.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d" +source = "git+https://github.com/skylartaylor/winit.git?rev=3d51077fd1a966f84df9ef287839dc84fd5b4ca7#3d51077fd1a966f84df9ef287839dc84fd5b4ca7" dependencies = [ "ahash", "android-activity", diff --git a/Cargo.toml b/Cargo.toml index c2126ad..b08a82f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -97,3 +97,7 @@ opt-level = 3 lto = "thin" codegen-units = 16 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" } diff --git a/ROADMAP.md b/ROADMAP.md index f826a37..91a9516 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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%) -> **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 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. +- **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** · 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. diff --git a/apps/designcraft/Cargo.toml b/apps/designcraft/Cargo.toml index 13254e1..b65efbe 100644 --- a/apps/designcraft/Cargo.toml +++ b/apps/designcraft/Cargo.toml @@ -31,4 +31,6 @@ rfd = { workspace = true } winit = { version = "0.30", default-features = false, features = ["x11"] } [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 } diff --git a/apps/designcraft/src/main.rs b/apps/designcraft/src/main.rs index f468125..26ce2ee 100644 --- a/apps/designcraft/src/main.rs +++ b/apps/designcraft/src/main.rs @@ -299,6 +299,15 @@ fn main() -> eframe::Result { 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( "DesignCraft", options, diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 8509021..76b5f89 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -29,6 +29,7 @@ serde_json = { workspace = true } log = { workspace = true } [dev-dependencies] +tempfile = { workspace = true } designcraft-idml = { workspace = true } zip = { workspace = true } # Headless (offscreen wgpu) rendering of the whole window for UI screenshots: `examples/ui_shot.rs`. diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index e0fa1df..8c61899 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -852,6 +852,11 @@ impl DesignApp { return; } 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() { 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(); } + #[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::>(), 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] fn quitting_first_cancel_restores_original_clean_active_document() { let ctx = egui::Context::default(); diff --git a/docs/architecture.md b/docs/architecture.md index be3274b..39a9039 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,6 +1,6 @@ # 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` (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 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 -cleanup after Undo. Unavailable or failed recovery is reported. Cancelable macOS Dock Quit -remains open (#544). +cleanup after Undo. Unavailable or failed recovery is reported. The desktop opts into a pinned +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 @@ -98,5 +102,6 @@ builds (on manifest changes), packaging lint and the release workflow; `xtask ci | 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-10 | major | Created from the code on main (92649ff) and `plan/architecture.md` | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 9095faf..78e968d 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # 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) 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. - 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`. -- 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. - 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`.