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"}}} +```