From 64eed455e5b112c637ee5ab73ae8ed24406b1b0c Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sun, 11 Oct 2026 17:18:49 +0200 Subject: [PATCH] Autosave (SAVETIME, SAVEFILEPATH) and Drawing Recovery after a crash (fixes #406) (#416) Squash of PR #416 by @vangel76. Co-Authored-By: Claude Opus 5.5 --- apps/cadcraft/src/main.rs | 13 +- crates/engine/src/autosave.rs | 410 ++++++++++++++++++ crates/engine/src/cmd/audit.rs | 72 ++- crates/engine/src/lib.rs | 1 + crates/engine/src/sysvars.rs | 9 + crates/engine/tests/autosave.rs | 229 ++++++++++ .../engine/tests/drawing_recovery_command.rs | 41 ++ crates/ui-egui/src/autosave.rs | 174 ++++++++ crates/ui-egui/src/i18n/uk.tsv | 1 + crates/ui-egui/src/lib.rs | 9 + crates/ui-egui/tests/drawing_recovery.rs | 68 +++ docs/gaps.md | 4 +- 12 files changed, 1024 insertions(+), 7 deletions(-) create mode 100644 crates/engine/src/autosave.rs create mode 100644 crates/engine/tests/autosave.rs create mode 100644 crates/engine/tests/drawing_recovery_command.rs create mode 100644 crates/ui-egui/src/autosave.rs create mode 100644 crates/ui-egui/tests/drawing_recovery.rs diff --git a/apps/cadcraft/src/main.rs b/apps/cadcraft/src/main.rs index 781c6ed..61bde65 100644 --- a/apps/cadcraft/src/main.rs +++ b/apps/cadcraft/src/main.rs @@ -73,12 +73,16 @@ impl eframe::App for App { self.cad.ui(ui); self.persist_workspace(false); } + /// A normal exit: the workspace is saved, and this run's autosave files are not needed (a + /// crash leaves them for recovery). fn on_exit(&mut self) { self.persist_workspace(true); + self.cad.shutdown_autosave(); } /// Preferences (the interface theme) survive restarts; window and egui state are not kept. fn save(&mut self, storage: &mut dyn eframe::Storage) { storage.set_string(cadcraft_ui_egui::PREFS_KEY, self.cad.prefs_json()); + storage.set_string(cadcraft_ui_egui::autosave::PREFS_KEY, cadcraft_ui_egui::autosave::prefs_json()); } fn persist_egui_memory(&self) -> bool { false @@ -124,7 +128,7 @@ fn filters_for_save(name: &str) -> [(&'static str, &'static str); 5] { filters } -/// The per-user settings directory (today it holds `logs/`): `CADCRAFT_CONFIG_DIR` if set, else +/// The per-user settings directory (it holds `logs/` and `autosave/`): `CADCRAFT_CONFIG_DIR` if set, else /// `$XDG_CONFIG_HOME/cadcraft` or `~/.config/cadcraft` on Linux and the BSDs, /// `~/Library/Application Support/CADCraft` on macOS, `%APPDATA%\CADCraft` on Windows. fn config_dir() -> Option { @@ -258,6 +262,13 @@ fn main() -> eframe::Result { if let Some(prefs) = cc.storage.and_then(|s| s.get_string(cadcraft_ui_egui::PREFS_KEY)) { app.load_prefs(&prefs); } + if let Some(prefs) = cc.storage.and_then(|s| s.get_string(cadcraft_ui_egui::autosave::PREFS_KEY)) { + cadcraft_ui_egui::autosave::load_prefs(&prefs); + } + // Autosave files (SAVEFILEPATH) default to `/autosave`. + if let Some(dir) = config_dir() { + app.enable_autosave(&dir.join("autosave")); + } app.integrated_titlebar = cfg!(target_os = "macos"); if surface.present_mode == eframe::wgpu::PresentMode::AutoNoVsync { app.frame_cap = Some(cadcraft_ui_egui::gpu::FrameCap::new(240)); diff --git a/crates/engine/src/autosave.rs b/crates/engine/src/autosave.rs new file mode 100644 index 0000000..11598e8 --- /dev/null +++ b/crates/engine/src/autosave.rs @@ -0,0 +1,410 @@ +//! Autosave (SAVETIME, SAVEFILEPATH) and the files Drawing Recovery offers after a crash. +//! +//! Every SAVETIME minutes after a change, each drawing with unsaved changes made since its last +//! autosave is written as DXF to `/__.sv$`. The file goes +//! away when the drawing is saved or closed and when the app exits normally, so a file left behind +//! means the app (or the machine) went down; the next start offers it for recovery. A running app +//! keeps a lock file (`.lock`) fresh, so another copy running at the same time doesn't +//! offer its files. SAVEFILEPATH may be a folder other programs use too, so only names built from +//! an instance id ([`is_instance_id`]) are ever read as ours or deleted. +//! +//! [`Autosaver::plan`] decides what to write and delete (no I/O, so it is testable); on the desktop +//! [`Autosaver::tick`] carries it out, writing on a background thread. SAVETIME and SAVEFILEPATH +//! are profile settings: process-wide, not saved in the drawing. + +use std::collections::{HashMap, HashSet}; +use std::sync::atomic::{AtomicU32, Ordering}; +use std::sync::{Arc, Mutex, Weak}; + +use cadcraft_doc::Drawing; +use serde::Serialize; + +use crate::Session; + +/// The autosave file extension. +pub const EXT: &str = "sv$"; +/// SAVETIME's default, in minutes. +pub const DEFAULT_SAVETIME: u32 = 10; +/// SAVETIME's largest value, in minutes. +pub const MAX_SAVETIME: u32 = 600; +/// A lock file not refreshed for this long belongs to an app that is no longer running. +pub const STALE_LOCK_SECS: u64 = 5 * 60; +/// How often a running app refreshes its lock file. +#[cfg(not(target_arch = "wasm32"))] +const HEARTBEAT_SECS: f64 = 60.0; + +static SAVETIME: AtomicU32 = AtomicU32::new(DEFAULT_SAVETIME); +static SAVEFILEPATH: Mutex = Mutex::new(String::new()); + +/// SAVETIME: minutes between autosaves; 0 turns autosave off. +pub fn savetime() -> u32 { + SAVETIME.load(Ordering::Relaxed) +} + +/// Set SAVETIME (clamped to 0–600 minutes). +pub fn set_savetime(minutes: u32) { + SAVETIME.store(minutes.min(MAX_SAVETIME), Ordering::Relaxed); +} + +/// SAVEFILEPATH: the folder autosave files go to (the host sets a per-user default at start). +pub fn savefilepath() -> String { + SAVEFILEPATH.lock().map(|p| p.clone()).unwrap_or_default() +} + +pub fn set_savefilepath(path: &str) { + if let Ok(mut p) = SAVEFILEPATH.lock() { + *p = path.trim().to_string(); + } +} + +/// The autosave file name for drawing `uid` titled `title`, written by app instance `instance`. +pub fn file_name(title: &str, instance: &str, uid: u64) -> String { + let stem: String = title.chars().map(|c| if c.is_alphanumeric() || " .-_()".contains(c) { c } else { '-' }).take(80).collect(); + let stem = stem.trim().trim_matches('.'); + let stem = if stem.is_empty() { "Drawing" } else { stem }; + format!("{stem}_{instance}_{uid}.{EXT}") +} + +/// Whether `s` has the form of an app instance id (`-`, see +/// `instance_id`). Only file names built from one are treated as CADCraft's own, so files of +/// other programs in the autosave folder are never offered for recovery or deleted. +pub fn is_instance_id(s: &str) -> bool { + let Some((pid, time)) = s.split_once('-') else { return false }; + (1..=10).contains(&pid.len()) + && pid.bytes().all(|b| b.is_ascii_digit()) + && (1..=32).contains(&time.len()) + && time.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) +} + +/// An autosave file name's parts: the drawing's title, the instance and the drawing uid. +pub fn parse_file_name(name: &str) -> Option<(&str, &str, u64)> { + let base = name.strip_suffix(&format!(".{EXT}"))?; + let (rest, uid) = base.rsplit_once('_')?; + let (title, instance) = rest.rsplit_once('_')?; + let uid_ok = !uid.is_empty() && uid.bytes().all(|b| b.is_ascii_digit()); + (!title.is_empty() && is_instance_id(instance) && uid_ok).then_some(())?; + Some((title, instance, uid.parse().ok()?)) +} + +/// The instance whose lock file is named `name` (`.lock`), if it is one of ours. +pub fn parse_lock_name(name: &str) -> Option<&str> { + name.strip_suffix(".lock").filter(|i| is_instance_id(i)) +} + +/// What to do with the file system. +#[derive(Clone, Debug)] +pub enum Job { + /// Write `doc` (drawing `uid`) to `path`. + Write { + uid: u64, + path: String, + doc: Arc, + }, + Delete { + path: String, + }, +} + +#[derive(Debug, Default)] +struct Tracked { + /// The drawing as last autosaved (a weak pointer: compared, never kept alive). + written: Option>, + /// Its autosave file, once written (or adopted). + file: Option, + /// When a change not yet autosaved was first seen (seconds). + since: Option, +} + +/// Autosave state for one running app. +pub struct Autosaver { + /// This app instance's id (`-`), part of every file name. + pub instance: String, + docs: HashMap, + /// Drawings whose autosave is being written right now. + busy: HashSet, + #[cfg(not(target_arch = "wasm32"))] + running: Vec<(u64, std::thread::JoinHandle<()>)>, + #[cfg(not(target_arch = "wasm32"))] + start: std::time::Instant, + #[cfg(not(target_arch = "wasm32"))] + last_beat: Option, +} + +impl Autosaver { + pub fn new(instance: impl Into) -> Self { + Autosaver { + instance: instance.into(), + docs: HashMap::new(), + busy: HashSet::new(), + #[cfg(not(target_arch = "wasm32"))] + running: Vec::new(), + #[cfg(not(target_arch = "wasm32"))] + start: std::time::Instant::now(), + #[cfg(not(target_arch = "wasm32"))] + last_beat: None, + } + } + + /// Take over an existing autosave file for drawing `uid` (a drawing opened from Drawing + /// Recovery, whose current state it holds): later autosaves overwrite it, and it is deleted + /// once the drawing is saved or closed. + pub fn adopt(&mut self, s: &Session, uid: u64, path: &str) { + let written = s.docs.iter().find(|d| d.uid == uid).map(|d| Arc::downgrade(&d.doc)); + self.docs.insert(uid, Tracked { written, file: Some(path.to_string()), since: None }); + } + + /// The autosave files this app has written or adopted. + pub fn files(&self) -> Vec { + self.docs.values().filter_map(|t| t.file.clone()).collect() + } + + /// What to do at `now` (seconds, any fixed origin) with SAVETIME `minutes` and autosave folder + /// `dir`: write drawings changed `minutes` ago or earlier and not autosaved since, delete the + /// files of drawings that were saved (no unsaved changes left) or closed. + pub fn plan(&mut self, s: &Session, now: f64, minutes: u32, dir: &str) -> Vec { + let mut jobs = Vec::new(); + let live: HashSet = s.docs.iter().map(|d| d.uid).collect(); + let busy = &self.busy; + self.docs.retain(|uid, t| { + // A file being written is deleted once the write is done. + if live.contains(uid) || busy.contains(uid) { + return true; + } + if let Some(path) = t.file.take() { + jobs.push(Job::Delete { path }); + } + false + }); + for st in &s.docs { + if self.busy.contains(&st.uid) { + continue; + } + let t = self.docs.entry(st.uid).or_default(); + if !st.is_dirty() { + if let Some(path) = t.file.take() { + jobs.push(Job::Delete { path }); + } + *t = Tracked::default(); + continue; + } + let changed = t.written.as_ref().is_none_or(|w| !std::ptr::eq(w.as_ptr(), Arc::as_ptr(&st.doc))); + if !changed { + t.since = None; + continue; + } + let since = *t.since.get_or_insert(now); + if minutes == 0 || dir.trim().is_empty() || now - since < f64::from(minutes) * 60.0 { + continue; + } + let path = t + .file + .clone() + .unwrap_or_else(|| std::path::Path::new(dir).join(file_name(&st.title, &self.instance, st.uid)).to_string_lossy().to_string()); + t.file = Some(path.clone()); + t.written = Some(Arc::downgrade(&st.doc)); + t.since = None; + jobs.push(Job::Write { uid: st.uid, path, doc: st.doc.clone() }); + } + jobs + } + + /// Seconds from `now` until the next autosave is due, if one is waiting. + pub fn next_due(&self, now: f64, minutes: u32) -> Option { + if minutes == 0 { + return None; + } + self.docs.values().filter_map(|t| t.since).map(|since| (since + f64::from(minutes) * 60.0 - now).max(0.0)).reduce(f64::min) + } +} + +/// A file Drawing Recovery offers: an autosave left behind by an app that is no longer running. +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Recoverable { + pub path: String, + /// The drawing's title when it was autosaved. + pub drawing: String, + /// When it was written, in seconds since 1970. + pub modified: u64, + pub bytes: u64, +} + +#[cfg(not(target_arch = "wasm32"))] +pub use native::*; + +#[cfg(not(target_arch = "wasm32"))] +mod native { + use std::path::Path; + use std::time::{Duration, SystemTime}; + + use super::*; + + /// A new instance id: the process id and the start time. + pub fn instance_id() -> String { + let t = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).map(|d| d.as_millis()).unwrap_or(0); + format!("{}-{t:x}", std::process::id()) + } + + fn lock_path(dir: &Path, instance: &str) -> std::path::PathBuf { + dir.join(format!("{instance}.lock")) + } + + /// Whether the app instance that owns a lock file modified `age` ago (`None`: no lock file) + /// may still be running. + pub fn instance_alive(age: Option) -> bool { + age.is_some_and(|a| a < Duration::from_secs(STALE_LOCK_SECS)) + } + + fn lock_age(dir: &Path, instance: &str) -> Option { + let m = std::fs::metadata(lock_path(dir, instance)).and_then(|m| m.modified()).ok()?; + // A time in the future (clock changes) counts as fresh. + Some(SystemTime::now().duration_since(m).unwrap_or_default()) + } + + /// The autosave files in `dir` left behind by apps that are no longer running (never those of + /// `own`, this app's instance), oldest first. + pub fn recoverable(dir: &Path, own: &str) -> Vec { + let Ok(entries) = std::fs::read_dir(dir) else { return Vec::new() }; + let mut alive: HashMap = HashMap::new(); + let mut out = Vec::new(); + for e in entries.flatten().take(10_000) { + let name = e.file_name().to_string_lossy().to_string(); + let Some((title, instance, _)) = parse_file_name(&name) else { continue }; + if instance == own || *alive.entry(instance.to_string()).or_insert_with(|| instance_alive(lock_age(dir, instance))) { + continue; + } + let Ok(meta) = e.metadata() else { continue }; + if !meta.is_file() { + continue; + } + let modified = meta.modified().ok().and_then(|m| m.duration_since(SystemTime::UNIX_EPOCH).ok()).map(|d| d.as_secs()).unwrap_or(0); + out.push(Recoverable { path: e.path().to_string_lossy().to_string(), drawing: title.to_string(), modified, bytes: meta.len() }); + } + out.sort_by(|a, b| a.modified.cmp(&b.modified).then_with(|| a.path.cmp(&b.path))); + out + } + + /// Delete a recovery file the user discarded (or that was recovered and then saved), and lock + /// files of stopped apps that have no files left. Only an autosave file is deleted: a path + /// whose name doesn't parse as one ([`parse_file_name`]) is left alone. + pub fn discard(path: &str) { + let ours = Path::new(path).file_name().and_then(|n| n.to_str()).is_some_and(|n| parse_file_name(n).is_some()); + if !ours { + log::warn!("autosave: not deleting {path}: not an autosave file"); + return; + } + if let Err(e) = std::fs::remove_file(path) + && e.kind() != std::io::ErrorKind::NotFound + { + log::warn!("autosave: can't delete {path}: {e}"); + } + if let Some(dir) = Path::new(path).parent() { + prune_locks(dir); + } + } + + /// Delete the lock files of apps that are no longer running and left no autosave files. Only + /// names of the form `.lock` are ours ([`parse_lock_name`]): SAVEFILEPATH may + /// point at a folder shared with other programs, whose `Cargo.lock` or `foo.lock` stay. + pub fn prune_locks(dir: &Path) { + let Ok(entries) = std::fs::read_dir(dir) else { return }; + let names: Vec = entries.flatten().take(10_000).map(|e| e.file_name().to_string_lossy().to_string()).collect(); + for n in &names { + let Some(instance) = parse_lock_name(n) else { continue }; + let has_files = names.iter().any(|f| parse_file_name(f).is_some_and(|(_, i, _)| i == instance)); + if !has_files && !instance_alive(lock_age(dir, instance)) { + let _ = std::fs::remove_file(dir.join(n)); + } + } + } + + type WriteHook = fn(&Drawing, &str) -> std::result::Result, String>; + + fn write_file(write: WriteHook, doc: &Drawing, path: &str) -> std::result::Result<(), String> { + let bytes = write(doc, "autosave.dxf")?; + if let Some(dir) = Path::new(path).parent() { + std::fs::create_dir_all(dir).map_err(|e| e.to_string())?; + } + let tmp = format!("{path}.tmp"); + std::fs::write(&tmp, bytes).map_err(|e| e.to_string())?; + std::fs::rename(&tmp, path).map_err(|e| e.to_string()) + } + + impl Autosaver { + /// Autosave what is due (writing in the background) and delete files no longer needed. + /// Returns how long until the next autosave is due, so the host can wake up for it. + pub fn tick(&mut self, s: &Session) -> Option { + let now = self.start.elapsed().as_secs_f64(); + let busy = &mut self.busy; + self.running.retain(|(uid, h)| { + let running = !h.is_finished(); + if !running { + busy.remove(uid); + } + running + }); + let dir = savefilepath(); + let minutes = savetime(); + let jobs = self.plan(s, now, minutes, &dir); + self.perform(jobs); + self.heartbeat(&dir, now); + self.next_due(now, minutes).map(Duration::from_secs_f64) + } + + /// Carry out jobs from [`Autosaver::plan`]: writes on a background thread, deletes now. + pub fn perform(&mut self, jobs: Vec) { + for job in jobs { + match job { + Job::Delete { path } => discard(&path), + Job::Write { uid, path, doc } => { + let Some(write) = crate::cmd::file::io().map(|h| h.write) else { + log::warn!("autosave: file formats are not available in this build"); + continue; + }; + let spawned = std::thread::Builder::new().name("autosave".into()).spawn(move || match write_file(write, &doc, &path) { + Ok(()) => log::info!("autosaved {path}"), + Err(e) => log::warn!("autosave {path}: {e}"), + }); + match spawned { + Ok(h) => { + self.busy.insert(uid); + self.running.push((uid, h)); + } + Err(e) => log::warn!("autosave: {e}"), + } + } + } + } + } + + /// Keep this app's lock file fresh while it has autosave files. + fn heartbeat(&mut self, dir: &str, now: f64) { + if dir.is_empty() || self.docs.values().all(|t| t.file.is_none()) || self.last_beat.is_some_and(|b| now - b < HEARTBEAT_SECS) { + return; + } + self.last_beat = Some(now); + let dir = Path::new(dir); + let r = std::fs::create_dir_all(dir).and_then(|()| std::fs::write(lock_path(dir, &self.instance), std::process::id().to_string())); + if let Err(e) = r { + log::warn!("autosave: lock file in {}: {e}", dir.display()); + } + } + + /// The app is closing normally: finish writes in progress, then delete this app's autosave + /// files and its lock file. + pub fn shutdown(&mut self) { + for (_, h) in self.running.drain(..) { + let _ = h.join(); + } + self.busy.clear(); + for path in self.files() { + discard(&path); + } + self.docs.clear(); + let dir = savefilepath(); + if !dir.is_empty() && is_instance_id(&self.instance) { + let _ = std::fs::remove_file(lock_path(Path::new(&dir), &self.instance)); + } + } + } +} diff --git a/crates/engine/src/cmd/audit.rs b/crates/engine/src/cmd/audit.rs index e7a4dca..9b1f136 100644 --- a/crates/engine/src/cmd/audit.rs +++ b/crates/engine/src/cmd/audit.rs @@ -16,13 +16,65 @@ pub fn specs() -> Vec { .interactive(|_| Ok(Box::new(Audit))), CommandSpec::new("recover", "Recover...", run_recover) .menu(&["File", "Drawing Utilities", "Recover..."]) - .params("{path} | {data: base64, name} → {index, errors, fixed, issues, message}") + .params("{path, copy?: bool (an unsaved copy without a file name), title?} | {data: base64, name} → {index, uid, errors, fixed, issues, message}") .enabled(always) .noundo() .interactive(|_| Ok(Box::new(Recover))), + CommandSpec::new("drawingrecovery", "Drawing Recovery Manager...", run_drawingrecovery) + .menu(&["File", "Drawing Utilities", "Drawing Recovery Manager..."]) + .params("{action?: list|open|discard (default list), path? (one of the listed files)} → {files: [{path, drawing, modified, bytes}]} | (open: as RECOVER)") + .enabled(always) + .noundo(), ] } +/// Drawing Recovery: list the autosave files that apps which stopped left in SAVEFILEPATH, open +/// one as an unsaved copy, or delete one. Only listed files can be opened or deleted. +fn run_drawingrecovery(s: &mut Session, p: &Value) -> Result { + let action = str_param(p, "action").unwrap_or("list").to_ascii_lowercase(); + let files = recoverable_files(); + let pick = || { + let path = str_param(p, "path").ok_or_else(|| bad("drawingrecovery", "`path` is required"))?; + files.iter().find(|f| f.path == path).cloned().ok_or_else(|| bad("drawingrecovery", format!("{path} is not a recoverable autosave file"))) + }; + match action.as_str() { + "list" => Ok(json!({ "files": files })), + "open" => { + let f = pick()?; + run_recover(s, &json!({ "path": f.path, "copy": true, "title": f.drawing })) + } + "discard" => { + let f = pick()?; + discard_file(&f.path); + Ok(json!({ "files": recoverable_files() })) + } + other => Err(bad("drawingrecovery", format!("unknown action `{other}` (list, open or discard)"))), + } +} + +#[cfg(not(target_arch = "wasm32"))] +fn recoverable_files() -> Vec { + let dir = crate::autosave::savefilepath(); + if dir.is_empty() { + return Vec::new(); + } + // This app's own files are never listed: its lock file is fresh. + crate::autosave::recoverable(std::path::Path::new(&dir), "") +} + +#[cfg(target_arch = "wasm32")] +fn recoverable_files() -> Vec { + Vec::new() +} + +#[cfg(not(target_arch = "wasm32"))] +fn discard_file(path: &str) { + crate::autosave::discard(path); +} + +#[cfg(target_arch = "wasm32")] +fn discard_file(_: &str) {} + /// Check the current drawing; with `fix`, repair it (one undo step). fn run_audit(s: &mut Session, p: &Value) -> Result { let fix = bool_or(p, "fix", false); @@ -36,7 +88,9 @@ fn run_audit(s: &mut Session, p: &Value) -> Result { } /// Open a drawing file as tolerantly as the readers allow, then fix what AUDIT finds. The -/// drawing keeps its file name; when anything was fixed it opens with unsaved changes. +/// drawing keeps its file name; when anything was fixed it opens with unsaved changes. With +/// `copy` it opens as an unsaved copy without a file name (Drawing Recovery opens autosave files +/// this way), titled `title`. fn run_recover(s: &mut Session, p: &Value) -> Result { let (bytes, name, path) = match (str_param(p, "path"), str_param(p, "data")) { (Some(path), _) => (read_path("recover", path)?, path.to_string(), Some(path.to_string())), @@ -48,16 +102,26 @@ fn run_recover(s: &mut Session, p: &Value) -> Result { }; let hooks = file::io().ok_or_else(|| bad("recover", "file formats are not available in this build"))?; let d = (hooks.read)(&bytes, &name).map_err(|e| bad("recover", format!("{name} can't be read: {e}")))?; - let title = std::path::Path::new(&name).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| name.clone()); - let i = s.open_drawing(d, &title, path); + let title = match str_param(p, "title") { + Some(t) => t.to_string(), + None => std::path::Path::new(&name).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| name.clone()), + }; + let copy = bool_or(p, "copy", false); + let i = s.open_drawing(d, &title, if copy { None } else { path }); let st = s.state_mut()?; let report = crate::audit::fix(Arc::make_mut(&mut st.doc)); if report.issues.is_empty() { // Nothing to fix: the drawing opens unchanged, like OPEN. st.doc = st.saved.clone(); } + if copy && !st.is_dirty() { + // An unsaved copy (Drawing Recovery): closing it asks to save it under a name. + st.saved = Arc::new((*st.doc).clone()); + } + let uid = st.uid; let mut r = report.to_json(); r["index"] = json!(i); + r["uid"] = json!(uid); Ok(r) } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 9c34416..45bb012 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -10,6 +10,7 @@ pub mod assoc; pub mod audit; +pub mod autosave; pub mod cmd; mod finite; pub mod grips; diff --git a/crates/engine/src/sysvars.rs b/crates/engine/src/sysvars.rs index d9e4945..5d9272b 100644 --- a/crates/engine/src/sysvars.rs +++ b/crates/engine/src/sysvars.rs @@ -32,6 +32,8 @@ const SESSION_VARS: &[&str] = &[ "LASTPOINT", "FONTALT", "FONTFALLBACK", + "SAVETIME", + "SAVEFILEPATH", ]; pub fn get(s: &Session, name: &str) -> Option { @@ -69,6 +71,9 @@ pub fn get(s: &Session, name: &str) -> Option { // Process-wide font substitution (profile settings, not saved in the drawing). "FONTALT" => json!(cadcraft_fonts::ttf::font_alt()), "FONTFALLBACK" => json!(cadcraft_fonts::ttf::fallback_fonts()), + // Autosave (profile settings too). + "SAVETIME" => json!(crate::autosave::savetime()), + "SAVEFILEPATH" => json!(crate::autosave::savefilepath()), "CMDNAMES" => json!(s.running.as_ref().map(|r| r.id.to_ascii_uppercase()).unwrap_or_default()), "DWGNAME" => json!(s.state().map(|d| d.title.clone()).unwrap_or_default()), "DBMOD" => json!(s.state().map(|d| i32::from(d.is_dirty())).unwrap_or(0)), @@ -172,6 +177,10 @@ pub fn set(s: &mut Session, name: &str, v: &Value) -> Result<()> { "MAXARRAY" => st.maxarray = as_f64(v).ok_or_else(bad)?.clamp(100.0, 10_000_000.0) as u64, "FONTALT" => cadcraft_fonts::ttf::set_font_alt(v.as_str().ok_or_else(bad)?), "FONTFALLBACK" => cadcraft_fonts::ttf::set_fallback_fonts(v.as_str().ok_or_else(bad)?), + "SAVETIME" => crate::autosave::set_savetime( + as_f64(v).filter(|m| *m >= 0.0 && *m <= f64::from(crate::autosave::MAX_SAVETIME)).ok_or_else(bad)?.round() as u32, + ), + "SAVEFILEPATH" => crate::autosave::set_savefilepath(v.as_str().ok_or_else(bad)?), // A number is in degrees; text is an angle typed in AUNITS (SETVAR at the command line). "ANGBASE" => { let a = match v { diff --git a/crates/engine/tests/autosave.rs b/crates/engine/tests/autosave.rs new file mode 100644 index 0000000..3654264 --- /dev/null +++ b/crates/engine/tests/autosave.rs @@ -0,0 +1,229 @@ +//! Autosave: SAVETIME timing, which drawings are written, files deleted on save and close, and +//! the files Drawing Recovery offers after a crash (#406, docs/gaps.md F4). + +use std::time::{Duration, SystemTime}; + +use cadcraft_engine::Session; +use cadcraft_engine::autosave::{self, Autosaver, Job}; +use cadcraft_engine::cmd::file::{IoHooks, set_io}; +use serde_json::json; + +fn writes(jobs: &[Job]) -> Vec { + jobs.iter() + .filter_map(|j| match j { + Job::Write { path, .. } => Some(path.clone()), + Job::Delete { .. } => None, + }) + .collect() +} + +fn deletes(jobs: &[Job]) -> Vec { + jobs.iter() + .filter_map(|j| match j { + Job::Delete { path } => Some(path.clone()), + Job::Write { .. } => None, + }) + .collect() +} + +fn draw(s: &mut Session) { + s.execute("circle", &json!({"center": [0, 0], "radius": 1})).unwrap(); +} + +#[test] +fn a_changed_drawing_is_written_savetime_minutes_after_the_change() { + let mut s = Session::new(); + let uid = s.state().unwrap().uid; + let mut a = Autosaver::new("77-a"); + assert!(a.plan(&s, 0.0, 10, "/auto").is_empty(), "nothing to save in a clean drawing"); + + draw(&mut s); + assert!(a.plan(&s, 30.0, 10, "/auto").is_empty(), "the change starts the clock"); + assert_eq!(a.next_due(100.0, 10), Some(530.0)); + assert!(a.plan(&s, 629.0, 10, "/auto").is_empty()); + let jobs = a.plan(&s, 630.0, 10, "/auto"); + let path = std::path::Path::new("/auto").join(format!("Drawing1_77-a_{uid}.sv$")).to_string_lossy().to_string(); + assert_eq!(writes(&jobs), vec![path.clone()]); + assert!(matches!(&jobs[0], Job::Write { doc, .. } if std::sync::Arc::ptr_eq(doc, &s.state().unwrap().doc))); + assert!(a.plan(&s, 5000.0, 10, "/auto").is_empty(), "no change since the autosave"); + assert_eq!(a.next_due(5000.0, 10), None); + + // The next change restarts the clock; the same file is written again. + draw(&mut s); + assert!(a.plan(&s, 6000.0, 10, "/auto").is_empty()); + assert_eq!(writes(&a.plan(&s, 6600.0, 10, "/auto")), vec![path.clone()]); + assert_eq!(a.files(), vec![path.clone()]); + + // Saving (nothing unsaved left) deletes the file. + let st = s.state_mut().unwrap(); + st.saved = st.doc.clone(); + assert_eq!(deletes(&a.plan(&s, 6601.0, 10, "/auto")), vec![path]); + assert!(a.files().is_empty()); +} + +#[test] +fn closing_deletes_and_savetime_zero_or_no_folder_writes_nothing() { + let mut s = Session::new(); + s.execute("new", &json!({})).unwrap(); + let mut a = Autosaver::new("5-b"); + draw(&mut s); + a.plan(&s, 0.0, 1, "/auto"); + assert!(a.plan(&s, 1e6, 0, "/auto").is_empty(), "SAVETIME 0 is off"); + assert!(a.plan(&s, 1e6, 1, " ").is_empty(), "no folder, no autosave"); + let path = writes(&a.plan(&s, 1e6, 1, "/auto")).pop().unwrap(); + assert!(path.contains("Drawing2_5-b_"), "{path}"); + + // Undo back to the saved state: no unsaved changes, no file. + s.undo().unwrap(); + assert_eq!(deletes(&a.plan(&s, 1e6 + 1.0, 1, "/auto")), vec![path.clone()]); + + draw(&mut s); + a.plan(&s, 0.0, 1, "/auto"); + assert_eq!(writes(&a.plan(&s, 60.0, 1, "/auto")).len(), 1); + s.execute("close", &json!({})).unwrap(); + assert_eq!(deletes(&a.plan(&s, 61.0, 1, "/auto")).len(), 1, "closing deletes the file"); +} + +#[test] +fn file_names_round_trip() { + let n = autosave::file_name("My plan_v2.dwg", "123-abc", 7); + assert_eq!(n, "My plan_v2.dwg_123-abc_7.sv$"); + assert_eq!(autosave::parse_file_name(&n), Some(("My plan_v2.dwg", "123-abc", 7))); + assert_eq!(autosave::file_name("a/b:c*", "1-2", 3), "a-b-c-_1-2_3.sv$"); + assert_eq!(autosave::file_name("..", "1-2", 3), "Drawing_1-2_3.sv$"); + for bad in + ["plan.dxf", "x_1.sv$", "_1-2_3.sv$", "a_1-2_x.sv$", "x.sv$", "notes_foo_3.sv$", "a_1-xyz_3.sv$", "a_-1_3.sv$", "a_1-2_+3.sv$", "a_1-2_.sv$"] + { + assert_eq!(autosave::parse_file_name(bad), None, "{bad}"); + } + assert!(autosave::is_instance_id(&autosave::instance_id())); + assert_eq!(autosave::parse_lock_name("123-18f3a2b4c5d.lock"), Some("123-18f3a2b4c5d")); + for bad in ["Cargo.lock", "foo.lock", "package-lock.json", "yarn-lock.lock", "1-2.lock.bak", ".lock", "-1.lock", "1-.lock", "1-2-3.lock"] { + assert_eq!(autosave::parse_lock_name(bad), None, "{bad}"); + } +} + +#[test] +fn files_of_other_programs_in_the_autosave_folder_are_never_deleted() { + // SAVEFILEPATH may point at a project folder: only CADCraft's own lock and autosave names + // (built from an instance id) are ever pruned or discarded. + let dir = temp_dir("foreign"); + let old = SystemTime::now() - Duration::from_secs(3600); + let foreign = ["Cargo.lock", "package-lock.json", "foo.lock", "yarn-lock.lock", "x.sv$", "notes_foo_3.sv$", "a_b-c_1.sv$"]; + for name in foreign.iter().chain(&["1-a.lock"]) { + let f = std::fs::File::create(dir.join(name)).unwrap(); + f.set_modified(old).unwrap(); + } + + assert!(autosave::recoverable(&dir, "9-f").is_empty(), "foreign files are not offered"); + autosave::prune_locks(&dir); + assert!(!dir.join("1-a.lock").exists(), "a stopped app's own lock is pruned"); + for name in foreign { + autosave::discard(&dir.join(name).to_string_lossy()); + } + autosave::prune_locks(&dir); + for name in foreign { + assert!(dir.join(name).exists(), "{name} must survive"); + } + let _ = std::fs::remove_dir_all(&dir); +} + +fn temp_dir(tag: &str) -> std::path::PathBuf { + let d = std::env::temp_dir().join(format!("cadcraft-autosave-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d +} + +#[test] +fn recovery_offers_files_of_apps_that_stopped() { + let dir = temp_dir("scan"); + let touch = |name: &str| std::fs::write(dir.join(name), b"0\nEOF\n").unwrap(); + touch("Crashed.dxf_1-a_3.sv$"); // no lock: the app went down + touch("Live.dxf_2-b_4.sv$"); // fresh lock: still running + std::fs::write(dir.join("2-b.lock"), b"2").unwrap(); + touch("Stale.dxf_3-c_5.sv$"); // lock not refreshed for 10 minutes + let lock = std::fs::File::create(dir.join("3-c.lock")).unwrap(); + lock.set_modified(SystemTime::now() - Duration::from_secs(600)).unwrap(); + drop(lock); + touch("Mine.dxf_9-f_1.sv$"); // this app's own + touch("notes.txt"); + std::fs::write(dir.join("Half.dxf_4-d_6.sv$.tmp"), b"").unwrap(); + + let found = autosave::recoverable(&dir, "9-f"); + let names: Vec<&str> = found.iter().map(|r| r.drawing.as_str()).collect(); + assert_eq!(names.len(), 2, "{found:?}"); + assert!(names.contains(&"Crashed.dxf") && names.contains(&"Stale.dxf")); + assert!(found.iter().all(|r| r.bytes == 6 && r.modified > 0)); + + // Discarding deletes the file and the dead app's lock once it has no files left. + let stale = found.iter().find(|r| r.drawing == "Stale.dxf").unwrap(); + autosave::discard(&stale.path); + assert!(!std::path::Path::new(&stale.path).exists()); + assert!(!dir.join("3-c.lock").exists()); + assert!(dir.join("2-b.lock").exists(), "a running app's lock stays"); + assert_eq!(autosave::recoverable(&dir, "9-f").len(), 1); + assert!(autosave::recoverable(&dir.join("missing"), "9-f").is_empty()); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn autosave_files_are_written_in_the_background_and_removed_on_exit() { + hooks(); + let dir = temp_dir("write"); + let mut s = Session::new(); + draw(&mut s); + let mut a = Autosaver::new(autosave::instance_id()); + a.plan(&s, 0.0, 1, &dir.to_string_lossy()); + let jobs = a.plan(&s, 60.0, 1, &dir.to_string_lossy()); + let path = writes(&jobs).pop().unwrap(); + a.perform(jobs); + for _ in 0..500 { + if std::path::Path::new(&path).exists() { + break; + } + std::thread::sleep(Duration::from_millis(10)); + } + assert!(std::fs::read(&path).unwrap().ends_with(b"EOF\n")); + // While the app runs, its lock keeps its files out of recovery. SAVETIME and SAVEFILEPATH + // are system variables (process-wide, so set in this test only). + assert_eq!(cadcraft_engine::sysvars::get(&s, "SAVETIME"), Some(json!(10))); + s.execute("setvar", &json!({"name": "SAVETIME", "value": 3})).unwrap(); + assert_eq!(autosave::savetime(), 3); + assert!(s.execute("setvar", &json!({"name": "SAVETIME", "value": -1})).is_err()); + assert!(s.execute("setvar", &json!({"name": "SAVETIME", "value": 601})).is_err()); + s.execute("setvar", &json!({"name": "SAVEFILEPATH", "value": format!(" {} ", dir.display())})).unwrap(); + assert_eq!(cadcraft_engine::sysvars::get(&s, "SAVEFILEPATH"), Some(json!(dir.to_string_lossy()))); + a.tick(&s); + assert!(dir.join(format!("{}.lock", a.instance)).exists()); + assert!(autosave::recoverable(&dir, "someone-else").is_empty()); + + a.shutdown(); + assert!(!std::path::Path::new(&path).exists()); + assert!(!dir.join(format!("{}.lock", a.instance)).exists()); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn recover_as_copy_opens_an_unsaved_untitled_drawing() { + hooks(); + let mut s = Session::new(); + let dir = temp_dir("copy"); + let file = dir.join("Plan.dxf_1-a_2.sv$"); + std::fs::write(&file, b"0\nEOF\n").unwrap(); + let r = s.execute("recover", &json!({"path": file.to_string_lossy(), "copy": true, "title": "Plan.dxf"})).unwrap(); + let st = s.state().unwrap(); + assert_eq!((st.title.as_str(), st.path.as_deref()), ("Plan.dxf", None)); + assert!(st.is_dirty(), "a recovered copy has unsaved changes"); + assert_eq!(r["uid"], st.uid); + let _ = std::fs::remove_dir_all(&dir); +} + +/// File hooks for every test in this file (the first call installs them). +fn hooks() { + set_io(IoHooks { + read: |_, _| Ok(cadcraft_engine::doc::Drawing::new_metric()), + write: |_, _| Ok(b"0\nSECTION\n0\nENDSEC\n0\nEOF\n".to_vec()), + plot: None, + }); +} diff --git a/crates/engine/tests/drawing_recovery_command.rs b/crates/engine/tests/drawing_recovery_command.rs new file mode 100644 index 0000000..1ed91b0 --- /dev/null +++ b/crates/engine/tests/drawing_recovery_command.rs @@ -0,0 +1,41 @@ +//! DRAWINGRECOVERY: list, open and delete the autosave files a stopped app left behind, and never +//! touch a file that isn't one of them. Its own test binary: SAVEFILEPATH is process-wide. + +#![cfg(not(target_arch = "wasm32"))] + +use cadcraft_engine::Session; +use cadcraft_engine::autosave; +use serde_json::json; + +#[test] +fn lists_and_discards_only_recoverable_autosave_files() { + let dir = std::env::temp_dir().join(format!("cadcraft-drawingrecovery-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + autosave::set_savefilepath(&dir.to_string_lossy()); + + // A file from an app that stopped (no lock file), and one that isn't an autosave file. + let left = dir.join(autosave::file_name("Bracket", "123-456", 7)); + std::fs::write(&left, b"0\nEOF\n").unwrap(); + let other = dir.join("notes.txt"); + std::fs::write(&other, b"keep me").unwrap(); + + let mut s = Session::new(); + let r = s.execute("drawingrecovery", &json!({})).unwrap(); + let files = r["files"].as_array().unwrap(); + assert_eq!(files.len(), 1); + assert_eq!(files[0]["drawing"], "Bracket"); + + // Unlisted paths are refused, whatever they are. + for path in [other.to_string_lossy().to_string(), "/etc/passwd".to_string()] { + assert!(s.execute("drawingrecovery", &json!({ "action": "discard", "path": path })).is_err()); + assert!(s.execute("drawingrecovery", &json!({ "action": "open", "path": path })).is_err()); + } + assert!(other.exists()); + assert!(s.execute("drawingrecovery", &json!({ "action": "frobnicate" })).is_err()); + + let r = s.execute("drawingrecovery", &json!({ "action": "discard", "path": left.to_string_lossy() })).unwrap(); + assert_eq!(r["files"].as_array().map(Vec::len), Some(0)); + assert!(!left.exists()); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/ui-egui/src/autosave.rs b/crates/ui-egui/src/autosave.rs new file mode 100644 index 0000000..53f7f02 --- /dev/null +++ b/crates/ui-egui/src/autosave.rs @@ -0,0 +1,174 @@ +//! Autosave and Drawing Recovery in the app. The engine decides what to write and writes it +//! ([`cadcraft_engine::autosave`]); this runs it every frame, keeps SAVETIME and SAVEFILEPATH with +//! the preferences, and shows the Drawing Recovery list after a crash: each autosaved drawing can +//! be opened (as an unsaved copy) or discarded. On the web there is no autosave yet. + +use cadcraft_engine::autosave::{self, Recoverable}; +use egui::{RichText, vec2}; +use serde_json::{Value, json}; + +use crate::CadApp; +use crate::theme::Tokens; + +/// The host storage key for [`prefs_json`]. +pub const PREFS_KEY: &str = "cadcraft.autosave"; + +/// SAVETIME and SAVEFILEPATH as JSON for the host's storage. +pub fn prefs_json() -> String { + json!({ "savetime": autosave::savetime(), "saveFilePath": autosave::savefilepath() }).to_string() +} + +/// Restore what [`prefs_json`] saved; malformed values are ignored. +pub fn load_prefs(json: &str) { + let Ok(v) = serde_json::from_str::(json) else { return }; + if let Some(m) = v.get("savetime").and_then(Value::as_u64) { + autosave::set_savetime(u32::try_from(m).unwrap_or(autosave::MAX_SAVETIME)); + } + if let Some(p) = v.get("saveFilePath").and_then(Value::as_str) { + autosave::set_savefilepath(p); + } +} + +/// Drawing Recovery: autosave files left behind by an app that stopped, listed once at start. +#[derive(Clone, Debug, Default)] +pub struct Recovery { + pub items: Vec, +} + +impl CadApp { + /// Turn autosave on (desktop). Files go to SAVEFILEPATH, or to `default_dir` when it isn't + /// set; files a crashed app left there are listed for Drawing Recovery. + #[cfg(not(target_arch = "wasm32"))] + pub fn enable_autosave(&mut self, default_dir: &std::path::Path) { + if autosave::savefilepath().is_empty() { + autosave::set_savefilepath(&default_dir.to_string_lossy()); + } + let saver = autosave::Autosaver::new(autosave::instance_id()); + let dir = std::path::PathBuf::from(autosave::savefilepath()); + autosave::prune_locks(&dir); + self.recovery.items = autosave::recoverable(&dir, &saver.instance); + self.autosave = Some(saver); + } + + /// Open recovery item `i` as an unsaved copy. Its autosave file stays until that drawing is + /// saved or closed, so a second crash doesn't lose it. + pub fn recover_item(&mut self, i: usize) -> Result<(), String> { + let item = self.recovery.items.get(i).cloned().ok_or("no such recovery file")?; + let r = self.run("recover", json!({ "path": item.path, "copy": true, "title": item.drawing }))?; + self.recovery.items.remove(i); + if let (Some(saver), Some(uid)) = (&mut self.autosave, r.get("uid").and_then(Value::as_u64)) { + saver.adopt(&self.session, uid, &item.path); + } + self.ui.start_tab = false; + self.canvas.zoom_pending = true; + Ok(()) + } + + /// Delete recovery item `i`'s file. + pub fn discard_item(&mut self, i: usize) { + if i < self.recovery.items.len() { + let _item = self.recovery.items.remove(i); + #[cfg(not(target_arch = "wasm32"))] + autosave::discard(&_item.path); + } + } + + /// Every frame: autosave what is due, and wake up when the next one is. + pub fn autosave_tick(&mut self, _ctx: &egui::Context) { + #[cfg(not(target_arch = "wasm32"))] + if let Some(wait) = self.autosave.as_mut().and_then(|s| s.tick(&self.session)) { + _ctx.request_repaint_after(wait); + } + } + + /// The app closes normally: this app's autosave files are not needed any more. + pub fn shutdown_autosave(&mut self) { + #[cfg(not(target_arch = "wasm32"))] + if let Some(s) = &mut self.autosave { + s.shutdown(); + } + } +} + +/// `secs` since 1970 as `YYYY-MM-DD HH:MM` (UTC). +fn utc(secs: u64) -> String { + let days = (secs / 86_400) as i64; + let (h, m) = ((secs % 86_400) / 3600, (secs % 3600) / 60); + // Civil date from days since 1970-01-01 (Howard Hinnant's algorithm). + let z = days + 719_468; + let era = z.div_euclid(146_097); + let doe = z - era * 146_097; + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = doy - (153 * mp + 2) / 5 + 1; + let mo = if mp < 10 { mp + 3 } else { mp - 9 }; + let y = yoe + era * 400 + i64::from(mo <= 2); + format!("{y:04}-{mo:02}-{d:02} {h:02}:{m:02}") +} + +/// The Drawing Recovery window, while autosave files from a crash are waiting. +pub fn show(app: &mut CadApp, ctx: &egui::Context) { + if app.recovery.items.is_empty() { + return; + } + let t = Tokens::get(); + let mut open = None; + let mut discard = None; + let mut later = false; + egui::Modal::new(egui::Id::new("cc_drawing_recovery")).show(ctx, |ui| { + ui.set_width(460.0); + ui.label(RichText::new("Drawing Recovery").strong()); + ui.add_space(4.0); + ui.label("CADCraft did not close normally. These drawings were autosaved:"); + ui.add_space(6.0); + egui::Grid::new("cc_recovery_list").num_columns(4).spacing(vec2(12.0, 6.0)).show(ui, |ui| { + for (i, item) in app.recovery.items.iter().enumerate() { + ui.label(&item.drawing); + ui.label(RichText::new(format!("{} UTC · {} KB", utc(item.modified), item.bytes.div_ceil(1024))).color(t.text_dim)); + if ui.button("Open").clicked() { + open = Some(i); + } + if ui.button("Discard").clicked() { + discard = Some(i); + } + ui.end_row(); + } + }); + ui.add_space(6.0); + ui.label(RichText::new("Opened drawings have no file name: save them to keep them.").color(t.text_dim)); + ui.add_space(8.0); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.button("Later").on_hover_text("Keep the files and ask again next time").clicked() { + later = true; + } + }); + if ui.input(|i| i.key_pressed(egui::Key::Escape)) { + later = true; + } + }); + if let Some(i) = open + && let Err(e) = app.recover_item(i) + { + app.set_status(e); + } + if let Some(i) = discard { + app.discard_item(i); + } + if later { + app.recovery.items.clear(); + } + if open.is_some() || discard.is_some() || later { + ctx.request_repaint(); + } +} + +#[cfg(test)] +mod tests { + #[test] + fn utc_dates() { + assert_eq!(super::utc(0), "1970-01-01 00:00"); + assert_eq!(super::utc(951_782_400 + 3_660), "2000-02-29 01:01"); + assert_eq!(super::utc(1_791_676_800), "2026-10-11 00:00"); + } +} diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index d7365ca..2e06232 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -996,3 +996,4 @@ Import... Імпорт... Export... Експорт... Batch Publish... Пакетна публікація... + Drawing Recovery Manager... Диспетчер відновлення креслень... diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index ac0695e..bf71657 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -8,6 +8,7 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] #![forbid(unsafe_code)] +pub mod autosave; pub mod canvas; pub mod chrome; pub mod clipboard; @@ -184,6 +185,10 @@ pub struct CadApp { pub quit_confirmed: bool, /// A close request waiting for "Save changes?" answers ([`closing`]). pub closing: Option, + /// Autosave (desktop; `None` until [`Self::enable_autosave`]). + pub autosave: Option, + /// Drawing Recovery: autosave files a crashed app left behind. + pub recovery: autosave::Recovery, } impl CadApp { @@ -211,6 +216,8 @@ impl CadApp { quit_requested: false, quit_confirmed: false, closing: None, + autosave: None, + recovery: autosave::Recovery::default(), } } @@ -383,6 +390,7 @@ impl CadApp { } self.collect_screenshots(ctx); self.issue_screenshots(ctx); + self.autosave_tick(ctx); menus::shortcuts(self, ctx); #[cfg(not(target_arch = "wasm32"))] for f in ctx.input(|i| i.raw.dropped_files.clone()) { @@ -442,6 +450,7 @@ impl CadApp { }); dialogs::show(self, ui.ctx()); closing::prompt(self, ui.ctx()); + autosave::show(self, ui.ctx()); self.frame_ms = now_ms() - t0; } diff --git a/crates/ui-egui/tests/drawing_recovery.rs b/crates/ui-egui/tests/drawing_recovery.rs new file mode 100644 index 0000000..a4c35d6 --- /dev/null +++ b/crates/ui-egui/tests/drawing_recovery.rs @@ -0,0 +1,68 @@ +//! Drawing Recovery after a crash (#406): autosave files a stopped app left behind are listed; +//! Open makes an unsaved copy whose file stays until it is saved, Discard deletes the file. +//! SAVETIME and SAVEFILEPATH are process-wide, so everything runs in one test. + +use cadcraft_engine::Session; +use cadcraft_engine::autosave; +use cadcraft_engine::cmd::file::{IoHooks, set_io}; +use cadcraft_engine::doc::Drawing; +use cadcraft_ui_egui::{CadApp, Services}; + +#[test] +fn recovery_list_opens_copies_and_discards_files() { + set_io(IoHooks { read: |_, _| Ok(Drawing::new_metric()), write: |_, _| Ok(b"0\nEOF\n".to_vec()), plot: None }); + let dir = std::env::temp_dir().join(format!("cadcraft-recovery-ui-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let plan = dir.join("Plan.dxf_1-a_3.sv$"); + let site = dir.join("Site.dwg_1-a_4.sv$"); + for f in [&plan, &site] { + std::fs::write(f, b"0\nEOF\n").unwrap(); + } + // A copy of the app that is still running keeps its lock fresh: its files are not offered. + std::fs::write(dir.join("Other.dxf_2-b_1.sv$"), b"0\nEOF\n").unwrap(); + std::fs::write(dir.join("2-b.lock"), b"2").unwrap(); + + let mut app = CadApp::new(Session::empty(), Services::default()); + app.enable_autosave(&dir); + assert_eq!(autosave::savefilepath(), dir.to_string_lossy()); + let names: Vec<_> = app.recovery.items.iter().map(|r| r.drawing.clone()).collect(); + assert_eq!(names.len(), 2, "{names:?}"); + + let i = names.iter().position(|n| n == "Plan.dxf").unwrap(); + app.recover_item(i).unwrap(); + assert_eq!(app.recovery.items.len(), 1); + let st = app.session.state().unwrap(); + assert_eq!((st.title.as_str(), st.path.as_deref()), ("Plan.dxf", None)); + assert!(st.is_dirty(), "a recovered drawing is an unsaved copy"); + assert!(plan.exists(), "the file stays until the recovered drawing is saved"); + assert!(app.autosave.as_ref().unwrap().files().contains(&plan.to_string_lossy().to_string())); + + app.discard_item(0); + assert!(app.recovery.items.is_empty()); + assert!(!site.exists(), "Discard deletes the file"); + assert!(app.recover_item(0).is_err()); + + // Saving the recovered drawing (no unsaved changes left) deletes its file. + let st = app.session.state_mut().unwrap(); + st.saved = st.doc.clone(); + app.autosave.as_mut().unwrap().tick(&app.session); + assert!(!plan.exists()); + + // Exiting normally removes this app's files; the running copy's file stays. + app.shutdown_autosave(); + assert!(dir.join("Other.dxf_2-b_1.sv$").exists()); + let _ = std::fs::remove_dir_all(&dir); + + // SAVETIME and SAVEFILEPATH survive restarts (process-wide, so checked in this one test). + cadcraft_ui_egui::autosave::load_prefs(r#"{"savetime": 25, "saveFilePath": "/tmp/cc-autosave-prefs"}"#); + assert_eq!(autosave::savetime(), 25); + let saved = cadcraft_ui_egui::autosave::prefs_json(); + cadcraft_ui_egui::autosave::load_prefs("not json"); + cadcraft_ui_egui::autosave::load_prefs(r#"{"savetime": "often"}"#); + assert_eq!(autosave::savetime(), 25, "bad values are ignored"); + cadcraft_ui_egui::autosave::load_prefs(r#"{"savetime": 0}"#); + assert_eq!(autosave::savetime(), 0); + cadcraft_ui_egui::autosave::load_prefs(&saved); + assert_eq!((autosave::savetime(), autosave::savefilepath().as_str()), (25, "/tmp/cc-autosave-prefs")); +} diff --git a/docs/gaps.md b/docs/gaps.md index 72bc5b2..1355e35 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -20,7 +20,7 @@ do them first. | 5 | **[alpha blocker]** **Stubbed prompt options** left in PEDIT Edit vertex, SPLINEDIT and MTEXT columns | Scripts and muscle memory hit "not available yet" (the most-used commands are done) | 3–6 | [features](#features) | | 6 | **Ellipse and spline geometry is tessellated** (intersections, offsets, closest points; polyline offset ignores bulges) | Coordinates are subtly wrong; offsets of ellipses come out as polylines | 30–50 | [geometry](geometry-parity.md) | | 7 | **[alpha blocker]** **No xrefs, block editor, dynamic blocks or groups** | Most production drawings use them | 80–120 | [features](#features) | -| 8 | **No autosave, crash recovery, AUDIT or RECOVER** | Work is lost if anything goes wrong; damaged files can't be repaired | 10–15 | [features](#features) | +| 8 | **No autosave in the web build** (the desktop app has SAVETIME autosave, Drawing Recovery, AUDIT and RECOVER since #406) | Browser users lose unsaved work when the tab or the browser goes down | 3–5 | [features](#features) | | 9 | **Dynamic input lacks grip input; no TK; grip options ignored** (FROM, M2P/MTP, point filters, `*` and `