mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-14 19:27:07 -04:00
fix(recovery): preserve documents on orderly app exit
Checkpoint the latest document state during App::on_exit after honoring current quit approvals. Keep incomplete or stale plans, clear recovery for clean documents and report unavailable or failed storage. Follow-up to #545; cancelable macOS Dock Quit remains open in #544. PR #617.
This commit is contained in:
1 parent
8e151a2fd4
commit
c3859c828b
3 files changed
+163
-4
No files matched your search
+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 (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.
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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` |
|
||||
Reference in new issue
Block a user