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 }