From bdfb77baf25d08e3f998d8fd40497a5ba222c6ee Mon Sep 17 00:00:00 2001 From: skylartaylor Date: Fri, 16 Oct 2026 03:46:50 +0200 Subject: [PATCH] feat(installer): unify Windows setup and completion screens (INSTALLER-UI) --- Cargo.lock | 1 + Cargo.toml | 1 + ROADMAP.md | 7 + crates/engine/Cargo.toml | 3 + crates/engine/src/cmd/book/safety_tests.rs | 4 +- crates/engine/src/recovery.rs | 71 +++++- crates/engine/tests/save_state_atomicity.rs | 2 +- docs/roadmap.md | 5 + packaging/windows/README.md | 34 +++ packaging/windows/designcraft.wxs | 7 +- packaging/windows/installer-ui.wxs | 229 ++++++++++++++++++++ packaging/windows/package.ps1 | 2 +- 12 files changed, 358 insertions(+), 8 deletions(-) create mode 100644 packaging/windows/README.md create mode 100644 packaging/windows/installer-ui.wxs diff --git a/Cargo.lock b/Cargo.lock index e77a81be..9c034617 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -987,6 +987,7 @@ dependencies = [ "log", "quick-xml 0.38.4", "regex", + "same-file", "serde", "serde_json", "tempfile", diff --git a/Cargo.toml b/Cargo.toml index e76609e0..f2e56f1c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -46,6 +46,7 @@ unicode-segmentation = "1" caseless = "0.2" regex = { version = "1", default-features = false, features = ["std", "unicode", "perf"] } tempfile = "3" +same-file = "1.0.6" # text / rendering / formats skrifa = "0.47" diff --git a/ROADMAP.md b/ROADMAP.md index c60a0410..ee5f0bd0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -209,3 +209,10 @@ Newest first. | 2026-10-10 | major | Restructured to the progress-docs standard; full re-measure; the "Working today" inventory moved to docs/target-app-parity.md, milestones to docs/roadmap.md, the CLI/MCP section left to docs/agents.md (which already covered it) | | 2026-10-04 | major | Parity estimate: breadth 99%, depth ~83%, ~170 h | | 2026-10-01 | major | First roadmap: status, milestones | + + +### Windows installer unification — release preparation, 2026-10-12 + +The shared craft-installer UI preserves this revision's install options and provides persistent installation, repair and Windows Settings removal completion screens. Silent modes stay unattended. Earlier Windows acceptance is recorded in packaging/windows/README.md; normal CI and production-profile upgrade qualification against pinned current releases are in progress. No release or PR has been published. + +- **2026-10-15** · Windows recovery folders validate the identity of the live locked file without reading it through a second handle. Missing or replaced locks disable writes and cleanup; regression tests cover retained exclusive locking and replacement-file protection. No readiness percentage change. diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 1de90d14..e3b27b4a 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -35,6 +35,9 @@ unicode-segmentation = { workspace = true } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] tempfile = { workspace = true } +[target.'cfg(windows)'.dependencies] +same-file = { workspace = true } + [dev-dependencies] designcraft-fonts = { workspace = true, features = ["testing"] } zip = { workspace = true } diff --git a/crates/engine/src/cmd/book/safety_tests.rs b/crates/engine/src/cmd/book/safety_tests.rs index e4ec20ef..390b87cb 100644 --- a/crates/engine/src/cmd/book/safety_tests.rs +++ b/crates/engine/src/cmd/book/safety_tests.rs @@ -34,8 +34,8 @@ fn a_commit_failure_reports_the_completed_prefix_and_cleans_staging() { assert!(error.contains("synthetic disk full"), "{error}"); assert!(error.contains(second.to_str().unwrap()), "{error}"); let completed = error.split("completed chapter paths:").nth(1).unwrap(); - assert!(completed.contains(first.to_str().unwrap()), "{error}"); - assert!(!completed.contains(second.to_str().unwrap()) && !completed.contains(third.to_str().unwrap()), "{error}"); + // The completed list uses debug formatting, which escapes Windows path separators. + assert_eq!(completed.trim(), format!("{:?}", [first.to_str().unwrap()]), "{error}"); assert_eq!(designcraft_format::load(&std::fs::read(first).unwrap()).unwrap().sections.first().unwrap().start_number, Some(9)); assert_eq!(std::fs::read(second).unwrap(), old_bytes); assert_eq!(std::fs::read(third).unwrap(), old_bytes); diff --git a/crates/engine/src/recovery.rs b/crates/engine/src/recovery.rs index 33b9a2a3..59552fe4 100644 --- a/crates/engine/src/recovery.rs +++ b/crates/engine/src/recovery.rs @@ -129,8 +129,9 @@ fn new_lock(dir: &Path) -> std::io::Result { new.mode(0o600); } let mut file = new.open(dir.join(LOCK))?; - // A marker distinguishes the held handle from a replacement at the same pathname on - // platforms without portable file-identity metadata. Empty older-version locks remain valid. + // A marker supplements Unix inode validation and identifies locks on other platforms. + // Windows uses live handle identity because its exclusive lock prevents separate-handle reads. + // Empty older-version locks remain valid. static NEXT_LOCK: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_err(std::io::Error::other)?.as_nanos(); let marker = format!("{}:{}:{now}", std::process::id(), NEXT_LOCK.fetch_add(1, std::sync::atomic::Ordering::Relaxed)); @@ -161,6 +162,18 @@ fn validate_lock(dir: &Path, lock: &std::fs::File) -> Result<()> { } #[cfg(not(unix))] let _ = (held, current); + // Windows locks prevent reads through separately opened handles, even in this process. + // Compare the held and named file identities without releasing the recovery lock. + #[cfg(windows)] + let held_identity = { + let held = same_file::Handle::from_file(lock.try_clone().map_err(|_| fail())?).map_err(|_| fail())?; + let current = same_file::Handle::from_file(named.try_clone().map_err(|_| fail())?).map_err(|_| fail())?; + if held != current { + return Err(fail()); + } + held + }; + #[cfg(not(windows))] if lock_marker(lock).map_err(|_| fail())? != lock_marker(&named).map_err(|_| fail())? { return Err(fail()); } @@ -175,10 +188,18 @@ fn validate_lock(dir: &Path, lock: &std::fs::File) -> Result<()> { return Err(fail()); } } + #[cfg(windows)] + { + let after_named = std::fs::File::open(&path).map_err(|_| fail())?; + let after_identity = same_file::Handle::from_file(after_named).map_err(|_| fail())?; + if held_identity != after_identity { + return Err(fail()); + } + } Ok(()) } -#[cfg(not(target_arch = "wasm32"))] +#[cfg(all(not(target_arch = "wasm32"), not(windows)))] fn lock_marker(file: &std::fs::File) -> std::io::Result> { use std::io::{Read, Seek}; let mut file = file.try_clone()?; @@ -1234,6 +1255,50 @@ mod tests { assert!(list(&own).is_empty(), "another owner's folder must not receive our recovery data"); } + #[cfg(windows)] + #[test] + fn an_exclusively_locked_windows_recovery_file_validates() { + let root = tempfile::tempdir().unwrap(); + let lock = new_lock(root.path()).unwrap(); + lock.try_lock().unwrap(); + validate_lock(root.path(), &lock).unwrap(); + let named = std::fs::File::open(root.path().join(LOCK)).unwrap(); + assert!(matches!(named.try_lock(), Err(std::fs::TryLockError::WouldBlock)), "validation must retain the exclusive lock"); + } + + #[cfg(windows)] + #[test] + fn windows_recovery_rejects_a_replacement_with_the_same_marker() { + let root = tempfile::tempdir().unwrap(); + let lock = new_lock(root.path()).unwrap(); + let marker = std::fs::read(root.path().join(LOCK)).unwrap(); + lock.try_lock().unwrap(); + let moved = root.path().join("original-lock"); + std::fs::rename(root.path().join(LOCK), &moved).unwrap(); + std::fs::write(root.path().join(LOCK), marker).unwrap(); + assert!(validate_lock(root.path(), &lock).is_err(), "matching marker bytes do not make another file our lock"); + std::fs::remove_file(root.path().join(LOCK)).unwrap(); + assert!(validate_lock(root.path(), &lock).is_err(), "a missing lock must not validate"); + } + + #[cfg(windows)] + #[test] + fn windows_recovery_does_not_write_or_discard_a_replacement_runs_entries() { + let root = tempfile::tempdir().unwrap(); + let own = run_dir(root.path()).unwrap(); + std::fs::rename(own.join(LOCK), own.join("original-lock")).unwrap(); + let replacement = new_lock(&own).unwrap(); + replacement.try_lock().unwrap(); + let session = dirty_session(); + let (doc, meta) = files(&own, session.docs[0].uid); + std::fs::write(&doc, b"another owner's document").unwrap(); + std::fs::write(&meta, b"{}").unwrap(); + assert!(save(&session, &own).is_err()); + discard(&own, session.docs[0].uid); + assert_eq!(std::fs::read(&doc).unwrap(), b"another owner's document"); + assert_eq!(std::fs::read(&meta).unwrap(), b"{}"); + } + /// A blocked attempt to recreate a run's lock must stop the save before it writes documents. #[cfg(unix)] #[test] diff --git a/crates/engine/tests/save_state_atomicity.rs b/crates/engine/tests/save_state_atomicity.rs index 326d83be..ac0f30f5 100644 --- a/crates/engine/tests/save_state_atomicity.rs +++ b/crates/engine/tests/save_state_atomicity.rs @@ -58,7 +58,7 @@ fn save_a_copy_rebases_its_data_source_without_changing_the_open_document() { session.execute("file.saveACopy", &json!({"path": destination})).unwrap(); let copy = designcraft_format::load(&std::fs::read(destination).unwrap()).unwrap(); - assert_eq!(copy.data_merge.sources[0].relative_path.as_deref(), Some("../../source/contacts.csv")); + assert_eq!(copy.data_merge.sources[0].relative_path.as_deref().map(Path::new), Some(Path::new("../../source/contacts.csv"))); let after = session.doc().unwrap(); assert!(Arc::ptr_eq(&before.doc, &after.doc)); assert!(Arc::ptr_eq(&before.saved_doc, &after.saved_doc)); diff --git a/docs/roadmap.md b/docs/roadmap.md index 85424132..d143a237 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -113,3 +113,8 @@ output intent · EPS place") is done except Books depth and EPS (preview only). | 2026-10-10 | trivial | Ready figure 42% → 47% (method aligned with the standard); beta distance 28 points | | 2026-10-10 | minor | Alpha gate table added (6 core workflows); gate fails on manual threading, stage pre-alpha; A1 milestone and focus item 0 | | 2026-10-10 | major | Created: milestones moved from ROADMAP.md and re-assessed; Current focus and the beta plan from the full re-measure | + + +### Windows installer unification — release preparation, 2026-10-12 + +The shared craft-installer UI preserves this revision's install options and provides persistent installation, repair and Windows Settings removal completion screens. Silent modes stay unattended. Earlier Windows acceptance is recorded in packaging/windows/README.md; normal CI and production-profile upgrade qualification against pinned current releases are in progress. No release or PR has been published. diff --git a/packaging/windows/README.md b/packaging/windows/README.md new file mode 100644 index 00000000..b4b83c4d --- /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 designcraft ../designcraft/packaging/windows/installer-ui.wxs +cargo run --locked -p craft-installer -- --check --app designcraft ../designcraft/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/designcraft.wxs b/packaging/windows/designcraft.wxs index 8e2de81a..2eba6880 100644 --- a/packaging/windows/designcraft.wxs +++ b/packaging/windows/designcraft.wxs @@ -2,7 +2,7 @@ + @@ -37,7 +39,10 @@ + + + diff --git a/packaging/windows/installer-ui.wxs b/packaging/windows/installer-ui.wxs new file mode 100644 index 00000000..c5c596c0 --- /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 7ca2ec14..10a560cf 100644 --- a/packaging/windows/package.ps1 +++ b/packaging/windows/package.ps1 @@ -93,7 +93,7 @@ Copy-Item (Join-Path $Bin 'designcraft.exe'), (Join-Path $Bin 'designcraft-cli.e # ---- MSI --------------------------------------------------------------------------------------- $Msi = Join-Path $Dist "designcraft-$Version-windows-$Arch.msi" Invoke-Native 'wix build' { - wix build (Join-Path $PSScriptRoot 'designcraft.wxs') -arch $Arch ` + wix build (Join-Path $PSScriptRoot 'designcraft.wxs') (Join-Path $PSScriptRoot 'installer-ui.wxs') -arch $Arch ` -d "Version=$MsiVersion" -d "BinDir=$Stage" -d "IconPath=$(Join-Path $Root 'assets\app-icon\designcraft.ico')" ` -o $Msi }