Autosave each session to its own folder and offer unsaved work at launch

Autosave wrote every session called "Untitled" to the same
Autosave/Untitled folder and reused audio files there by source id, so
a second untitled session, or a take recorded after an undo, could be
autosaved with another take's audio. Its first run also copied all of
the session's audio again. Nothing ever offered an autosave back
(latest_autosave had no callers), so after a crash the work could only
be found by digging in the settings folder.

Each open session now autosaves to a folder of its own, locked while
the session is open. Audio already saved with the session is referenced
where it is; audio that exists only in memory is copied once per
recording or render. Saving, closing the session, opening another and
quitting with SoundCraft's Quit delete the folder. A crash, or an exit
that skips Quit with unsaved changes, leaves it behind, after one last
autosave when SoundCraft gets the chance. At the next launch,
SoundCraft offers each kept folder, newest first, to recover or
discard. Recovered work opens as an unsaved version of the session it
came from, so Save writes it back there; unsaved changes in the session
it replaces are kept the same way. Folders older than their session
file are dropped.
This commit is contained in:
grovebit 2026-10-11 20:36:15 +01:00
parent df0c4790b7
commit b5ea465274
6 changed files with 489 additions and 7 deletions

View File

@ -88,6 +88,7 @@ impl eframe::App for App {
self.0.ui(ui);
}
fn on_exit(&mut self) {
self.0.close_autosave();
save_prefs(&self.0.ui);
if let Some(logger) = self.1 {
logger.report_audio_thread();
@ -258,6 +259,7 @@ fn main() -> eframe::Result {
let player = if no_audio { None } else { Some(soundcraft_playback::Player::new(Arc::new(engine.session().clone()))) };
let mut app = SoundApp::new(engine, player, services());
app.autosave_dir = prefs_path().and_then(|p| p.parent().map(|d| d.join("Autosave")));
app.leftovers = app.autosave_dir.as_deref().map(soundcraft_engine::autosave::leftovers).unwrap_or_default();
app.preset_dir = prefs_path().and_then(|p| p.parent().map(|d| d.join("Presets")));
app.extra.ui_scale = prefs.ui_scale;
app.ui = prefs;

View File

@ -0,0 +1,268 @@
//! Crash-recovery copies of open sessions, one folder each, and the recovery of the ones left
//! behind with unsaved work.
use crate::io::{open_session, sanitize_name};
use crate::{Engine, EngineError, Result};
use soundcraft_audio_io::{BitDepth, EncodeOptions, FileFormat};
use soundcraft_model::{Session, SourceAudio, SourceId};
use std::collections::BTreeMap;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Weak};
use std::time::SystemTime;
const SESSION: &str = "session.scraft";
const ORIGIN: &str = "origin";
const LOCK: &str = "lock";
/// An open session's autosave folder, locked while the session is open so a later launch can tell
/// it from one a crash left behind.
pub struct Autosave {
dir: PathBuf,
lock: Option<std::fs::File>,
copies: BTreeMap<SourceId, (Weak<SourceAudio>, String)>,
next: u64,
}
impl Autosave {
/// A new folder under `root` for the session called `name`.
pub fn create(root: &Path, name: &str) -> Result<Autosave> {
let stamp = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).unwrap_or_default().as_millis();
let dir = root.join(format!("{} {stamp}-{}", sanitize_name(name), std::process::id()));
let lock = std::fs::create_dir_all(&dir).and_then(|()| std::fs::File::create(dir.join(LOCK))).map_err(|err| io_error(&dir, err))?;
lock.try_lock().map_err(|err| EngineError::Io(format!("{}: {err}", dir.display())))?;
Ok(Autosave { dir, lock: Some(lock), copies: BTreeMap::new(), next: 1 })
}
/// Write the session as it is now. Audio saved with it is referenced where it is; audio that
/// exists only in memory is copied, once per recording or render.
pub fn write(&mut self, e: &Engine) -> Result<()> {
let base = e.path.as_deref().and_then(|p| Path::new(p).parent()).and_then(|p| std::path::absolute(p).ok());
let audio_dir = self.dir.join("Audio Files");
let mut s = e.session().clone();
let mut copies = BTreeMap::new();
for src in &mut s.sources {
let file = if Path::new(&src.path).is_absolute() { Some(PathBuf::from(&src.path)) } else { base.as_ref().map(|b| b.join(&src.path)) };
let audio = s.pool.get(src.id);
if let Some(f) = &file
&& (!src.unsaved && f.is_file() || audio.is_none())
{
src.path = f.to_string_lossy().into_owned();
continue;
}
let Some(audio) = audio else { continue };
let name = match self.copies.remove(&src.id) {
Some((copy, name)) if copy.upgrade().is_some_and(|a| Arc::ptr_eq(&a, audio)) => name,
_ => {
let name = format!("{} {}.wav", sanitize_name(&src.name), self.next);
self.next += 1;
let opts = EncodeOptions { format: FileFormat::Wav, bit_depth: BitDepth::Float32, dither: false, bwf: None };
let bytes = soundcraft_audio_io::encode(&audio.buffer, &opts).map_err(|err| EngineError::Io(err.to_string()))?;
std::fs::create_dir_all(&audio_dir)
.and_then(|()| write_synced(&audio_dir.join(&name), &bytes))
.map_err(|err| io_error(&audio_dir, err))?;
name
}
};
src.path = audio_dir.join(&name).to_string_lossy().into_owned();
src.unsaved = true;
copies.insert(src.id, (Arc::downgrade(audio), name));
}
let text = s.to_json().map_err(|err| EngineError::Io(err.to_string()))?;
write_atomically(&self.dir.join(ORIGIN), e.path.as_deref().unwrap_or_default().as_bytes())?;
write_atomically(&self.dir.join(SESSION), text.as_bytes())?;
for (_, name) in std::mem::replace(&mut self.copies, copies).into_values() {
let _ = std::fs::remove_file(audio_dir.join(name));
}
Ok(())
}
/// Delete the folder: the session closed with nothing left to recover.
pub fn discard(mut self) {
drop(self.lock.take());
let _ = std::fs::remove_dir_all(&self.dir);
}
}
/// Unsaved work left behind in an autosave folder.
#[derive(Debug, Clone, PartialEq)]
pub struct Leftover {
pub dir: PathBuf,
/// The session's name.
pub name: String,
/// The session file it was autosaved from, if it had one.
pub origin: Option<String>,
pub saved: SystemTime,
}
/// Leftover autosaves under `root`, newest first: folders no open session holds, written after the
/// session file they came from. Empty and outdated ones are deleted.
pub fn leftovers(root: &Path) -> Vec<Leftover> {
let mut out = Vec::new();
for dir in std::fs::read_dir(root).into_iter().flatten().flatten().map(|d| d.path()) {
let Ok(lock) = std::fs::OpenOptions::new().write(true).open(dir.join(LOCK)) else { continue };
if lock.try_lock().is_err() {
continue;
}
let origin = std::fs::read_to_string(dir.join(ORIGIN)).ok().filter(|o| !o.is_empty());
let origin_saved = origin.as_ref().and_then(|o| std::fs::metadata(o).and_then(|m| m.modified()).ok());
let saved = std::fs::metadata(dir.join(SESSION)).and_then(|m| m.modified());
drop(lock);
match saved {
Ok(saved) if origin_saved.is_none_or(|o| saved > o) => {
let text = std::fs::read_to_string(dir.join(SESSION)).unwrap_or_default();
let named = serde_json::from_str::<serde_json::Value>(&text).ok().and_then(|v| v.get("name")?.as_str().map(str::to_string));
let name = named.unwrap_or_else(|| dir.file_name().unwrap_or_default().to_string_lossy().into_owned());
out.push(Leftover { dir, name, origin, saved });
}
_ => {
let _ = std::fs::remove_dir_all(&dir);
}
}
}
out.sort_by_key(|l| std::cmp::Reverse(l.saved));
out
}
/// Open `left` as an unsaved version of the session it came from, so Save writes it back there.
/// Returns the media that couldn't be loaded.
pub fn recover(e: &mut Engine, left: &Leftover) -> Result<Vec<String>> {
let mut r = Engine::default();
let missing = open_session(&mut r, &left.dir.join(SESSION).to_string_lossy())?;
let mut s = r.session().clone();
if let Some(home) = left.origin.as_deref().and_then(|o| Path::new(o).parent()) {
for src in s.sources.iter_mut().filter(|src| !src.unsaved) {
if let Ok(rel) = Path::new(&src.path).strip_prefix(home) {
src.path = rel.components().map(|c| c.as_os_str().to_string_lossy()).collect::<Vec<_>>().join("/");
}
}
}
e.replace_session(Session::default());
*e.session_mut() = s;
e.path = left.origin.clone();
Ok(missing)
}
/// Delete a leftover autosave.
pub fn discard(left: &Leftover) {
let _ = std::fs::remove_dir_all(&left.dir);
}
fn write_synced(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
let mut f = std::fs::File::create(path)?;
f.write_all(bytes)?;
f.sync_all()
}
fn write_atomically(path: &Path, bytes: &[u8]) -> Result<()> {
let tmp = path.with_extension("tmp");
write_synced(&tmp, bytes).and_then(|()| std::fs::rename(&tmp, path)).map_err(|err| {
let _ = std::fs::remove_file(&tmp);
io_error(path, err)
})
}
fn io_error(path: &Path, err: std::io::Error) -> EngineError {
EngineError::Io(format!("{}: {err}", path.display()))
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use soundcraft_audio_io::AudioBuffer;
use soundcraft_model::{ChannelFormat, Clip, TrackKind};
fn folder(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("soundcraft-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
dir
}
fn record(e: &mut Engine, level: f32) {
let before = e.session_arc();
let s = e.session_mut();
let t = match s.tracks.first() {
Some(t) => t.id,
None => s.add_track(TrackKind::Audio, ChannelFormat::Mono, None),
};
let src =
crate::io::add_source(s, "Audio 1_01", AudioBuffer { sample_rate: 48_000, channels: vec![vec![level; 4_800]] }, None, FileFormat::Wav);
let id = s.new_clip_id();
crate::edit::place_clip(s, t, Clip::audio(id, "Audio 1_01", src, 0, 0, 4_800));
e.push_undo("Record", before);
}
fn levels(e: &Engine) -> Vec<f32> {
let s = e.session();
s.sources.iter().map(|src| s.pool.get(src.id).map_or(f32::NAN, |a| a.buffer.channels[0][0])).collect()
}
#[test]
fn saved_audio_is_referenced_and_takes_are_copied_once() {
let dir = folder("autosave-copies");
let path = dir.join("Song/Song.scraft");
let mut e = Engine::default();
record(&mut e, 0.5);
crate::io::save_session(&mut e, &path.to_string_lossy()).unwrap();
record(&mut e, -0.25);
let mut a = Autosave::create(&dir.join("Autosave"), "Song").unwrap();
a.write(&e).unwrap();
a.write(&e).unwrap();
assert_eq!(std::fs::read_dir(a.dir.join("Audio Files")).unwrap().count(), 1);
drop(a);
let left = leftovers(&dir.join("Autosave"));
let mut r = Engine::default();
assert_eq!(recover(&mut r, &left[0]).unwrap(), Vec::<String>::new());
let paths: Vec<_> = r.session().sources.iter().map(|s| (s.path.starts_with("Audio Files/"), s.unsaved)).collect();
assert_eq!(paths, [(true, false), (false, true)]);
assert_eq!(levels(&r), [0.5, -0.25]);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_take_recorded_after_undo_gets_its_own_copy() {
let dir = folder("autosave-undo");
let mut e = Engine::default();
record(&mut e, 0.5);
let mut a = Autosave::create(&dir, "Untitled").unwrap();
a.write(&e).unwrap();
e.execute("edit.undo", &json!({})).unwrap();
record(&mut e, -0.25);
a.write(&e).unwrap();
drop(a);
let mut r = Engine::default();
recover(&mut r, &leftovers(&dir)[0]).unwrap();
assert_eq!(levels(&r), [-0.25]);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn unsaved_work_is_offered_once_its_session_closes() {
let dir = folder("autosave-leftovers");
let path = dir.join("Song.scraft").to_string_lossy().into_owned();
let root = dir.join("Autosave");
std::fs::create_dir_all(&dir).unwrap();
let mut e = Engine::default();
crate::io::save_session(&mut e, &path).unwrap();
record(&mut e, 0.5);
let mut a = Autosave::create(&root, "Song").unwrap();
a.write(&e).unwrap();
assert!(leftovers(&root).is_empty());
drop(a);
let left = leftovers(&root);
assert_eq!((left.len(), left[0].name.as_str(), left[0].origin.as_deref()), (1, "Song", Some(path.as_str())));
let mut r = Engine::default();
recover(&mut r, &left[0]).unwrap();
assert!(r.is_dirty());
r.execute("session.save", &json!({})).unwrap();
assert!(leftovers(&root).is_empty());
assert!(!left[0].dir.exists());
let mut reopened = Engine::default();
open_session(&mut reopened, &path).unwrap();
assert_eq!(levels(&reopened), [0.5]);
Autosave::create(&root, "Song").unwrap().discard();
assert_eq!(std::fs::read_dir(&root).unwrap().count(), 0);
let _ = std::fs::remove_dir_all(&dir);
}
}

View File

@ -5,6 +5,7 @@
//! The engine owns the [`Session`] behind an `Arc` (copy-on-write), so undo snapshots are cheap.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
pub mod autosave;
pub mod catalog;
pub mod catalog_more;
pub mod clip_group_file;

View File

@ -18,6 +18,7 @@ pub mod mix_window;
pub mod ops_windows;
pub mod palette;
pub mod panels;
pub mod recovery;
pub mod score_editor;
pub mod shortcuts;
pub mod theme;
@ -232,6 +233,10 @@ pub struct SoundApp {
pub native_menu_bar: bool,
/// Folder for crash-recovery autosaves (native apps set it).
pub autosave_dir: Option<std::path::PathBuf>,
/// This session's autosave folder, once it has been autosaved.
autosaved: Option<soundcraft_engine::autosave::Autosave>,
/// Unsaved work that earlier runs left in the autosave folder, offered for recovery.
pub leftovers: Vec<soundcraft_engine::autosave::Leftover>,
/// Folder for user plugin presets (native apps set it).
pub preset_dir: Option<std::path::PathBuf>,
last_autosave: Option<std::time::Instant>,
@ -296,6 +301,8 @@ impl SoundApp {
services,
native_menu_bar: false,
autosave_dir: None,
autosaved: None,
leftovers: Vec::new(),
preset_dir: None,
last_autosave: None,
dialogs: dialogs::Dialogs::default(),
@ -343,8 +350,12 @@ impl SoundApp {
// Third-party plugins keep state the session can't see: store it first.
self.capture_plugin_states();
}
let replacing = matches!(id, "session.new" | "session.open" | "session.close" | "session.revert");
match self.engine.execute(id, &params) {
Ok(v) => {
if replacing || (id.starts_with("session.save") && !self.engine.is_dirty()) {
self.release_autosave(false);
}
if id == "app.quit" {
self.quit_requested = true;
}
@ -520,14 +531,62 @@ impl SoundApp {
}
self.last_autosave = Some(std::time::Instant::now());
self.capture_plugin_states();
let name = soundcraft_engine::io::sanitize_name(&self.engine.session().name);
let path = dir.join(&name).join(format!("{name}.scraft"));
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
self.autosave_to(&dir);
}
/// Write the session to its own folder under `root`; false when that failed.
fn autosave_to(&mut self, root: &std::path::Path) -> bool {
let written = match &mut self.autosaved {
Some(a) => a.write(&self.engine),
None => soundcraft_engine::autosave::Autosave::create(root, &self.engine.session().name)
.and_then(|mut a| a.write(&self.engine).map(|()| self.autosaved = Some(a))),
};
if let Err(e) = &written {
log::warn!("autosave failed: {e}");
self.ui.status = format!("Autosave failed: {e}");
}
match soundcraft_engine::io::save_copy(&self.engine, &path.to_string_lossy()) {
Ok(_) => log::info!("autosaved to {}", path.display()),
Err(e) => log::warn!("autosave failed: {e}"),
written.is_ok()
}
/// Autosave now, if the app has an autosave folder.
fn write_autosave(&mut self) -> bool {
self.autosave_dir.clone().is_some_and(|root| self.autosave_to(&root))
}
/// Done with this session's autosave folder: keep it for the next launch, or delete it.
fn release_autosave(&mut self, keep: bool) {
if let Some(a) = self.autosaved.take()
&& !keep
{
a.discard();
}
}
/// Done with the open session. Unsaved changes stay in its autosave folder for the next
/// launch, unless the user quit with SoundCraft's Quit.
pub fn close_autosave(&mut self) {
let keep = self.engine.is_dirty() && !self.quit_requested;
if keep {
self.capture_plugin_states();
self.write_autosave();
}
self.release_autosave(keep);
}
/// Replace the open session with unsaved work an earlier run left behind.
pub fn recover(&mut self, left: &soundcraft_engine::autosave::Leftover) {
self.close_autosave();
match soundcraft_engine::autosave::recover(&mut self.engine, left) {
Ok(missing) => {
if self.write_autosave() {
soundcraft_engine::autosave::discard(left);
}
self.ui.status = match missing.len() {
0 => format!("Recovered “{}”. Save it to keep the changes.", left.name),
n => format!("Recovered “{}”, but {n} audio file(s) are missing. Save it to keep the changes.", left.name),
};
}
Err(e) => self.ui.status = format!("Couldn't recover “{}”: {e}", left.name),
}
}
@ -899,6 +958,7 @@ impl SoundApp {
video_window::show(self, &ctx);
ops_windows::show(self, &ctx);
extra_windows::show(self, &ctx);
recovery::show(self, &ctx);
score_editor::show(self, &ctx);
palette::show(self, &ctx);
dialogs::show(self, &ctx);

View File

@ -0,0 +1,48 @@
//! The offer to recover unsaved work an earlier run left in the autosave folder.
use crate::SoundApp;
use crate::theme::{Tokens, bold};
use egui::{Align2, vec2};
/// Offer the newest leftover autosave, one at a time.
pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
let Some(left) = app.leftovers.first().cloned() else { return };
let mut open = true;
let mut recover = None;
egui::Window::new(egui::RichText::new("Recover Unsaved Work").font(bold(13.0)))
.open(&mut open)
.collapsible(false)
.resizable(false)
.anchor(Align2::CENTER_CENTER, vec2(0.0, -80.0))
.show(ctx, |ui| {
let age = left.saved.elapsed().map_or_else(|_| "recently".into(), |d| ago(d.as_secs()));
ui.label(format!("“{}” has unsaved changes from an earlier run, autosaved {age}.", left.name));
ui.separator();
ui.horizontal(|ui| {
if ui.button("Discard").clicked() {
recover = Some(false);
}
if ui.add(egui::Button::new(egui::RichText::new("Recover").strong()).fill(Tokens::current().accent)).clicked() {
recover = Some(true);
}
});
});
if !open || recover.is_some() {
app.leftovers.retain(|l| *l != left);
}
match recover {
Some(true) => app.recover(&left),
Some(false) => soundcraft_engine::autosave::discard(&left),
None => {}
}
}
fn ago(secs: u64) -> String {
let (n, unit) = match secs {
0..60 => return "less than a minute ago".into(),
60..3_600 => (secs / 60, "minute"),
3_600..86_400 => (secs / 3_600, "hour"),
_ => (secs / 86_400, "day"),
};
format!("{n} {unit}{} ago", if n == 1 { "" } else { "s" })
}

View File

@ -0,0 +1,103 @@
//! Unsaved work outlives a crash or an exit without Quit and is offered at the next launch.
use egui::vec2;
use egui_kittest::{Harness, kittest::Queryable};
use serde_json::json;
use soundcraft_engine::autosave::leftovers;
use soundcraft_ui_egui::{Services, SoundApp};
use std::path::{Path, PathBuf};
use std::sync::{Mutex, MutexGuard, PoisonError};
/// One test at a time: a child process another test starts (fontconfig's `fc-match`) holds copies
/// of this process's open files until it execs, so a released autosave lock can still look taken.
fn one_at_a_time() -> MutexGuard<'static, ()> {
static ONE: Mutex<()> = Mutex::new(());
ONE.lock().unwrap_or_else(PoisonError::into_inner)
}
fn folder(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("soundcraft-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
fn launch(root: &Path) -> SoundApp {
let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default());
app.autosave_dir = Some(root.to_path_buf());
app.leftovers = leftovers(root);
app
}
#[test]
fn an_exit_without_quit_offers_unsaved_changes_at_the_next_launch() {
let _one = one_at_a_time();
let dir = folder("recovery-quit");
let root = dir.join("Autosave");
let path = dir.join("Song.scraft");
let mut first = launch(&root);
first.run("session.save_as", json!({"path": path})).unwrap();
first.run("track.new", json!({"name": "Vocal"})).unwrap();
first.close_autosave();
drop(first);
let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_ui_state(
|ui, app: &mut SoundApp| {
app.logic(&ui.ctx().clone());
app.ui(ui);
},
launch(&root),
);
h.run_steps(3);
h.get_by_label("Recover").click();
h.run_steps(3);
let e = &h.state().engine;
assert!(e.session().track_by_name("Vocal").is_some() && e.is_dirty());
assert_eq!(e.path, Some(path.to_string_lossy().into_owned()));
assert!(leftovers(&root).is_empty());
assert_eq!(std::fs::read_dir(&root).unwrap().count(), 1);
h.state_mut().run("session.save", json!({})).unwrap();
assert_eq!(std::fs::read_dir(&root).unwrap().count(), 0);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn recovering_keeps_the_unsaved_work_it_replaces() {
let _one = one_at_a_time();
let dir = folder("recovery-replace");
let root = dir.join("Autosave");
let mut first = launch(&root);
first.run("track.new", json!({"name": "Vocal"})).unwrap();
first.close_autosave();
let left = leftovers(&root);
assert_eq!((left.len(), left[0].name.as_str(), left[0].origin.as_deref()), (1, "Untitled", None));
let mut next = launch(&root);
next.run("track.new", json!({"name": "Drums"})).unwrap();
next.recover(&left[0]);
assert!(next.engine.session().track_by_name("Vocal").is_some() && next.engine.path.is_none());
let kept = leftovers(&root);
assert_eq!(kept.len(), 1);
let mut third = launch(&root);
third.recover(&kept[0]);
assert!(third.engine.session().track_by_name("Drums").is_some());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn closing_the_session_or_quitting_drops_its_autosave() {
let _one = one_at_a_time();
let dir = folder("recovery-close");
let root = dir.join("Autosave");
for end in ["session.close", "app.quit"] {
let mut first = launch(&root);
first.run("track.new", json!({"name": "Vocal"})).unwrap();
first.close_autosave();
let mut next = launch(&root);
let left = next.leftovers[0].clone();
next.recover(&left);
assert_eq!(std::fs::read_dir(&root).unwrap().count(), 1, "{end}");
next.run(end, json!({})).unwrap();
next.close_autosave();
assert_eq!(std::fs::read_dir(&root).unwrap().count(), 0, "{end}");
}
let _ = std::fs::remove_dir_all(&dir);
}