//! Auto-save and crash recovery (PRJ-7). //! //! - **Auto-save**: every *n* minutes a dirty project is written to an "EffectCraft Auto-Save" //! folder next to it (or a custom folder) as ` auto-save N.ecproj`, the After Effects //! naming. Slots rotate through 1…max versions, overwriting the oldest. Writes are atomic //! (temporary file + rename), so a crash mid-write never damages the previous auto-save. //! - **Crash recovery**: while the app runs, a sentinel (`session.lock` in the config store) //! records the open project and its latest auto-save. A clean exit removes it; finding one at //! launch means the last session ended unexpectedly, and the frontend offers to open the //! latest auto-save. use std::path::{Path, PathBuf}; use serde::{Deserialize, Serialize}; use crate::config::{ConfigStore, FileOps, StdFiles}; use crate::prefs::Prefs; /// Folder created next to a project for its auto-saves. pub const AUTOSAVE_FOLDER: &str = "EffectCraft Auto-Save"; /// Crash-recovery sentinel in the config store. pub const SENTINEL: &str = "session.lock"; const EXT: &str = "ecproj"; /// Auto-save bookkeeping of a session. #[derive(Clone, Debug, Default)] pub struct AutoSaveState { /// Serialise and write on a background thread (frontends with an event loop turn this on; /// ignored on wasm32). The UI thread then only picks the slot and snapshots the project /// (an `Arc` clone), so a large project's auto-save never stalls a frame. pub background: bool, /// The background write in flight, if any. pub pending: Option, /// Wall-clock seconds of the last auto-save (or of the first tick). pub last: Option, /// Slot written last in this session. pub last_slot: Option, /// Revision written last (an unchanged project isn't saved again). pub saved_revision: Option, /// Path of the latest auto-save. pub last_path: Option, /// This session owns the crash-recovery sentinel ([`crate::Session::begin_recovery`]). pub sentinel: bool, } /// A background auto-save write ([`AutoSaveState::background`]). #[derive(Clone)] pub struct PendingWrite { pub path: PathBuf, handle: std::sync::Arc>>>>, } impl std::fmt::Debug for PendingWrite { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("PendingWrite").field("path", &self.path).finish() } } impl PendingWrite { pub fn spawn(path: PathBuf, work: impl FnOnce() -> std::io::Result<()> + Send + 'static) -> std::io::Result { let h = std::thread::Builder::new().name("autosave".into()).spawn(work)?; Ok(PendingWrite { path, handle: std::sync::Arc::new(std::sync::Mutex::new(Some(h))) }) } pub fn is_finished(&self) -> bool { self.handle.lock().map(|h| h.as_ref().is_none_or(|h| h.is_finished())).unwrap_or(true) } /// Wait for the write; its result (`Ok` if already collected). pub fn join(&self) -> std::io::Result<()> { let h = self.handle.lock().ok().and_then(|mut h| h.take()); match h { Some(h) => h.join().unwrap_or_else(|_| Err(std::io::Error::other("auto-save thread panicked"))), None => Ok(()), } } } /// File-name stem of a project (`/a/Intro.ecproj` → `Intro`; untitled → `Untitled Project`). pub fn project_stem(path: Option<&str>) -> String { path.and_then(|p| Path::new(p).file_stem()).map(|s| s.to_string_lossy().to_string()).unwrap_or_else(|| "Untitled Project".into()) } /// ` auto-save N.ecproj`. pub fn file_name(stem: &str, n: u32) -> String { format!("{stem} auto-save {n}.{EXT}") } /// The slot number of an auto-save file name for `stem`. pub fn slot_of(stem: &str, name: &str) -> Option { let rest = name.strip_prefix(stem)?.strip_prefix(" auto-save ")?; rest.strip_suffix(&format!(".{EXT}"))?.parse().ok() } /// Where a project's auto-saves go: next to the project, or the custom folder; untitled projects /// use the custom folder or `fallback` (the app's own Auto-Save folder). pub fn folder(prefs: &Prefs, project: Option<&str>, fallback: Option<&Path>) -> Option { let a = &prefs.auto_save; if a.location == "custom" && !a.folder.trim().is_empty() { return Some(PathBuf::from(a.folder.trim())); } match project.and_then(|p| Path::new(p).parent()) { Some(dir) => Some(dir.join(AUTOSAVE_FOLDER)), None => fallback.map(|f| f.join(AUTOSAVE_FOLDER)), } } /// Existing auto-saves of `stem` in `dir`: (slot, path, modified). pub fn existing(dir: &Path, stem: &str) -> Vec<(u32, PathBuf, std::time::SystemTime)> { existing_in(&StdFiles, dir, stem) } /// [`existing`] through `fs`. pub fn existing_in(fs: &dyn FileOps, dir: &Path, stem: &str) -> Vec<(u32, PathBuf, std::time::SystemTime)> { let mut v: Vec<_> = fs.list(dir).into_iter().filter_map(|(name, m)| Some((slot_of(stem, &name)?, dir.join(&name), m))).collect(); v.sort_by_key(|x| x.0); v } /// The slot to write next: after `last` (this session), else after the newest file on disk, /// wrapping at `max`. pub fn next_slot(existing: &[(u32, PathBuf, std::time::SystemTime)], last: Option, max: u32) -> u32 { let max = max.max(1); let after = last.or_else(|| existing.iter().filter(|e| e.0 <= max).max_by_key(|e| (e.2, e.0)).map(|e| e.0)); match after { Some(n) => n % max + 1, None => 1, } } /// Write one auto-save of `json` for the project at `project` and rotate. Returns its path. pub fn write(prefs: &Prefs, project: Option<&str>, fallback: Option<&Path>, last_slot: Option, json: &str) -> std::io::Result<(PathBuf, u32)> { write_in(&StdFiles, prefs, project, fallback, last_slot, json) } /// [`write`] through `fs`. pub fn write_in( fs: &dyn FileOps, prefs: &Prefs, project: Option<&str>, fallback: Option<&Path>, last_slot: Option, json: &str, ) -> std::io::Result<(PathBuf, u32)> { let plan = plan_in(fs, prefs, project, fallback, last_slot)?; plan.write(fs, json.as_bytes())?; Ok((plan.path, plan.slot)) } /// Where the next auto-save goes ([`plan_in`]). #[derive(Clone, Debug)] pub struct WritePlan { pub path: PathBuf, pub slot: u32, /// Slots above the (lowered) maximum, removed after the write. pub stale: Vec, } impl WritePlan { pub fn write(&self, fs: &dyn FileOps, data: &[u8]) -> std::io::Result<()> { fs.write(&self.path, data)?; for p in &self.stale { let _ = fs.remove(p); } Ok(()) } } /// Pick the next auto-save slot (without writing anything). pub fn plan_in(fs: &dyn FileOps, prefs: &Prefs, project: Option<&str>, fallback: Option<&Path>, last_slot: Option) -> std::io::Result { let dir = folder(prefs, project, fallback).ok_or_else(|| std::io::Error::other("no auto-save folder for an untitled project"))?; let stem = project_stem(project); let max = prefs.auto_save.max_versions.max(1); let have = existing_in(fs, &dir, &stem); let slot = next_slot(&have, last_slot, max); let path = dir.join(file_name(&stem, slot)); // Max versions lowered: drop slots above it. let stale = have.into_iter().filter(|(n, _, _)| *n > max).map(|(_, p, _)| p).collect(); Ok(WritePlan { path, slot, stale }) } /// The newest auto-save of a project, if any. pub fn latest(prefs: &Prefs, project: Option<&str>, fallback: Option<&Path>) -> Option { latest_in(&StdFiles, prefs, project, fallback) } /// [`latest`] through `fs`. pub fn latest_in(fs: &dyn FileOps, prefs: &Prefs, project: Option<&str>, fallback: Option<&Path>) -> Option { let dir = folder(prefs, project, fallback)?; existing_in(fs, &dir, &project_stem(project)).into_iter().max_by_key(|e| e.2).map(|e| e.1) } /// What the crash-recovery sentinel records. #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] pub struct Sentinel { pub pid: u32, /// The open project (None = untitled). pub project: Option, /// Latest auto-save of this run. pub autosave: Option, /// Whether the project had unsaved changes. pub dirty: bool, } /// A previous run that didn't exit cleanly. #[derive(Clone, Debug, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] pub struct Recovery { pub project: Option, /// The auto-save to offer (exists on disk). pub autosave: Option, pub dirty: bool, } /// At launch: report an unclean previous exit (a leftover sentinel), then start this run's /// sentinel. pub fn begin(store: &dyn ConfigStore, prefs: &Prefs) -> Option { let found = store.read(SENTINEL).map(|t| serde_json::from_str::(&t).unwrap_or_default()); let _ = store.write(SENTINEL, &serde_json::to_string(&Sentinel { pid: pid(), ..Default::default() }).unwrap_or_default()); let s = found?; let fallback = store.dir(); let autosave = s .autosave .clone() .filter(|p| store.files().is_file(Path::new(p))) .or_else(|| latest_in(store.files(), prefs, s.project.as_deref(), fallback.as_deref()).map(|p| p.to_string_lossy().to_string())); Some(Recovery { project: s.project, autosave, dirty: s.dirty }) } /// This process id (0 where processes don't exist, e.g. the browser). pub fn pid() -> u32 { #[cfg(not(target_arch = "wasm32"))] return std::process::id(); #[cfg(target_arch = "wasm32")] 0 } /// Record the current project / latest auto-save in the sentinel. pub fn update(store: &dyn ConfigStore, s: &Sentinel) { let _ = store.write(SENTINEL, &serde_json::to_string(s).unwrap_or_default()); } /// Clean exit: remove the sentinel. pub fn end(store: &dyn ConfigStore) { let _ = store.remove(SENTINEL); } /// After Effects' Increment and Save name: `Intro.ecproj` → `Intro 2.ecproj`, `Intro 2.ecproj` /// → `Intro 3.ecproj` (skipping names that exist; the extension, `.ecprojx` too, is kept). pub fn increment_path(path: &str, exists: impl Fn(&str) -> bool) -> String { let p = Path::new(path); let stem = p.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_default(); let ext = p.extension().map(|e| e.to_string_lossy().to_string()).unwrap_or_else(|| EXT.to_string()); let (base, mut n) = match stem.rsplit_once(' ') { Some((b, n)) if !n.is_empty() && n.chars().all(|c| c.is_ascii_digit()) => (b.to_string(), n.parse::().unwrap_or(1) + 1), _ => (stem.clone(), 2), }; loop { let cand = p.with_file_name(format!("{base} {n}.{ext}")).to_string_lossy().to_string(); if !exists(&cand) { return cand; } n += 1; } }