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.
This commit is contained in:
Jeremy committed 2026-10-27 21:34:48 +00:00
1 parent ed34ecc898
commit 38361c33f1
4 files changed
+95 -10

No files matched your search

+1 -1
View File
@@ -119,7 +119,7 @@ installed by hand (and the launcher finds apps you installed yourself):
| System | Where | How |
|---|---|---|
| macOS | `/Applications/<App>.app` | Copied from the signed, notarized release DMG |
| Windows | `%LOCALAPPDATA%\Programs\ArtCraft\<App>` | 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\<App>`, or `Program Files (x86)` for a 32-bit build) keeps its folder and scope when updated |
| Windows | `%LOCALAPPDATA%\Programs\ArtCraft\<App>` | 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\<App>`, 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/<app>` | 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 |
+70 -9
View File
@@ -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\<App>`.
#[cfg_attr(not(windows), allow(dead_code))]
fn suite_folders(app: &CraftApp, roots: &[PathBuf]) -> Vec<PathBuf> {
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<String> {
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 `<app> --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<crate::install::MsiScope> {
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<MsiInstall> {
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<PathBuf> {
let key = RegKey::predef(hive).open_subkey_with_flags(format!(r"SOFTWARE\{}", app.name), KEY_READ | flags).ok()?;
let dir = key.get_value::<String, _>("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::<String, _>("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<PathBuf> = 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<PathBuf> = 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()]);
}
}
+17
View File
@@ -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<Item = MsiScope>) -> Option<MsiScope> {
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");
@@ -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
}