From 38361c33f12fabf6a327e1b40407cd25ab29bf29 Mon Sep 17 00:00:00 2001 From: Jeremy Date: Tue, 27 Oct 2026 21:34:48 +0000 Subject: [PATCH] fix(installer): stop the fallback on a doubly-registered copy; check the launcher restore in the execute sequence - The uninstall-record fallback read the deduplicated install list, so a copy registered per-user and per-machine reported only the machine record and the update went ahead. It now reads every record of the product code and gives no scope when they disagree, so the update stops as the probe path does. - The launcher policy check returned before looking at InstallExecuteSequence. It now requires CraftRestoreLauncherFolder after AppSearch and before CostInitialize, so silent updates cannot lose the folder. - Launcher self-detection also looks in ArtCraft\Launcher (the folder the MSI uses) and reads its folder from the publisher-named registry key. - README notes that a custom folder the account cannot write installs for everyone, with an administrator prompt. --- README.md | 2 +- crates/native/src/detect.rs | 79 +++++++++++++++++++--- crates/native/src/msi_context.rs | 17 +++++ packaging/windows/check-install-policy.ps1 | 7 ++ 4 files changed, 95 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 872b3d92..809af403 100644 --- a/README.md +++ b/README.md @@ -119,7 +119,7 @@ installed by hand (and the launcher finds apps you installed yourself): | System | Where | How | |---|---|---| | macOS | `/Applications/.app` | Copied from the signed, notarized release DMG | -| Windows | `%LOCALAPPDATA%\Programs\ArtCraft\` | The release MSI, through Windows Installer, for your account only so there is no administrator prompt (with Start menu entry and Apps & features entry). An app already installed for everyone (`C:\Program Files\ArtCraft\`, or `Program Files (x86)` for a 32-bit build) keeps its folder and scope when updated | +| Windows | `%LOCALAPPDATA%\Programs\ArtCraft\` | The release MSI, through Windows Installer, for your account only so there is no administrator prompt (with Start menu entry and Apps & features entry). An app already installed for everyone (`C:\Program Files\ArtCraft\`, or `Program Files (x86)` for a 32-bit build) keeps its folder and scope when updated. A custom folder this account can't write to (such as one under `Program Files`) installs for everyone, with an administrator prompt | | Linux | `/usr/bin`, `/usr/share/applications` | The release `.deb` (apt) or `.rpm` (dnf, zypper), with your system's password prompt. Without those, a Flatpak, or an AppImage in `~/Applications` | | Linux, inside the launcher's Flatpak, with no Flatpak on the host | `~/.var/app/ai.storyteller.artcraft-launcher/data/artcraft-launcher/apps/` | The release tarball, unpacked into the launcher's own folder and run inside the same sandbox, with its settings in a `home/` folder beside it. Nothing is installed on the host, and each app's configuration stays in the sandbox | | FreeBSD | `/usr/local` | The release tree, with a password prompt (polkit); `~/.local` when polkit isn't installed | diff --git a/crates/native/src/detect.rs b/crates/native/src/detect.rs index c0ad7387..c62ded9d 100644 --- a/crates/native/src/detect.rs +++ b/crates/native/src/detect.rs @@ -110,6 +110,27 @@ fn exe_candidates(app: &CraftApp, location: Option<&Path>, program_files: &[Path dirs.flat_map(|d| app.stems().map(move |s| d.join(format!("{s}.exe")))).collect() } +/// The folder under `ArtCraft` that the MSI installs to when it is not the display name: the launcher +/// installs to `ArtCraft\Launcher`, the apps to `ArtCraft\`. +#[cfg_attr(not(windows), allow(dead_code))] +fn suite_folders(app: &CraftApp, roots: &[PathBuf]) -> Vec { + if app.id != artcraft_launcher_core::catalog::LAUNCHER_ID { + return Vec::new(); + } + roots.iter().map(|root| root.join("ArtCraft").join("Launcher")).collect() +} + +/// The registry keys an MSI records its install folder in (value `InstallDir`). The launcher's package +/// keeps its own key, under the publisher name. +#[cfg_attr(not(windows), allow(dead_code))] +fn install_dir_keys(app: &CraftApp) -> Vec { + let mut keys = vec![format!(r"SOFTWARE\{}", app.name)]; + if app.id == artcraft_launcher_core::catalog::LAUNCHER_ID { + keys.push(format!(r"SOFTWARE\{PUBLISHER}\{}", app.name)); + } + keys +} + /// Version from ` --version` output. Binaries print the machine id (`photocraft 0.7.0`), /// the display name (`SoundCraft 0.5.0`), or a former id (`printcraft 0.2.1` for PdfCraft). #[cfg_attr(not(any(target_os = "linux", target_os = "freebsd")), allow(dead_code))] @@ -181,7 +202,7 @@ pub mod windows { use winreg::enums::{HKEY_CURRENT_USER, HKEY_LOCAL_MACHINE, KEY_READ, KEY_WOW64_32KEY, KEY_WOW64_64KEY}; use winreg::{HKEY, RegKey}; - use super::{CraftApp, InstallSource, Installed, exe_candidates, is_installer_entry, version}; + use super::{CraftApp, InstallSource, Installed, exe_candidates, install_dir_keys, is_installer_entry, suite_folders, version}; const UNINSTALL: &str = r"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall"; @@ -214,15 +235,31 @@ pub mod windows { pub scope: crate::install::MsiScope, } - /// The context `product_code`'s uninstall record places it in, when Windows Installer cannot be asked. + /// Where the uninstall records of an MSI are kept: the machine's (both registry views) and the account's own. + const UNINSTALL_ROOTS: [(HKEY, u32); 3] = [(HKEY_LOCAL_MACHINE, KEY_WOW64_64KEY), (HKEY_LOCAL_MACHINE, KEY_WOW64_32KEY), (HKEY_CURRENT_USER, 0)]; + + fn record_context(hive: HKEY) -> crate::install::MsiScope { + if hive == HKEY_CURRENT_USER { crate::install::MsiScope::PerUser } else { crate::install::MsiScope::PerMachine } + } + + /// The context every uninstall record of `product_code` places it in, when Windows Installer cannot be + /// asked. All records are read, not the deduplicated list, so a copy registered both per-user and + /// per-machine gives no answer and the update stops. pub fn record_scope(app: &CraftApp, product_code: &str) -> Option { - msi_installs(app).into_iter().find(|m| m.product_code.eq_ignore_ascii_case(product_code)).map(|m| m.scope) + let scopes = UNINSTALL_ROOTS.into_iter().filter_map(|(hive, flags)| { + let root = RegKey::predef(hive).open_subkey_with_flags(UNINSTALL, KEY_READ | flags).ok()?; + let key = root.open_subkey_with_flags(product_code, KEY_READ | flags).ok()?; + let name: String = key.get_value("DisplayName").unwrap_or_default(); + let publisher: String = key.get_value("Publisher").unwrap_or_default(); + is_installer_entry(app, &name, &publisher).then(|| record_context(hive)) + }); + crate::msi_context::single_scope(scopes) } /// Every Windows Installer entry for `app`, newest first. pub fn msi_installs(app: &CraftApp) -> Vec { let mut out = Vec::new(); - for (hive, flags) in [(HKEY_LOCAL_MACHINE, KEY_WOW64_64KEY), (HKEY_LOCAL_MACHINE, KEY_WOW64_32KEY), (HKEY_CURRENT_USER, 0)] { + for (hive, flags) in UNINSTALL_ROOTS { let Ok(root) = RegKey::predef(hive).open_subkey_with_flags(UNINSTALL, KEY_READ | flags) else { continue }; for code in root.enum_keys().flatten() { // App-authored uninstall rows are not MSI product identities. @@ -239,8 +276,7 @@ pub mod windows { let location: String = key.get_value("InstallLocation").unwrap_or_default(); let exe = exe_path(app, (!location.is_empty()).then(|| PathBuf::from(location)).or_else(|| install_dir(app, hive, flags)), hive, flags); - let scope = if hive == HKEY_CURRENT_USER { crate::install::MsiScope::PerUser } else { crate::install::MsiScope::PerMachine }; - out.push(MsiInstall { product_code: code, version, exe, scope }); + out.push(MsiInstall { product_code: code, version, exe, scope: record_context(hive) }); } } out.sort_by(|a, b| match (&b.version, &a.version) { @@ -256,9 +292,11 @@ pub mod windows { /// uninstall record that named the install. A per-machine record (HKLM) therefore reads HKLM, and a /// per-user record (HKCU, dual-scope packages) reads HKCU, so one scope never resolves to the other's folder. fn install_dir(app: &CraftApp, hive: HKEY, flags: u32) -> Option { - let key = RegKey::predef(hive).open_subkey_with_flags(format!(r"SOFTWARE\{}", app.name), KEY_READ | flags).ok()?; - let dir = key.get_value::("InstallDir").ok()?; - (!dir.is_empty()).then(|| PathBuf::from(dir)) + install_dir_keys(app).into_iter().find_map(|path| { + let key = RegKey::predef(hive).open_subkey_with_flags(path, KEY_READ | flags).ok()?; + let dir = key.get_value::("InstallDir").ok()?; + (!dir.is_empty()).then(|| PathBuf::from(dir)) + }) } /// The app's exe for one uninstall record, found only in that record's own scope: the recorded @@ -294,6 +332,10 @@ pub mod windows { } roots }; + let launcher_folders = suite_folders(app, &roots); + if let Some(found) = launcher_folders.iter().find_map(|dir| exe_candidates(app, Some(dir), &[]).into_iter().find(|p| p.exists())) { + return Some(found); + } let mut candidates: Vec = roots.iter().map(|root| root.join("ArtCraft")).collect(); candidates.extend(roots); exe_candidates(app, None, &candidates).into_iter().find(|p| p.exists()) @@ -803,4 +845,23 @@ mod tests { assert_eq!(exe_candidates(a, Some(&location), &roots), with_location); } } + + #[test] + fn the_launcher_msi_installs_to_artcraft_launcher_and_the_apps_do_not() { + let roots = roots(); + let want: Vec = roots.iter().map(|root| root.join("ArtCraft").join("Launcher")).collect(); + assert_eq!(suite_folders(&artcraft_launcher_core::catalog::LAUNCHER, &roots), want); + let photo = CraftApp::by_id("photocraft").unwrap(); + assert!(suite_folders(photo, &roots).is_empty()); + } + + #[test] + fn the_launcher_records_its_folder_under_the_publisher_key() { + assert_eq!( + install_dir_keys(&artcraft_launcher_core::catalog::LAUNCHER), + [r"SOFTWARE\ArtCraft Launcher".to_string(), format!(r"SOFTWARE\{LEARNING_MACHINES}\ArtCraft Launcher")] + ); + let photo = CraftApp::by_id("photocraft").unwrap(); + assert_eq!(install_dir_keys(photo), [r"SOFTWARE\PhotoCraft".to_string()]); + } } diff --git a/crates/native/src/msi_context.rs b/crates/native/src/msi_context.rs index 6d28034a..8d309013 100644 --- a/crates/native/src/msi_context.rs +++ b/crates/native/src/msi_context.rs @@ -47,6 +47,14 @@ pub(crate) fn update_scope( } } +/// The one context every uninstall record of a product agrees on. Records that disagree (the copy is +/// registered for this account and for everyone) or no records at all give `None`, so the update stops. +pub(crate) fn single_scope(scopes: impl IntoIterator) -> Option { + let mut scopes = scopes.into_iter(); + let first = scopes.next()?; + scopes.all(|scope| scope == first).then_some(first) +} + /// The context for a fresh install. A package that defaults to per-user cannot write to a folder this /// account has no write access to (a custom folder under Program Files), so that install is machine-wide /// and Windows asks for permission. Any other fresh install takes the package's own default. @@ -217,6 +225,15 @@ mod tests { assert_eq!(result, Err(UNKNOWN_CONTEXT.to_string())); } + #[test] + fn records_in_two_contexts_give_no_fallback_scope() { + assert_eq!(single_scope([]), None); + assert_eq!(single_scope([MsiScope::PerUser]), Some(MsiScope::PerUser)); + assert_eq!(single_scope([MsiScope::PerMachine, MsiScope::PerMachine]), Some(MsiScope::PerMachine)); + assert_eq!(single_scope([MsiScope::PerMachine, MsiScope::PerUser]), None); + assert_eq!(single_scope([MsiScope::PerUser, MsiScope::PerMachine, MsiScope::PerMachine]), None); + } + #[test] fn a_fresh_install_into_a_folder_the_account_cannot_write_is_machine_wide() { let folder = Path::new(r"C:\Program Files\Custom\PhotoCraft"); diff --git a/packaging/windows/check-install-policy.ps1 b/packaging/windows/check-install-policy.ps1 index df37b236..e7aab19f 100644 --- a/packaging/windows/check-install-policy.ps1 +++ b/packaging/windows/check-install-policy.ps1 @@ -70,6 +70,13 @@ try { throw 'Launcher registration or shortcut preference changed unexpectedly.' } } + # Silent and reduced-UI updates never show the wizard, so the folder must also be restored in the execute sequence. + $execute = @{} + foreach ($row in (Read-Rows $database 'SELECT `Action`, `Sequence` FROM `InstallExecuteSequence`' 2)) { $execute[$row[0]] = [int]$row[1] } + if (-not ($execute.ContainsKey('CraftRestoreLauncherFolder') -and $execute['AppSearch'] -lt $execute['CraftRestoreLauncherFolder'] -and + $execute['CraftRestoreLauncherFolder'] -lt $execute['CostInitialize'])) { + throw 'Silent updates must restore the launcher folder after AppSearch and before directory costing.' + } Write-Output 'PASS ArtCraft Launcher: per-user scope, suite folder, remembered/custom paths and existing registration. No installation performed.' return }