diff --git a/README.md b/README.md index 47d5e5af..24006fbd 100644 --- a/README.md +++ b/README.md @@ -180,7 +180,7 @@ tests, layering, asset attribution and the WebAssembly build). See [CONTRIBUTING EffectCraft is young. The core of After Effects is in place and gets deeper every week: what is done and what comes next is in the [ROADMAP](ROADMAP.md). Not there yet: motion tracking, puppet, -paint and roto tools, Lottie, preferences and a shortcut editor, and GPU compositing. The web build +paint and roto tools, Lottie and GPU compositing. The web build ([docs/web.md](docs/web.md)) runs the full app in the browser, single-threaded for now. Projects are saved as `.ecproj`, readable versioned JSON; After Effects `.aep` files cannot be opened. diff --git a/ROADMAP.md b/ROADMAP.md index dc12d39f..0f0aca55 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -19,8 +19,8 @@ once. | M10 | Export: render queue, H.264, ProRes, image sequences, GIF, audio | Done | | M11 | Animation tools: audio playback, meters and waveforms (done); presets, Motion Sketch, Wiggler, Lottie | In progress | | M12 | Performance: layer cache and parallel compositing (done), GPU compositing, disk cache, motion tracking | In progress | -| M13+ | Puppet, paint, roto, motion tracking, preferences and shortcut editor, a plugin API | Planned | +| M13+ | Puppet, paint, roto, motion tracking, a plugin API | Planned | | M15 | The web app (WebAssembly, WebGPU) | Done, single-threaded | -| M14 | Built for agents: MCP server, command-line tool, control channel | Done | +| M14 | Built for agents: MCP server, command-line tool, control channel; Settings, keyboard shortcut editor, auto-save and crash recovery ([docs/preferences.md](docs/preferences.md)) | Done | Come tell us what matters most to you on [Discord](https://discord.gg/artcraft). diff --git a/apps/effectcraft/src/main.rs b/apps/effectcraft/src/main.rs index 32d922fa..70cbd933 100644 --- a/apps/effectcraft/src/main.rs +++ b/apps/effectcraft/src/main.rs @@ -46,11 +46,13 @@ fn main() -> eframe::Result { options, Box::new(move |cc| { let mut session = effectcraft_host::session(); - // Settings and shortcut presets live in the platform config directory. + // Settings, shortcut presets and the crash-recovery sentinel live in the platform + // config directory. Agent-driven runs (`--control`) skip crash recovery. if let Some(dir) = config_dir() { session.config = Some(std::sync::Arc::new(effectcraft_engine::config::DirConfig::new(dir))); } session.load_settings(); + let recovery = if control_port.is_none() { session.begin_recovery() } else { None }; let project = files.iter().find(|f| f.ends_with(".ecproj")).cloned(); if let Some(p) = project { if let Err(e) = session.execute("file.open", json!({"path": p})) { @@ -68,6 +70,9 @@ fn main() -> eframe::Result { let show_home = home.unwrap_or(session.prefs.startup.show_home_on_launch && files.is_empty() && control_port.is_none()); let mut app = EffectcraftApp::new(session); app.ui.start_screen = show_home; + if let Some(r) = recovery { + app.offer_recovery(r); + } app.hooks.pick_files = Some(Box::new(|exts: &[&str]| { rfd::FileDialog::new().add_filter("Media", exts).pick_files().unwrap_or_default().into_iter().map(|p| p.to_string_lossy().to_string()).collect() })); diff --git a/crates/engine/src/autosave.rs b/crates/engine/src/autosave.rs new file mode 100644 index 00000000..2b6321f5 --- /dev/null +++ b/crates/engine/src/autosave.rs @@ -0,0 +1,193 @@ +//! 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; +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 { + /// 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, +} + +/// 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)> { + let Ok(rd) = std::fs::read_dir(dir) else { return vec![] }; + let mut v: Vec<_> = rd + .flatten() + .filter_map(|e| { + let name = e.file_name().to_string_lossy().to_string(); + let n = slot_of(stem, &name)?; + let m = e.metadata().ok()?.modified().ok()?; + Some((n, e.path(), 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)> { + let dir = folder(prefs, project, fallback).ok_or_else(|| std::io::Error::other("no auto-save folder for an untitled project"))?; + std::fs::create_dir_all(&dir)?; + let stem = project_stem(project); + let max = prefs.auto_save.max_versions.max(1); + let have = existing(&dir, &stem); + let slot = next_slot(&have, last_slot, max); + let path = dir.join(file_name(&stem, slot)); + crate::config::atomic_write(&path, json.as_bytes())?; + // Max versions lowered: drop slots above it. + for (n, p, _) in have { + if n > max { + let _ = std::fs::remove_file(p); + } + } + Ok((path, slot)) +} + +/// The newest auto-save of a project, if any. +pub fn latest(prefs: &Prefs, project: Option<&str>, fallback: Option<&Path>) -> Option { + let dir = folder(prefs, project, fallback)?; + existing(&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| Path::new(p).is_file()) + .or_else(|| latest(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). +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 (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; + } +} diff --git a/crates/engine/src/commands/file.rs b/crates/engine/src/commands/file.rs index 84b48279..92e8836e 100644 --- a/crates/engine/src/commands/file.rs +++ b/crates/engine/src/commands/file.rs @@ -18,9 +18,11 @@ fn new_project(s: &mut Session, _: &Value) -> Result { let bytes = s.services.read_file(&tpl).map_err(|e| EngineError::Other(format!("cannot read the new project template {tpl}: {e}")))?; let text = String::from_utf8(bytes).map_err(|_| EngineError::Other("the new project template is not a project file".into()))?; s.replace_project(Project::from_json(&text)?, None); + s.update_sentinel(); return Ok(json!({"template": tpl})); } s.replace_project(Project::default(), None); + s.update_sentinel(); Ok(Value::Null) } @@ -34,12 +36,13 @@ fn demo(s: &mut Session, _: &Value) -> Result { Ok(json!({"comp": s.state.active_comp.map(|c| c.0)})) } -fn open(s: &mut Session, p: &Value) -> Result { +pub(crate) fn open(s: &mut Session, p: &Value) -> Result { let path = str_p(p, "path").ok_or_else(|| bad("file.open", "missing `path`"))?; let bytes = s.services.read_file(path).map_err(|e| EngineError::Other(format!("cannot read {path}: {e}")))?; let text = String::from_utf8(bytes).map_err(|_| EngineError::Other("not a text project file".into()))?; let proj = Project::from_json(&text)?; s.replace_project(proj, Some(path.to_string())); + s.note_project_path(path); Ok(json!({"path": path})) } @@ -48,6 +51,7 @@ fn save_to(s: &mut Session, path: &str) -> Result { s.services.write_file(path, json.as_bytes()).map_err(|e| EngineError::Other(format!("cannot write {path}: {e}")))?; s.path = Some(path.to_string()); s.saved_revision = s.revision; + s.note_project_path(path); s.toast(format!("Saved {path}")); Ok(json!({"path": path, "bytes": json.len()})) } @@ -64,12 +68,9 @@ fn save_as(s: &mut Session, p: &Value) -> Result { fn increment_save(s: &mut Session, _: &Value) -> Result { let cur = s.path.clone().ok_or_else(|| bad("file.incrementAndSave", "save the project first"))?; - let stem = cur.trim_end_matches(".ecproj"); - let (base, n) = match stem.rsplit_once('_') { - Some((b, n)) if n.chars().all(|c| c.is_ascii_digit()) && !n.is_empty() => (b.to_string(), n.parse::().unwrap_or(0) + 1), - _ => (stem.to_string(), 2), - }; - save_to(s, &format!("{base}_{n}.ecproj")) + // After Effects: `Intro.aep` → `Intro 2.aep` → `Intro 3.aep`. + let next = crate::autosave::increment_path(&cur, |p| std::path::Path::new(p).exists()); + save_to(s, &next) } fn revert(s: &mut Session, _: &Value) -> Result { diff --git a/crates/engine/src/commands/render_queue.rs b/crates/engine/src/commands/render_queue.rs index d07b03a7..b2d4f9e8 100644 --- a/crates/engine/src/commands/render_queue.rs +++ b/crates/engine/src/commands/render_queue.rs @@ -344,6 +344,14 @@ fn duplicate(s: &mut Session, p: &Value) -> Result { fn render(s: &mut Session, p: &Value) -> Result { let wait = b_p(p, "wait").unwrap_or(true); + // Settings ▸ Project ▸ Auto-Save ▸ Save When Starting Render Queue. + if s.prefs.auto_save.enabled + && s.prefs.auto_save.save_on_render_start + && s.is_dirty() + && let Err(e) = s.autosave_now() + { + log::warn!("{e}"); + } let ids = s.start_render(wait).map_err(EngineError::Other)?; let items: Vec = s.project.render_queue.iter().enumerate().filter(|(_, i)| ids.contains(&i.id)).map(|(k, i)| item_json(s, i, k)).collect(); Ok(json!({"rendering": s.is_rendering(), "items": items})) diff --git a/crates/engine/src/commands/settings.rs b/crates/engine/src/commands/settings.rs index 8c6b8550..fe5aec57 100644 --- a/crates/engine/src/commands/settings.rs +++ b/crates/engine/src/commands/settings.rs @@ -1,4 +1,5 @@ -//! Settings (`prefs.*`) and keyboard shortcut presets (`shortcuts.*`). +//! Settings (`prefs.*`), keyboard shortcut presets (`shortcuts.*`), File ▸ Open Recent and +//! auto-save commands. use serde_json::{Value, json}; @@ -198,6 +199,55 @@ fn sc_conflicts(s: &mut Session, _: &Value) -> Result { Ok(json!(c)) } +// ---------------------------------------------------------------- recent / auto-save + +fn open_recent(s: &mut Session, p: &Value) -> Result { + let path = match (str_p(p, "path"), p.get("index").and_then(Value::as_u64)) { + (Some(path), _) => path.to_string(), + (None, Some(i)) => s.prefs.recent_projects.get(i as usize).cloned().ok_or_else(|| bad("file.openRecent", format!("no recent project #{i}")))?, + _ => s.prefs.recent_projects.first().cloned().ok_or_else(|| bad("file.openRecent", "no recent projects"))?, + }; + match super::file::open(s, &json!({"path": path})) { + Ok(v) => Ok(v), + Err(e) => { + // A project that's gone drops out of the list. + if !std::path::Path::new(&path).exists() { + s.prefs.recent_projects.retain(|x| *x != path); + s.save_prefs(); + } + Err(e) + } + } +} + +fn clear_recent(s: &mut Session, _: &Value) -> Result { + s.prefs.recent_projects.clear(); + s.save_prefs(); + s.prefs_revision += 1; + Ok(Value::Null) +} + +fn has_recent(s: &Session) -> std::result::Result<(), String> { + if s.prefs.recent_projects.is_empty() { Err("no recent projects".into()) } else { Ok(()) } +} + +fn auto_save(s: &mut Session, _: &Value) -> Result { + let path = s.autosave_now()?; + s.toast(format!("Auto-saved to {path}")); + Ok(json!({"path": path})) +} + +fn recovery_info(s: &mut Session, _: &Value) -> Result { + let root = s.default_autosave_root(); + let latest = crate::autosave::latest(&s.prefs, s.path.as_deref(), root.as_deref()).map(|p| p.to_string_lossy().to_string()); + Ok(json!({ + "lastAutoSave": s.autosave.last_path, + "latestOnDisk": latest, + "folder": crate::autosave::folder(&s.prefs, s.path.as_deref(), root.as_deref()).map(|p| p.to_string_lossy().to_string()), + "recentProjects": s.prefs.recent_projects, + })) +} + pub fn specs() -> Vec { vec![ query!("prefs.get", "Get Setting", "{key?: e.g. general.undoLevels (omit for all settings)}", prefs_get), @@ -220,5 +270,9 @@ pub fn specs() -> Vec { cmd!("shortcuts.export", "Export Keyboard Shortcuts", [], None, "{preset?, path?}", always, sc_export), cmd!("shortcuts.import", "Import Keyboard Shortcuts", [], None, "{path? | preset?: exported document, name?}", always, sc_import), query!("shortcuts.conflicts", "Keyboard Shortcut Conflicts", "{}", sc_conflicts), + cmd!("file.openRecent", "Open Recent", [], None, "{index? | path?}", has_recent, open_recent), + cmd!("file.clearRecent", "Clear Recent Projects", ["File", "Open Recent"], None, "{}", has_recent, clear_recent), + cmd!("file.autoSave", "Auto-Save Now", [], None, "{}", always, auto_save), + query!("file.recoveryInfo", "Auto-Save Status", "{}", recovery_info), ] } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 9bd475f6..6fc92da2 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -8,6 +8,7 @@ //! The project is an `Arc` edited copy-on-write; undo keeps whole-project snapshots //! (compositions are `Arc`s, so untouched comps are shared). +pub mod autosave; pub mod commands; pub mod config; pub mod demo; @@ -68,10 +69,7 @@ impl Services for FsServices { std::fs::read(path) } fn write_file(&self, path: &str, data: &[u8]) -> std::io::Result<()> { - let p = std::path::Path::new(path); - let tmp = p.with_extension("ecproj.tmp"); - std::fs::write(&tmp, data)?; - std::fs::rename(&tmp, p) + config::atomic_write(std::path::Path::new(path), data) } } @@ -225,6 +223,8 @@ pub struct Session { pub ui_commands: Vec, /// Cache of the resolved active preset (read it with [`Session::shortcuts`]). pub shortcut_table: std::sync::OnceLock, + /// Auto-save bookkeeping. + pub autosave: autosave::AutoSaveState, } impl Default for Session { @@ -252,6 +252,7 @@ impl Default for Session { keymaps: shortcuts::Keymaps::default(), ui_commands: vec![], shortcut_table: std::sync::OnceLock::new(), + autosave: autosave::AutoSaveState::default(), } } } diff --git a/crates/engine/src/menus.rs b/crates/engine/src/menus.rs index 450df85d..661c2276 100644 --- a/crates/engine/src/menus.rs +++ b/crates/engine/src/menus.rs @@ -303,6 +303,8 @@ File New Project | file.newProject New Folder | project.newFolder Open Project... | file.open + Open Recent + Clear Recent Projects | file.clearRecent --- Close | file.close Close Project | file.closeProject diff --git a/crates/engine/src/session_settings.rs b/crates/engine/src/session_settings.rs index 3e3d90d9..b0512a6a 100644 --- a/crates/engine/src/session_settings.rs +++ b/crates/engine/src/session_settings.rs @@ -1,8 +1,11 @@ -//! Session side of Settings and keyboard shortcut presets. +//! Session side of Settings, keyboard shortcut presets, auto-save and crash recovery. -use crate::Session; +use std::path::Path; + +use crate::autosave::{self, Sentinel}; use crate::prefs::{PREFS_FILE, Prefs}; use crate::shortcuts::{Keymaps, SHORTCUTS_FILE, ShortcutTable, UiCommand}; +use crate::{EngineError, Result, Session}; impl Session { /// Load settings and shortcut presets from the config store and apply them. @@ -62,4 +65,88 @@ impl Session { pub fn shortcuts(&self) -> &ShortcutTable { self.shortcut_table.get_or_init(|| ShortcutTable::build(&self.keymaps, &self.ui_commands)) } + + /// Remember a project in File ▸ Open Recent and in the crash-recovery sentinel. + pub(crate) fn note_project_path(&mut self, path: &str) { + self.prefs.push_recent(path); + self.save_prefs(); + self.autosave = autosave::AutoSaveState { last: self.autosave.last, sentinel: self.autosave.sentinel, ..Default::default() }; + self.update_sentinel(); + } + + /// Refresh the crash-recovery sentinel (when one is active). + pub fn update_sentinel(&self) { + if let Some(c) = &self.config + && self.autosave.sentinel + { + let s = Sentinel { pid: autosave::pid(), project: self.path.clone(), autosave: self.autosave.last_path.clone(), dirty: self.is_dirty() }; + autosave::update(c.as_ref(), &s); + } + } + + /// Fallback folder for auto-saves of untitled projects (next to the settings). + pub fn default_autosave_root(&self) -> Option { + self.config.as_ref().and_then(|c| c.dir()) + } + + /// Write an auto-save now (whether or not the project is dirty). Returns its path. + pub fn autosave_now(&mut self) -> Result { + let json = self.project.to_json(); + let root = self.default_autosave_root(); + let (path, slot) = autosave::write(&self.prefs, self.path.as_deref(), root.as_deref(), self.autosave.last_slot, &json) + .map_err(|e| EngineError::Other(format!("auto-save failed: {e}")))?; + let p = path.to_string_lossy().to_string(); + self.autosave.last_slot = Some(slot); + self.autosave.saved_revision = Some(self.revision); + self.autosave.last_path = Some(p.clone()); + self.update_sentinel(); + Ok(p) + } + + /// Call periodically with wall-clock seconds: auto-saves a dirty project once the interval + /// has passed since the last auto-save (or since the first call). Returns the written path. + pub fn autosave_tick(&mut self, now: f64) -> Option> { + let a = &self.prefs.auto_save; + if !a.enabled { + self.autosave.last = Some(now); + return None; + } + let interval = a.interval_minutes.max(1) as f64 * 60.0; + let last = *self.autosave.last.get_or_insert(now); + if now - last < interval { + return None; + } + self.autosave.last = Some(now); + if !self.is_dirty() || self.autosave.saved_revision == Some(self.revision) || self.render_job.is_some() { + return None; + } + if self.path.is_none() && self.prefs.auto_save.location != "custom" && self.default_autosave_root().is_none() { + return None; + } + let r = self.autosave_now(); + if let Ok(p) = &r { + self.toast(format!("Auto-saved {}", Path::new(p).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default())); + } + Some(r) + } + + /// At launch: start crash recovery. Returns the previous run's state when it didn't exit + /// cleanly (and the setting asks for it). + pub fn begin_recovery(&mut self) -> Option { + let c = self.config.clone()?; + let r = autosave::begin(c.as_ref(), &self.prefs); + self.autosave.sentinel = true; + self.update_sentinel(); + r.filter(|_| self.prefs.startup.offer_crash_recovery) + } + + /// Clean exit: remove the crash-recovery sentinel. + pub fn end_recovery(&mut self) { + if let Some(c) = &self.config + && self.autosave.sentinel + { + autosave::end(c.as_ref()); + self.autosave.sentinel = false; + } + } } diff --git a/crates/engine/src/tests_settings.rs b/crates/engine/src/tests_settings.rs index c045e560..50fe186d 100644 --- a/crates/engine/src/tests_settings.rs +++ b/crates/engine/src/tests_settings.rs @@ -1,14 +1,14 @@ -//! Settings and keyboard shortcut presets. +//! Settings, keyboard shortcut presets, auto-save and crash recovery. use std::path::PathBuf; use std::sync::Arc; use serde_json::json; -use crate::Session; -use crate::config::{ConfigStore, MemoryConfig}; +use crate::config::{ConfigStore, DirConfig, MemoryConfig, atomic_write, atomic_write_with, temp_path}; use crate::prefs::{PREFS_FILE, Prefs}; use crate::shortcuts::{DEFAULT_PRESET, Keymaps, normalize}; +use crate::{Session, autosave}; fn tmp(name: &str) -> PathBuf { use std::sync::atomic::{AtomicU32, Ordering}; @@ -326,3 +326,141 @@ fn frontend_commands_are_bindable_with_panel_scope() { let c = s.shortcuts().conflicts(&b, "H"); assert!(c.iter().any(|c| c.key == "tool.hand")); } + +// ---------------------------------------------------------------- auto-save / recovery + +#[test] +fn atomic_write_survives_a_crash_mid_write() { + let d = tmp("atomic"); + let f = d.join("p.ecproj"); + atomic_write(&f, b"version 1").unwrap(); + // The writer dies halfway through the temporary file. + let r = atomic_write_with(&f, b"version 2 is much longer", |tmp, data| { + std::fs::write(tmp, &data[..5])?; + Err(std::io::Error::other("power cut")) + }); + assert!(r.is_err()); + assert_eq!(std::fs::read(&f).unwrap(), b"version 1"); + assert!(!temp_path(&f).exists(), "the partial temp file is cleaned up"); + // A temp file left by a hard kill doesn't count as an auto-save. + std::fs::write(d.join("p auto-save 1.ecproj.tmp"), b"junk").unwrap(); + assert!(autosave::existing(&d, "p").is_empty()); + atomic_write(&f, b"version 3").unwrap(); + assert_eq!(std::fs::read(&f).unwrap(), b"version 3"); + let _ = std::fs::remove_dir_all(d); +} + +#[test] +fn autosave_rotates_through_max_versions() { + let d = tmp("rotate"); + let proj = d.join("Intro.ecproj"); + let mut s = session_with_comp(); + s.execute("file.saveAs", json!({"path": proj.to_string_lossy()})).unwrap(); + s.execute("prefs.set", json!({"key": "autoSave.maxVersions", "value": 3})).unwrap(); + let folder = d.join(autosave::AUTOSAVE_FOLDER); + let mut written = vec![]; + for i in 0..5 { + s.execute("layer.newNull", json!({"name": format!("save{i}")})).unwrap(); + written.push(s.autosave_now().unwrap()); + } + let names: Vec = written.iter().map(|p| PathBuf::from(p).file_name().unwrap().to_string_lossy().to_string()).collect(); + assert_eq!( + names, + ["Intro auto-save 1.ecproj", "Intro auto-save 2.ecproj", "Intro auto-save 3.ecproj", "Intro auto-save 1.ecproj", "Intro auto-save 2.ecproj"] + ); + let have: Vec = autosave::existing(&folder, "Intro").iter().map(|e| e.0).collect(); + assert_eq!(have, [1, 2, 3]); + let slot2 = std::fs::read_to_string(folder.join("Intro auto-save 2.ecproj")).unwrap(); + assert!(slot2.contains("save4"), "slot 2 holds the newest save"); + // The project file itself is untouched and still dirty. + assert!(s.is_dirty()); + assert!(!std::fs::read_to_string(&proj).unwrap().contains("save0")); + // Lowering the maximum drops the extra slots. + s.execute("prefs.set", json!({"key": "autoSave.maxVersions", "value": 2})).unwrap(); + s.autosave_now().unwrap(); + let have: Vec = autosave::existing(&folder, "Intro").iter().map(|e| e.0).collect(); + assert_eq!(have, [1, 2]); + // A fresh session continues after the newest file on disk. + let next = autosave::next_slot(&autosave::existing(&folder, "Intro"), None, 2); + let newest = autosave::existing(&folder, "Intro").into_iter().max_by_key(|e| e.2).unwrap().0; + assert_eq!(next, newest % 2 + 1); + let _ = std::fs::remove_dir_all(d); +} + +#[test] +fn autosave_tick_waits_for_the_interval_and_changes() { + let d = tmp("tick"); + let mut s = session_with_comp(); + s.execute("file.saveAs", json!({"path": d.join("T.ecproj").to_string_lossy()})).unwrap(); + s.execute( + "prefs.set", + json!({"values": {"autoSave.intervalMinutes": 1, "autoSave.location": "custom", "autoSave.folder": d.join("custom").to_string_lossy()}}), + ) + .unwrap(); + assert!(s.autosave_tick(1000.0).is_none(), "the first tick starts the clock"); + s.execute("layer.newNull", json!({})).unwrap(); + assert!(s.autosave_tick(1030.0).is_none(), "interval not reached"); + let p = s.autosave_tick(1061.0).unwrap().unwrap(); + assert!(p.contains("custom") && p.ends_with("T auto-save 1.ecproj"), "{p}"); + assert!(s.autosave_tick(1200.0).is_none(), "nothing changed since"); + s.execute("prefs.set", json!({"key": "autoSave.enabled", "value": false})).unwrap(); + s.execute("layer.newNull", json!({})).unwrap(); + assert!(s.autosave_tick(5000.0).is_none(), "disabled"); + let _ = std::fs::remove_dir_all(d); +} + +#[test] +fn crash_recovery_detects_an_unclean_exit() { + let d = tmp("recovery"); + let store: Arc = Arc::new(DirConfig::new(d.join("config"))); + // First run: open a project, auto-save, then "crash" (no end_recovery). + let mut s = session_with_comp(); + s.config = Some(store.clone()); + assert!(s.begin_recovery().is_none(), "first launch is clean"); + let proj = d.join("Crash.ecproj"); + s.execute("file.saveAs", json!({"path": proj.to_string_lossy()})).unwrap(); + s.execute("layer.newNull", json!({"name": "unsaved work"})).unwrap(); + let auto = s.autosave_now().unwrap(); + drop(s); + // Second run finds the sentinel and offers the auto-save. + let mut s2 = Session { config: Some(store.clone()), ..Default::default() }; + let r = s2.begin_recovery().expect("unclean exit detected"); + assert_eq!(r.project.as_deref(), Some(proj.to_string_lossy().as_ref())); + assert_eq!(r.autosave.as_deref(), Some(auto.as_str())); + s2.execute("file.open", json!({"path": r.autosave.unwrap()})).unwrap(); + assert!(s2.active_comp().unwrap().layers.iter().any(|l| l.name == "unsaved work")); + // Clean exit: nothing to recover next time. + s2.end_recovery(); + let mut s3 = Session { config: Some(store.clone()), ..Default::default() }; + assert!(s3.begin_recovery().is_none()); + // The setting can turn the offer off. + let mut s4 = Session { config: Some(store), ..Default::default() }; + s4.prefs.startup.offer_crash_recovery = false; + assert!(s4.begin_recovery().is_none()); + let _ = std::fs::remove_dir_all(d); +} + +#[test] +fn open_recent_increment_and_revert() { + let d = tmp("recent"); + let a = d.join("A.ecproj"); + let mut s = session_with_comp(); + s.execute("file.saveAs", json!({"path": a.to_string_lossy()})).unwrap(); + let r = s.execute("file.incrementAndSave", json!({})).unwrap(); + assert!(r["path"].as_str().unwrap().ends_with("A 2.ecproj")); + let r = s.execute("file.incrementAndSave", json!({})).unwrap(); + assert!(r["path"].as_str().unwrap().ends_with("A 3.ecproj")); + assert!(s.prefs.recent_projects[0].ends_with("A 3.ecproj")); + assert!(s.prefs.recent_projects[2].ends_with("A.ecproj")); + s.execute("layer.newNull", json!({})).unwrap(); + s.execute("file.revert", json!({})).unwrap(); + assert_eq!(s.active_comp().unwrap().layers.len(), 0); + s.execute("file.openRecent", json!({"index": 2})).unwrap(); + assert!(s.path.as_deref().unwrap().ends_with("A.ecproj")); + assert!(s.prefs.recent_projects[0].ends_with("A.ecproj")); + s.execute("file.clearRecent", json!({})).unwrap(); + assert!(!s.is_enabled("file.openRecent")); + assert_eq!(autosave::increment_path("/x/Intro 9.ecproj", |_| false), "/x/Intro 10.ecproj"); + assert_eq!(autosave::increment_path("/x/v1.ecproj", |p| p.ends_with("v1 2.ecproj")), "/x/v1 3.ecproj"); + let _ = std::fs::remove_dir_all(d); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 0e6ed058..ef2c3cdc 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -57,6 +57,8 @@ pub enum Dialog { KeyVelocity, KeyInterpolation, TimeStretch, + /// Crash recovery: offer the latest auto-save (`EffectcraftApp::recovery`). + Recovery, } /// Host hooks provided by the native app (file pickers etc.). @@ -130,6 +132,8 @@ pub struct EffectcraftApp { pub(crate) last_reveal: Option<(String, f64)>, /// Settings revision applied to the theme, tooltips and caches. applied_prefs: Option, + /// A previous run that didn't exit cleanly (shown by `Dialog::Recovery`). + pub recovery: Option, } impl EffectcraftApp { @@ -167,6 +171,7 @@ impl EffectcraftApp { waveforms: Default::default(), last_reveal: None, applied_prefs: None, + recovery: None, } .with_ui_commands() } @@ -181,6 +186,14 @@ impl EffectcraftApp { self } + /// Show the crash-recovery dialog for a previous run that didn't exit cleanly. + pub fn offer_recovery(&mut self, r: effectcraft_engine::autosave::Recovery) { + if r.autosave.is_some() { + self.recovery = Some(r); + self.dialog = Some(Dialog::Recovery); + } + } + /// Apply changed settings: theme and brightness, label colours, tool tips, preview caches. pub fn apply_prefs(&mut self, ctx: &egui::Context) { if self.applied_prefs == Some(self.session.prefs_revision) { @@ -590,6 +603,7 @@ impl EffectcraftApp { } self.apply_prefs(&ctx); self.handle_events(&ctx); + self.tick_autosave(&ctx); if let Some(rx) = self.command_inbox.take() { while let Ok(id) = rx.try_recv() { if let Err(e) = menus::invoke(self, &ctx, &id, json!({})) { @@ -701,6 +715,17 @@ impl EffectcraftApp { } impl EffectcraftApp { + /// Auto-save a dirty project when the interval has passed (Settings ▸ Project ▸ Auto-Save). + fn tick_autosave(&mut self, ctx: &egui::Context) { + let now = web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH).map(|d| d.as_secs_f64()).unwrap_or(0.0); + if let Some(Err(e)) = self.session.autosave_tick(now) { + self.ui.status = e.to_string(); + } + if self.session.prefs.auto_save.enabled { + ctx.request_repaint_after(std::time::Duration::from_secs(30)); + } + } + /// Take the pending synthetic input (from `ui.click`, `ui.key`, …). Hosts that don't call /// [`eframe::App::raw_input_hook`] (the headless test harness) feed these in themselves. pub fn take_synthetic_input(&mut self) -> Vec { @@ -709,6 +734,11 @@ impl EffectcraftApp { } impl eframe::App for EffectcraftApp { + fn on_exit(&mut self) { + // Clean exit: no crash recovery next launch. + self.session.end_recovery(); + } + fn raw_input_hook(&mut self, _ctx: &egui::Context, raw_input: &mut egui::RawInput) { if !self.synthetic.is_empty() { // Pointer events go one per frame so egui sees press → moves → release as a real drag. diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 466f2e3e..947c651c 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -840,6 +840,27 @@ fn menu_nodes(app: &mut EffectcraftApp, ui: &mut egui::Ui, nodes: &[MenuNode], c MenuNode::Separator => { ui.separator(); } + MenuNode::Submenu { label, children } if label == "Open Recent" => { + // File ▸ Open Recent: the recent projects (Settings), then the static entries. + ui.menu_button((gutter(false), label.as_str()), |ui| { + ui.set_min_width(320.0); + let recent = app.session.prefs.recent_projects.clone(); + if recent.is_empty() { + ui.add_enabled(false, egui::Button::new((gutter(false), "No Recent Projects"))); + } + for (i, path) in recent.iter().enumerate() { + let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or(path.clone()); + let r = ui.add(egui::Button::new((gutter(false), name))).on_hover_text(path); + app.auto.add(&format!("menu.openRecent.{i}"), r.rect, path); + if r.clicked() { + *clicked = Some(("file.openRecent".into(), json!({"index": i}))); + ui.close(); + } + } + ui.separator(); + menu_nodes(app, ui, children, clicked); + }); + } MenuNode::Submenu { label, children } => { ui.menu_button((gutter(false), label.as_str()), |ui| { ui.set_min_width(if children.len() > 30 { 200.0 } else { 240.0 }); diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index 366a8575..56db853c 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -104,6 +104,7 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context) { Dialog::CommandPalette => palette(app, ctx, &t), Dialog::Settings => super::settings::show(app, ctx, &t), Dialog::Shortcuts => super::shortcut_editor::show(app, ctx, &t), + Dialog::Recovery => super::settings::recovery(app, ctx, &t), Dialog::Form => super::forms::show_form(app, ctx, &t), Dialog::Info => super::forms::show_info(app, ctx, &t), Dialog::ViewOptions => super::forms::show_view_options(app, ctx, &t), diff --git a/crates/ui-egui/src/panels/settings.rs b/crates/ui-egui/src/panels/settings.rs index 31dd320c..180dae96 100644 --- a/crates/ui-egui/src/panels/settings.rs +++ b/crates/ui-egui/src/panels/settings.rs @@ -344,3 +344,68 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { app.dialog = None; } } + +/// Crash recovery: the previous session didn't exit cleanly; offer its latest auto-save. +pub fn recovery(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { + let Some(r) = app.recovery.clone() else { + app.dialog = None; + return; + }; + let mut choice: Option<&str> = None; + super::dialogs::modal(ctx, "Recover Project", vec2(560.0, 230.0), t, |ui| { + ui.label("EffectCraft didn't quit normally last time."); + ui.add_space(6.0); + if let Some(p) = &r.project { + ui.label(format!("Project: {p}")); + } + if let Some(a) = &r.autosave { + let when = std::fs::metadata(a).and_then(|m| m.modified()).ok().and_then(|m| m.elapsed().ok()).map(|d| { + let m = d.as_secs() / 60; + if m < 1 { + "less than a minute ago".to_string() + } else if m < 120 { + format!("{m} minutes ago") + } else { + format!("{} hours ago", m / 60) + } + }); + ui.label(format!("Latest auto-save: {a}")); + if let Some(w) = when { + ui.label(RichText::new(format!("Saved {w}")).color(t.text_dim)); + } + } + ui.add_space(14.0); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let b = ui.add(egui::Button::new(RichText::new(" Open Auto-Save ").color(Color32::WHITE)).fill(t.accent)); + app.auto.add("recovery.openAutoSave", b.rect, "Open Auto-Save"); + if b.clicked() { + choice = Some("autosave"); + } + if r.project.is_some() { + let b = ui.button("Open Last Saved Project"); + app.auto.add("recovery.openProject", b.rect, "Open Last Saved Project"); + if b.clicked() { + choice = Some("project"); + } + } + let b = ui.button("Don't Recover"); + app.auto.add("recovery.dismiss", b.rect, "Don't Recover"); + if b.clicked() { + choice = Some("none"); + } + }); + }); + let Some(c) = choice else { return }; + app.dialog = None; + app.recovery = None; + let path = match c { + "autosave" => r.autosave, + "project" => r.project, + _ => None, + }; + if let Some(p) = path + && let Err(e) = crate::menus::invoke(app, ctx, "file.open", json!({"path": p})) + { + app.ui.status = e; + } +} diff --git a/docs/architecture.md b/docs/architecture.md index 4baa4c79..e510068b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -106,3 +106,9 @@ the JSON control channel and the MCP server all dispatch by id, so anything a pe menu, an agent can do too. Every interactive widget registers an automation id, so agents can also inspect and click the interface. See [agents.md](agents.md) and [control-protocol.md](control-protocol.md). + +Settings (`engine::prefs`), keyboard shortcut presets (`engine::shortcuts`) and auto-save / +crash recovery (`engine::autosave`) live in the engine too; they persist through a +`ConfigStore` the frontend provides (a directory on the desktop, `localStorage` on the web). +The shortcut dispatcher and the menus read the active preset. See +[preferences.md](preferences.md). diff --git a/docs/preferences.md b/docs/preferences.md index 65111034..cb05229e 100644 --- a/docs/preferences.md +++ b/docs/preferences.md @@ -1,4 +1,4 @@ -# Settings and keyboard shortcuts +# Settings, keyboard shortcuts, auto-save and crash recovery ## Settings (Preferences) @@ -157,3 +157,20 @@ Commands: `shortcuts.list {query?, keys?, assigned?}`, `shortcuts.set {command, `shortcuts.reset {command?}`, `shortcuts.preset {op: select|new|duplicate|delete|rename, name?, from?, newName?}`, `shortcuts.export {preset?, path?}`, `shortcuts.import {path? | preset?}`, `shortcuts.conflicts`. + +## Auto-save and crash recovery + +- Every *n* minutes (Settings ▸ Project ▸ Auto-Save) a project with unsaved changes is written + to an `EffectCraft Auto-Save` folder next to it (or the custom folder) as + ` auto-save N.ecproj`. Slots rotate through 1…maximum versions, overwriting the oldest. + Untitled projects go to the custom folder, or to `Auto-Save` in the config directory. +- Every project and auto-save write is atomic: a temporary file is written and flushed, then + renamed over the target, so a crash mid-write leaves the previous file intact. +- While the app runs, `session.lock` in the config directory records the open project and its + latest auto-save. A clean quit removes it. If it is still there at the next launch, the app + offers to open the latest auto-save (Settings ▸ Startup & Repair can turn this off). +- File ▸ Open Recent lists recent projects (stored in the settings), File ▸ Revert reloads the + saved project, File ▸ Increment and Save saves `Intro.ecproj` as `Intro 2.ecproj`. + +Commands: `file.autoSave` (now), `file.recoveryInfo`, `file.openRecent {index? | path?}`, +`file.clearRecent`, `file.revert`, `file.incrementAndSave`.