diff --git a/apps/soundcraft/src/main.rs b/apps/soundcraft/src/main.rs index 28723b7..65db539 100644 --- a/apps/soundcraft/src/main.rs +++ b/apps/soundcraft/src/main.rs @@ -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; diff --git a/crates/engine/src/autosave.rs b/crates/engine/src/autosave.rs new file mode 100644 index 0000000..a989718 --- /dev/null +++ b/crates/engine/src/autosave.rs @@ -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, + copies: BTreeMap, String)>, + next: u64, +} + +impl Autosave { + /// A new folder under `root` for the session called `name`. + pub fn create(root: &Path, name: &str) -> Result { + 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, + 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 { + 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::(&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> { + 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::>().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 { + 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::::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); + } +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index b607534..c2a0b2d 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -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; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 5c4a751..30ea7f0 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -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, + /// This session's autosave folder, once it has been autosaved. + autosaved: Option, + /// Unsaved work that earlier runs left in the autosave folder, offered for recovery. + pub leftovers: Vec, /// Folder for user plugin presets (native apps set it). pub preset_dir: Option, last_autosave: Option, @@ -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, ¶ms) { 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); diff --git a/crates/ui-egui/src/recovery.rs b/crates/ui-egui/src/recovery.rs new file mode 100644 index 0000000..be2810c --- /dev/null +++ b/crates/ui-egui/src/recovery.rs @@ -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" }) +} diff --git a/crates/ui-egui/tests/recovery.rs b/crates/ui-egui/tests/recovery.rs new file mode 100644 index 0000000..73e0367 --- /dev/null +++ b/crates/ui-egui/tests/recovery.rs @@ -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); +}