diff --git a/ROADMAP.md b/ROADMAP.md index 171c129..f826a37 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 (recovery checkpoint retention) · **Target:** Adobe InDesign 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (orderly native exit recovery) · **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** · 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. - **2026-10-12** · Desktop write callbacks and CLI raster exports use the atomic writer too; open readers retain the original bytes, while private Unix modes and symlink targets survive replacement. Permanent tests cover both actual callers. No readiness percentage change. diff --git a/apps/designcraft/src/main.rs b/apps/designcraft/src/main.rs index e3ff86c..f468125 100644 --- a/apps/designcraft/src/main.rs +++ b/apps/designcraft/src/main.rs @@ -64,10 +64,33 @@ impl eframe::App for App { if let Some(s) = self.startup.as_mut() { s.presented(); } + if let Err(e) = preserve_documents_on_exit(&mut self.app) { + log::error!("Couldn't preserve documents on exit: {e}"); + eprintln!("designcraft: couldn't preserve documents on exit: {e}"); + } save_prefs(&self.app); } } +/// Orderly OS termination can bypass egui's cancelable close event. Finish only a complete, +/// current quit plan; otherwise preserve every live document, including edits after the timer. +fn preserve_documents_on_exit(app: &mut DesignApp) -> Result<(), String> { + app.finish_confirmed_quit()?; + if app.session.documents().is_empty() { + return Ok(()); + } + if app.session.recovery_dir.is_none() { + return if app.session.documents().iter().any(|d| d.is_dirty()) { + Err("Recovery is unavailable; unsaved documents could not be preserved on exit".into()) + } else { + Ok(()) + }; + } + // Also discard stale recovery for clean documents (for example, Undo back to the saved + // state after the last timer tick). Never choose a fallback recovery path here. + app.run("file.recovery.save", serde_json::json!({})).map(|_| ()) +} + /// `DESIGNCRAFT_NO_PREFS`: read and write nothing in the settings directory (tests, demos). fn no_prefs() -> bool { std::env::var_os("DESIGNCRAFT_NO_PREFS").is_some() @@ -387,3 +410,134 @@ mod atomic_writer_tests { assert!(std::fs::symlink_metadata(alias).unwrap().file_type().is_symlink()); } } + +#[cfg(test)] +mod exit_recovery_tests { + use super::*; + use serde_json::json; + + fn dirty_app(label: &str) -> (DesignApp, std::path::PathBuf) { + let mut app = DesignApp::new(Session::new(), Services::default()); + app.run("file.new", json!({})).unwrap(); + app.run("frame.create", json!({"rect": [20, 20, 120, 120]})).unwrap(); + let uid = app.session.active().unwrap().uid; + let dir = std::env::temp_dir().join(format!("designcraft-exit-{label}-{}-{uid}", std::process::id())); + assert!(!dir.exists()); + app.session.recovery_dir = Some(dir.clone()); + (app, dir) + } + + fn close_frame(app: &mut DesignApp, ctx: &egui::Context, close: bool) { + let mut raw = egui::RawInput::default(); + if close { + raw.viewports.entry(egui::ViewportId::ROOT).or_default().events.push(egui::ViewportEvent::Close); + } + let mut out = ctx.run_ui(raw, |ui| app.logic(ui.ctx())); + out.textures_delta.clear(); + } + + #[test] + fn exit_recovery_preserves_latest_dirty_document_without_mutating_it() { + let (mut app, dir) = dirty_app("latest"); + app.run("file.recovery.save", json!({})).unwrap(); + app.run("frame.create", json!({"rect": [200, 200, 300, 300]})).unwrap(); + let d = app.session.active().unwrap(); + let (uid, revision, saved_revision, doc, selection, undo, redo) = + (d.uid, d.revision, d.saved_revision, d.doc.clone(), d.selection.items.clone(), d.history.undo.len(), d.history.redo.len()); + preserve_documents_on_exit(&mut app).unwrap(); + let current = app.session.active().unwrap(); + assert_eq!( + (current.uid, current.revision, current.saved_revision, ¤t.selection.items, current.history.undo.len(), current.history.redo.len()), + (uid, revision, saved_revision, &selection, undo, redo) + ); + assert!(std::sync::Arc::ptr_eq(¤t.doc, &doc)); + assert!(current.is_dirty()); + let recovered = designcraft_engine::cmd::from_bytes(&std::fs::read(dir.join(format!("{uid}.designcraft"))).unwrap()).unwrap(); + assert_eq!(serde_json::to_value(&recovered).unwrap(), serde_json::to_value(doc.as_ref()).unwrap()); + std::fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn exit_recovery_removes_stale_data_after_undo_to_saved_state() { + let (mut app, dir) = dirty_app("clean"); + let uid = app.session.active().unwrap().uid; + app.run("file.recovery.save", json!({})).unwrap(); + let entry = dir.join(format!("{uid}.designcraft")); + assert!(entry.exists()); + app.run("edit.undo", json!({})).unwrap(); + assert!(!app.session.active().unwrap().is_dirty()); + let doc = app.session.active().unwrap().doc.clone(); + preserve_documents_on_exit(&mut app).unwrap(); + assert!(!entry.exists(), "clean exit must not resurrect the pre-Undo edits"); + assert!(std::sync::Arc::ptr_eq(&app.session.active().unwrap().doc, &doc)); + std::fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn exit_recovery_reports_unavailable_or_failed_storage_without_changing_documents() { + let (mut app, dir) = dirty_app("failure"); + let doc = app.session.active().unwrap().doc.clone(); + app.session.recovery_dir = None; + assert!(preserve_documents_on_exit(&mut app).unwrap_err().contains("unavailable")); + std::fs::create_dir(&dir).unwrap(); + let blocked = dir.join("not-a-directory"); + std::fs::write(&blocked, b"preserve this file").unwrap(); + app.session.recovery_dir = Some(blocked.clone()); + assert!(preserve_documents_on_exit(&mut app).is_err()); + assert_eq!(std::fs::read(&blocked).unwrap(), b"preserve this file"); + assert!(std::sync::Arc::ptr_eq(&app.session.active().unwrap().doc, &doc)); + assert!(app.session.active().unwrap().is_dirty()); + assert_eq!(app.session.documents().len(), 1); + std::fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn exit_recovery_respects_complete_incomplete_stale_and_new_document_quit_plans() { + for case in ["complete", "incomplete", "stale", "new"] { + let (mut app, dir) = dirty_app(case); + if case == "incomplete" { + app.run("file.new", json!({})).unwrap(); + app.run("frame.create", json!({"rect": [20, 20, 120, 120]})).unwrap(); + } + app.run("file.recovery.save", json!({})).unwrap(); + let first_uid = app.session.documents()[0].uid; + let ctx = egui::Context::default(); + close_frame(&mut app, &ctx, true); + app.ui.dialog.as_mut().unwrap().fields.insert("discard".into(), json!(true)); + designcraft_ui_egui::dialogs::confirm(&mut app).unwrap(); + close_frame(&mut app, &ctx, false); + if case == "stale" { + app.run("frame.create", json!({"rect": [200, 200, 300, 300]})).unwrap(); + } else if case == "new" { + app.run("file.new", json!({})).unwrap(); + app.run("frame.create", json!({"rect": [20, 20, 120, 120]})).unwrap(); + } + let active = app.session.active().unwrap().uid; + let before: Vec<_> = app + .session + .documents() + .iter() + .map(|d| (d.uid, d.revision, d.doc.clone(), d.selection.items.clone(), d.history.undo.len(), d.history.redo.len())) + .collect(); + preserve_documents_on_exit(&mut app).unwrap(); + if case == "complete" { + assert!(app.session.documents().is_empty()); + assert!(!dir.join(format!("{first_uid}.designcraft")).exists(), "accepted Don't Save must not be recovered"); + } else { + assert_eq!(app.session.documents().len(), before.len(), "{case}"); + assert_eq!(app.session.active().unwrap().uid, active, "{case}"); + for (d, (uid, revision, doc, selection, undo, redo)) in app.session.documents().iter().zip(before) { + assert_eq!( + (d.uid, d.revision, &d.selection.items, d.history.undo.len(), d.history.redo.len()), + (uid, revision, &selection, undo, redo), + "{case}" + ); + assert!(std::sync::Arc::ptr_eq(&d.doc, &doc), "{case}"); + let recovered = designcraft_engine::cmd::from_bytes(&std::fs::read(dir.join(format!("{uid}.designcraft"))).unwrap()).unwrap(); + assert_eq!(serde_json::to_value(&recovered).unwrap(), serde_json::to_value(doc.as_ref()).unwrap(), "{case}"); + } + } + std::fs::remove_dir_all(dir).unwrap(); + } + } +} diff --git a/docs/architecture.md b/docs/architecture.md index 4f06fbc..be3274b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,6 +1,6 @@ # DesignCraft architecture -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first committed version, from the code on main and the local plan) · **Target:** Adobe InDesign 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (orderly native exit recovery) · **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 @@ -57,8 +57,11 @@ and the control channel all call `Session::execute(id, params)`. UI-only command panels, zoom, dialogs) are `UI_COMMANDS` in `crates/ui-egui/src/menus.rs`, reachable the same way. Tool gestures emit Begin/Preview/Commit commands so a drag is one undo step. Every journaled command is replayable as a script. Escaped panics during a command or an import/export become an -error (`engine/src/guard.rs`), never a lost document; unsaved documents are written to a recovery -folder every 30 s. +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). ## Composition and rendering @@ -95,4 +98,5 @@ builds (on manifest changes), packaging lint and the release workflow; `xtask ci | Date | Change | Summary | |---|---|---| +| 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` |