M3.7: auto-save + crash recovery

PRJ-7. `engine::autosave` with the session hooks the frontends call:

- Auto-save: every N minutes a dirty project is written to "EffectCraft Auto-Save" next to
  it (or a custom folder; untitled projects use the custom folder or the config dir) as
  "<name> auto-save N.ecproj", rotating through max versions (oldest overwritten, extra
  slots dropped when the maximum shrinks). Optional save when the render queue starts.
- Atomic writes for projects, auto-saves and config files: temp file + fsync + rename; a
  failed write removes the temp file and leaves the previous file intact.
- Crash recovery: a session.lock sentinel in the config dir records the open project and
  its latest auto-save; a clean exit removes it. At launch a leftover sentinel offers the
  latest auto-save in a Recover Project dialog (Settings > Startup can turn it off).
- File > Open Recent (stored in the settings) with Clear Recent Projects; Increment and
  Save uses After Effects naming ("Intro 2.ecproj"); Revert reloads the saved file.
- Commands: file.autoSave, file.recoveryInfo, file.openRecent {index|path},
  file.clearRecent.
- Tests: rotation, interval/dirty tick, simulated crash mid-write, unclean-exit detection
  and recovery, Open Recent / Increment and Save / Revert.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Brandon ThomasandClaude Opus 5.5 committed 2026-10-02 09:15:04 -04:00
1 parent 35f429363e
commit 590f2f67ba
17 files changed
+651 -22

No files matched your search

+1 -1
View File
@@ -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.
+2 -2
View File
@@ -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).
+6 -1
View File
@@ -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()
}));
+193
View File
@@ -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 `<name> 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<f64>,
/// Slot written last in this session.
pub last_slot: Option<u32>,
/// Revision written last (an unchanged project isn't saved again).
pub saved_revision: Option<u64>,
/// Path of the latest auto-save.
pub last_path: Option<String>,
/// 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())
}
/// `<stem> 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<u32> {
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<PathBuf> {
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<u32>, 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<u32>, 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<PathBuf> {
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<String>,
/// Latest auto-save of this run.
pub autosave: Option<String>,
/// 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<String>,
/// The auto-save to offer (exists on disk).
pub autosave: Option<String>,
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<Recovery> {
let found = store.read(SENTINEL).map(|t| serde_json::from_str::<Sentinel>(&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::<u32>().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;
}
}
+8 -7
View File
@@ -18,9 +18,11 @@ fn new_project(s: &mut Session, _: &Value) -> Result<Value> {
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<Value> {
Ok(json!({"comp": s.state.active_comp.map(|c| c.0)}))
}
fn open(s: &mut Session, p: &Value) -> Result<Value> {
pub(crate) fn open(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
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<Value> {
fn increment_save(s: &mut Session, _: &Value) -> Result<Value> {
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::<u32>().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<Value> {
@@ -344,6 +344,14 @@ fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
fn render(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> = 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}))
+55 -1
View File
@@ -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<Value> {
Ok(json!(c))
}
// ---------------------------------------------------------------- recent / auto-save
fn open_recent(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
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<Value> {
let path = s.autosave_now()?;
s.toast(format!("Auto-saved to {path}"));
Ok(json!({"path": path}))
}
fn recovery_info(s: &mut Session, _: &Value) -> Result<Value> {
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<CommandSpec> {
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<CommandSpec> {
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),
]
}
+5 -4
View File
@@ -8,6 +8,7 @@
//! The project is an `Arc<Project>` 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<shortcuts::UiCommand>,
/// Cache of the resolved active preset (read it with [`Session::shortcuts`]).
pub shortcut_table: std::sync::OnceLock<shortcuts::ShortcutTable>,
/// 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(),
}
}
}
+2
View File
@@ -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
+89 -2
View File
@@ -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<std::path::PathBuf> {
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<String> {
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<Result<String>> {
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<autosave::Recovery> {
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;
}
}
}
+141 -3
View File
@@ -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<String> = 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<u32> = 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<u32> = 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<dyn ConfigStore> = 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);
}
+30
View File
@@ -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<u64>,
/// A previous run that didn't exit cleanly (shown by `Dialog::Recovery`).
pub recovery: Option<effectcraft_engine::autosave::Recovery>,
}
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<egui::Event> {
@@ -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.
+21
View File
@@ -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 });
+1
View File
@@ -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),
+65
View File
@@ -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;
}
}
+6
View File
@@ -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).
+18 -1
View File
@@ -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
`<name> 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`.