mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-15 03:37:07 -04:00
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:
1 parent
17cf5b4e91
commit
64f0ca8fa6
9 files changed
+134
-8
No files matched your search
Generated
+2
-2
@@ -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",
|
||||
|
||||
@@ -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" }
|
||||
+2
-1
@@ -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.
|
||||
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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::<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]
|
||||
fn quitting_first_cancel_restores_original_clean_active_document() {
|
||||
let ctx = egui::Context::default();
|
||||
|
||||
@@ -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` |
|
||||
@@ -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`.
|
||||
|
||||
Reference in new issue
Block a user