diff --git a/Cargo.lock b/Cargo.lock
index ea339402..f87398f6 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -4533,6 +4533,7 @@ dependencies = [
"log",
"rfd",
"vectorcraft-engine",
+ "vectorcraft-format",
"vectorcraft-ui-egui",
"wasm-bindgen",
"wasm-bindgen-futures",
diff --git a/apps/vectorcraft-web/Cargo.toml b/apps/vectorcraft-web/Cargo.toml
index c3e92758..cedc2282 100644
--- a/apps/vectorcraft-web/Cargo.toml
+++ b/apps/vectorcraft-web/Cargo.toml
@@ -16,6 +16,7 @@ path = "src/main.rs"
[dependencies]
vectorcraft-engine = { workspace = true }
vectorcraft-ui-egui = { workspace = true }
+vectorcraft-format = { workspace = true }
egui = { workspace = true }
log = { workspace = true }
@@ -26,4 +27,4 @@ rfd = { workspace = true }
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
js-sys = "0.3"
-web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Event", "EventTarget", "MouseEvent", "DragEvent"] }
+web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Event", "EventTarget", "MouseEvent", "DragEvent", "Storage"] }
diff --git a/apps/vectorcraft-web/src/web.rs b/apps/vectorcraft-web/src/web.rs
index d3f966d7..4dc0bb27 100644
--- a/apps/vectorcraft-web/src/web.rs
+++ b/apps/vectorcraft-web/src/web.rs
@@ -6,6 +6,7 @@ use std::sync::{Arc, Mutex};
use vectorcraft_engine::Session;
use vectorcraft_engine::cmd::fileio;
+use vectorcraft_engine::cmd::recovery::RecoveryStore;
use vectorcraft_ui_egui::place::{DropTarget, PlaceArrival, PlaceInbox};
use vectorcraft_ui_egui::{Services, VectorcraftApp};
use wasm_bindgen::JsCast as _;
@@ -172,10 +173,52 @@ fn services(inbox: Inbox, place_inbox: PlaceInbox, ctx: egui::Context) -> Servic
}
})),
inbox: Some(inbox),
+ recovery_store: Some(Arc::new(BrowserStore)),
..Default::default()
}
}
+/// Data Recovery's store on the web: the browser's local storage (kept across visits and shared by
+/// the site's tabs), each entry as base64 under [`RECOVERY_PREFIX`]` /`. It has no
+/// locks: each tab holds its area with a heartbeat, judged by the browser's clock.
+struct BrowserStore;
+
+const RECOVERY_PREFIX: &str = "vectorcraft-recovery/";
+
+fn js_err(e: wasm_bindgen::JsValue) -> String {
+ format!("{e:?}")
+}
+
+fn local_storage() -> Result {
+ web_sys::window().ok_or("no window")?.local_storage().map_err(js_err)?.ok_or_else(|| "browser storage is turned off".into())
+}
+
+impl RecoveryStore for BrowserStore {
+ fn list(&self) -> Result, String> {
+ let s = local_storage()?;
+ let n = s.length().map_err(js_err)?;
+ Ok((0..n).filter_map(|i| s.key(i).ok().flatten()).filter_map(|k| k.strip_prefix(RECOVERY_PREFIX).map(str::to_string)).collect())
+ }
+ fn read(&self, name: &str) -> Result, String> {
+ let text = local_storage()?.get_item(&format!("{RECOVERY_PREFIX}{name}")).map_err(js_err)?;
+ text.and_then(|t| vectorcraft_format::base64_decode(&t)).ok_or_else(|| format!("{name}: no such recovery entry"))
+ }
+ fn write(&self, name: &str, bytes: &[u8]) -> Result<(), String> {
+ local_storage()?
+ .set_item(&format!("{RECOVERY_PREFIX}{name}"), &vectorcraft_format::base64_encode(bytes))
+ .map_err(|e| format!("browser storage is full or turned off: {}", js_err(e)))
+ }
+ fn remove(&self, name: &str) -> Result<(), String> {
+ local_storage()?.remove_item(&format!("{RECOVERY_PREFIX}{name}")).map_err(js_err)
+ }
+ fn location(&self) -> String {
+ "browser storage".into()
+ }
+ fn now(&self) -> Option {
+ Some((js_sys::Date::now() / 1000.0) as i64)
+ }
+}
+
/// Trigger a browser download of `bytes` named after the last component of `path`.
fn download(path: &str, bytes: &[u8]) -> Result<(), String> {
let js = |e: wasm_bindgen::JsValue| format!("{e:?}");
diff --git a/apps/vectorcraft/src/main.rs b/apps/vectorcraft/src/main.rs
index db1aed4e..b544c55f 100644
--- a/apps/vectorcraft/src/main.rs
+++ b/apps/vectorcraft/src/main.rs
@@ -252,6 +252,12 @@ fn main() -> eframe::Result {
app.session.swatch_libraries.set_user_dir(swatches);
let styles = prefs_path().and_then(|p| Some(p.parent()?.join("Graphic Styles").to_string_lossy().to_string()));
app.session.style_libraries.set_user_dir(styles);
+ // Data Recovery copies live next to the preferences too (none for runs without
+ // preferences, such as agents' test runs, unless the recoveryFolder preference is set).
+ if std::env::var_os("VECTORCRAFT_NO_PREFS").is_none() {
+ let recovery = prefs_path().and_then(|p| Some(p.parent()?.join("Data Recovery").to_string_lossy().to_string()));
+ app.session.recovery.set_default_folder(recovery);
+ }
app.integrated_titlebar = cfg!(target_os = "macos");
app.custom_titlebar = CUSTOM_TITLEBAR;
if let Some(port) = control_port {
diff --git a/crates/engine/src/cmd/fileio/mod.rs b/crates/engine/src/cmd/fileio/mod.rs
index 756e7436..52b7587c 100644
--- a/crates/engine/src/cmd/fileio/mod.rs
+++ b/crates/engine/src/cmd/fileio/mod.rs
@@ -47,6 +47,7 @@ pub(crate) use load::source;
pub use load::{Loaded, RasterImage, detect, file_name, load, load_with, open_bytes, open_bytes_with, open_template, raster_image};
pub use native::with_compression_pref;
pub use pdfimport::{LoadOptions, page_document};
+pub(crate) use save::job_for;
pub use save::{
SAVE_FORMATS, SaveJob, SaveMode, SavePlan, export_folder, save_filters, save_format, save_job, save_plan, save_with, stamp_save_dates,
templates_folder,
@@ -648,7 +649,7 @@ fn write_encoded(path: Option<&str>, name: &str, doc: &vectorcraft_doc::Document
}
/// `a` with the fields of `b`.
-fn merge(mut a: Value, b: Value) -> Value {
+pub(crate) fn merge(mut a: Value, b: Value) -> Value {
if let (Some(a), Value::Object(b)) = (a.as_object_mut(), b) {
a.extend(b);
}
diff --git a/crates/engine/src/cmd/fileio/save.rs b/crates/engine/src/cmd/fileio/save.rs
index 00739995..cb6d284d 100644
--- a/crates/engine/src/cmd/fileio/save.rs
+++ b/crates/engine/src/cmd/fileio/save.rs
@@ -67,7 +67,7 @@ impl SaveMode {
pub struct SavePlan {
pub mode: SaveMode,
/// Where to write. `None` when no path is known (never saved, converted from an older version,
- /// or a format other than the document's own): the caller asks for one or takes the bytes.
+ /// restored by Data Recovery, or a format other than the document's own): the caller asks for one or takes the bytes.
pub path: Option,
pub format: &'static Format,
/// The format's options (only those its encoder reads).
@@ -178,7 +178,8 @@ pub fn save_plan(s: &Session, mode: SaveMode, p: &Value) -> Result {
// None given: the ones the document was last saved with in this format.
let options = if given.is_empty() && format.id == st.format { st.save_options.clone() } else { given };
// Save writes the document's own file; the other modes only where they are told to.
- let path = path.or_else(|| (mode == SaveMode::Save && !st.converted && format.id == st.format).then(|| st.path.clone()).flatten());
+ let path =
+ path.or_else(|| (mode == SaveMode::Save && !st.converted && !st.recovered && format.id == st.format).then(|| st.path.clone()).flatten());
let stem = file_stem(st.path.as_deref().unwrap_or(&st.doc.title));
let ext = format.extensions[0];
let name = match mode {
@@ -255,16 +256,20 @@ pub struct SaveJob {
/// Snapshot the active document for `plan` (stamping File Info's dates when the file becomes the
/// document's own). Bad options fail here, before anything is encoded.
pub fn save_job(s: &mut Session, plan: SavePlan) -> Result {
- let cmd = plan.mode.command();
- let retargets = plan.retargets();
// The web build, without a clock, leaves the dates unless given one.
if plan.path.is_some()
- && retargets
+ && plan.retargets()
&& let Some(at) = clock_date(s, "modified", plan.modified)
{
stamp_save_dates(s.doc_mut()?, at);
}
- let st = s.doc()?;
+ job_for(s, s.doc()?, plan)
+}
+
+/// Snapshot `st` (any open document of `s`) for `plan`, without stamping dates (Data Recovery
+/// snapshots documents that aren't active).
+pub(crate) fn job_for(s: &Session, st: &DocState, plan: SavePlan) -> Result {
+ let cmd = plan.mode.command();
let own = doc_to_save(st, plan.format);
// A native file records its links' paths relative to where it is written.
let relative = plan.path.as_deref().filter(|_| is_native(plan.format)).and_then(|p| crate::cmd::links::with_relative_paths(&own, p));
@@ -285,7 +290,7 @@ pub fn save_job(s: &mut Session, plan: SavePlan) -> Result {
// A PDF of every artboard with the PDF options, always carrying the native document.
o.extend([("preserveEditing".into(), json!(true)), ("range".into(), json!("all"))]);
}
- Ok(SaveJob { plan, doc, params, snapshot: s.doc()?.doc.clone() })
+ Ok(SaveJob { plan, doc, params, snapshot: st.doc.clone() })
}
impl SaveJob {
@@ -293,10 +298,14 @@ impl SaveJob {
&self.plan
}
- /// Encode the file and write it with `write` (the file, then the images an SVG links to,
- /// beside it). Without a path → `{dataBase64, bytes, format, name, folder?, warnings, linked?:
- /// [{name, dataBase64}]}`; with one → `{path, format, bytes, warnings, linked?: [path…]}`.
- pub fn write(&self, mut write: impl FnMut(&str, &[u8]) -> Result<()>) -> Result {
+ /// The document when the job was made (what counts as saved once the file is written).
+ pub(crate) fn snapshot(&self) -> &Arc {
+ &self.snapshot
+ }
+
+ /// Encode the file → it, with the warnings (what the format loses first, then the encoder's
+ /// own notes: PDF options not applied yet…).
+ pub fn encode(&self) -> Result {
let (cmd, plan, doc) = (self.plan.mode.command(), &self.plan, &*self.doc);
let mut enc = if plan.format.id == "ai" {
let (bytes, warnings) = super::pdf::encode(cmd, &doc.without_edit_modes(), &self.params)?;
@@ -307,9 +316,17 @@ impl SaveJob {
if enc.files.len() != 1 {
return Err(bad(cmd, "Save writes one artboard: name one, or export several with document.export"));
}
- // What the format loses first, then the encoder's own notes (PDF options not applied yet…).
- let mut warnings = std::mem::take(&mut enc.warnings);
- warnings.splice(0..0, fidelity_warning(plan.format));
+ enc.warnings.splice(0..0, fidelity_warning(plan.format));
+ Ok(enc)
+ }
+
+ /// Encode the file and write it with `write` (the file, then the images an SVG links to,
+ /// beside it). Without a path → `{dataBase64, bytes, format, name, folder?, warnings, linked?:
+ /// [{name, dataBase64}]}`; with one → `{path, format, bytes, warnings, linked?: [path…]}`.
+ pub fn write(&self, mut write: impl FnMut(&str, &[u8]) -> Result<()>) -> Result {
+ let (plan, doc) = (&self.plan, &*self.doc);
+ let mut enc = self.encode()?;
+ let warnings = std::mem::take(&mut enc.warnings);
let extra = json!({ "format": plan.format.id, "warnings": warnings });
let Some(path) = &plan.path else {
let mut out = write_encoded(None, &plan.name, doc, &enc, extra)?;
@@ -341,17 +358,31 @@ impl SaveJob {
st.format = self.plan.format.id;
st.save_options = self.plan.options;
st.converted = false;
+ st.recovered = false;
st.mark_saved_as(&self.snapshot);
}
}
+
+ /// [`SaveJob::finish`] for document `uid` of `s`, if it is still open; a file that became
+ /// its own also ends its Data Recovery copy.
+ pub fn complete(self, s: &mut Session, uid: u64) {
+ let saved = self.plan.retargets() && self.plan.path.is_some();
+ if let Some(st) = s.document_mut(uid) {
+ self.finish(st);
+ }
+ if saved {
+ crate::cmd::recovery::forget(s, uid);
+ }
+ }
}
/// Encode `plan` and write it with `write`, then make the file the document's own (see
/// [`SaveJob`]). Without a path the document is unchanged.
pub fn save_with(s: &mut Session, plan: SavePlan, write: impl FnMut(&str, &[u8]) -> Result<()>) -> Result {
let job = save_job(s, plan)?;
+ let uid = s.doc()?.uid;
let out = job.write(write)?;
- job.finish(s.doc_mut()?);
+ job.complete(s, uid);
Ok(out)
}
@@ -377,6 +408,9 @@ fn revert(s: &mut Session, _: &Value) -> Result {
let path = s.doc()?.path.clone().ok_or_else(|| bad("file.revert", "the document has never been saved"))?;
let Loaded { mut doc, .. } = load(&path, &read_file(&path)?)?;
doc.template = false;
+ // Back to the saved file: its recovery copy has nothing left to recover.
+ let uid = s.doc()?.uid;
+ crate::cmd::recovery::forget(s, uid);
s.replace_document(index, doc);
Ok(json!({ "path": path }))
}
@@ -409,7 +443,7 @@ pub(super) fn specs() -> Vec {
"Save Document",
[],
None,
- "{path?, format?: vectorcraft|template|pdf|svg|svgz|ai (default: the path's extension, else the document's own format), options?: {…the format's options, see file.formatOptions; default: as last saved}, svg?: {…SVG options} (SVG options may also be given flat; an SVG save keeps hidden layers, display:none, unless hiddenLayers is false), native (also flat): compress?: bool (gzip; default: the useCompression preference), version?: 3 (2 or 1: for older VectorCraft versions, never compressed), preview?: false (embed a PNG of the first artboard, at most 256 px), modified?: Unix seconds|null (the File Info modified date, and created date when there is none, a save to a file stamps; default now, recorded in the journal so a replay matches; null: leave the dates)} → {path, format, bytes, warnings, linked?: [path…] (images an SVG links to)}. Save writes one artboard, except a .ai file: a PDF-compatible file of every artboard carrying the native document (preserveEditing always on; PDF options flat or in options), which document.open restores exactly. Without a path it writes the document's own file in its own format: a document opened from or saved as SVG/PDF saves as that again (warnings name what the format loses). No path known (never saved, converted from an older version, or another format) → {dataBase64, format, name, folder?, warnings} and the document stays modified",
+ "{path?, format?: vectorcraft|template|pdf|svg|svgz|ai (default: the path's extension, else the document's own format), options?: {…the format's options, see file.formatOptions; default: as last saved}, svg?: {…SVG options} (SVG options may also be given flat; an SVG save keeps hidden layers, display:none, unless hiddenLayers is false), native (also flat): compress?: bool (gzip; default: the useCompression preference), version?: 3 (2 or 1: for older VectorCraft versions, never compressed), preview?: false (embed a PNG of the first artboard, at most 256 px), modified?: Unix seconds|null (the File Info modified date, and created date when there is none, a save to a file stamps; default now, recorded in the journal so a replay matches; null: leave the dates)} → {path, format, bytes, warnings, linked?: [path…] (images an SVG links to)}. Save writes one artboard, except a .ai file: a PDF-compatible file of every artboard carrying the native document (preserveEditing always on; PDF options flat or in options), which document.open restores exactly. Without a path it writes the document's own file in its own format: a document opened from or saved as SVG/PDF saves as that again (warnings name what the format loses). No path known (never saved, converted from an older version, restored by Data Recovery, or another format) → {dataBase64, format, name, folder?, warnings} and the document stays modified",
has_doc,
|s, p| save(s, SaveMode::Save, p)
),
diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs
index b0bdffbc..a56a048e 100644
--- a/crates/engine/src/cmd/mod.rs
+++ b/crates/engine/src/cmd/mod.rs
@@ -47,6 +47,7 @@ pub mod prefscmds;
pub mod print;
pub mod rasterfx;
mod recolor;
+pub mod recovery;
mod select;
pub(crate) mod slices;
mod stroke;
@@ -230,6 +231,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(package::specs());
v.extend(slices::specs());
v.extend(print::specs());
+ v.extend(recovery::specs());
v
})
}
diff --git a/crates/engine/src/cmd/recovery.rs b/crates/engine/src/cmd/recovery.rs
new file mode 100644
index 00000000..f3451e23
--- /dev/null
+++ b/crates/engine/src/cmd/recovery.rs
@@ -0,0 +1,840 @@
+//! Data Recovery: copies of modified documents kept in a recovery store, so a crash loses at most
+//! the last few minutes of work.
+//!
+//! - `file.recovery.save` writes a copy of every modified document changed since its last copy
+//! (the app runs it every Preferences → File Handling → Data Recovery interval, in the
+//! background like Background Save: [`jobs`] snapshots, [`RecoveryJob::write`] encodes and
+//! stores, [`RecoveryJob::finish`] records the copy).
+//! - A copy is removed when its document is saved, reverted or closed ([`forget`]), so copies
+//! left in the store after the app has quit are what a crash left behind:
+//! `file.recovery.list` lists them, `file.recovery.restore` opens them as
+//! " [Recovered]" (modified, never saved, the original path remembered for Save As) and
+//! `file.recovery.discard` deletes them.
+//!
+//! Every running app (each browser tab) keeps its copies in an area of its own, ` /…`, and
+//! holds it while it runs: a lock on the area for its lifetime where the store has locks (folders:
+//! [`FolderStore`]), else a heartbeat it refreshes ([`heartbeat`]; browser storage). Only areas
+//! nobody holds any more (their app is gone) are offered, and while their copies are restored or
+//! discarded the area is held, so two apps launched together never both take it.
+//!
+//! Each copy is two entries: ` /.vectorcraft` (the native document, written as a save
+//! writes it) and ` /.json` (its title, original path and format, and when it was
+//! written). The store is a folder on the desktop ([`FolderStore`]: the `recoveryFolder`
+//! preference, else the folder the app sets), browser storage on the web, or any
+//! [`RecoveryStore`] the host installs (tests use [`MemoryStore`]).
+
+use std::collections::{BTreeMap, BTreeSet};
+use std::sync::{Arc, Mutex};
+
+use serde::{Deserialize, Serialize};
+use serde_json::{Value, json};
+use vectorcraft_doc::Document;
+
+use super::fileio::{self, SAVE_FORMATS, SaveJob, SaveMode, SavePlan};
+use super::*;
+use crate::DocState;
+
+/// Documents with more objects than this are complex: with Preferences → Turn off Data Recovery
+/// for complex documents, they get no recovery copy.
+pub const COMPLEX_OBJECTS: usize = 20_000;
+
+/// The extension of a copy's document entry.
+const DOC_EXT: &str = ".vectorcraft";
+/// The extension of a copy's details entry.
+const META_EXT: &str = ".json";
+/// The heartbeat entry of an area in a store without locks: when its app last said it runs (Unix
+/// seconds, as text).
+const BEAT: &str = "heartbeat";
+/// The least time without a heartbeat after which an area's app counts as gone (seconds); else
+/// three Data Recovery intervals.
+const MIN_STALE: i64 = 180;
+/// How often a running app refreshes its heartbeat (seconds).
+pub const HEARTBEAT_EVERY: f64 = 60.0;
+
+/// Holds an area while it lives (dropping it releases the lock).
+pub type Hold = Box;
+
+/// What [`RecoveryStore::lock`] got.
+pub enum Lock {
+ /// The area is ours until the hold is dropped.
+ Held(Hold),
+ /// A running app holds it.
+ Busy,
+ /// The store has no locks: areas are held with heartbeats.
+ Unsupported,
+}
+
+/// Where recovery copies are kept: named entries of bytes, ` /`. Called from the UI
+/// thread and the background worker.
+pub trait RecoveryStore: Send + Sync {
+ /// The names of the entries (` /`).
+ fn list(&self) -> std::result::Result, String>;
+ fn read(&self, name: &str) -> std::result::Result, String>;
+ /// Create or replace an entry (a failed write must not leave a damaged entry behind).
+ fn write(&self, name: &str, bytes: &[u8]) -> std::result::Result<(), String>;
+ /// Delete an entry (one that isn't there is no error).
+ fn remove(&self, name: &str) -> std::result::Result<(), String>;
+ /// Where the entries are, as the user knows it (a folder, "browser storage").
+ fn location(&self) -> String;
+ /// The areas in the store (default: those with entries).
+ fn areas(&self) -> std::result::Result, String> {
+ let names = self.list()?;
+ let set: BTreeSet = names.iter().filter_map(|n| n.split_once('/')).map(|(a, _)| a.to_string()).collect();
+ Ok(set.into_iter().collect())
+ }
+ /// Hold area `area` exclusively, if the store has locks (default: it hasn't).
+ fn lock(&self, _area: &str) -> std::result::Result {
+ Ok(Lock::Unsupported)
+ }
+ /// Tidy away area `area` once it holds no copies and nobody holds it (its lock file and folder).
+ fn remove_area(&self, _area: &str) {}
+ /// The clock heartbeats are judged by (Unix seconds; `None`: none, so no area counts as gone).
+ fn now(&self) -> Option {
+ vectorcraft_doc::metadata::now_unix()
+ }
+}
+
+/// A store in memory (tests, and hosts without storage). Areas are locked in memory, so sessions
+/// sharing one store behave like apps sharing a folder; [`MemoryStore::without_locks`] uses
+/// heartbeats like browser storage, judged by a clock tests set ([`MemoryStore::set_now`]).
+pub struct MemoryStore {
+ entries: Mutex>>,
+ held: Arc>>,
+ locks: bool,
+ now: Mutex>,
+}
+
+impl Default for MemoryStore {
+ fn default() -> Self {
+ Self { entries: Default::default(), held: Default::default(), locks: true, now: Mutex::new(Some(1_000_000)) }
+ }
+}
+
+impl MemoryStore {
+ /// A store without locks: areas are held with heartbeats.
+ pub fn without_locks() -> Self {
+ Self { locks: false, ..Self::default() }
+ }
+
+ /// Set the clock heartbeats are judged by.
+ pub fn set_now(&self, t: i64) {
+ *self.now.lock().unwrap_or_else(|e| e.into_inner()) = Some(t);
+ }
+
+ fn entries(&self) -> std::sync::MutexGuard<'_, BTreeMap>> {
+ self.entries.lock().unwrap_or_else(|e| e.into_inner())
+ }
+}
+
+/// An area of a [`MemoryStore`] held until dropped.
+struct MemoryHold {
+ held: Arc>>,
+ area: String,
+}
+
+impl Drop for MemoryHold {
+ fn drop(&mut self) {
+ self.held.lock().unwrap_or_else(|e| e.into_inner()).remove(&self.area);
+ }
+}
+
+impl RecoveryStore for MemoryStore {
+ fn list(&self) -> std::result::Result, String> {
+ Ok(self.entries().keys().cloned().collect())
+ }
+ fn read(&self, name: &str) -> std::result::Result, String> {
+ self.entries().get(name).cloned().ok_or_else(|| format!("{name}: no such recovery entry"))
+ }
+ fn write(&self, name: &str, bytes: &[u8]) -> std::result::Result<(), String> {
+ self.entries().insert(name.to_string(), bytes.to_vec());
+ Ok(())
+ }
+ fn remove(&self, name: &str) -> std::result::Result<(), String> {
+ self.entries().remove(name);
+ Ok(())
+ }
+ fn location(&self) -> String {
+ "memory".into()
+ }
+ fn lock(&self, area: &str) -> std::result::Result {
+ if !self.locks {
+ return Ok(Lock::Unsupported);
+ }
+ if !self.held.lock().unwrap_or_else(|e| e.into_inner()).insert(area.to_string()) {
+ return Ok(Lock::Busy);
+ }
+ Ok(Lock::Held(Box::new(MemoryHold { held: self.held.clone(), area: area.to_string() })))
+ }
+ fn now(&self) -> Option {
+ *self.now.lock().unwrap_or_else(|e| e.into_inner())
+ }
+}
+
+/// A folder of files (desktop): an area is a sub-folder whose `.lock` file its app keeps locked
+/// while it runs. Created when the first copy is written.
+#[cfg(not(target_arch = "wasm32"))]
+pub struct FolderStore(std::path::PathBuf);
+
+/// The lock file of a [`FolderStore`] area.
+#[cfg(not(target_arch = "wasm32"))]
+const LOCK_FILE: &str = ".lock";
+
+#[cfg(not(target_arch = "wasm32"))]
+impl FolderStore {
+ pub fn new(folder: impl Into) -> Self {
+ Self(folder.into())
+ }
+
+ /// Is `part` a plain file or folder name (no separators, not hidden)?
+ fn plain(part: &str) -> bool {
+ !part.starts_with('.') && std::path::Path::new(part).file_name().is_some_and(|f| f == part)
+ }
+
+ /// The folder of area `area`.
+ fn area(&self, area: &str) -> std::result::Result {
+ if !Self::plain(area) {
+ return Err(format!("`{area}` is no recovery area name"));
+ }
+ Ok(self.0.join(area))
+ }
+
+ /// The file of entry `name` (` /`: anything else is refused).
+ fn file(&self, name: &str) -> std::result::Result {
+ match name.split_once('/') {
+ Some((area, file)) if Self::plain(file) => Ok(self.area(area)?.join(file)),
+ _ => Err(format!("`{name}` is no recovery entry name")),
+ }
+ }
+
+ /// The plain names of the files (or, `dirs`, folders) in `dir`; none when it doesn't exist.
+ fn names(dir: &std::path::Path, dirs: bool) -> std::result::Result, String> {
+ let entries = match std::fs::read_dir(dir) {
+ Ok(e) => e,
+ Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(vec![]),
+ Err(e) => return Err(format!("{}: {e}", dir.display())),
+ };
+ Ok(entries
+ .filter_map(|e| e.ok())
+ .filter(|e| e.file_type().is_ok_and(|t| if dirs { t.is_dir() } else { t.is_file() }))
+ .filter_map(|e| e.file_name().to_str().map(str::to_string))
+ // Lock files, and files left by an interrupted atomic write.
+ .filter(|n| Self::plain(n))
+ .collect())
+ }
+}
+
+#[cfg(not(target_arch = "wasm32"))]
+impl RecoveryStore for FolderStore {
+ fn list(&self) -> std::result::Result, String> {
+ let mut out = vec![];
+ for area in self.areas()? {
+ out.extend(Self::names(&self.0.join(&area), false)?.into_iter().map(|f| format!("{area}/{f}")));
+ }
+ Ok(out)
+ }
+ fn read(&self, name: &str) -> std::result::Result, String> {
+ let f = self.file(name)?;
+ std::fs::read(&f).map_err(|e| format!("{}: {e}", f.display()))
+ }
+ fn write(&self, name: &str, bytes: &[u8]) -> std::result::Result<(), String> {
+ let f = self.file(name)?;
+ if let Some(dir) = f.parent() {
+ std::fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
+ }
+ vectorcraft_format::write_atomic(&f, bytes).map_err(|e| format!("{}: {e}", f.display()))
+ }
+ fn remove(&self, name: &str) -> std::result::Result<(), String> {
+ let f = self.file(name)?;
+ match std::fs::remove_file(&f) {
+ Err(e) if e.kind() != std::io::ErrorKind::NotFound => Err(format!("{}: {e}", f.display())),
+ _ => Ok(()),
+ }
+ }
+ fn location(&self) -> String {
+ self.0.to_string_lossy().into_owned()
+ }
+ fn areas(&self) -> std::result::Result, String> {
+ Self::names(&self.0, true)
+ }
+ fn lock(&self, area: &str) -> std::result::Result {
+ let dir = self.area(area)?;
+ std::fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?;
+ let path = dir.join(LOCK_FILE);
+ let file = std::fs::OpenOptions::new()
+ .read(true)
+ .write(true)
+ .create(true)
+ .truncate(false)
+ .open(&path)
+ .map_err(|e| format!("{}: {e}", path.display()))?;
+ match file.try_lock() {
+ Ok(()) => Ok(Lock::Held(Box::new(file))),
+ Err(std::fs::TryLockError::WouldBlock) => Ok(Lock::Busy),
+ Err(std::fs::TryLockError::Error(e)) => Err(format!("{}: {e}", path.display())),
+ }
+ }
+ fn remove_area(&self, area: &str) {
+ // Best effort: whatever is left is tidied at a later launch. A folder that isn't empty
+ // (copies came back meanwhile) stays.
+ if let Ok(dir) = self.area(area) {
+ let _ = std::fs::remove_file(dir.join(LOCK_FILE));
+ let _ = std::fs::remove_dir(&dir);
+ }
+ }
+}
+
+/// The area this session keeps its copies in, while it holds it.
+struct Own {
+ /// The store it is in (copies are removed from there even after the preference moves on).
+ store: Arc,
+ area: String,
+ /// The lock held for the session's lifetime; none in stores with heartbeats.
+ hold: Option,
+}
+
+/// The session's recovery store settings and its own area ([`crate::Session::recovery`]).
+#[derive(Default)]
+pub struct Recovery {
+ /// A store the host installed (the web's browser storage, tests): wins over folders.
+ store: Option>,
+ /// The folder the desktop app keeps copies in when the `recoveryFolder` preference is empty.
+ default_folder: Option,
+ own: Option,
+ /// Gone apps' areas this session took over by heartbeat (stores without locks), so its own
+ /// fresh heartbeat there doesn't make them look running.
+ adopted: BTreeSet,
+}
+
+impl Recovery {
+ /// Keep copies in `store` (instead of a folder).
+ pub fn set_store(&mut self, store: Arc) {
+ self.store = Some(store);
+ }
+
+ /// The folder copies go to while the `recoveryFolder` preference is empty (desktop; `None`:
+ /// none, so Data Recovery is off until the preference names one).
+ pub fn set_default_folder(&mut self, folder: Option) {
+ self.default_folder = folder;
+ }
+
+ /// This session's area, once it has written a copy.
+ pub fn own_area(&self) -> Option<&str> {
+ self.own.as_ref().map(|o| o.area.as_str())
+ }
+}
+
+/// The store copies go to now (see [`Recovery`]), if any.
+pub fn store(s: &Session) -> Option> {
+ if let Some(st) = &s.recovery.store {
+ return Some(st.clone());
+ }
+ #[cfg(not(target_arch = "wasm32"))]
+ {
+ let folder = Some(s.prefs.recovery_folder.trim()).filter(|f| !f.is_empty()).map(str::to_string).or_else(|| s.recovery.default_folder.clone());
+ folder.map(|f| Arc::new(FolderStore::new(f)) as Arc)
+ }
+ #[cfg(target_arch = "wasm32")]
+ None
+}
+
+fn has_store(s: &Session) -> std::result::Result<(), String> {
+ match store(s) {
+ Some(_) => Ok(()),
+ None => Err("Data Recovery has nowhere to keep copies: set Preferences → File Handling → Data Recovery → Folder (recoveryFolder)".into()),
+ }
+}
+
+fn need_store(s: &Session, cmd: &str) -> Result> {
+ store(s).ok_or_else(|| bad(cmd, has_store(s).err().unwrap_or_default()))
+}
+
+/// Seconds without a heartbeat after which an area's app counts as gone: three Data Recovery
+/// intervals, at least [`MIN_STALE`].
+fn stale_after(s: &Session) -> i64 {
+ (i64::from(s.prefs.autosave_interval.max(1)) * 180).max(MIN_STALE)
+}
+
+/// Record that `area`'s app runs (stores without locks).
+fn beat(store: &dyn RecoveryStore, area: &str) -> std::result::Result<(), String> {
+ let now = store.now().ok_or("no clock to keep a heartbeat by")?;
+ store.write(&format!("{area}/{BEAT}"), now.to_string().as_bytes())
+}
+
+/// Has `area`'s app said it runs within `stale` seconds (stores without locks)? Without a clock
+/// every app counts as running.
+fn beating(store: &dyn RecoveryStore, area: &str, stale: i64) -> bool {
+ let Some(now) = store.now() else { return true };
+ let at = store.read(&format!("{area}/{BEAT}")).ok().and_then(|b| String::from_utf8(b).ok()).and_then(|t| t.trim().parse::().ok());
+ at.is_some_and(|t| now.saturating_sub(t) <= stale)
+}
+
+/// Refresh this session's heartbeat in a store without locks (the app does this every
+/// [`HEARTBEAT_EVERY`] seconds). Best effort: a missed beat only lets another app offer the copies
+/// once three intervals have passed.
+pub fn heartbeat(s: &Session) {
+ if let Some(own) = s.recovery.own.as_ref().filter(|o| o.hold.is_none()) {
+ let _ = beat(own.store.as_ref(), &own.area);
+ }
+}
+
+/// This session's area in `store`, taken now if it has none there yet: a fresh area, held for the
+/// session's lifetime (locked, or with a heartbeat).
+pub fn claim(s: &mut Session, store: &Arc) -> Result {
+ if let Some(own) = &s.recovery.own {
+ if own.store.location() == store.location() {
+ return Ok(own.area.clone());
+ }
+ // The store moved (the preference names another folder): copies start again there.
+ release(s);
+ }
+ let taken = store.areas().map_err(EngineError::Other)?;
+ let base = store.now().unwrap_or(0);
+ for n in 1..=1000 {
+ let area = format!("{base}-{n}");
+ if taken.contains(&area) {
+ continue;
+ }
+ let hold = match store.lock(&area).map_err(EngineError::Other)? {
+ Lock::Held(h) => Some(h),
+ Lock::Busy => continue,
+ Lock::Unsupported => {
+ beat(store.as_ref(), &area).map_err(EngineError::Other)?;
+ None
+ }
+ };
+ s.recovery.own = Some(Own { store: store.clone(), area: area.clone(), hold });
+ return Ok(area);
+ }
+ Err(EngineError::Other("no free recovery area".into()))
+}
+
+/// Give up this session's area: its copies, heartbeat and lock go (quitting with nothing unsaved).
+pub fn release(s: &mut Session) {
+ for st in &mut s.docs {
+ st.recovery = None;
+ }
+ let Some(Own { store, area, hold }) = s.recovery.own.take() else { return };
+ let mine = |n: &String| n.split_once('/').is_some_and(|(a, _)| a == area);
+ for name in store.list().unwrap_or_default().iter().filter(|n| mine(n)) {
+ let _ = store.remove(name);
+ }
+ drop(hold);
+ store.remove_area(&area);
+}
+
+/// A document's recovery copy: its entry and the document it holds.
+#[derive(Clone, Debug)]
+pub struct RecoveryCopy {
+ /// The copy's name in the store (` /`, without extension).
+ pub file: String,
+ /// The document as last copied (unchanged since: no new copy needed).
+ pub doc: Arc,
+}
+
+/// What a copy's details entry says.
+#[derive(Clone, Debug, Default, Serialize, Deserialize)]
+#[serde(default)]
+struct Meta {
+ title: String,
+ /// The document's file when it was copied, if it had one.
+ path: Option,
+ /// The format Save writes for it ([`SAVE_FORMATS`]).
+ format: String,
+ /// When the copy was written (Unix seconds; none without a clock).
+ saved: Option,
+}
+
+fn read_meta(store: &dyn RecoveryStore, file: &str) -> Meta {
+ store.read(&format!("{file}{META_EXT}")).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default()
+}
+
+/// A copy of one document, snapshotted and ready to encode and store anywhere (the background
+/// worker too): [`jobs`], then [`RecoveryJob::write`], then [`RecoveryJob::finish`].
+#[derive(Clone)]
+pub struct RecoveryJob {
+ /// The document's [`DocState::uid`].
+ pub uid: u64,
+ /// The copy's name in the store (` /`).
+ pub file: String,
+ /// The document's title (status messages).
+ pub title: String,
+ meta: Meta,
+ save: SaveJob,
+}
+
+impl RecoveryJob {
+ /// Encode the snapshot and store it with its details → `{file, title, bytes}`.
+ pub fn write(&self, store: &dyn RecoveryStore) -> std::result::Result {
+ let enc = self.save.encode().map_err(|e| e.to_string())?;
+ let bytes = enc.files.first().map(|f| f.1.as_slice()).ok_or("nothing encoded")?;
+ let meta = serde_json::to_vec_pretty(&self.meta).map_err(|e| e.to_string())?;
+ store.write(&format!("{}{DOC_EXT}", self.file), bytes)?;
+ store.write(&format!("{}{META_EXT}", self.file), &meta)?;
+ Ok(json!({ "file": self.file, "title": self.title, "bytes": bytes.len() }))
+ }
+
+ /// Once stored: the document (if still open) remembers the copy, so it isn't written again
+ /// while unchanged and goes when the document is saved or closed. A document saved or closed
+ /// meanwhile has nothing left to recover: its copy goes at once.
+ pub fn finish(self, s: &mut Session) {
+ let snapshot = self.save.snapshot().clone();
+ let copy = RecoveryCopy { file: self.file.clone(), doc: snapshot };
+ match s.document_mut(self.uid).filter(|st| st.is_dirty()) {
+ Some(st) => st.recovery = Some(copy),
+ None => remove_copy(s, &self.file),
+ }
+ }
+}
+
+/// How many objects `doc` has (its complexity).
+fn object_count(doc: &Document) -> usize {
+ doc.layers.iter().map(|l| l.count()).sum()
+}
+
+/// Why document `st` gets no recovery copy now, if it doesn't: unchanged since its last copy (or
+/// never changed), or complex while the preference turns Data Recovery off for those.
+pub fn skip_reason(s: &Session, st: &DocState) -> Option<&'static str> {
+ if !st.is_dirty() {
+ return Some("no unsaved changes");
+ }
+ if st.recovery.as_ref().is_some_and(|c| Arc::ptr_eq(&c.doc, &st.doc)) {
+ return Some("unchanged since its last recovery copy");
+ }
+ if s.prefs.recovery_off_for_complex && object_count(&st.doc) > COMPLEX_OBJECTS {
+ return Some("complex document (Data Recovery is off for complex documents)");
+ }
+ None
+}
+
+/// A file name part from a title: letters, digits, `-`, `_` and spaces (others as `-`).
+fn safe_stem(title: &str) -> String {
+ let stem: String = fileio::file_stem(title).chars().map(|c| if c.is_alphanumeric() || matches!(c, '-' | '_' | ' ') { c } else { '-' }).collect();
+ let stem = stem.trim().chars().take(60).collect::();
+ if stem.is_empty() { "Untitled".into() } else { stem }
+}
+
+/// The copies among store entries `names`, each once, as `(area, " /")`.
+fn copies_in(names: &[String]) -> Vec<(String, String)> {
+ let mut v: Vec<(String, String)> =
+ names.iter().filter_map(|n| n.strip_suffix(DOC_EXT)).filter_map(|f| f.split_once('/').map(|(a, _)| (a.to_string(), f.to_string()))).collect();
+ v.sort();
+ v.dedup();
+ v
+}
+
+/// A copy name ` /-` none of `taken` has.
+fn fresh_name(area: &str, stem: &str, taken: &[String]) -> String {
+ (1..=10_000).map(|n| format!("{area}/{stem}-{n}")).find(|f| !taken.contains(f)).unwrap_or_else(|| format!("{area}/{stem}-x"))
+}
+
+/// The jobs for every document that needs a copy now, in this session's area of `store` (taken
+/// when it has none and a copy is needed) → (jobs, skipped: `[{title, reason}]` for modified
+/// documents that get none).
+pub fn jobs(s: &mut Session, store: &Arc) -> Result<(Vec, Vec)> {
+ let skipped: Vec = s
+ .documents()
+ .iter()
+ .filter(|st| st.is_dirty())
+ .filter_map(|st| skip_reason(s, st).map(|why| json!({ "title": st.title(), "reason": why })))
+ .collect();
+ if s.documents().iter().all(|st| skip_reason(s, st).is_some()) {
+ return Ok((vec![], skipped));
+ }
+ let area = claim(s, store)?;
+ let mut taken: Vec =
+ copies_in(&store.list().map_err(EngineError::Other)?).into_iter().filter(|(a, _)| *a == area).map(|(_, f)| f).collect();
+ taken.extend(s.documents().iter().filter_map(|d| d.recovery.as_ref()).map(|c| c.file.clone()));
+ let native = fileio::format("vectorcraft").ok_or_else(|| EngineError::Other("no native format".into()))?;
+ let saved = store.now();
+ let mut out = vec![];
+ for st in s.documents().iter().filter(|st| skip_reason(s, st).is_none()) {
+ let title = st.title();
+ let file = match &st.recovery {
+ Some(c) => c.file.clone(),
+ None => {
+ let f = fresh_name(&area, &safe_stem(&title), &taken);
+ taken.push(f.clone());
+ f
+ }
+ };
+ // Written as a save writes it, links relative to the document's own file.
+ let plan = SavePlan {
+ mode: SaveMode::Copy,
+ path: st.path.clone(),
+ format: native,
+ options: Default::default(),
+ name: format!("{file}{DOC_EXT}"),
+ folder: None,
+ modified: None,
+ };
+ let save = fileio::job_for(s, st, plan)?;
+ let meta = Meta { title: title.clone(), path: st.path.clone(), format: st.format.to_string(), saved };
+ out.push(RecoveryJob { uid: st.uid, file, title, meta, save });
+ }
+ Ok((out, skipped))
+}
+
+/// Remove copy `file` from this session's area. Best effort: a copy that can't be removed is
+/// offered again after the next launch, which loses nothing.
+fn remove_copy(s: &Session, file: &str) {
+ if let Some(own) = &s.recovery.own {
+ let _ = own.store.remove(&format!("{file}{DOC_EXT}"));
+ let _ = own.store.remove(&format!("{file}{META_EXT}"));
+ }
+}
+
+/// Document `uid` was saved, reverted or closed: its recovery copy goes.
+pub fn forget(s: &mut Session, uid: u64) {
+ let Some(copy) = s.document_mut(uid).and_then(|st| st.recovery.take()) else { return };
+ remove_copy(s, ©.file);
+}
+
+/// Quitting with nothing unsaved: the copies of every open document go, with this session's area.
+pub fn forget_all(s: &mut Session) {
+ release(s);
+}
+
+/// The copies of documents with no unsaved changes left (undone back to the saved state) go.
+pub fn forget_clean(s: &mut Session) {
+ let uids: Vec = s.documents().iter().filter(|d| !d.is_dirty() && d.recovery.is_some()).map(|d| d.uid).collect();
+ for uid in uids {
+ forget(s, uid);
+ }
+}
+
+fn save(s: &mut Session, _: &Value) -> Result {
+ let store = need_store(s, "file.recovery.save")?;
+ let (jobs, skipped) = jobs(s, &store)?;
+ let mut saved = vec![];
+ for j in jobs {
+ saved.push(j.write(store.as_ref()).map_err(EngineError::Other)?);
+ j.finish(s);
+ }
+ forget_clean(s);
+ heartbeat(s);
+ Ok(json!({ "saved": saved, "skipped": skipped, "location": store.location() }))
+}
+
+/// Who an area of the store belongs to.
+#[derive(Clone, Copy, PartialEq, Eq)]
+enum Owner {
+ /// This session.
+ Mine,
+ /// Another app that is running.
+ Running,
+ /// An app that is gone (crashed): its copies are offered.
+ Gone,
+}
+
+/// Whose `area` of `store` is. A gone app's area is held by the caller (its lock in `hold` until
+/// dropped) and, with `take` in a store without locks, taken over with a fresh heartbeat, so
+/// another app leaves it alone meanwhile.
+fn owner(s: &mut Session, store: &dyn RecoveryStore, area: &str, hold: &mut Vec, take: bool) -> Owner {
+ if s.recovery.own.as_ref().is_some_and(|o| o.area == area && o.store.location() == store.location()) {
+ return Owner::Mine;
+ }
+ match store.lock(area) {
+ Ok(Lock::Held(h)) => {
+ hold.push(h);
+ Owner::Gone
+ }
+ Ok(Lock::Busy) | Err(_) => Owner::Running,
+ Ok(Lock::Unsupported) => {
+ if !s.recovery.adopted.contains(area) && beating(store, area, stale_after(s)) {
+ return Owner::Running;
+ }
+ if take {
+ // Best effort: without it another app might offer the copies too, which loses
+ // nothing.
+ let _ = beat(store, area);
+ s.recovery.adopted.insert(area.to_string());
+ }
+ Owner::Gone
+ }
+ }
+}
+
+/// Tidy away a gone app's area `area` once no copies are left in it, then let it go (`hold`).
+fn tidy(store: &dyn RecoveryStore, area: &str, hold: Vec) {
+ let names = store.list().unwrap_or_default();
+ let mine: Vec<&String> = names.iter().filter(|n| n.split_once('/').is_some_and(|(a, _)| a == area)).collect();
+ if mine.iter().any(|n| n.ends_with(DOC_EXT)) {
+ return;
+ }
+ for n in mine {
+ let _ = store.remove(n);
+ }
+ drop(hold);
+ store.remove_area(area);
+}
+
+/// The copies of documents open now in this session.
+fn open_copies(s: &Session) -> Vec {
+ s.documents().iter().filter_map(|d| d.recovery.as_ref()).map(|c| c.file.clone()).collect()
+}
+
+fn list(s: &mut Session, _: &Value) -> Result {
+ let store = need_store(s, "file.recovery.list")?;
+ let open = open_copies(s);
+ let copies = copies_in(&store.list().map_err(EngineError::Other)?);
+ let mut rows = vec![];
+ for area in store.areas().map_err(EngineError::Other)? {
+ let mut hold = vec![];
+ let who = owner(s, store.as_ref(), &area, &mut hold, false);
+ let files: Vec<&String> = copies.iter().filter(|(a, _)| *a == area).map(|(_, f)| f).collect();
+ for f in &files {
+ let meta = read_meta(store.as_ref(), f);
+ let title = if meta.title.is_empty() { f.rsplit('/').next().unwrap_or(f).to_string() } else { meta.title };
+ rows.push(json!({
+ "file": f, "title": title, "path": meta.path, "format": meta.format, "saved": meta.saved,
+ "open": who == Owner::Mine && open.contains(f), "running": who == Owner::Running,
+ }));
+ }
+ // A gone app's empty area is tidied away.
+ if who == Owner::Gone && files.is_empty() {
+ tidy(store.as_ref(), &area, hold);
+ }
+ }
+ Ok(json!({ "copies": rows, "location": store.location() }))
+}
+
+/// A gone app's area to act on: its name, the copies to act on, and its lock.
+type Target = (String, Vec, Vec);
+
+/// The copies a command acts on, by gone app's area, each area held until its hold is dropped:
+/// `file` (a copy a crash left behind), else every one.
+fn targets(s: &mut Session, store: &dyn RecoveryStore, cmd: &str, p: &Value) -> Result> {
+ let copies = copies_in(&store.list().map_err(EngineError::Other)?);
+ let file = str_param(p, "file");
+ if let Some(f) = file
+ && !copies.iter().any(|(_, c)| c == f)
+ {
+ return Err(bad(cmd, format!("no recovery copy `{f}` (see file.recovery.list)")));
+ }
+ let picked = |c: &str| file.is_none_or(|f| f == c);
+ let areas: BTreeSet<&str> = copies.iter().filter(|(_, c)| picked(c)).map(|(a, _)| a.as_str()).collect();
+ let mut out = vec![];
+ for area in areas {
+ let mut hold = vec![];
+ match (owner(s, store, area, &mut hold, true), file) {
+ (Owner::Gone, _) => {
+ let files = copies.iter().filter(|(a, c)| a == area && picked(c)).map(|(_, c)| c.clone()).collect();
+ out.push((area.to_string(), files, hold));
+ }
+ (Owner::Mine, Some(f)) => return Err(bad(cmd, format!("`{f}` is the copy of a document open now"))),
+ (Owner::Running, Some(f)) => return Err(bad(cmd, format!("`{f}` belongs to a VectorCraft that is running"))),
+ _ => {}
+ }
+ }
+ Ok(out)
+}
+
+/// Open copy `file` as a new document: " [Recovered]", modified, with its original path
+/// (for Save As) and format. The copy moves into this session's area (another crash keeps it).
+fn restore_one(s: &mut Session, store: &Arc, file: &str) -> Result {
+ let bytes = store.read(&format!("{file}{DOC_EXT}")).map_err(EngineError::Other)?;
+ let meta_bytes = store.read(&format!("{file}{META_EXT}")).unwrap_or_default();
+ let meta: Meta = serde_json::from_slice(&meta_bytes).unwrap_or_default();
+ let mut doc = vectorcraft_format::load(&bytes).map_err(|e| EngineError::Other(format!("recovery copy `{file}`: {e}")))?;
+ let area = claim(s, store)?;
+ let mut taken: Vec = copies_in(&store.list().map_err(EngineError::Other)?).into_iter().map(|(_, f)| f).collect();
+ taken.extend(open_copies(s));
+ let stem = file.rsplit('/').next().unwrap_or(file);
+ let mine = match format!("{area}/{stem}") {
+ f if taken.contains(&f) => fresh_name(&area, stem, &taken),
+ f => f,
+ };
+ store.write(&format!("{mine}{DOC_EXT}"), &bytes).map_err(EngineError::Other)?;
+ store.write(&format!("{mine}{META_EXT}"), &meta_bytes).map_err(EngineError::Other)?;
+ store.remove(&format!("{file}{DOC_EXT}")).map_err(EngineError::Other)?;
+ store.remove(&format!("{file}{META_EXT}")).map_err(EngineError::Other)?;
+ if doc.title.is_empty() {
+ doc.title = if meta.title.is_empty() { stem.to_string() } else { meta.title.clone() };
+ }
+ // Linked files are looked for from the document's own folder.
+ let links = super::links::resolve(&mut doc, meta.path.as_deref(), s.prefs.update_links == "automatically");
+ let index = s.add_document(doc, meta.path.clone());
+ let st = s.doc_mut()?;
+ st.recovered = true;
+ st.format = SAVE_FORMATS.iter().copied().find(|f| *f == meta.format).unwrap_or("vectorcraft");
+ st.mark_unsaved();
+ // The copy stays until the document is saved or closed.
+ st.recovery = Some(RecoveryCopy { file: mine.clone(), doc: st.doc.clone() });
+ let title = st.title();
+ Ok(super::fileio::merge(json!({ "index": index, "title": title, "file": mine, "path": meta.path }), links.to_json()))
+}
+
+fn restore(s: &mut Session, p: &Value) -> Result {
+ const C: &str = "file.recovery.restore";
+ let store = need_store(s, C)?;
+ let one = str_param(p, "file").is_some();
+ let (mut restored, mut failed) = (vec![], vec![]);
+ for (area, files, hold) in targets(s, store.as_ref(), C, p)? {
+ for f in files {
+ match restore_one(s, &store, &f) {
+ Ok(r) => restored.push(r),
+ Err(e) if one => return Err(e),
+ // A damaged copy doesn't hold the others back.
+ Err(e) => failed.push(json!({ "file": f, "error": e.to_string() })),
+ }
+ }
+ tidy(store.as_ref(), &area, hold);
+ }
+ Ok(json!({ "restored": restored, "failed": failed }))
+}
+
+fn discard(s: &mut Session, p: &Value) -> Result {
+ const C: &str = "file.recovery.discard";
+ let store = need_store(s, C)?;
+ let mut discarded = vec![];
+ for (area, files, hold) in targets(s, store.as_ref(), C, p)? {
+ for f in files {
+ store.remove(&format!("{f}{DOC_EXT}")).map_err(EngineError::Other)?;
+ store.remove(&format!("{f}{META_EXT}")).map_err(EngineError::Other)?;
+ discarded.push(f);
+ }
+ tidy(store.as_ref(), &area, hold);
+ }
+ Ok(json!({ "discarded": discarded }))
+}
+
+pub fn specs() -> Vec {
+ vec![
+ cmd!(
+ "file.recovery.save",
+ "Save Recovery Data",
+ [],
+ None,
+ "{} write a recovery copy of every modified document changed since its last copy, in this app's own area of the store (the app does this every autosaveInterval minutes while autosaveRecovery is on; documents of more than 20000 objects are skipped while recoveryOffForComplex is on). A copy goes when its document is saved, reverted or closed → {saved: [{file, title, bytes}], skipped: [{title, reason}], location} (location: the recovery folder, or browser storage on the web)",
+ has_store,
+ save
+ ),
+ cmd!(
+ query "file.recovery.list",
+ "Recovery Data",
+ [],
+ None,
+ "{} the recovery copies in the store → {copies: [{file (\" /\"), title, path (the document's file, if it had one), format, saved (Unix seconds or null), open (the copy of a document open here), running (kept by another VectorCraft that is running)}], location}; copies neither open nor running were left behind by a crash",
+ has_store,
+ list
+ ),
+ cmd!(
+ "file.recovery.restore",
+ "Restore Recovered Documents",
+ [],
+ None,
+ "{file?} open a copy left behind by a crash (default: every one; a running VectorCraft's copies are never taken) as a new document titled \" [Recovered]\": modified, and Save asks where to save it (suggesting its original file) → {restored: [{index, title, file, path, missingLinks, modifiedLinks, updatedLinks}], failed: [{file, error}] (damaged copies, when restoring every one; a named one fails the command)}. The copy moves to this app's area and stays until the document is saved or closed",
+ has_store,
+ restore
+ ),
+ cmd!(
+ "file.recovery.discard",
+ "Discard Recovered Documents",
+ [],
+ None,
+ "{file?} delete a copy left behind by a crash (default: every one; copies of open documents and of running VectorCraft apps stay) → {discarded: [file…]}",
+ has_store,
+ discard
+ ),
+ ]
+}
diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs
index c14cd248..7bc0c5b5 100644
--- a/crates/engine/src/lib.rs
+++ b/crates/engine/src/lib.rs
@@ -124,6 +124,11 @@ pub struct DocState {
/// The view saved into native files (`Document::last_view`); the UI keeps it current before a
/// save and restores it when the document opens.
pub view: Option,
+ /// Restored by Data Recovery: the title says "[Recovered]" and Save asks where to save it
+ /// (suggesting `path`, the file it was copied from) instead of overwriting that file.
+ pub recovered: bool,
+ /// The document's Data Recovery copy, once one was written ([`cmd::recovery`]).
+ pub recovery: Option,
}
static NEXT_DOC_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
@@ -153,6 +158,8 @@ impl DocState {
save_options: Default::default(),
converted: false,
view,
+ recovered: false,
+ recovery: None,
}
}
/// Unsaved changes: the document differs from the saved one (selection changes don't count).
@@ -186,6 +193,10 @@ impl DocState {
self.revision += 1;
}
}
+ /// Count the document as modified, as if never saved (a document restored by Data Recovery).
+ pub fn mark_unsaved(&mut self) {
+ self.saved_doc = Arc::new(Document::new(1.0, 1.0));
+ }
pub fn title(&self) -> String {
let name = self
.path
@@ -193,7 +204,13 @@ impl DocState {
.and_then(|p| std::path::Path::new(p).file_name())
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| self.doc.title.clone());
- if self.converted { format!("{} [Converted]", cmd::fileio::file_stem(&name)) } else { name }
+ if self.converted {
+ format!("{} [Converted]", cmd::fileio::file_stem(&name))
+ } else if self.recovered {
+ format!("{} [Recovered]", cmd::fileio::file_stem(&name))
+ } else {
+ name
+ }
}
/// Where new art is inserted: the isolation container, else the active layer.
/// The current layer, if the remembered id still names a layer (ids are reused after undo).
@@ -632,6 +649,8 @@ pub struct Session {
/// While `command.batch` runs: the documents its steps closed or replaced (Revert), which an
/// error brings back; `None` otherwise.
pub(crate) batch_stash: Option>,
+ /// Where Data Recovery keeps its copies ([`cmd::recovery`]).
+ pub recovery: cmd::recovery::Recovery,
}
impl Default for Session {
@@ -671,6 +690,7 @@ impl Session {
journal_note: Default::default(),
note_depth: 1,
batch_stash: None,
+ recovery: Default::default(),
}
}
@@ -791,6 +811,9 @@ impl Session {
return false;
}
self.reset_tool_for_doc_switch();
+ // Closed (saved or discarded): nothing left to recover.
+ let uid = self.docs[index].uid;
+ cmd::recovery::forget(self, uid);
let old = self.docs.remove(index);
if let Some(stash) = &mut self.batch_stash {
stash.push(old);
@@ -1176,6 +1199,8 @@ mod tests_rastersettings;
#[cfg(test)]
mod tests_recolor;
#[cfg(test)]
+mod tests_recovery;
+#[cfg(test)]
mod tests_registration;
#[cfg(test)]
mod tests_save;
diff --git a/crates/engine/src/tests_recovery.rs b/crates/engine/src/tests_recovery.rs
new file mode 100644
index 00000000..b503f387
--- /dev/null
+++ b/crates/engine/src/tests_recovery.rs
@@ -0,0 +1,361 @@
+//! Data Recovery: `file.recovery.save/list/restore/discard`, copies removed on save, revert and
+//! close, complex documents skipped, each app's own area (another running app's copies are never
+//! offered: locks, heartbeats), and the folder store with real file locks.
+
+use std::sync::Arc;
+
+use serde_json::json;
+
+use super::*;
+use crate::cmd::recovery::{FolderStore, MemoryStore, RecoveryStore};
+
+/// A session keeping its copies in `store`, with one modified document.
+fn session_in(store: &Arc) -> Session {
+ let mut s = Session::new();
+ s.recovery.set_store(store.clone());
+ s.execute("file.new", &json!({"width": 200, "height": 100})).unwrap();
+ s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 20})).unwrap();
+ s
+}
+
+/// A session keeping its copies in memory (the store is returned to look inside), with one
+/// modified document.
+fn session() -> (Session, Arc) {
+ let store = Arc::new(MemoryStore::default());
+ (session_in(&store), store)
+}
+
+/// The copies in the store, without their areas.
+fn copies(store: &dyn RecoveryStore) -> Vec {
+ let mut v: Vec =
+ store.list().unwrap().iter().filter_map(|n| n.strip_suffix(".vectorcraft")).map(|n| n.split_once('/').unwrap().1.to_string()).collect();
+ v.sort();
+ v
+}
+
+/// The titles of the copies `file.recovery.list` offers after a crash (neither open here nor
+/// kept by a running app).
+fn offered(s: &mut Session) -> Vec {
+ let l = s.execute("file.recovery.list", &json!({})).unwrap();
+ l["copies"]
+ .as_array()
+ .unwrap()
+ .iter()
+ .filter(|c| c["open"] == false && c["running"] == false)
+ .map(|c| c["title"].as_str().unwrap().to_string())
+ .collect()
+}
+
+/// A crash: a new session over the same store (the copies stay behind; dropping the old session
+/// releases its lock, as the system does for a process that ends).
+fn relaunch(store: &Arc) -> Session {
+ let mut s = Session::new();
+ s.recovery.set_store(store.clone());
+ s
+}
+
+fn restored(r: serde_json::Value) -> usize {
+ r["restored"].as_array().unwrap().len()
+}
+
+#[test]
+fn recovery_save_writes_modified_documents_once() {
+ let (mut s, store) = session();
+ // A clean second document gets no copy.
+ s.execute("file.new", &json!({"width": 50, "height": 50})).unwrap();
+ let r = s.execute("file.recovery.save", &json!({})).unwrap();
+ assert_eq!(r["saved"].as_array().unwrap().len(), 1, "{r}");
+ let area = s.recovery.own_area().unwrap().to_string();
+ assert_eq!(r["saved"][0]["file"], format!("{area}/Untitled-1-1"));
+ assert_eq!(store.list().unwrap(), [format!("{area}/Untitled-1-1.json"), format!("{area}/Untitled-1-1.vectorcraft")]);
+ let doc = store.read(&format!("{area}/Untitled-1-1.vectorcraft")).unwrap();
+ assert_eq!(vectorcraft_format::load(&doc).unwrap().node_count(), s.documents()[0].doc.node_count());
+ // Unchanged since: nothing written again.
+ let again = s.execute("file.recovery.save", &json!({})).unwrap();
+ assert!(again["saved"].as_array().unwrap().is_empty());
+ assert_eq!(again["skipped"][0]["reason"], "unchanged since its last recovery copy");
+ // Changed: the same copy is rewritten.
+ s.execute("document.activate", &json!({"index": 0})).unwrap();
+ s.execute("shape.ellipse", &json!({"x": 100, "y": 10, "width": 20, "height": 20})).unwrap();
+ let r = s.execute("file.recovery.save", &json!({})).unwrap();
+ assert_eq!(r["saved"][0]["file"], format!("{area}/Untitled-1-1"));
+ assert_eq!(copies(store.as_ref()), ["Untitled-1-1"]);
+ // The list shows it as the copy of an open document, not one to offer.
+ let l = s.execute("file.recovery.list", &json!({})).unwrap();
+ assert_eq!((l["copies"][0]["title"].as_str(), l["copies"][0]["open"].as_bool()), (Some("Untitled-1"), Some(true)));
+ assert_eq!(l["location"], "memory");
+ assert!(offered(&mut s).is_empty());
+}
+
+#[test]
+fn a_crash_leaves_copies_that_restore_modified_and_titled() {
+ let (mut s, store) = session();
+ s.doc_mut().unwrap().path = Some("/art/Poster.svg".into());
+ s.doc_mut().unwrap().format = "svg";
+ s.execute("file.recovery.save", &json!({})).unwrap();
+ let nodes = s.doc().unwrap().doc.node_count();
+ let crashed = s.recovery.own_area().unwrap().to_string();
+ drop(s);
+
+ let mut s = relaunch(&store);
+ let l = s.execute("file.recovery.list", &json!({})).unwrap();
+ let copy = &l["copies"][0];
+ assert_eq!(copy["file"], format!("{crashed}/Poster-1"));
+ assert_eq!((copy["path"].as_str(), copy["open"].as_bool(), copy["running"].as_bool()), (Some("/art/Poster.svg"), Some(false), Some(false)));
+ let r = s.execute("file.recovery.restore", &json!({})).unwrap();
+ assert_eq!(r["restored"][0]["title"], "Poster [Recovered]");
+ let st = s.doc().unwrap();
+ assert_eq!(st.title(), "Poster [Recovered]");
+ assert!(st.is_dirty() && st.recovered, "restored documents are unsaved");
+ assert_eq!((st.path.as_deref(), st.format, st.doc.node_count()), (Some("/art/Poster.svg"), "svg", nodes));
+ // The copy moved into this session's area; the crashed one's area is gone.
+ let mine = s.recovery.own_area().unwrap().to_string();
+ assert_eq!(st.recovery.as_ref().unwrap().file, format!("{mine}/Poster-1"));
+ assert_eq!(store.areas().unwrap(), std::slice::from_ref(&mine));
+ // Save asks for a path (suggesting the original file) rather than overwriting it.
+ let saved = s.execute("document.save", &json!({})).unwrap();
+ assert!(saved.get("dataBase64").is_some(), "no path: {saved}");
+ assert_eq!(saved["name"], "Poster.svg");
+ // Restored once: nothing is left to restore.
+ assert_eq!(restored(s.execute("file.recovery.restore", &json!({})).unwrap()), 0);
+ assert!(s.execute("file.recovery.restore", &json!({"file": format!("{mine}/Poster-1")})).is_err(), "an open document's copy");
+}
+
+#[test]
+fn another_running_apps_copies_are_never_offered() {
+ let store = Arc::new(MemoryStore::default());
+ let mut a = session_in(&store);
+ a.execute("file.recovery.save", &json!({})).unwrap();
+ let a_file = format!("{}/Untitled-1-1", a.recovery.own_area().unwrap());
+ // B runs at the same time: A's copy is listed as running, never offered or taken.
+ let mut b = session_in(&store);
+ b.execute("file.recovery.save", &json!({})).unwrap();
+ let l = b.execute("file.recovery.list", &json!({})).unwrap();
+ let rows = l["copies"].as_array().unwrap();
+ assert_eq!(rows.len(), 2);
+ assert!(rows.iter().any(|c| c["file"] == a_file.as_str() && c["running"] == true));
+ assert!(offered(&mut b).is_empty());
+ assert_eq!(restored(b.execute("file.recovery.restore", &json!({})).unwrap()), 0);
+ assert!(b.execute("file.recovery.discard", &json!({})).unwrap()["discarded"].as_array().unwrap().is_empty());
+ let e = b.execute("file.recovery.discard", &json!({"file": a_file})).unwrap_err().to_string();
+ assert!(e.contains("running"), "{e}");
+ assert_eq!(copies(store.as_ref()).len(), 2, "nothing deleted");
+ // A quits normally: its area goes, and B still has nothing to offer.
+ a.execute("file.close", &json!({})).unwrap();
+ cmd::recovery::forget_all(&mut a);
+ assert!(offered(&mut b).is_empty());
+ assert_eq!(copies(store.as_ref()), ["Untitled-1-1"], "B's own copy");
+}
+
+#[test]
+fn a_crashed_apps_copies_are_offered_once_its_lock_is_released() {
+ let store = Arc::new(MemoryStore::default());
+ let mut a = session_in(&store);
+ a.execute("file.recovery.save", &json!({})).unwrap();
+ let mut b = relaunch(&store);
+ assert!(offered(&mut b).is_empty(), "A still runs");
+ drop(a);
+ assert_eq!(offered(&mut b), ["Untitled-1"]);
+ // Restored by B, the copy is B's: C, launched at the same time, can't take it.
+ let mut c = relaunch(&store);
+ assert_eq!(restored(b.execute("file.recovery.restore", &json!({})).unwrap()), 1);
+ assert!(offered(&mut c).is_empty(), "B owns the copy now");
+ assert_eq!(restored(c.execute("file.recovery.restore", &json!({})).unwrap()), 0);
+}
+
+#[test]
+fn heartbeats_hold_areas_where_the_store_has_no_locks() {
+ let store = Arc::new(MemoryStore::without_locks());
+ store.set_now(10_000);
+ let mut a = session_in(&store);
+ a.execute("file.recovery.save", &json!({})).unwrap();
+ let mut b = relaunch(&store);
+ b.prefs.autosave_interval = 2;
+ // Three intervals (6 minutes) without a heartbeat: until then A counts as running.
+ store.set_now(10_000 + 359);
+ assert!(offered(&mut b).is_empty());
+ // A beats (as the app does every minute): fresh again.
+ cmd::recovery::heartbeat(&a);
+ store.set_now(10_000 + 359 + 300);
+ assert!(offered(&mut b).is_empty());
+ assert_eq!(restored(b.execute("file.recovery.restore", &json!({})).unwrap()), 0);
+ // A stops beating (it crashed): its copies are offered once three intervals have passed.
+ drop(a);
+ store.set_now(10_000 + 359 + 361);
+ assert_eq!(offered(&mut b), ["Untitled-1"]);
+ let mut c = relaunch(&store);
+ // B restores the copies one by one (as the dialog does): its own heartbeat on A's area doesn't
+ // stop it, and C sees the area taken.
+ let file = b.execute("file.recovery.list", &json!({})).unwrap()["copies"][0]["file"].as_str().unwrap().to_string();
+ b.execute("file.recovery.restore", &json!({"file": file})).unwrap();
+ assert!(offered(&mut c).is_empty());
+ assert_eq!(b.doc().unwrap().title(), "Untitled-1 [Recovered]");
+ // Never under three minutes, whatever the interval.
+ let store = Arc::new(MemoryStore::without_locks());
+ let mut a = session_in(&store);
+ a.execute("file.recovery.save", &json!({})).unwrap();
+ drop(a);
+ let mut b = relaunch(&store);
+ b.prefs.autosave_interval = 1;
+ store.set_now(1_000_000 + 179);
+ assert!(offered(&mut b).is_empty());
+ store.set_now(1_000_000 + 181);
+ assert_eq!(offered(&mut b), ["Untitled-1"]);
+}
+
+#[test]
+fn folder_areas_are_locked_by_running_apps() {
+ let dir = std::env::temp_dir().join(format!("vc-recovery-locks-{}", std::process::id()));
+ let _ = std::fs::remove_dir_all(&dir);
+ let folder = dir.to_string_lossy().to_string();
+ let app = |dirty: bool| {
+ let mut s = Session::new();
+ s.prefs.recovery_folder = folder.clone();
+ s.execute("file.new", &json!({"width": 50, "height": 50})).unwrap();
+ if dirty {
+ s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 5, "height": 5})).unwrap();
+ }
+ s
+ };
+ let mut a = app(true);
+ a.execute("file.recovery.save", &json!({})).unwrap();
+ let area = a.recovery.own_area().unwrap().to_string();
+ assert!(dir.join(&area).join(".lock").is_file() && dir.join(&area).join("Untitled-1-1.vectorcraft").is_file());
+ // The lock file is held: another app (another handle) can't take it.
+ let store = FolderStore::new(&dir);
+ assert!(matches!(store.lock(&area), Ok(cmd::recovery::Lock::Busy)));
+ let mut b = app(false);
+ assert!(offered(&mut b).is_empty());
+ assert!(b.execute("file.recovery.discard", &json!({})).unwrap()["discarded"].as_array().unwrap().is_empty());
+ assert!(dir.join(&area).join("Untitled-1-1.vectorcraft").is_file());
+ // A crashes (its handle closes): B offers its copy, and restoring it tidies A's folder away.
+ drop(a);
+ assert_eq!(offered(&mut b), ["Untitled-1"]);
+ b.execute("file.recovery.restore", &json!({})).unwrap();
+ assert!(!dir.join(&area).exists(), "the crashed app's folder is removed");
+ let mine = b.recovery.own_area().unwrap().to_string();
+ assert!(dir.join(&mine).join("Untitled-1-1.vectorcraft").is_file());
+ // An empty folder a crash left (no copies) is tidied by the next list.
+ std::fs::create_dir_all(dir.join("123-1")).unwrap();
+ std::fs::write(dir.join("123-1").join(".lock"), b"").unwrap();
+ b.execute("file.recovery.list", &json!({})).unwrap();
+ assert!(!dir.join("123-1").exists());
+ // B quits with nothing unsaved: its folder goes too.
+ b.execute("file.close", &json!({})).unwrap();
+ b.execute("file.close", &json!({})).unwrap();
+ cmd::recovery::forget_all(&mut b);
+ assert!(!dir.join(&mine).exists());
+ let _ = std::fs::remove_dir_all(&dir);
+}
+
+#[test]
+fn saving_reverting_or_closing_removes_the_copy() {
+ let dir = std::env::temp_dir().join(format!("vc-recovery-save-{}", std::process::id()));
+ std::fs::create_dir_all(&dir).unwrap();
+ let path = dir.join("doc.vectorcraft").to_string_lossy().to_string();
+ // Save to a file.
+ let (mut s, store) = session();
+ s.execute("file.recovery.save", &json!({})).unwrap();
+ assert_eq!(copies(store.as_ref()).len(), 1);
+ s.execute("document.save", &json!({"path": path})).unwrap();
+ assert!(copies(store.as_ref()).is_empty(), "saved: the copy goes");
+ assert!(s.doc().unwrap().recovery.is_none());
+ // Revert.
+ s.execute("shape.ellipse", &json!({"x": 100, "y": 10, "width": 20, "height": 20})).unwrap();
+ s.execute("file.recovery.save", &json!({})).unwrap();
+ assert_eq!(copies(store.as_ref()).len(), 1);
+ s.execute("file.revert", &json!({})).unwrap();
+ assert!(copies(store.as_ref()).is_empty(), "reverted: the copy goes");
+ // Close (discarding the changes).
+ s.execute("shape.ellipse", &json!({"x": 100, "y": 10, "width": 20, "height": 20})).unwrap();
+ s.execute("file.recovery.save", &json!({})).unwrap();
+ s.execute("file.close", &json!({})).unwrap();
+ assert!(copies(store.as_ref()).is_empty(), "closed: the copy goes");
+ // A restored document's copy goes once it is saved.
+ let (mut s, store) = session();
+ s.execute("file.recovery.save", &json!({})).unwrap();
+ drop(s);
+ let mut s2 = relaunch(&store);
+ assert_eq!(restored(s2.execute("file.recovery.restore", &json!({})).unwrap()), 1);
+ s2.execute("document.save", &json!({"path": path})).unwrap();
+ assert!(copies(store.as_ref()).is_empty());
+ let st = s2.doc().unwrap();
+ assert!(!st.recovered && !st.is_dirty());
+ assert_eq!(st.title(), "doc.vectorcraft");
+ let _ = std::fs::remove_dir_all(&dir);
+}
+
+#[test]
+fn undoing_back_to_the_saved_state_drops_the_copy() {
+ let (mut s, store) = session();
+ s.execute("file.recovery.save", &json!({})).unwrap();
+ s.execute("edit.undo", &json!({})).unwrap();
+ assert!(!s.doc().unwrap().is_dirty());
+ let r = s.execute("file.recovery.save", &json!({})).unwrap();
+ assert!(r["saved"].as_array().unwrap().is_empty() && r["skipped"].as_array().unwrap().is_empty(), "{r}");
+ assert!(copies(store.as_ref()).is_empty());
+}
+
+#[test]
+fn complex_documents_are_skipped_when_the_preference_says() {
+ let store = Arc::new(MemoryStore::default());
+ let mut s = Session::new();
+ s.recovery.set_store(store.clone());
+ let mut d = vectorcraft_doc::Document::new(1000.0, 1000.0);
+ let layer = d.layers[0].id;
+ for i in 0..cmd::recovery::COMPLEX_OBJECTS {
+ let id = d.alloc_id();
+ let r = vectorcraft_geom::Rect::new((i % 200) as f64, (i / 200) as f64, (i % 200) as f64 + 1.0, (i / 200) as f64 + 1.0);
+ let n = vectorcraft_doc::Node::path(id, vectorcraft_geom::shapes::rectangle(r), vectorcraft_doc::Appearance::default_art());
+ d.insert(Some(layer), i, n).unwrap();
+ }
+ s.add_document(d, None);
+ // One more object makes it complex (and modified).
+ s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 5, "height": 5})).unwrap();
+ s.prefs.recovery_off_for_complex = true;
+ let r = s.execute("file.recovery.save", &json!({})).unwrap();
+ assert!(r["saved"].as_array().unwrap().is_empty());
+ assert!(r["skipped"][0]["reason"].as_str().unwrap().starts_with("complex document"), "{r}");
+ assert!(store.list().unwrap().is_empty(), "no copy, no area");
+ s.prefs.recovery_off_for_complex = false;
+ assert_eq!(s.execute("file.recovery.save", &json!({})).unwrap()["saved"].as_array().unwrap().len(), 1);
+}
+
+#[test]
+fn discard_deletes_copies_left_behind_only() {
+ let (mut s, store) = session();
+ s.execute("file.recovery.save", &json!({})).unwrap();
+ let crashed = s.recovery.own_area().unwrap().to_string();
+ drop(s);
+ let mut s = relaunch(&store);
+ s.execute("file.new", &json!({"width": 50, "height": 50})).unwrap();
+ s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 5, "height": 5})).unwrap();
+ s.execute("file.recovery.save", &json!({})).unwrap();
+ assert_eq!(copies(store.as_ref()).len(), 2);
+ assert!(s.execute("file.recovery.discard", &json!({"file": "nope"})).is_err());
+ // The open document's copy stays.
+ let r = s.execute("file.recovery.discard", &json!({})).unwrap();
+ assert_eq!(r["discarded"], json!([format!("{crashed}/Untitled-1-1")]));
+ let mine = s.recovery.own_area().unwrap();
+ assert_eq!(store.list().unwrap(), [format!("{mine}/Untitled-1-1.json"), format!("{mine}/Untitled-1-1.vectorcraft")]);
+}
+
+#[test]
+fn without_a_store_the_commands_say_where_to_set_one() {
+ let mut s = Session::new();
+ let e = s.execute("file.recovery.list", &json!({})).unwrap_err().to_string();
+ assert!(e.contains("recoveryFolder"), "{e}");
+ // The preference names a folder: copies go there.
+ let dir = std::env::temp_dir().join(format!("vc-recovery-folder-{}", std::process::id()));
+ let _ = std::fs::remove_dir_all(&dir);
+ s.prefs.recovery_folder = dir.to_string_lossy().to_string();
+ assert!(s.execute("file.recovery.list", &json!({})).unwrap()["copies"].as_array().unwrap().is_empty(), "no folder yet: no copies");
+ s.execute("file.new", &json!({"width": 50, "height": 50})).unwrap();
+ s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 5, "height": 5})).unwrap();
+ s.execute("file.recovery.save", &json!({})).unwrap();
+ let area = dir.join(s.recovery.own_area().unwrap());
+ assert!(area.join("Untitled-1-1.vectorcraft").is_file() && area.join("Untitled-1-1.json").is_file());
+ s.execute("file.close", &json!({})).unwrap();
+ assert!(!area.join("Untitled-1-1.vectorcraft").exists());
+ let _ = std::fs::remove_dir_all(&dir);
+}
diff --git a/crates/engine/tests/command_sweep.rs b/crates/engine/tests/command_sweep.rs
index ef109335..e28c2876 100644
--- a/crates/engine/tests/command_sweep.rs
+++ b/crates/engine/tests/command_sweep.rs
@@ -328,3 +328,38 @@ fn every_command_undoes_exactly() {
}
assert!(failures.is_empty(), "{}", failures.join("\n"));
}
+
+/// Data Recovery reads whatever its store holds: damaged copies and details fail cleanly, and junk
+/// params never panic.
+#[test]
+fn recovery_commands_with_a_damaged_store_and_junk_params() {
+ use std::sync::Arc;
+ use vectorcraft_engine::cmd::recovery::{MemoryStore, RecoveryStore};
+ let store = Arc::new(MemoryStore::default());
+ for (name, bytes) in [
+ ("1-1/bad-1.vectorcraft", &b"{not json"[..]),
+ ("1-1/bad-1.json", b"[1, 2"),
+ ("1-1/gz-1.vectorcraft", &[0x1f, 0x8b, 0, 1, 2][..]),
+ ("1-1/meta-only.json", br#"{"title": 5, "path": [], "saved": "x"}"#),
+ ("2-1/heartbeat", b"not a number"),
+ ("2-1/x.vectorcraft", b"{}"),
+ ("../escape.vectorcraft", b"{}"),
+ ("no-area.vectorcraft", b"{}"),
+ ("/.vectorcraft", b"{}"),
+ ] {
+ store.write(name, bytes).unwrap();
+ }
+ let path = safe_path();
+ for fx in Fixture::ALL {
+ for id in ["file.recovery.save", "file.recovery.list", "file.recovery.restore", "file.recovery.discard"] {
+ let spec = command_specs().iter().find(|c| c.id == id).unwrap();
+ for p in std::iter::once(json!({})).chain(junk_params(spec.params, &path)) {
+ let mut s = fx.session();
+ s.recovery.set_store(store.clone());
+ let r = catch_quiet(|| s.execute(id, &p));
+ assert!(r.is_ok(), "PANIC {id} {p} [{fx:?}]");
+ check_session(&s).unwrap_or_else(|e| panic!("{id} {p} [{fx:?}]: {e}"));
+ }
+ }
+ }
+}
diff --git a/crates/ui-egui/src/dialogs/mod.rs b/crates/ui-egui/src/dialogs/mod.rs
index cdcf8293..512b7329 100644
--- a/crates/ui-egui/src/dialogs/mod.rs
+++ b/crates/ui-egui/src/dialogs/mod.rs
@@ -42,6 +42,7 @@ pub mod placement_options;
mod png_options;
pub mod raster_effects;
pub mod recolor;
+mod recovery;
pub mod saturate;
mod save_changes;
pub(crate) mod save_options;
@@ -223,6 +224,7 @@ registry! {
SliceOptions: [slices::OPTIONS] => slices::OPTIONS_SPEC,
DivideSlices: [slices::DIVIDE] => slices::DIVIDE_SPEC,
EpsOptions: [eps_options::KIND] => eps_options::SPEC,
+ Recovery: [crate::recovery::KIND] => recovery::SPEC,
}
/// The spec for a `Dialog::kind` ([`DialogSpec::FORM`] when unregistered).
diff --git a/crates/ui-egui/src/dialogs/recovery.rs b/crates/ui-egui/src/dialogs/recovery.rs
new file mode 100644
index 00000000..59a8fa10
--- /dev/null
+++ b/crates/ui-egui/src/dialogs/recovery.rs
@@ -0,0 +1,37 @@
+//! Recover Documents (shown at launch when the last session left recovery copies behind):
+//! Restore / Discard / Cancel (ask again next launch). The flow lives in [`crate::recovery`]; this
+//! is its face in the shared dialog frame.
+
+use serde_json::Value;
+
+use super::DialogSpec;
+use crate::theme::Tokens;
+
+pub(super) const SPEC: DialogSpec = DialogSpec {
+ heading: |_| "Recover Documents".into(),
+ body: |_, ui, d| {
+ let t = Tokens::get(ui.ctx());
+ ui.label(egui::RichText::new("VectorCraft didn't quit normally last time. These documents had unsaved changes:").color(t.text_dim));
+ ui.add_space(8.0);
+ let copies = d.fields.get("copies").and_then(Value::as_array);
+ egui::ScrollArea::vertical().max_height(220.0).show(ui, |ui| {
+ for c in copies.into_iter().flatten() {
+ ui.horizontal(|ui| {
+ ui.label(egui::RichText::new(c["title"].as_str().unwrap_or_default()).color(t.text));
+ if let Some(at) = c["saved"].as_i64().and_then(|s| u64::try_from(s).ok()) {
+ ui.label(egui::RichText::new(crate::panels::links::date_label(at.saturating_mul(1000))).color(t.text_dim));
+ }
+ });
+ }
+ });
+ ui.add_space(8.0);
+ ui.label(egui::RichText::new("Restored documents open unsaved: save them to keep them.").color(t.text_dim));
+ false
+ },
+ confirm: crate::recovery::confirm,
+ ok: Some("Restore"),
+ discard: Some("Discard"),
+ min_width: 380.0,
+ max_width: Some(460.0),
+ ..DialogSpec::FORM
+};
diff --git a/crates/ui-egui/src/io.rs b/crates/ui-egui/src/io.rs
index fd1808c8..04f1d8e0 100644
--- a/crates/ui-egui/src/io.rs
+++ b/crates/ui-egui/src/io.rs
@@ -312,9 +312,7 @@ fn write_plan(app: &mut VectorcraftApp, plan: SavePlan) -> Result
let then = move |app: &mut VectorcraftApp, r: Result| {
let r = r?;
// The document may have closed meanwhile: the file is written all the same.
- if let Some(st) = app.session.document_mut(uid) {
- done.finish(st);
- }
+ done.complete(&mut app.session, uid);
let path = r["path"].as_str().unwrap_or_default().to_string();
if retargets {
note_recent(app, &path);
diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs
index 02e81234..ad05e480 100644
--- a/crates/ui-egui/src/lib.rs
+++ b/crates/ui-egui/src/lib.rs
@@ -23,6 +23,7 @@ pub mod palette;
pub mod panels;
pub mod place;
pub mod prefs_dialog;
+pub mod recovery;
pub mod render_worker;
pub mod shortcut_editor;
pub mod shortcuts;
@@ -54,6 +55,8 @@ mod tests_place;
#[cfg(test)]
mod tests_recolor;
#[cfg(test)]
+mod tests_recovery;
+#[cfg(test)]
mod tests_save;
#[cfg(test)]
mod tests_slices;
@@ -148,6 +151,9 @@ pub struct Services {
pub open_file: Option,
/// Show a folder picker; returns its path (desktop: Relink to Folder, Package).
pub pick_folder: Option Option>>,
+ /// Where Data Recovery keeps its copies when not in a folder (the web's browser storage;
+ /// tests): installed into the session ([`vectorcraft_engine::cmd::recovery`]).
+ pub recovery_store: Option>,
}
/// Cached canvas raster.
@@ -240,13 +246,18 @@ pub struct VectorcraftApp {
pub(crate) paste_chord: shortcuts::PasteChord,
/// Saves and exports running in the background (Preferences → File Handling).
pub background: background::Background,
+ /// Data Recovery's timer and startup question ([`recovery`]).
+ pub recovery: recovery::Timer,
}
/// Seconds between two looks at the system clipboard for [`VectorcraftApp::system_paste`].
const SYSTEM_CLIPBOARD_POLL: f64 = 0.25;
impl VectorcraftApp {
- pub fn new(session: Session, services: Services) -> Self {
+ pub fn new(mut session: Session, services: Services) -> Self {
+ if let Some(store) = &services.recovery_store {
+ session.recovery.set_store(store.clone());
+ }
let views = session.documents().iter().map(View::of).collect();
Self {
session,
@@ -288,6 +299,7 @@ impl VectorcraftApp {
system_paste_at: f64::NEG_INFINITY,
paste_chord: Default::default(),
background: Default::default(),
+ recovery: Default::default(),
}
}
@@ -566,8 +578,15 @@ impl VectorcraftApp {
if let Err(e) = unsaved::close_all(self, "quit") {
self.status(e);
}
+ } else {
+ // Quitting with nothing unsaved: no copies to leave behind.
+ vectorcraft_engine::cmd::recovery::forget_all(&mut self.session);
}
}
+ if let Some(wait) = recovery::frame(self, now) {
+ // The timer and heartbeat run in an idle window too.
+ ctx.request_repaint_after(std::time::Duration::from_secs_f64(wait));
+ }
shortcut_editor::sync(&self.ui);
prefs_dialog::apply_runtime(self, ctx);
self.drain_control(ctx);
diff --git a/crates/ui-egui/src/panels/links.rs b/crates/ui-egui/src/panels/links.rs
index 617aa9ad..0bd23347 100644
--- a/crates/ui-egui/src/panels/links.rs
+++ b/crates/ui-egui/src/panels/links.rs
@@ -335,7 +335,7 @@ fn size_label(bytes: u64) -> String {
}
/// Milliseconds since the Unix epoch as a UTC date and time.
-fn date_label(ms: u64) -> String {
+pub(crate) fn date_label(ms: u64) -> String {
let [y, mo, d, h, mi, _] = vectorcraft_doc::metadata::civil(i64::try_from(ms / 1000).unwrap_or(i64::MAX));
format!("{y:04}-{mo:02}-{d:02} {h:02}:{mi:02} UTC")
}
diff --git a/crates/ui-egui/src/recovery.rs b/crates/ui-egui/src/recovery.rs
new file mode 100644
index 00000000..db9d94f9
--- /dev/null
+++ b/crates/ui-egui/src/recovery.rs
@@ -0,0 +1,135 @@
+//! Data Recovery in the app: the timer that keeps recovery copies of modified documents every
+//! Preferences → File Handling → Data Recovery interval (written in the background like Background
+//! Save, the status bar showing it), and the dialog the first frame shows when the last session
+//! left copies behind (a crash). The copies themselves are the engine's
+//! ([`vectorcraft_engine::cmd::recovery`]).
+
+use serde_json::{Value, json};
+use vectorcraft_engine::cmd::recovery;
+
+use crate::VectorcraftApp;
+use crate::background::{self, Writer};
+use crate::state::Dialog;
+
+/// Dialog kind of the startup question. Fields: `copies` (`[{file, title, saved}]`, from
+/// `file.recovery.list`) and `discard` (Discard instead of Restore).
+pub const KIND: &str = "recovery";
+
+/// The timer's state.
+#[derive(Default)]
+pub struct Timer {
+ /// The first frame looked for copies left behind.
+ checked: bool,
+ /// When copies were last written (app time, s); the interval runs from there.
+ last: Option,
+ /// When the heartbeat was last refreshed (app time, s).
+ beat: f64,
+}
+
+/// Each frame: the startup question once, the timer and the heartbeat (`now`: app time in
+/// seconds) → in how many seconds they want the next frame (none while Data Recovery is off).
+pub fn frame(app: &mut VectorcraftApp, now: f64) -> Option {
+ if !app.recovery.checked {
+ app.recovery.checked = true;
+ offer(app);
+ }
+ tick(app, now);
+ if !(0.0..recovery::HEARTBEAT_EVERY).contains(&(now - app.recovery.beat)) {
+ app.recovery.beat = now;
+ recovery::heartbeat(&app.session);
+ }
+ let interval = f64::from(app.session.prefs.autosave_interval.max(1)) * 60.0;
+ let next = app.recovery.last.map(|t| t + interval - now)?;
+ Some(next.min(app.recovery.beat + recovery::HEARTBEAT_EVERY - now).max(1.0))
+}
+
+/// Ask about the copies a crash left behind, if any (the Recover Documents dialog). → whether it
+/// asked.
+pub fn offer(app: &mut VectorcraftApp) -> bool {
+ if recovery::store(&app.session).is_none() || app.ui.dialog.is_some() {
+ return false;
+ }
+ let Ok(r) = app.session.execute("file.recovery.list", &json!({})) else { return false };
+ // Neither this app's own nor kept by another VectorCraft that is running.
+ let left: Vec = r["copies"].as_array().into_iter().flatten().filter(|c| c["open"] == false && c["running"] == false).cloned().collect();
+ if left.is_empty() {
+ return false;
+ }
+ app.ui.dialog = Some(Dialog::new(KIND, json!({ "copies": left })));
+ true
+}
+
+/// Start a recovery copy of every document that needs one when the interval has passed since the
+/// last (the first call only starts the clock) → how many were started.
+pub fn tick(app: &mut VectorcraftApp, now: f64) -> usize {
+ let prefs = &app.session.prefs;
+ let Some(store) = recovery::store(&app.session).filter(|_| prefs.autosave_recovery) else {
+ app.recovery.last = None;
+ return 0;
+ };
+ let interval = f64::from(prefs.autosave_interval.max(1)) * 60.0;
+ match app.recovery.last {
+ Some(t) if now >= t && now - t < interval => return 0,
+ Some(t) if now >= t => app.recovery.last = Some(now),
+ // First call, or a clock that went back: the interval starts now.
+ _ => {
+ app.recovery.last = Some(now);
+ return 0;
+ }
+ }
+ recovery::forget_clean(&mut app.session);
+ let jobs = match recovery::jobs(&mut app.session, &store) {
+ // A document whose save or last copy is still being written waits for the next round.
+ Ok((jobs, _)) => jobs.into_iter().filter(|j| !app.background.busy_with(j.uid)).collect::>(),
+ Err(e) => {
+ app.status(format!("Couldn't save recovery data: {e}"));
+ return 0;
+ }
+ };
+ let mut started = 0;
+ for job in jobs {
+ let (uid, label, title) = (job.uid, format!("Saving recovery data for {}", job.title), job.title.clone());
+ let (done, store) = (job.clone(), store.clone());
+ let work = move |_: Writer| job.write(store.as_ref());
+ let then = move |app: &mut VectorcraftApp, r: Result| {
+ let r = r?;
+ done.finish(&mut app.session);
+ app.status(format!("Saved recovery data for {title}"));
+ Ok(r)
+ };
+ // Its own store, not the file writer: this only decides whether it runs on the worker.
+ if let Err(e) = background::run(app, true, label, Some(uid), work, then) {
+ app.status(format!("Couldn't save recovery data: {e}"));
+ }
+ started += 1;
+ }
+ started
+}
+
+/// The startup dialog's OK (Restore) or Discard: every copy it lists.
+pub fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result {
+ app.ui.dialog = None;
+ let id = if d.bool("discard") { "file.recovery.discard" } else { "file.recovery.restore" };
+ let files: Vec =
+ d.fields.get("copies").and_then(Value::as_array).into_iter().flatten().filter_map(|c| c["file"].as_str()).map(str::to_string).collect();
+ let (mut done, mut errors) = (vec![], vec![]);
+ for file in files {
+ match app.run(id, json!({ "file": file })) {
+ Ok(r) => {
+ if let Some(restored) = r["restored"].as_array().and_then(|v| v.first()) {
+ crate::dialogs::missing_links::after_open(app, restored);
+ }
+ done.push(r);
+ }
+ Err(e) => errors.push(e),
+ }
+ }
+ if !errors.is_empty() {
+ return Err(errors.join("; "));
+ }
+ app.status(match id {
+ "file.recovery.discard" => format!("Discarded {} recovered document(s)", done.len()),
+ _ => format!("Restored {} recovered document(s): save them to keep them", done.len()),
+ });
+ Ok(json!(done))
+}
diff --git a/crates/ui-egui/src/tests_recovery.rs b/crates/ui-egui/src/tests_recovery.rs
new file mode 100644
index 00000000..c865442b
--- /dev/null
+++ b/crates/ui-egui/src/tests_recovery.rs
@@ -0,0 +1,178 @@
+//! Data Recovery in the app: the timer (driven by a fake clock), copies written in the background,
+//! the Recover Documents dialog at launch, and quitting leaving no copies behind.
+
+use std::sync::Arc;
+
+use serde_json::json;
+use vectorcraft_engine::Session;
+use vectorcraft_engine::cmd::recovery::{MemoryStore, RecoveryStore};
+
+use crate::{Services, VectorcraftApp, background, dialogs, recovery, theme};
+
+/// An app keeping its copies in `store`, with a modified document.
+fn app_with(store: &Arc, services: Services) -> VectorcraftApp {
+ let mut app = VectorcraftApp::new(Session::new(), Services { recovery_store: Some(store.clone()), ..services });
+ app.run("file.new", json!({"width": 200, "height": 100})).unwrap();
+ app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 20})).unwrap();
+ app
+}
+
+fn copies(store: &MemoryStore) -> usize {
+ store.list().unwrap().iter().filter(|n| n.ends_with(".vectorcraft")).count()
+}
+
+/// One headless frame of the dialog layer.
+fn frame(app: &mut VectorcraftApp) {
+ let ctx = egui::Context::default();
+ theme::install_fonts(&ctx);
+ let mut out = ctx.run_ui(egui::RawInput::default(), |ui| dialogs::show(app, ui.ctx()));
+ out.textures_delta.clear();
+}
+
+#[test]
+fn the_timer_writes_modified_documents_every_interval() {
+ let store = Arc::new(MemoryStore::default());
+ let mut app = app_with(&store, Services::default());
+ app.session.prefs.autosave_interval = 2;
+ // The first tick starts the clock; nothing before the interval has passed.
+ assert_eq!(recovery::tick(&mut app, 10.0), 0);
+ assert_eq!(recovery::tick(&mut app, 129.0), 0);
+ assert_eq!(copies(&store), 0);
+ assert_eq!(recovery::tick(&mut app, 130.0), 1);
+ assert_eq!(copies(&store), 1);
+ assert_eq!(app.ui.status, "Saved recovery data for Untitled-1");
+ // Unchanged: the next round writes nothing.
+ assert_eq!(recovery::tick(&mut app, 250.0), 0);
+ app.run("shape.ellipse", json!({"x": 100, "y": 10, "width": 20, "height": 20})).unwrap();
+ assert_eq!(recovery::tick(&mut app, 300.0), 0, "the interval runs from the last round");
+ assert_eq!(recovery::tick(&mut app, 370.0), 1);
+ assert_eq!(copies(&store), 1, "the same copy, rewritten");
+ // Turned off: no copies, and the clock starts again when turned back on.
+ app.session.prefs.autosave_recovery = false;
+ app.run("shape.ellipse", json!({"x": 150, "y": 10, "width": 20, "height": 20})).unwrap();
+ assert_eq!(recovery::tick(&mut app, 1000.0), 0);
+ app.session.prefs.autosave_recovery = true;
+ assert_eq!(recovery::tick(&mut app, 1001.0), 0);
+ assert_eq!(recovery::tick(&mut app, 1121.0), 1);
+ // A clock that goes back restarts the interval instead of writing.
+ app.run("shape.ellipse", json!({"x": 10, "y": 50, "width": 20, "height": 20})).unwrap();
+ assert_eq!(recovery::tick(&mut app, 5.0), 0);
+ assert_eq!(recovery::tick(&mut app, 125.0), 1);
+}
+
+#[test]
+fn copies_are_written_in_the_background_and_go_on_save() {
+ let store = Arc::new(MemoryStore::default());
+ let services = Services {
+ write: Some(Box::new(|_: &str, _: &[u8]| Ok(()))),
+ write_shared: Some(Arc::new(|_: &str, _: &[u8]| Ok(()))),
+ ..Default::default()
+ };
+ let mut app = app_with(&store, services);
+ recovery::tick(&mut app, 0.0);
+ assert_eq!(recovery::tick(&mut app, 120.0), 1);
+ assert_eq!(app.background.jobs.len(), 1, "on the worker");
+ assert!(app.ui.status.starts_with("Saving recovery data for Untitled-1"), "{}", app.ui.status);
+ background::wait_all(&mut app);
+ assert_eq!(copies(&store), 1);
+ assert!(app.session.doc().unwrap().recovery.is_some());
+ // Saving (in the background too) removes it.
+ app.run("file.save", json!({"path": "/docs/a.vectorcraft"})).unwrap();
+ background::wait_all(&mut app);
+ assert_eq!(copies(&store), 0);
+}
+
+#[test]
+fn launching_after_a_crash_offers_the_copies() {
+ let store = Arc::new(MemoryStore::default());
+ let mut crashed = app_with(&store, Services::default());
+ crashed.run("file.recovery.save", json!({})).unwrap();
+ drop(crashed);
+
+ // Restore.
+ let mut app = VectorcraftApp::new(Session::new(), Services { recovery_store: Some(store.clone()), ..Default::default() });
+ recovery::frame(&mut app, 0.0);
+ let d = app.ui.dialog.clone().expect("the Recover Documents dialog");
+ assert_eq!((d.kind.as_str(), d.fields["copies"][0]["title"].as_str()), (recovery::KIND, Some("Untitled-1")));
+ frame(&mut app);
+ assert!(app.ui.dialog.is_some(), "drawing keeps it open");
+ dialogs::confirm(&mut app).unwrap();
+ assert!(app.ui.dialog.is_none());
+ let st = app.session.doc().unwrap();
+ assert_eq!(st.title(), "Untitled-1 [Recovered]");
+ assert!(st.is_dirty());
+ assert_eq!(app.views.len(), 1);
+ // Asked once per launch.
+ recovery::frame(&mut app, 1.0);
+ assert!(app.ui.dialog.is_none());
+ // Closing it discards its copy; nothing is offered next time.
+ app.run("file.close", json!({})).unwrap();
+ app.ui.dialog.as_mut().unwrap().fields.insert("discard".into(), json!(true));
+ dialogs::confirm(&mut app).unwrap();
+ assert_eq!(copies(&store), 0);
+ let mut next = VectorcraftApp::new(Session::new(), Services { recovery_store: Some(store.clone()), ..Default::default() });
+ recovery::frame(&mut next, 0.0);
+ assert!(next.ui.dialog.is_none());
+}
+
+#[test]
+fn discard_at_launch_deletes_the_copies_and_cancel_keeps_them() {
+ let store = Arc::new(MemoryStore::default());
+ let mut crashed = app_with(&store, Services::default());
+ crashed.run("file.recovery.save", json!({})).unwrap();
+ drop(crashed);
+ // Cancel: asked again next launch.
+ let mut app = VectorcraftApp::new(Session::new(), Services { recovery_store: Some(store.clone()), ..Default::default() });
+ assert!(recovery::offer(&mut app));
+ dialogs::cancel(&mut app);
+ assert_eq!(copies(&store), 1);
+ // Discard (as agents do: set discard, confirm).
+ let mut app = VectorcraftApp::new(Session::new(), Services { recovery_store: Some(store.clone()), ..Default::default() });
+ assert!(recovery::offer(&mut app));
+ app.ui.dialog.as_mut().unwrap().fields.insert("discard".into(), json!(true));
+ dialogs::confirm(&mut app).unwrap();
+ assert_eq!(copies(&store), 0);
+ assert!(app.session.documents().is_empty());
+}
+
+#[test]
+fn quitting_with_nothing_unsaved_leaves_no_copies() {
+ let store = Arc::new(MemoryStore::default());
+ let mut app = app_with(&store, Services::default());
+ app.run("file.recovery.save", json!({})).unwrap();
+ assert_eq!(copies(&store), 1);
+ // Undone back to the saved state, then quit: the copy goes.
+ app.run("edit.undo", json!({})).unwrap();
+ app.run("app.quit", json!({})).unwrap();
+ assert_eq!(app.ui.status, "quit");
+ assert_eq!(copies(&store), 0);
+}
+
+#[test]
+fn a_second_app_never_asks_about_a_running_apps_copies() {
+ let store = Arc::new(MemoryStore::default());
+ let mut a = app_with(&store, Services::default());
+ a.run("file.recovery.save", json!({})).unwrap();
+ // B launches while A runs: no Recover Documents dialog, A's copy untouched.
+ let mut b = VectorcraftApp::new(Session::new(), Services { recovery_store: Some(store.clone()), ..Default::default() });
+ recovery::frame(&mut b, 0.0);
+ assert!(b.ui.dialog.is_none());
+ assert_eq!(copies(&store), 1);
+ // A crashes: the next launch asks.
+ drop(a);
+ let mut c = VectorcraftApp::new(Session::new(), Services { recovery_store: Some(store.clone()), ..Default::default() });
+ recovery::frame(&mut c, 0.0);
+ assert_eq!(c.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some(recovery::KIND));
+}
+
+#[test]
+fn the_timer_asks_for_frames_while_idle() {
+ let store = Arc::new(MemoryStore::without_locks());
+ let mut app = app_with(&store, Services::default());
+ app.session.prefs.autosave_interval = 2;
+ // The interval and the heartbeat each want a frame on time, even with no input.
+ let wait = recovery::frame(&mut app, 0.0).unwrap();
+ assert!((1.0..=60.0).contains(&wait), "{wait}");
+ app.session.prefs.autosave_recovery = false;
+ assert!(recovery::frame(&mut app, 1.0).is_none());
+}
diff --git a/crates/ui-egui/src/unsaved.rs b/crates/ui-egui/src/unsaved.rs
index 24ddb160..fc8e6d17 100644
--- a/crates/ui-egui/src/unsaved.rs
+++ b/crates/ui-egui/src/unsaved.rs
@@ -32,6 +32,8 @@ pub fn close_all(app: &mut VectorcraftApp, then: &str) -> Result
return ask(app, i, then);
}
if then == "quit" {
+ // Nothing unsaved is left: no recovery copies either.
+ vectorcraft_engine::cmd::recovery::forget_all(&mut app.session);
// The host closes the window.
app.ui.status = "quit".into();
return Ok(Value::Null);
diff --git a/docs/control-protocol.md b/docs/control-protocol.md
index adbc832a..99b91dae 100644
--- a/docs/control-protocol.md
+++ b/docs/control-protocol.md
@@ -339,3 +339,10 @@ EPS Options: Export As… with the EPS format (`file.exportAs {format: "eps"}`,
`cmykPostScript`, `compatibleGradients`, `level` (`2`, `3`), plus `path` and the artboard choice (`useArtboards`,
`range`). `ui.dialog.confirm` checks them (a bad value or an unknown preset keeps the dialog open), remembers them for
the next EPS Options and writes the file(s), asking for a path when there is none.
+
+Data Recovery: at launch, copies left behind by a crash open the `recovery` dialog (never copies another running
+instance keeps, so instances started side by side for screenshots don't get it). Fields: `copies` (`[{file, title,
+saved}]`, from `file.recovery.list`) and `discard`. `ui.dialog.confirm` restores them all (`file.recovery.restore`
+each, opening " [Recovered]" documents), `discard: true` then confirm deletes them, `ui.dialog.cancel` keeps them
+for the next launch. While copies are written in the background `ui.inspect` lists them under `background`
+("Saving recovery data for ").
diff --git a/docs/mcp.md b/docs/mcp.md
index d78f2db6..906c8d38 100644
--- a/docs/mcp.md
+++ b/docs/mcp.md
@@ -1265,3 +1265,29 @@ as they do for art larger than the imageable area and for overprints in composit
{"name":"run_command","arguments":{"command":"print.preview","params":{"settings":{"output":{"mode":"separations"}}}}}
{"name":"run_command","arguments":{"command":"file.print","params":{"path":"/tmp/job.pdf","settings":{"copies":2}}}}
```
+
+## Data Recovery
+
+Modified documents get recovery copies (`file.recovery.save`; the app runs it every `autosaveInterval` minutes while
+`autosaveRecovery` is on, skipping documents of more than 20,000 objects while `recoveryOffForComplex` is on). A copy
+goes when its document is saved, reverted or closed, so copies still there after the app quits were left by a crash.
+The copies live in the `recoveryFolder` preference's folder (default: `Data Recovery` beside the app's preferences;
+none when the app runs with `VECTORCRAFT_NO_PREFS`) or, on the web, in browser storage.
+
+Each running app (each browser tab) keeps its copies in an area of its own (` /`): a sub-folder whose
+`.lock` file it keeps locked while it runs, or on the web an area with a heartbeat it refreshes every minute. Only
+areas nobody holds are offered: their lock is free, or their heartbeat is older than three intervals (at least three
+minutes). Several apps running at once (agents' instances included) never see each other's copies as crash leftovers,
+and an area being restored or discarded is held, so two apps launched together never both take it.
+
+`file.recovery.list` → `{copies: [{file, title, path, format, saved, open, running}], location}` (`open`: the copy of a
+document open here; `running`: kept by another VectorCraft that is running). `file.recovery.restore {file?}` opens
+copies left behind (default: all) as `" [Recovered]"`: modified, and Save asks where to save them (suggesting
+their original file); the copy moves into this app's area. `file.recovery.discard {file?}` deletes them. Without a
+folder or browser storage the commands are disabled and say how to set one.
+
+```json
+{"name":"run_command","arguments":{"command":"prefs.set","params":{"key":"recoveryFolder","value":"/tmp/vc-recovery"}}}
+{"name":"run_command","arguments":{"command":"file.recovery.save","params":{}}}
+{"name":"run_command","arguments":{"command":"file.recovery.restore","params":{"file":"1759650000-1/Poster-1"}}}
+```