diff --git a/ROADMAP.md b/ROADMAP.md index 6ff525c9..497a94ef 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -52,6 +52,7 @@ headline counts in-scope tabs only and lists the deferred ones separately. | M10 | Sheet metal: flange (base, edge, contour), hem, unfold/refold, convert, flat pattern + DXF, rules: done (oracle 67-76). PLASTIC enclosure subset: Boss, Rib, Web, Lip/Groove, Snap Fit, Rest, Plastic Rules (library, edit, assign; rule draft and clearance as feature defaults): done, no Fusion oracle (licence), tested against hand-computed geometry. UI: SHEET METAL and PLASTIC tabs in Fusion's layout with dialogs (live preview, Edit Feature, pre-selection) for Flange, Hem, Unfold/Refold, Convert, Sheet Metal Rules, Create Flat Pattern (flat view with bend lines, bend table, DXF export), Boss, Lip/Groove, Snap Fit, Rest, Manage and Assign Plastic Rules; Bend (Fold) has no engine command yet | in progress | 36 | | M11 | 2D drawings | deferred | — | | M12 | Release: installers, signing, docs, performance | planned | 16 | +| M13 | Never lose work: atomic saves (temporary file, flush, rename; a crash at any moment leaves the old file or the new one) with the replaced version kept as `.bak`; autosave of unsaved changes to the recovery folder every 5 minutes (preference) and after big operations, only when something changed; "Recover unsaved design?" on the next launch (`doc.recovery_list`, `doc.recover`, `doc.recovery_discard`); a kill-mid-save test (process killed while saving a 0.5 MB design 25 times). Next: file format version and migrations, old-design corpus, fuzzed design files | in progress | 6 | ## Known limitations diff --git a/apps/solvecraft-cli/src/main.rs b/apps/solvecraft-cli/src/main.rs index f964ccc5..2be465ba 100644 --- a/apps/solvecraft-cli/src/main.rs +++ b/apps/solvecraft-cli/src/main.rs @@ -55,6 +55,8 @@ fn main() -> ExitCode { Some("oracle") => oracle::run(&args[1..]), Some("step-corpus") => oracle::step_corpus(&args[1..]), Some("mcp") => cmd_mcp(&args[1..]), + // Hidden, for the kill-mid-save test: save a large design over and over. + Some("save-stress") => cmd_save_stress(&args[1..]), Some("--version" | "-V") => { println!("solvecraft-cli {}", env!("CARGO_PKG_VERSION")); Ok(()) @@ -217,3 +219,25 @@ fn cmd_mcp(args: &[String]) -> Result<(), String> { let stdout = std::io::stdout(); solvecraft_mcp::Server::new(backend).serve(stdin.lock(), stdout.lock()).map_err(|e| e.to_string()) } + +/// `save-stress `: save a design of a few hundred kilobytes `rounds` times, its +/// parameter `save_round` set to the round number each time. +fn cmd_save_stress(args: &[String]) -> Result<(), String> { + let (Some(path), Some(rounds)) = (args.first(), args.get(1).and_then(|n| n.parse::().ok())) else { + return Err("usage: solvecraft-cli save-stress ".into()); + }; + let mut s = Session::default(); + let filler = (0..3000).map(|i| solvecraft_engine::doc::Param { + name: format!("p{i}"), + expr: format!("{i} mm + 0.5 mm"), + unit: "mm".into(), + comment: "filler to make the file large".into(), + model: false, + }); + s.doc_mut().params.extend(filler); + for r in 0..rounds { + s.doc_mut().set_param("save_round", &r.to_string(), None, None).map_err(|e| e.to_string())?; + s.execute("SaveDocumentCommand", &json!({ "path": path })).map_err(|e| e.to_string())?; + } + Ok(()) +} diff --git a/apps/solvecraft-cli/tests/kill_mid_save.rs b/apps/solvecraft-cli/tests/kill_mid_save.rs new file mode 100644 index 00000000..8cc4dc7a --- /dev/null +++ b/apps/solvecraft-cli/tests/kill_mid_save.rs @@ -0,0 +1,45 @@ +//! Kill the process while it saves: the design file is always the old version or the new one, +//! whole, and opens. + +use std::process::Command; +use std::time::Duration; + +fn round_of(path: &std::path::Path) -> Option { + let bytes = std::fs::read(path).ok()?; + let doc = solvecraft_engine::io::read_design(&bytes).ok()?; + doc.params.iter().find(|p| p.name == "save_round").map(|p| p.expr.clone()) +} + +#[test] +fn killed_mid_save_the_old_file_survives() { + let exe = env!("CARGO_BIN_EXE_solvecraft-cli"); + let dir = std::env::temp_dir().join(format!("solvecraft-kill-mid-save-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("design.solvecraft"); + let ok = Command::new(exe).args(["save-stress", &path.to_string_lossy(), "1"]).status().unwrap(); + assert!(ok.success()); + assert_eq!(round_of(&path).as_deref(), Some("0")); + let mut kills = 0; + let mut seen = std::collections::BTreeSet::new(); + for i in 0..25u64 { + let mut child = Command::new(exe).args(["save-stress", &path.to_string_lossy(), "100000"]).spawn().unwrap(); + // Let it get into its save loop (building the design takes a moment), then kill it. + std::thread::sleep(Duration::from_millis(150 + (i * 37) % 200)); + if child.try_wait().unwrap().is_none() { + child.kill().unwrap(); + kills += 1; + } + let _ = child.wait(); + let round = round_of(&path); + assert!(round.is_some(), "after kill {i} the design file is torn or missing"); + seen.insert(round); + let bak = solvecraft_engine::io::backup_path(&path); + if bak.exists() { + assert!(round_of(&bak).is_some(), "after kill {i} the backup is torn"); + } + } + assert!(kills > 20, "the saver was killed while running ({kills})"); + assert!(seen.len() > 5, "the saver got to saving before it was killed ({seen:?})"); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/apps/solvecraft/src/main.rs b/apps/solvecraft/src/main.rs index 721d0f95..8a7f8973 100644 --- a/apps/solvecraft/src/main.rs +++ b/apps/solvecraft/src/main.rs @@ -35,6 +35,9 @@ impl eframe::App for App { fn save(&mut self, storage: &mut dyn eframe::Storage) { storage.set_string(PREFS_KEY, self.0.prefs()); } + fn on_exit(&mut self) { + solvecraft_ui_egui::recovery_ui::close(&mut self.0); + } } /// Where the preferences (theme) are kept in eframe's storage. @@ -134,6 +137,11 @@ fn main() -> eframe::Result { let rev = app.session.revision; app.home.show(rev); } + // Autosave, and offer what crashed sessions left behind. + if let Some(dir) = solvecraft_engine::recovery::default_dir() { + let every = if app.autosave_minutes > 0.0 { app.autosave_minutes * 60.0 } else { f64::MAX / 4.0 }; + solvecraft_ui_egui::recovery_ui::start(&mut app, &dir, std::time::Duration::from_secs_f64(every.min(1e9))); + } Ok(Box::new(App(app))) }), ) diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index f4b1f1ff..d991a280 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -25,6 +25,13 @@ pub static COMMANDS: &[CommandSpec] = &[ .params("path: .3mf or .stl file (its meshes join the design as mesh bodies); name?"), CommandSpec::new("SaveDocumentCommand", "Save", save).icon("save").key("Ctrl+S").noundo().params("path? (default: current file)"), CommandSpec::new("SaveDocumentAsCommand", "Save As", save_as).icon("save").noundo().params("path"), + CommandSpec::new("doc.recovery_list", "Recoverable Designs", recovery_list) + .noundo() + .params("dir? (default: the recovery folder) → designs autosaved by apps that crashed or closed with unsaved changes"), + CommandSpec::new("doc.recover", "Recover Design", recover) + .noundo() + .params("id (from doc.recovery_list); dir?; discard?: bool (delete the entry once open) — opens it with its file path, unsaved"), + CommandSpec::new("doc.recovery_discard", "Discard Recovered Design", recovery_discard).noundo().params("id, or all: true; dir?"), CommandSpec::new("ExportCommand", "Export", export) .icon("export") .noundo() @@ -139,7 +146,8 @@ fn open(s: &mut Session, p: &Value) -> Result { fn write(s: &mut Session, path: &str) -> Result { let bytes = solvecraft_io::write_design(&s.doc); - std::fs::write(path, &bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + // Atomic, keeping the version being replaced as `.bak`. + solvecraft_io::write_atomic(std::path::Path::new(path), &bytes, true).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; s.path = Some(path.to_string()); s.mark_saved(); Ok(json!({"path": path, "bytes": bytes.len()})) @@ -182,3 +190,44 @@ fn save_stl(s: &mut Session, p: &Value) -> Result { let f = if bool_(p, "ascii").unwrap_or(false) { Format::StlAscii } else { Format::StlBinary }; export_to(s, path, f, &string_list(p, "bodies")) } + +fn recovery_dir(p: &Value, cmd: &str) -> Result { + match str_(p, "dir").filter(|d| !d.trim().is_empty() && d.len() < 4096) { + Some(d) => Ok(d.into()), + None => crate::recovery::default_dir().ok_or_else(|| bad(cmd, "no recovery folder on this system (give `dir`)")), + } +} + +fn recovery_list(_s: &mut Session, p: &Value) -> Result { + let dir = recovery_dir(p, "doc.recovery_list")?; + Ok(json!({"dir": dir, "designs": crate::recovery::orphans(&dir)})) +} + +fn recover(s: &mut Session, p: &Value) -> Result { + let cmd = "doc.recover"; + let dir = recovery_dir(p, cmd)?; + let id = str_(p, "id").ok_or_else(|| bad(cmd, "`id` is required (see doc.recovery_list)"))?; + let (doc, entry) = crate::recovery::load(&dir, id)?; + *s = Session::new(doc); + s.path = entry.path.clone(); + s.mark_unsaved(); + if bool_(p, "discard").unwrap_or(false) { + crate::recovery::remove(&dir, id); + } + let errors = s.model.results.iter().filter(|r| r.error.is_some()).count(); + Ok(json!({"id": id, "path": entry.path, "name": entry.name, "features": s.doc.features.len(), "errors": errors})) +} + +fn recovery_discard(_s: &mut Session, p: &Value) -> Result { + let cmd = "doc.recovery_discard"; + let dir = recovery_dir(p, cmd)?; + let ids: Vec = if bool_(p, "all").unwrap_or(false) { + crate::recovery::orphans(&dir).into_iter().map(|e| e.id).collect() + } else { + vec![str_(p, "id").ok_or_else(|| bad(cmd, "`id` or `all` is required"))?.to_string()] + }; + for id in &ids { + crate::recovery::remove(&dir, id); + } + Ok(json!({"discarded": ids})) +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 85373f83..c633cbad 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -10,6 +10,7 @@ pub mod cmd; pub mod params; +pub mod recovery; pub mod sample; pub mod view; @@ -224,6 +225,10 @@ impl Session { pub fn mark_saved(&mut self) { self.saved = self.doc.clone(); } + /// The design has changes not in its file (a recovered design). + pub fn mark_unsaved(&mut self) { + self.saved = Arc::new(Document::new("")); + } pub fn echo(&mut self, s: impl Into) { self.log.push(s.into()); diff --git a/crates/engine/src/recovery.rs b/crates/engine/src/recovery.rs new file mode 100644 index 00000000..b4495cd1 --- /dev/null +++ b/crates/engine/src/recovery.rs @@ -0,0 +1,248 @@ +//! Autosave and crash recovery. +//! +//! A running app keeps one recovery entry in the recovery folder: `.solvecraft` (the design +//! as of the last autosave), `.json` (its file path, name and time) and `.lock`, which +//! the app holds locked while it runs. Autosaves are written atomically, only when the design +//! has unsaved changes it hasn't written yet; a design with nothing unsaved has no entry. An +//! entry whose lock nobody holds belongs to an app that crashed (or closed with unsaved changes): +//! the next launch offers to recover it. + +use std::fs::{self, File, OpenOptions}; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +use serde::{Deserialize, Serialize}; +use solvecraft_doc::Document; + +use crate::{EngineError, Result, Session}; + +/// The recovery folder: `SOLVECRAFT_RECOVERY_DIR`, else the platform's per-user data folder. +pub fn default_dir() -> Option { + // Tests never touch the user's real recovery folder. + if cfg!(test) { + return Some(std::env::temp_dir().join(format!("solvecraft-test-recovery-{}", std::process::id()))); + } + if let Some(d) = std::env::var_os("SOLVECRAFT_RECOVERY_DIR").filter(|d| !d.is_empty()) { + return Some(PathBuf::from(d)); + } + let env = |k: &str| std::env::var_os(k).filter(|d| !d.is_empty()).map(PathBuf::from); + let base = if cfg!(windows) { + env("LOCALAPPDATA")?.join("SolveCraft") + } else if cfg!(target_os = "macos") { + env("HOME")?.join("Library/Application Support/SolveCraft") + } else { + env("XDG_DATA_HOME").or_else(|| env("HOME").map(|h| h.join(".local/share")))?.join("solvecraft") + }; + Some(base.join("recovery")) +} + +/// What an entry's `.json` holds. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Entry { + pub id: String, + /// The design's file, if it had one. + pub path: Option, + pub name: String, + /// When it was autosaved (seconds since 1970). + pub saved_at: u64, + pub features: usize, +} + +fn unix_now() -> u64 { + SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0) +} + +fn valid_id(id: &str) -> bool { + !id.is_empty() && id.len() <= 64 && id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') +} + +fn file(dir: &Path, id: &str, ext: &str) -> PathBuf { + dir.join(format!("{id}.{ext}")) +} + +/// Entries left by apps that are no longer running, newest first. +pub fn orphans(dir: &Path) -> Vec { + let mut out: Vec = fs::read_dir(dir) + .into_iter() + .flatten() + .flatten() + .filter_map(|e| { + let p = e.path(); + if p.extension().and_then(|x| x.to_str()) != Some("json") { + return None; + } + let entry: Entry = serde_json::from_slice(&fs::read(&p).ok()?).ok()?; + if !valid_id(&entry.id) || !file(dir, &entry.id, "solvecraft").is_file() { + return None; + } + // Held by a running app: not an orphan. + let lock = File::open(file(dir, &entry.id, "lock")); + if let Ok(l) = &lock + && l.try_lock().is_err() + { + return None; + } + Some(entry) + }) + .take(1000) + .collect(); + out.sort_by_key(|e| std::cmp::Reverse(e.saved_at)); + out +} + +/// Read an entry's design. +pub fn load(dir: &Path, id: &str) -> Result<(Document, Entry)> { + if !valid_id(id) { + return Err(EngineError::Other(format!("no recovered design `{id}`"))); + } + let entry: Entry = fs::read(file(dir, id, "json")) + .ok() + .and_then(|b| serde_json::from_slice(&b).ok()) + .ok_or_else(|| EngineError::Other(format!("no recovered design `{id}`")))?; + let bytes = fs::read(file(dir, id, "solvecraft")).map_err(|e| EngineError::Other(format!("recovered design `{id}`: {e}")))?; + Ok((solvecraft_io::read_design(&bytes)?, entry)) +} + +/// Delete an entry. +pub fn remove(dir: &Path, id: &str) { + if valid_id(id) { + for ext in ["json", "solvecraft", "lock"] { + let _ = fs::remove_file(file(dir, id, ext)); + } + } +} + +/// One app's autosaver. +pub struct Autosaver { + pub dir: PathBuf, + pub id: String, + /// Autosave this often while there are unsaved changes. + pub interval: Duration, + lock: Option, + last: Option>, + last_at: Instant, + has_entry: bool, +} + +impl Autosaver { + /// Start autosaving into `dir` (created if needed), holding this app's lock. + pub fn new(dir: &Path, interval: Duration) -> Result { + let io = |e: std::io::Error| EngineError::Other(format!("{}: {e}", dir.display())); + fs::create_dir_all(dir).map_err(io)?; + // Locks of apps that ended with nothing to recover. + for e in fs::read_dir(dir).into_iter().flatten().flatten() { + let p = e.path(); + if p.extension().and_then(|x| x.to_str()) == Some("lock") + && !p.with_extension("json").exists() + && File::open(&p).is_ok_and(|l| l.try_lock().is_ok()) + { + let _ = fs::remove_file(&p); + } + } + let id = format!("{}-{}", unix_now(), std::process::id()); + let lock = OpenOptions::new().create(true).truncate(false).write(true).open(file(dir, &id, "lock")).map_err(io)?; + lock.lock().map_err(io)?; + Ok(Autosaver { dir: dir.to_path_buf(), id, interval, lock: Some(lock), last: None, last_at: Instant::now(), has_entry: false }) + } + + /// Autosave now if the design has unsaved changes not written yet (and drop the entry when + /// nothing is unsaved). Returns whether it wrote. + pub fn save(&mut self, s: &Session) -> Result { + self.last_at = Instant::now(); + if !s.is_dirty() { + if self.has_entry { + for ext in ["json", "solvecraft"] { + let _ = fs::remove_file(file(&self.dir, &self.id, ext)); + } + self.has_entry = false; + } + self.last = None; + return Ok(false); + } + if self.last.as_ref().is_some_and(|l| Arc::ptr_eq(l, &s.doc) || **l == *s.doc) { + return Ok(false); + } + let io = |e: std::io::Error| EngineError::Other(format!("autosave: {e}")); + solvecraft_io::write_atomic(&file(&self.dir, &self.id, "solvecraft"), &solvecraft_io::write_design(&s.doc), false).map_err(io)?; + let entry = + Entry { id: self.id.clone(), path: s.path.clone(), name: s.doc.name.clone(), saved_at: unix_now(), features: s.doc.features.len() }; + let meta = serde_json::to_vec_pretty(&entry).map_err(|e| EngineError::Other(e.to_string()))?; + solvecraft_io::write_atomic(&file(&self.dir, &self.id, "json"), &meta, false).map_err(io)?; + self.last = Some(s.doc.clone()); + self.has_entry = true; + Ok(true) + } + + /// Autosave when the interval has passed, or right away after a big operation. + pub fn tick(&mut self, s: &Session, big: bool) -> Result { + if big || self.last_at.elapsed() >= self.interval { self.save(s) } else { Ok(false) } + } + + /// Closing the app: with nothing unsaved the entry goes; otherwise it stays for the next + /// launch to offer. The lock is released either way. + pub fn close(mut self, s: &Session) { + let _ = self.save(s); + if !self.has_entry { + let _ = fs::remove_file(file(&self.dir, &self.id, "lock")); + } + self.lock = None; + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn dir(tag: &str) -> PathBuf { + let d = std::env::temp_dir().join(format!("solvecraft-recovery-{tag}-{}", std::process::id())); + let _ = fs::remove_dir_all(&d); + d + } + + #[test] + fn autosave_only_unsaved_changes_and_recover_after_a_crash() { + let d = dir("crash"); + let mut s = Session::default(); + let mut a = Autosaver::new(&d, Duration::from_secs(3600)).unwrap(); + assert!(!a.save(&s).unwrap(), "nothing unsaved"); + s.execute("PrimitiveBox", &json!({"length": 10, "width": 10, "height": 10})).unwrap(); + assert!(!a.tick(&s, false).unwrap(), "not due yet"); + assert!(a.tick(&s, true).unwrap(), "after a big operation"); + assert!(!a.save(&s).unwrap(), "unchanged since the last autosave"); + // Our own entry is locked: not offered. + assert!(orphans(&d).is_empty()); + // The app dies without closing (the lock goes with the process). + drop(a); + let found = orphans(&d); + assert_eq!(found.len(), 1); + let (doc, entry) = load(&d, &found[0].id).unwrap(); + assert_eq!(doc, *s.doc); + assert_eq!(entry.features, 1); + remove(&d, &entry.id); + assert!(orphans(&d).is_empty()); + let _ = fs::remove_dir_all(&d); + } + + #[test] + fn saving_drops_the_entry_and_a_clean_close_leaves_nothing() { + let d = dir("clean"); + let mut s = Session::default(); + let mut a = Autosaver::new(&d, Duration::ZERO).unwrap(); + s.execute("PrimitiveBox", &json!({"length": 10, "width": 10, "height": 10})).unwrap(); + assert!(a.tick(&s, false).unwrap()); + s.mark_saved(); + a.tick(&s, false).unwrap(); + assert!(!file(&d, &a.id, "solvecraft").exists()); + a.close(&s); + assert_eq!(fs::read_dir(&d).unwrap().count(), 0); + // Closing with unsaved changes keeps them for the next launch. + let b = Autosaver::new(&d, Duration::ZERO).unwrap(); + s.execute("PrimitiveBox", &json!({"length": 5, "width": 5, "height": 5})).unwrap(); + b.close(&s); + assert_eq!(orphans(&d).len(), 1); + assert!(load(&d, "../etc").is_err()); + let _ = fs::remove_dir_all(&d); + } +} diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index e790bb7b..ab408005 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -6,11 +6,13 @@ #![forbid(unsafe_code)] mod dxf; +pub mod safe_write; mod sketch2d; mod svg; mod threemf; pub use dxf::{read_dxf, write_dxf}; +pub use safe_write::{backup_path, write_atomic}; pub use sketch2d::{Geom2, MAX_DRAWING_BYTES}; pub use svg::read_svg; pub use threemf::{MAX_3MF_BYTES, MeshObject, model_xml, read_3mf, weld, write_3mf}; diff --git a/crates/io/src/safe_write.rs b/crates/io/src/safe_write.rs new file mode 100644 index 00000000..61a31910 --- /dev/null +++ b/crates/io/src/safe_write.rs @@ -0,0 +1,163 @@ +//! Crash-safe file writes: the new content goes to a temporary file next to the target, is +//! flushed to disk, and replaces the target in one rename. A crash or power cut at any moment +//! leaves the old file or the new one, never a torn mix. The previous version can be kept as +//! `.bak` (itself written the same way). + +use std::fs::{self, File, OpenOptions}; +use std::io::Write; +use std::path::{Path, PathBuf}; + +/// The backup of a file written with `backup: true`. +pub fn backup_path(path: &Path) -> PathBuf { + let mut s = path.as_os_str().to_owned(); + s.push(".bak"); + PathBuf::from(s) +} + +fn temp_beside(path: &Path, tag: &str) -> PathBuf { + let name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "file".into()); + let nanos = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.subsec_nanos()).unwrap_or(0); + path.with_file_name(format!(".{name}.{tag}.{}.{nanos}.tmp", std::process::id())) +} + +fn write_synced(path: &Path, bytes: &[u8]) -> std::io::Result<()> { + let mut f = OpenOptions::new().write(true).create_new(true).open(path)?; + f.write_all(bytes)?; + f.sync_all() +} + +/// Flush the directory entry (the rename) to disk where the platform allows it. +fn sync_dir(path: &Path) { + #[cfg(unix)] + if let Some(dir) = path.parent().filter(|d| !d.as_os_str().is_empty()) + && let Ok(d) = File::open(dir) + { + let _ = d.sync_all(); + } + #[cfg(not(unix))] + let _ = path; +} + +/// Steps of a save, so tests can stop one part way (as a crash would). +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug)] +enum Step { + TempWritten, + BackupWritten, + #[allow(dead_code)] // stopping after the last step is a whole save (tests name it) + Replaced, +} + +fn write_steps(path: &Path, bytes: &[u8], backup: bool, stop: Option) -> std::io::Result<()> { + let tmp = temp_beside(path, "new"); + if let Err(e) = write_synced(&tmp, bytes) { + let _ = fs::remove_file(&tmp); + return Err(e); + } + if stop == Some(Step::TempWritten) { + return Ok(()); + } + if backup && path.is_file() { + // Copy (not move) the old file, so the target exists at every moment. + let old = fs::read(path); + let bak_tmp = temp_beside(path, "bak"); + let r = old.and_then(|old| write_synced(&bak_tmp, &old)).and_then(|_| fs::rename(&bak_tmp, backup_path(path))); + if let Err(e) = r { + let _ = fs::remove_file(&bak_tmp); + let _ = fs::remove_file(&tmp); + return Err(e); + } + } + if stop == Some(Step::BackupWritten) { + return Ok(()); + } + if let Err(e) = fs::rename(&tmp, path) { + let _ = fs::remove_file(&tmp); + return Err(e); + } + sync_dir(path); + // Temporary files of saves that crashed part way (a minute old or more) go. + for t in stale_temps(path) { + if fs::metadata(&t).and_then(|m| m.modified()).ok().and_then(|m| m.elapsed().ok()).is_some_and(|age| age.as_secs() >= 60) { + let _ = fs::remove_file(t); + } + } + Ok(()) +} + +/// Write `bytes` to `path` atomically; with `backup`, the file being replaced is kept as +/// `.bak` first. +pub fn write_atomic(path: &Path, bytes: &[u8], backup: bool) -> std::io::Result<()> { + write_steps(path, bytes, backup, None) +} + +/// Leftover temporary files of saves that never finished (a crash part way), next to `path`. +pub fn stale_temps(path: &Path) -> Vec { + let Some(name) = path.file_name().map(|n| n.to_string_lossy().to_string()) else { return Vec::new() }; + let dir = path.parent().filter(|d| !d.as_os_str().is_empty()).unwrap_or(Path::new(".")); + let prefix = format!(".{name}."); + fs::read_dir(dir) + .into_iter() + .flatten() + .flatten() + .map(|e| e.path()) + .filter(|p| p.file_name().map(|n| n.to_string_lossy().to_string()).is_some_and(|n| n.starts_with(&prefix) && n.ends_with(".tmp"))) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn dir(tag: &str) -> PathBuf { + let d = std::env::temp_dir().join(format!("solvecraft-safe-write-{tag}-{}", std::process::id())); + let _ = fs::remove_dir_all(&d); + fs::create_dir_all(&d).unwrap(); + d + } + + #[test] + fn replaces_and_keeps_a_backup() { + let d = dir("bak"); + let p = d.join("a.solvecraft"); + write_atomic(&p, b"one", true).unwrap(); + assert!(!backup_path(&p).exists(), "nothing to back up the first time"); + write_atomic(&p, b"two", true).unwrap(); + assert_eq!(fs::read(&p).unwrap(), b"two"); + assert_eq!(fs::read(backup_path(&p)).unwrap(), b"one"); + write_atomic(&p, b"three", false).unwrap(); + assert_eq!(fs::read(backup_path(&p)).unwrap(), b"one"); + assert!(stale_temps(&p).is_empty()); + let _ = fs::remove_dir_all(&d); + } + + /// A save stopped after any step leaves the old file whole (or the new one). + #[test] + fn a_save_stopped_part_way_keeps_the_old_file() { + let d = dir("stop"); + let p = d.join("a.solvecraft"); + write_atomic(&p, b"old", true).unwrap(); + for stop in [Step::TempWritten, Step::BackupWritten, Step::Replaced] { + write_steps(&p, b"new content", true, Some(stop)).unwrap(); + let now = fs::read(&p).unwrap(); + assert!(now == b"old" || now == b"new content", "{stop:?}: {now:?}"); + if stop < Step::Replaced { + assert_eq!(now, b"old"); + assert!(!stale_temps(&p).is_empty(), "the unfinished save's temporary file is left over"); + for t in stale_temps(&p) { + fs::remove_file(t).unwrap(); + } + } + write_atomic(&p, b"old", false).unwrap(); + } + let _ = fs::remove_dir_all(&d); + } + + #[test] + fn a_failed_write_leaves_the_target() { + let d = dir("fail"); + let p = d.join("missing-dir").join("a.solvecraft"); + assert!(write_atomic(&p, b"x", true).is_err()); + assert!(!p.exists()); + let _ = fs::remove_dir_all(&d); + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 2a8bb08d..ff45cda6 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -33,6 +33,7 @@ pub mod params_dialog; #[cfg(test)] mod preselect_tests; pub mod preview; +pub mod recovery_ui; pub mod ref_images; pub mod scenario; pub mod selection; @@ -163,6 +164,14 @@ pub struct SolveApp { pub esc_handled: bool, pub status: Option<(String, f64, bool)>, pub quit_requested: bool, + /// Autosave into the recovery folder (the desktop app turns it on). + pub autosave: Option, + /// Designs left by crashed apps, offered for recovery. + pub recoverable: Vec, + /// The last command was a big operation: autosave after it. + pub big_operation: bool, + /// Autosave interval (minutes; 0 = only after big operations). A preference. + pub autosave_minutes: f64, /// The application bar is the window's title bar (Windows, Linux: no OS decorations). pub custom_titlebar: bool, /// macOS: the traffic lights sit over the application bar's left end. @@ -206,6 +215,10 @@ impl SolveApp { esc_handled: false, status: None, quit_requested: false, + autosave: None, + recoverable: Vec::new(), + big_operation: false, + autosave_minutes: 5.0, custom_titlebar: false, integrated_titlebar: false, frame_ms: 0.0, @@ -241,6 +254,7 @@ impl SolveApp { "recent": self.home.recent, "shortcut_box": self.sbox.prefs(), "shortcuts": self.keymap.prefs(), + "autosave_minutes": self.autosave_minutes, }) .to_string() } @@ -264,6 +278,9 @@ impl SolveApp { if let Some(a) = flag("auto_project") { self.session.auto_project = a; } + if let Some(m) = v.get("autosave_minutes").and_then(Value::as_f64).filter(|m| m.is_finite()) { + self.autosave_minutes = m.clamp(0.0, 600.0); + } if let Some(s) = v.get("visual_style").and_then(Value::as_u64) { self.ui.visual_style = u8::try_from(s.min(2)).unwrap_or(0); } @@ -296,7 +313,9 @@ impl SolveApp { /// Run a command programmatically (JSON parameters, no dialogs). pub fn run(&mut self, id: &str, params: Value) -> Result { + let t0 = now_ms(); let r = self.session.execute(id, ¶ms).map_err(|e| e.to_string()); + self.big_operation |= now_ms() - t0 >= recovery_ui::BIG_OPERATION_MS; match &r { Ok(_) => { self.session.echo(format!("{id}: done")); @@ -604,6 +623,7 @@ impl SolveApp { self.collect_screenshots(ctx); self.issue_screenshots(ctx); toolbar::shortcuts(self, ctx); + recovery_ui::tick(self); for f in ctx.input(|i| i.raw.dropped_files.clone()) { let p = f.path().to_string_lossy().to_string(); if !p.is_empty() { @@ -663,6 +683,7 @@ impl SolveApp { dialogs::show(self, ui.ctx()); sketch_palette::show(self, ui.ctx()); params_dialog::show(self, ui.ctx()); + recovery_ui::show(self, ui.ctx()); context_menu::show(self, ui.ctx()); delete::show(self, ui.ctx()); if self.custom_titlebar { diff --git a/crates/ui-egui/src/recovery_ui.rs b/crates/ui-egui/src/recovery_ui.rs new file mode 100644 index 00000000..42a0b82b --- /dev/null +++ b/crates/ui-egui/src/recovery_ui.rs @@ -0,0 +1,112 @@ +//! Autosave ticks and the "Recover unsaved design?" window shown at launch. + +use egui::RichText; +use serde_json::json; +use solvecraft_engine::recovery::{self, Autosaver, Entry}; + +use crate::SolveApp; + +/// A command that ran at least this long counts as a big operation: autosave right after it. +pub const BIG_OPERATION_MS: f64 = 1500.0; + +/// Autosave (when due, or after a big operation). +pub fn tick(app: &mut SolveApp) { + let big = std::mem::take(&mut app.big_operation); + let Some(a) = app.autosave.as_mut() else { return }; + if let Err(e) = a.tick(&app.session, big) { + let msg = format!("autosave failed: {e}"); + if app.status.as_ref().is_none_or(|(s, _, _)| *s != msg) { + app.set_status(msg, true); + } + } +} + +/// Start autosaving into `dir` and queue the designs that crashed apps left there. +pub fn start(app: &mut SolveApp, dir: &std::path::Path, interval: std::time::Duration) { + match Autosaver::new(dir, interval) { + Ok(a) => { + app.recoverable = recovery::orphans(dir); + app.autosave = Some(a); + } + Err(e) => app.set_status(format!("autosave is off: {e}"), true), + } +} + +/// The app is closing: keep unsaved work for the next launch, drop everything else. +pub fn close(app: &mut SolveApp) { + if let Some(a) = app.autosave.take() { + a.close(&app.session); + } +} + +fn ago(secs: u64) -> String { + let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(secs); + let d = now.saturating_sub(secs); + match d { + 0..=59 => "just now".into(), + 60..=3599 => format!("{} min ago", d / 60), + 3600..=86_399 => format!("{} h ago", d / 3600), + _ => format!("{} days ago", d / 86_400), + } +} + +pub fn show(app: &mut SolveApp, ctx: &egui::Context) { + if app.recoverable.is_empty() { + return; + } + let dir = app.autosave.as_ref().map(|a| a.dir.clone()).or_else(recovery::default_dir); + let mut act: Option<(Entry, bool)> = None; + let mut later = false; + egui::Window::new(RichText::new("Recover unsaved design?").strong().size(13.0)) + .collapsible(false) + .resizable(false) + .min_width(420.0) + .anchor(egui::Align2::CENTER_CENTER, egui::vec2(0.0, 0.0)) + .show(ctx, |ui| { + ui.label("SolveCraft closed before these designs were saved. Their last autosave can be opened."); + ui.add_space(6.0); + for e in app.recoverable.iter().take(20) { + ui.label(RichText::new(&e.name).strong()); + let file = e.path.clone().unwrap_or_else(|| "never saved".into()); + ui.label(RichText::new(format!("{file} · {} features · autosaved {}", e.features, ago(e.saved_at))).small().weak()); + ui.horizontal(|ui| { + if ui.button("Recover").clicked() { + act = Some((e.clone(), true)); + } + if ui.button("Discard").clicked() { + act = Some((e.clone(), false)); + } + }); + ui.separator(); + } + ui.horizontal(|ui| { + if ui.button("Later").on_hover_text("Keep them; ask again next launch").clicked() { + later = true; + } + }); + }); + let dir_s = dir.map(|d| d.to_string_lossy().to_string()); + if let Some((e, recover)) = act { + app.recoverable.retain(|x| x.id != e.id); + if recover { + if app.session.is_dirty() { + app.set_status("save or discard the open design's changes before recovering another", true); + app.recoverable.insert(0, e); + return; + } + if app.run("doc.recover", json!({"id": e.id, "dir": dir_s})).is_ok() { + // Ours now: autosave it under this app's entry before the old entry goes. + let kept = app.autosave.as_mut().map(|a| a.save(&app.session)); + if let Some(Ok(_)) = kept { + let _ = app.run("doc.recovery_discard", json!({"id": e.id, "dir": dir_s})); + } + app.set_status(format!("recovered {}: save it to keep it", e.name), false); + } + } else { + let _ = app.run("doc.recovery_discard", json!({"id": e.id, "dir": dir_s})); + } + } + if later { + app.recoverable.clear(); + } +}