diff --git a/ROADMAP.md b/ROADMAP.md index 73197650..596472ac 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: pre-alpha** · next: alpha, ~5% and ~110–180 h away (the alpha gate's three failing workflows) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (readiness by audience with hours to ~95 %) · **Target:** Avid Pro Tools Ultimate 2026.4.1 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (shared Windows installer UI; existing folder/shortcut/launch options retained; install and uninstall completion) · **Target:** Avid Pro Tools Ultimate 2026.4.1 SoundCraft is a clean-room, pure-Rust digital audio workstation that aims at full parity with Avid Pro Tools, and then beyond it: faster, open, scriptable and agent-controllable. This page is @@ -167,3 +167,29 @@ mutated and truncated movies. Field reports from the first public week are track | 2026-10-10 | major | Rewritten to the progress-docs standard: stage alpha (re-normalized the same day), two numbers (breadth ~70 %, ready ~40 %), dimension/feature/language tables, hours re-calibrated; area table moved to docs/target-app-parity.md, milestones to docs/roadmap.md | | 2026-10-07 | major | Menu parity 94 %, ~65 % estimated feature parity, alpha list | | 2026-10-06 | minor | Alpha and parity estimates | + +## Packaging update — 2026-10-10 + +Windows setup now uses the shared `craft-installer` native UI source, with install/repair/remove +completion, cancellation and failure screens. Existing MSI ownership and upgrade identities are +preserved; unattended modes remain unattended. Source/generator checks pass; native Windows +installer acceptance with real release payloads is still pending. See [setup validation](packaging/windows/README.md). + +## Packaging update (2026-10-11) + +Windows setup now uses the suite's shared `craft-installer` UI, preserving existing folder, +shortcut and launch options and app-owned installation policy. Install, uninstall and repair +have explicit completion screens; cancellation and failure have separate endings. Source/profile +checks, WiX UI library compilation for x64/x86/ARM64, PowerShell parsing and package identity +comparisons pass. Native fixture MSI linking/UI tables pass on all three architectures, and +Windows full UI install/repair/uninstall, x86 install/remove, folder upgrades, desktop choices +and launch checks pass with fixtures. Native evidence and real-release acceptance limits +are linked from +[Windows packaging](packaging/windows/README.md). + +Windows Settings removal now opens the shared completion screen. Quiet install/removal +remains unattended; Settings labels preserve compatibility with existing Launchers, and +previous Modify policy is retained. Windows 11 fixture verification covers both installation +scopes and x64/x86 removal, with full MSI validation on x64/x86/ARM64. + +- **2026-10-15** · Recovery retains autosaves whose timestamps equal the saved project. New autosave folders are created atomically and retry occupied names, preserving existing recovery data when runs collide. Regression tests cover timestamp ties and occupied folders. No readiness percentage change. diff --git a/crates/engine/src/autosave.rs b/crates/engine/src/autosave.rs index d42e81fd..c680e06e 100644 --- a/crates/engine/src/autosave.rs +++ b/crates/engine/src/autosave.rs @@ -179,10 +179,27 @@ impl Autosave { /// A new folder under `root` for the session called `name`. 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, recording: false }) + Self::create_with_stamp(root, name, stamp) + } + + fn create_with_stamp(root: &Path, name: &str, stamp: u128) -> Result { + std::fs::create_dir_all(root).map_err(|err| io_error(root, err))?; + let prefix = format!("{} {stamp}-{}", sanitize_name(name), std::process::id()); + // Several sessions can close in one clock tick. Claim a fresh directory atomically; + // reopening an existing one could overwrite work another session left for recovery. + for suffix in 0..1024 { + let dir = root.join(format!("{prefix}-{suffix}")); + match std::fs::create_dir(&dir) { + Ok(()) => { + let lock = 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())))?; + return Ok(Autosave { dir, lock: Some(lock), copies: BTreeMap::new(), next: 1, recording: false }); + } + Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(err) => return Err(io_error(&dir, err)), + } + } + Err(EngineError::Io(format!("{}: could not allocate a new autosave folder", root.display()))) } /// Write `session`, opened from the session file `origin`, and the plan of the `recording` @@ -257,7 +274,9 @@ pub struct Leftover { } /// 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. +/// session file they came from or holding a recording. Equal timestamps are ambiguous, so those +/// copies are retained too: a filesystem clock tick can cover both writes. Empty and provably +/// 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()) { @@ -271,7 +290,7 @@ pub fn leftovers(root: &Path) -> Vec { let recording = dir.join(RECORDING).is_file(); drop(lock); match saved { - Ok(saved) if recording || origin_saved.is_none_or(|o| saved > o) => { + 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::(&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()); @@ -423,6 +442,11 @@ mod tests { dir } + fn set_modified(path: &Path, time: SystemTime) { + std::fs::OpenOptions::new().write(true).open(path).unwrap().set_modified(time).unwrap(); + assert_eq!(std::fs::metadata(path).unwrap().modified().unwrap(), time); + } + fn record(e: &mut Engine, level: f32) { let before = e.session_arc(); let s = e.session_mut(); @@ -464,6 +488,82 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + #[test] + fn equal_timestamps_keep_unsaved_audio_for_recovery() { + let dir = folder("autosave-equal-timestamps"); + let path = dir.join("Song/Song.scraft"); + let root = dir.join("Autosave"); + 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(&root, "Song").unwrap(); + a.write(e.session(), e.path.as_deref(), None).unwrap(); + let saved = a.dir.join(SESSION); + let same_time = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000_000); + set_modified(&path, same_time); + set_modified(&saved, same_time); + assert!(leftovers(&root).is_empty(), "a live session still owns its recovery copy"); + drop(a); + let left = leftovers(&root); + assert_eq!(left.len(), 1, "equal timestamps cannot prove unsaved work obsolete"); + assert!(saved.is_file()); + let mut recovered = Engine::default(); + assert!(recover(&mut recovered, &left[0]).unwrap().is_empty()); + assert_eq!(levels(&recovered), [0.5, -0.25]); + recovered.execute("session.save", &json!({})).unwrap(); + set_modified(&path, same_time + Duration::from_secs(1)); + assert!(leftovers(&root).is_empty()); + assert!(!left[0].dir.exists()); + let mut reopened = Engine::default(); + open_session(&mut reopened, &path.to_string_lossy()).unwrap(); + assert_eq!(levels(&reopened), [0.5, -0.25]); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn same_tick_autosaves_preserve_each_closed_session() { + let root = folder("autosave-same-tick"); + let mut first = Engine::default(); + record(&mut first, 0.5); + let mut a = Autosave::create_with_stamp(&root, "Untitled", 1_000_000).unwrap(); + a.write(first.session(), None, None).unwrap(); + let first_dir = a.dir.clone(); + let first_session = std::fs::read(first_dir.join(SESSION)).unwrap(); + let first_audio = std::fs::read_dir(first_dir.join("Audio Files")).unwrap().next().unwrap().unwrap().path(); + let first_samples = std::fs::read(&first_audio).unwrap(); + drop(a); + let mut second = Engine::default(); + record(&mut second, -0.25); + let mut b = Autosave::create_with_stamp(&root, "Untitled", 1_000_000).unwrap(); + b.write(second.session(), None, None).unwrap(); + let second_dir = b.dir.clone(); + drop(b); + assert_ne!(first_dir, second_dir, "a new autosave must not reopen an earlier session's folder"); + assert_eq!(std::fs::read(first_dir.join(SESSION)).unwrap(), first_session); + assert_eq!(std::fs::read(first_audio).unwrap(), first_samples); + let left = leftovers(&root); + assert_eq!(left.len(), 2); + for (dir, expected) in [(first_dir, 0.5), (second_dir, -0.25)] { + let copy = left.iter().find(|copy| copy.dir == dir).unwrap(); + let mut restored = Engine::default(); + assert!(recover(&mut restored, copy).unwrap().is_empty()); + assert_eq!(levels(&restored), [expected]); + } + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn same_tick_autosaves_do_not_reuse_a_live_folder() { + let root = folder("autosave-live-same-tick"); + let first = Autosave::create_with_stamp(&root, "Untitled", 1_000_000).unwrap(); + let second = Autosave::create_with_stamp(&root, "Untitled", 1_000_000).unwrap(); + assert_ne!(first.dir, second.dir); + assert!(first.lock.is_some() && second.lock.is_some()); + drop((first, second)); + let _ = std::fs::remove_dir_all(&root); + } + #[test] fn a_take_recorded_after_undo_gets_its_own_copy() { let dir = folder("autosave-undo"); @@ -557,13 +657,15 @@ mod tests { std::fs::create_dir_all(path.parent().unwrap()).unwrap(); std::fs::write(path, bytes).unwrap(); } - let mut paths: Vec = files.iter().map(|(p, _)| p.clone()).collect(); + let paths: Vec = files.iter().map(|(p, _)| p.clone()).collect(); #[cfg(unix)] - { + let paths = { + let mut paths = paths; let link = takes.join("Vox_03.wav"); std::os::unix::fs::symlink(&files[1].0, &link).unwrap(); paths.push(link); - } + paths + }; let plan = Recording { start: 0, pass: None, @@ -603,6 +705,8 @@ mod tests { recover(&mut r, &left[0]).unwrap(); assert!(r.is_dirty()); r.execute("session.save", &json!({})).unwrap(); + let autosaved = std::fs::metadata(left[0].dir.join(SESSION)).unwrap().modified().unwrap(); + set_modified(Path::new(&path), autosaved + Duration::from_secs(1)); assert!(leftovers(&root).is_empty()); assert!(!left[0].dir.exists()); let mut reopened = Engine::default(); diff --git a/crates/engine/src/file_access.rs b/crates/engine/src/file_access.rs index b0f02fd3..1f1a0c5f 100644 --- a/crates/engine/src/file_access.rs +++ b/crates/engine/src/file_access.rs @@ -398,7 +398,7 @@ mod tests { fn windows_ignores_case_and_refuses_devices() { let (base, roots) = folders("case"); let upper = s(base.join("IN/a.scraft")); - assert_eq!(roots.check(upper, Access::Read), Ok(()), "{upper}"); + assert_eq!(roots.check(&upper, Access::Read), Ok(()), "{upper}"); assert_eq!(roots.check(s(base.join("In/Sub/New.scraft")), Access::Read), Ok(())); let e = roots.check(s(base.join("out/NUL")), Access::Write).unwrap_err(); assert!(e.contains("reserved device names"), "{e}"); diff --git a/docs/roadmap.md b/docs/roadmap.md index d8ea3f8c..aec8a515 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # SoundCraft roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (alpha gate added; stage re-normalized to pre-alpha) · **Target:** Avid Pro Tools Ultimate 2026.4.1 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (shared Windows installer UI; existing folder/shortcut/launch options retained; install and uninstall completion) · **Target:** Avid Pro Tools Ultimate 2026.4.1 Forward-looking plan. Status and numbers: [`../ROADMAP.md`](../ROADMAP.md) and [`target-app-parity.md`](target-app-parity.md); the ranked work list: [`gaps.md`](gaps.md). @@ -88,3 +88,19 @@ There is no `cargo xtask scorecard` in this repo; the measured number is `cargo |---|---|---| | 2026-10-10 | minor | Added the alpha gate (3 of 6 core workflows fail → pre-alpha) | | 2026-10-10 | major | Created from ROADMAP.md's milestones and alpha list; added M12–M17 and the beta plan with estimates | + +## Packaging update — 2026-10-10 + +Windows setup now uses the shared `craft-installer` native UI source, with install/repair/remove +completion, cancellation and failure screens. Existing MSI ownership and upgrade identities are +preserved; unattended modes remain unattended. Source/generator checks pass; native Windows +installer acceptance is still pending. See [setup validation](../packaging/windows/README.md). + +## Packaging update (2026-10-11) + +Windows setup now uses the suite's shared `craft-installer` UI, preserving existing folder, +shortcut and launch options and app-owned installation policy. Install, uninstall and repair +have explicit completion screens; cancellation and failure have separate endings. Source/profile +checks, WiX UI library compilation for x64/x86/ARM64, PowerShell parsing and package identity +comparisons pass. Full MSI linking and interactive Windows acceptance remain pending; see +[Windows packaging](../packaging/windows/README.md). diff --git a/packaging/windows/README.md b/packaging/windows/README.md new file mode 100644 index 00000000..c0fa4094 --- /dev/null +++ b/packaging/windows/README.md @@ -0,0 +1,34 @@ +# Windows installer UI + +`installer-ui.wxs` is generated from the shared `craft-installer` crate in +[craft-libs](https://github.com/storytold/craft-libs/tree/main/crates/installer). +`package.ps1` compiles it with this app's package source. The common UI provides progress, +repair/remove, and explicit install/uninstall/repair success, cancellation and failure screens. + +Regenerate or check from a sibling craft-libs checkout: + +```sh +cargo run --locked -p craft-installer -- --write --app soundcraft ../soundcraft/packaging/windows/installer-ui.wxs +cargo run --locked -p craft-installer -- --check --app soundcraft ../soundcraft/packaging/windows/installer-ui.wxs +``` + +Edit the shared templates/profile and regenerate; avoid editing this snapshot by hand. The +profile retains this app's existing folder, shortcut and launch options; defaults, persistence, +components, associations and signing stay in the app-owned source. Release builds require no +sibling checkout, published crate or additional runtime dependency. `/qn`, `/qb` and `/passive` +remain unattended and do not launch the app. + +Source/profile checks and native fixture MSI linking/UI table checks passed for x64, x86 and +ARM64. All 14 profiles passed full UI install, missing-file repair and cached uninstall, and +x86 silent install/removal. Folder upgrades, desktop choices and all six launch profiles also +passed their Windows fixture checks. See the +[Windows verification record](../../../craft-libs/docs/windows-installer-verification.md) for +interactive results, scope validation and remaining acceptance checks with real release payloads. +The shared crate includes reusable linking, full UI, option/upgrade/launch and x86 test runners. +Windows Settings now uses a separate MSI-owned entry whose uninstall command selects full UI, +including the completion screen. Its quiet command and direct `/qn`, `/qb`, `/passive` commands +remain unattended. The main feature references `CraftInstallerSettings`; the shared snapshot +owns this registration and hides the native MSI entry while retaining MSI repair/upgrade APIs. + +The Settings display name appends `(Craft Apps)` for compatibility with older Launchers; the +hidden native MSI name stays unchanged. Existing ARP Modify policy is retained explicitly. diff --git a/packaging/windows/installer-ui.wxs b/packaging/windows/installer-ui.wxs new file mode 100644 index 00000000..aba5eb3f --- /dev/null +++ b/packaging/windows/installer-ui.wxs @@ -0,0 +1,229 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packaging/windows/package.ps1 b/packaging/windows/package.ps1 index b46ba330..d1033dc9 100644 --- a/packaging/windows/package.ps1 +++ b/packaging/windows/package.ps1 @@ -93,7 +93,7 @@ Copy-Item (Join-Path $Bin 'soundcraft.exe'), (Join-Path $Bin 'soundcraft-cli.exe # ---- MSI --------------------------------------------------------------------------------------- $Msi = Join-Path $Dist "soundcraft-$Version-windows-$Arch.msi" Invoke-Native 'wix build' { - wix build (Join-Path $PSScriptRoot 'soundcraft.wxs') -arch $Arch ` + wix build (Join-Path $PSScriptRoot 'soundcraft.wxs') (Join-Path $PSScriptRoot 'installer-ui.wxs') -arch $Arch ` -d "Version=$MsiVersion" -d "BinDir=$Stage" -d "IconPath=$(Join-Path $Root 'assets\app-icon\soundcraft.ico')" ` -o $Msi } diff --git a/packaging/windows/soundcraft.wxs b/packaging/windows/soundcraft.wxs index 77f538ee..43b93900 100644 --- a/packaging/windows/soundcraft.wxs +++ b/packaging/windows/soundcraft.wxs @@ -2,7 +2,7 @@ + @@ -37,7 +39,10 @@ + + +