mirror of
https://github.com/storytold/soundcraft.git
synced 2026-10-24 02:26:46 -06:00
Autosaves and the takes of a session never saved are unsaved work. Their folders were made with create_dir_all, so on Unix they got the default mode and other accounts on the machine could read them. The folders the autosaver creates, and the folder a never-saved session records into, are now 0o700. A folder that already exists keeps its mode. The same change as storytold/pdfcraft#3320 and storytold/vectorcraft#3406.
668 lines
30 KiB
Rust
668 lines
30 KiB
Rust
//! Crash-recovery copies of open sessions, one folder each, written in the background, and the
|
|
//! recovery of the ones left behind with unsaved work.
|
|
|
|
use crate::io::{Recording, open_session_linked, 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::mpsc::{Receiver, RecvTimeoutError, Sender, channel};
|
|
use std::sync::{Arc, Mutex, PoisonError, Weak};
|
|
use std::time::{Duration, Instant, SystemTime};
|
|
|
|
const SESSION: &str = "session.scraft";
|
|
const ORIGIN: &str = "origin";
|
|
const LOCK: &str = "lock";
|
|
/// The plan of a recording still running when the folder was written.
|
|
const RECORDING: &str = "recording.json";
|
|
/// While changes keep coming, the newest is written at most this often.
|
|
const INTERVAL: Duration = Duration::from_secs(1);
|
|
const STOPPED: &str = "the autosave thread stopped";
|
|
|
|
/// Writes autosaves on a thread of its own, so the UI never waits on the disk.
|
|
pub struct Autosaver {
|
|
jobs: Option<Sender<Job>>,
|
|
thread: Option<std::thread::JoinHandle<()>>,
|
|
failure: Arc<Mutex<Option<String>>>,
|
|
}
|
|
|
|
enum Job {
|
|
Write(Arc<Session>, Option<String>, Option<Recording>),
|
|
/// Done with the session's folder: keep it for the next launch, or delete it.
|
|
Close(bool),
|
|
Flush(Sender<std::result::Result<(), String>>),
|
|
}
|
|
|
|
impl Autosaver {
|
|
/// A writer for autosave folders under `root`.
|
|
pub fn new(root: PathBuf) -> Autosaver {
|
|
let (tx, rx) = channel();
|
|
let failure = Arc::new(Mutex::new(None));
|
|
let writer = Writer { root, failure: Arc::clone(&failure), folder: None, pending: None, last: None, written: Ok(()) };
|
|
let thread = std::thread::Builder::new().name("soundcraft-autosave".into()).spawn(move || writer.run(&rx));
|
|
if let Err(e) = &thread {
|
|
report(&failure, format!("{STOPPED}: {e}"));
|
|
}
|
|
Autosaver { jobs: Some(tx), thread: thread.ok(), failure }
|
|
}
|
|
|
|
/// Autosave `session`, opened from the session file `origin`, soon, with the plan of the
|
|
/// recording running in it.
|
|
pub fn write(&self, session: Arc<Session>, origin: Option<String>, recording: Option<Recording>) {
|
|
self.send(Job::Write(session, origin, recording));
|
|
}
|
|
|
|
/// Done with the open session's folder: keep it for the next launch, or delete it.
|
|
pub fn close(&self, keep: bool) {
|
|
self.send(Job::Close(keep));
|
|
}
|
|
|
|
/// Wait until everything handed over so far is done. A failure returned here isn't reported
|
|
/// again by `failure`.
|
|
pub fn flush(&self) -> Result<()> {
|
|
let (tx, rx) = channel();
|
|
self.send(Job::Flush(tx));
|
|
let done = rx.recv().unwrap_or_else(|_| Err(STOPPED.into()));
|
|
if done.is_err() {
|
|
self.failure();
|
|
}
|
|
done.map_err(EngineError::Io)
|
|
}
|
|
|
|
/// The newest failure since the last call.
|
|
pub fn failure(&self) -> Option<String> {
|
|
self.failure.lock().unwrap_or_else(PoisonError::into_inner).take()
|
|
}
|
|
|
|
fn send(&self, job: Job) {
|
|
if self.jobs.as_ref().is_none_or(|tx| tx.send(job).is_err()) {
|
|
report(&self.failure, STOPPED.into());
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Drop for Autosaver {
|
|
/// What was handed over is written, and an open folder stays for the next launch.
|
|
fn drop(&mut self) {
|
|
drop(self.jobs.take());
|
|
if let Some(t) = self.thread.take() {
|
|
let _ = t.join();
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The writer thread.
|
|
struct Writer {
|
|
root: PathBuf,
|
|
failure: Arc<Mutex<Option<String>>>,
|
|
/// The open session's folder.
|
|
folder: Option<Autosave>,
|
|
/// The newest session not written yet.
|
|
pending: Option<(Arc<Session>, Option<String>, Option<Recording>)>,
|
|
last: Option<Instant>,
|
|
/// How the newest write into the open folder went, for `Flush`.
|
|
written: std::result::Result<(), String>,
|
|
}
|
|
|
|
impl Writer {
|
|
fn run(mut self, jobs: &Receiver<Job>) {
|
|
loop {
|
|
let wait = match (&self.pending, self.last) {
|
|
(None, _) => Duration::MAX,
|
|
(Some(_), last) => last.map_or(Duration::ZERO, |t| INTERVAL.saturating_sub(t.elapsed())),
|
|
};
|
|
match jobs.recv_timeout(wait) {
|
|
Ok(Job::Write(session, origin, recording)) => self.pending = Some((session, origin, recording)),
|
|
Ok(Job::Flush(reply)) => {
|
|
self.write_pending();
|
|
let _ = reply.send(self.written.clone());
|
|
}
|
|
Ok(Job::Close(keep)) => {
|
|
if keep {
|
|
self.write_pending();
|
|
}
|
|
self.pending = None;
|
|
self.written = Ok(());
|
|
if let Some(a) = self.folder.take()
|
|
&& !keep
|
|
{
|
|
a.discard();
|
|
}
|
|
}
|
|
Err(RecvTimeoutError::Timeout) => self.write_pending(),
|
|
Err(RecvTimeoutError::Disconnected) => {
|
|
self.write_pending();
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn write_pending(&mut self) {
|
|
let Some((session, origin, recording)) = self.pending.take() else { return };
|
|
self.last = Some(Instant::now());
|
|
let written = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| -> Result<()> {
|
|
if self.folder.is_none() {
|
|
self.folder = Some(Autosave::create(&self.root, &session.name)?);
|
|
}
|
|
self.folder.as_mut().map_or(Ok(()), |a| a.write(&session, origin.as_deref(), recording.as_ref()))
|
|
}));
|
|
self.written = match written {
|
|
Ok(written) => written.map_err(|e| e.to_string()),
|
|
Err(_) => Err("internal error (see the log)".into()),
|
|
};
|
|
if let Err(e) = &self.written {
|
|
report(&self.failure, e.clone());
|
|
}
|
|
}
|
|
}
|
|
|
|
fn report(failure: &Mutex<Option<String>>, e: String) {
|
|
log::warn!("autosave failed: {e}");
|
|
*failure.lock().unwrap_or_else(PoisonError::into_inner) = Some(e);
|
|
}
|
|
|
|
/// 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.
|
|
struct Autosave {
|
|
dir: PathBuf,
|
|
lock: Option<std::fs::File>,
|
|
copies: BTreeMap<SourceId, (Weak<SourceAudio>, String)>,
|
|
next: u64,
|
|
/// A recording's plan is in the folder.
|
|
recording: bool,
|
|
}
|
|
|
|
impl Autosave {
|
|
/// A new folder under `root` for the session called `name`.
|
|
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 = create_folder(&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, recording: false })
|
|
}
|
|
|
|
/// Write `session`, opened from the session file `origin`, and the plan of the `recording`
|
|
/// running in it. Audio saved with it is referenced where it is; audio that exists only in
|
|
/// memory is copied, once per recording or render.
|
|
fn write(&mut self, session: &Session, origin: Option<&str>, recording: Option<&Recording>) -> Result<()> {
|
|
let base = origin.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 = 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
|
|
&& (audio.is_none() || !src.unsaved && f.is_file())
|
|
{
|
|
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()))?;
|
|
create_folder(&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), origin.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));
|
|
}
|
|
if let Some(r) = recording {
|
|
let plan = serde_json::to_string(r).map_err(|err| EngineError::Io(err.to_string()))?;
|
|
write_atomically(&self.dir.join(RECORDING), plan.as_bytes())?;
|
|
} else if self.recording {
|
|
let _ = std::fs::remove_file(self.dir.join(RECORDING));
|
|
}
|
|
self.recording = recording.is_some();
|
|
Ok(())
|
|
}
|
|
|
|
/// Delete the folder: the session closed with nothing left to recover.
|
|
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,
|
|
/// A recording was still running in it.
|
|
pub recording: bool,
|
|
}
|
|
|
|
/// Leftover autosaves under `root`, newest first: folders no open session holds, written after the
|
|
/// session file they came from or holding a recording. 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());
|
|
let recording = dir.join(RECORDING).is_file();
|
|
drop(lock);
|
|
match saved {
|
|
Ok(saved) if recording || 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, recording });
|
|
}
|
|
_ => {
|
|
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,
|
|
/// with the takes of a recording that never finished placed as Stop would have. 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 session = left.dir.join(SESSION);
|
|
let links = left.origin.as_deref().map_or(session.as_path(), Path::new);
|
|
let mut missing = open_session_linked(&mut r, &session.to_string_lossy(), links)?;
|
|
let mut s = r.session().clone();
|
|
let unfinished = unfinished_takes(left, &s, &mut missing);
|
|
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();
|
|
if let Some((plan, takes)) = unfinished {
|
|
crate::io::place_recording(e, &plan, takes);
|
|
}
|
|
Ok(missing)
|
|
}
|
|
|
|
/// The takes of the recording `left` holds the plan of, repaired, minus those already in `s`.
|
|
/// A take the plan names that isn't a take file SoundCraft recorded, in a folder it records into,
|
|
/// is left untouched and added to `skipped`.
|
|
fn unfinished_takes(left: &Leftover, s: &Session, skipped: &mut Vec<String>) -> Option<(Recording, Vec<(soundcraft_model::TrackId, PathBuf)>)> {
|
|
let plan: Recording = serde_json::from_str(&std::fs::read_to_string(left.dir.join(RECORDING)).ok()?).ok()?;
|
|
let known: Vec<PathBuf> =
|
|
s.sources.iter().filter(|src| s.pool.get(src.id).is_some()).filter_map(|src| std::fs::canonicalize(&src.path).ok()).collect();
|
|
let folders = take_folders(left);
|
|
let mut takes = Vec::new();
|
|
for t in &plan.takes {
|
|
if std::fs::canonicalize(&t.path).is_ok_and(|p| known.contains(&p)) {
|
|
continue;
|
|
}
|
|
match repair_take(&t.path, &folders, t.channels, plan.rate) {
|
|
Ok(frames) if frames > 0 => takes.push((t.track, t.path.clone())),
|
|
Ok(_) => {}
|
|
Err(err) => {
|
|
log::warn!("recovery skipped the take {}: {err}", t.path.display());
|
|
skipped.push(format!("{}: {err}", t.path.display()));
|
|
}
|
|
}
|
|
}
|
|
Some((plan, takes))
|
|
}
|
|
|
|
/// The folders a recording in `left`'s session writes its takes into: the `Audio Files` next to
|
|
/// the session file, or, never saved, the one in its folder under the autosave root.
|
|
fn take_folders(left: &Leftover) -> Vec<PathBuf> {
|
|
let saved = left.origin.as_deref().and_then(|o| Path::new(o).parent()).map(|d| d.join("Audio Files"));
|
|
let unsaved = left.dir.parent().map(|root| root.join(sanitize_name(&left.name)).join("Audio Files"));
|
|
saved.into_iter().chain(unsaved).filter_map(|d| std::fs::canonicalize(d).ok()).collect()
|
|
}
|
|
|
|
/// Make a take file's header cover all the audio in it; returns its frames. The recorder
|
|
/// rewrites the header only every second, so a crash leaves up to a second uncovered. Only a
|
|
/// regular file in one of `folders` that starts with the header the recorder writes is touched.
|
|
fn repair_take(path: &Path, folders: &[PathBuf], channels: usize, rate: u32) -> std::io::Result<u64> {
|
|
let refuse = |why: &str| std::io::Error::new(std::io::ErrorKind::InvalidData, why.to_string());
|
|
if !std::fs::symlink_metadata(path)?.file_type().is_file() {
|
|
return Err(refuse("not a regular file"));
|
|
}
|
|
let folder = path.parent().and_then(|d| std::fs::canonicalize(d).ok());
|
|
if !folder.is_some_and(|d| folders.contains(&d)) {
|
|
return Err(refuse("not in a folder SoundCraft records into"));
|
|
}
|
|
let header = |frames| soundcraft_audio_io::wav_float_header(channels, rate, frames).map_err(std::io::Error::other);
|
|
let expected = header(0)?;
|
|
let mut f = std::fs::OpenOptions::new().read(true).write(true).open(path)?;
|
|
let mut head = vec![0u8; expected.len()];
|
|
if std::io::Read::read_exact(&mut f, &mut head).is_err() || !is_take_header(&head, &expected) {
|
|
return Err(refuse("not a take SoundCraft recorded"));
|
|
}
|
|
let audio = f.metadata()?.len().saturating_sub(expected.len() as u64);
|
|
let frames = audio / (channels.max(1) as u64 * 4);
|
|
std::io::Seek::rewind(&mut f)?;
|
|
f.write_all(&header(frames)?)?;
|
|
f.sync_all()?;
|
|
Ok(frames)
|
|
}
|
|
|
|
/// `head` is a take header like `expected` (the recorder's header for no frames yet): RIFF or
|
|
/// RF64 WAVE, the same format chunk at the same place, and the data chunk right after it all.
|
|
/// Only the sizes and frame counts may differ.
|
|
fn is_take_header(head: &[u8], expected: &[u8]) -> bool {
|
|
let magic = matches!(head.get(..4), Some(b"RIFF" | b"RF64")) && head.get(8..12) == Some(b"WAVE");
|
|
let fmt = expected.windows(4).position(|w| w == b"fmt ");
|
|
let fmt_len = fmt.and_then(|at| expected.get(at + 4..at + 8)).and_then(|b| b.try_into().ok()).map(u32::from_le_bytes);
|
|
let same_fmt = match (fmt, fmt_len) {
|
|
(Some(at), Some(n)) => {
|
|
let end = at.saturating_add(8).saturating_add(n as usize);
|
|
expected.get(at..end).is_some_and(|chunk| head.get(at..end) == Some(chunk))
|
|
}
|
|
_ => false,
|
|
};
|
|
let data = expected.len().checked_sub(8).and_then(|at| head.get(at..at + 4)) == Some(b"data");
|
|
magic && same_fmt && data
|
|
}
|
|
|
|
/// Delete a leftover autosave.
|
|
pub fn discard(left: &Leftover) {
|
|
let _ = std::fs::remove_dir_all(&left.dir);
|
|
}
|
|
|
|
/// Create `dir` and its missing parents under the autosave root. On Unix every folder made here is
|
|
/// owner-only (`0o700`): autosaves and the takes of a session never saved are unsaved work, and
|
|
/// with the default mode other accounts on the machine could read them. A folder that already
|
|
/// exists keeps its mode.
|
|
pub fn create_folder(dir: &Path) -> std::io::Result<()> {
|
|
let mut builder = std::fs::DirBuilder::new();
|
|
builder.recursive(true);
|
|
#[cfg(unix)]
|
|
std::os::unix::fs::DirBuilderExt::mode(&mut builder, 0o700);
|
|
builder.create(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 crate::io::open_session;
|
|
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.session(), e.path.as_deref(), None).unwrap();
|
|
a.write(e.session(), e.path.as_deref(), None).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.session(), None, None).unwrap();
|
|
e.execute("edit.undo", &json!({})).unwrap();
|
|
record(&mut e, -0.25);
|
|
a.write(e.session(), None, None).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 the_writer_saves_the_newest_change_and_drops_the_folder_when_done() {
|
|
let root = folder("autosave-writer");
|
|
let w = Autosaver::new(root.clone());
|
|
let mut e = Engine::default();
|
|
for _ in 0..5 {
|
|
record(&mut e, 0.5);
|
|
w.write(e.session_arc(), None, None);
|
|
}
|
|
w.flush().unwrap();
|
|
let file = std::fs::read_dir(&root).unwrap().flatten().map(|d| d.path().join(SESSION)).collect::<Vec<_>>();
|
|
let mut r = Engine::default();
|
|
open_session(&mut r, &file[0].to_string_lossy()).unwrap();
|
|
assert_eq!((file.len(), r.session().sources.len()), (1, 5));
|
|
w.close(false);
|
|
w.flush().unwrap();
|
|
assert_eq!(std::fs::read_dir(&root).unwrap().count(), 0);
|
|
record(&mut e, 0.5);
|
|
w.write(e.session_arc(), None, None);
|
|
drop(w);
|
|
let mut r = Engine::default();
|
|
recover(&mut r, &leftovers(&root)[0]).unwrap();
|
|
assert_eq!(r.session().sources.len(), 6);
|
|
let _ = std::fs::remove_dir_all(&root);
|
|
}
|
|
|
|
/// Autosaves are unsaved work: on Unix the folders the autosaver makes are owner-only, and an
|
|
/// autosave root that already exists keeps its mode.
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn the_autosaver_makes_owner_only_folders() {
|
|
use std::os::unix::fs::PermissionsExt as _;
|
|
let mode = |p: &Path| std::fs::metadata(p).unwrap().permissions().mode() & 0o777;
|
|
let base = folder("autosave-modes");
|
|
let mut e = Engine::default();
|
|
record(&mut e, 0.5);
|
|
// A root that doesn't exist yet is made with the first autosave, owner-only like the
|
|
// session's folder and the audio copied into it.
|
|
let new = base.join("Autosave");
|
|
let w = Autosaver::new(new.clone());
|
|
w.write(e.session_arc(), None, None);
|
|
w.flush().unwrap();
|
|
let session = std::fs::read_dir(&new).unwrap().flatten().map(|d| d.path()).next().unwrap();
|
|
assert_eq!((mode(&new), mode(&session), mode(&session.join("Audio Files"))), (0o700, 0o700, 0o700));
|
|
w.close(false);
|
|
w.flush().unwrap();
|
|
// A root that already exists keeps its mode; the folders made inside it are owner-only,
|
|
// also the one a session never saved records its takes into.
|
|
let old = base.join("old");
|
|
std::fs::create_dir_all(&old).unwrap();
|
|
std::fs::set_permissions(&old, std::fs::Permissions::from_mode(0o755)).unwrap();
|
|
let w = Autosaver::new(old.clone());
|
|
w.write(e.session_arc(), None, None);
|
|
w.flush().unwrap();
|
|
let session = std::fs::read_dir(&old).unwrap().flatten().map(|d| d.path()).next().unwrap();
|
|
create_folder(&old.join("Untitled")).unwrap();
|
|
assert_eq!((mode(&old), mode(&session), mode(&old.join("Untitled"))), (0o755, 0o700, 0o700));
|
|
w.close(false);
|
|
w.flush().unwrap();
|
|
let _ = std::fs::remove_dir_all(&base);
|
|
}
|
|
|
|
#[test]
|
|
fn a_take_still_recording_at_a_crash_is_placed_on_recovery() {
|
|
use crate::io::Take;
|
|
let dir = folder("autosave-take");
|
|
let root = dir.join("Autosave");
|
|
let mut e = Engine::default();
|
|
let track =
|
|
soundcraft_model::TrackId(e.execute("track.new", &json!({"name": "Vox", "format": "mono"})).unwrap()["tracks"][0].as_u64().unwrap());
|
|
let take = root.join(sanitize_name(&e.session().name)).join("Audio Files/Vox_01.wav");
|
|
std::fs::create_dir_all(take.parent().unwrap()).unwrap();
|
|
let audio: Vec<u8> = (0..480).flat_map(|i| (i as f32 / 1000.0).to_le_bytes()).collect();
|
|
std::fs::write(&take, [soundcraft_audio_io::wav_float_header(1, 48_000, 100).unwrap(), audio].concat()).unwrap();
|
|
let plan = Recording { start: 48_000, pass: None, punch: None, rate: 48_000, takes: vec![Take { track, path: take, channels: 1 }] };
|
|
let clips = |e: &Engine| e.session().track(track).unwrap().clips().iter().map(|c| (c.start, c.length)).collect::<Vec<_>>();
|
|
let w = Autosaver::new(root.clone());
|
|
w.write(e.session_arc(), None, Some(plan.clone()));
|
|
drop(w);
|
|
let left = leftovers(&root);
|
|
assert!(left[0].recording);
|
|
let mut r = Engine::default();
|
|
recover(&mut r, &left[0]).unwrap();
|
|
assert_eq!(clips(&r), [(48_000, 480)], "all of the take, its last second included");
|
|
discard(&left[0]);
|
|
let w = Autosaver::new(root.clone());
|
|
w.write(r.session_arc(), None, Some(plan));
|
|
drop(w);
|
|
let mut again = Engine::default();
|
|
recover(&mut again, &leftovers(&root)[0]).unwrap();
|
|
assert_eq!((clips(&again), again.session().sources.len()), (vec![(48_000, 480)], 1), "placed once");
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
}
|
|
|
|
#[test]
|
|
fn recovery_never_writes_a_file_that_isnt_a_take_in_a_take_folder() {
|
|
use crate::io::Take;
|
|
let dir = folder("autosave-hostile-take");
|
|
let root = dir.join("Autosave");
|
|
let mut e = Engine::default();
|
|
let track =
|
|
soundcraft_model::TrackId(e.execute("track.new", &json!({"name": "Vox", "format": "mono"})).unwrap()["tracks"][0].as_u64().unwrap());
|
|
let takes = root.join(sanitize_name(&e.session().name)).join("Audio Files");
|
|
std::fs::create_dir_all(&takes).unwrap();
|
|
let wav = [soundcraft_audio_io::wav_float_header(1, 48_000, 0).unwrap(), vec![0; 4_000]].concat();
|
|
let text = b"not audio at all, but long enough to be mistaken for a header if nobody looked at it ......".repeat(4);
|
|
let files =
|
|
[(takes.join("notes.wav"), text.clone()), (dir.join("Elsewhere/Vox_01.wav"), wav.clone()), (takes.join("../Vox_02.wav"), wav.clone())];
|
|
for (path, bytes) in &files {
|
|
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
|
std::fs::write(path, bytes).unwrap();
|
|
}
|
|
// Only the symlink case below adds to it, and symlinks are a Unix-only part of the test.
|
|
#[cfg_attr(not(unix), allow(unused_mut))]
|
|
let mut paths: Vec<PathBuf> = files.iter().map(|(p, _)| p.clone()).collect();
|
|
#[cfg(unix)]
|
|
{
|
|
let link = takes.join("Vox_03.wav");
|
|
std::os::unix::fs::symlink(&files[1].0, &link).unwrap();
|
|
paths.push(link);
|
|
}
|
|
let plan = Recording {
|
|
start: 0,
|
|
pass: None,
|
|
punch: None,
|
|
rate: 48_000,
|
|
takes: paths.iter().map(|p| Take { track, path: p.clone(), channels: 1 }).collect(),
|
|
};
|
|
let w = Autosaver::new(root.clone());
|
|
w.write(e.session_arc(), None, Some(plan));
|
|
drop(w);
|
|
let mut r = Engine::default();
|
|
let skipped = recover(&mut r, &leftovers(&root)[0]).unwrap();
|
|
assert_eq!(skipped.len(), paths.len(), "{skipped:?}");
|
|
assert!(r.session().sources.is_empty());
|
|
for (path, bytes) in &files {
|
|
assert_eq!(&std::fs::read(path).unwrap(), bytes, "{} untouched", path.display());
|
|
}
|
|
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.session(), e.path.as_deref(), None).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);
|
|
}
|
|
}
|