From ff7f3204d59dfb005ab6084ae8278beb837aa25e Mon Sep 17 00:00:00 2001 From: Mateusz Sych <151755914+SychPL@users.noreply.github.com> Date: Sat, 17 Oct 2026 10:08:46 +0200 Subject: [PATCH] Updater: recognise an MSI install outside Program Files (#1870) (#1873) Thanks @SychPL. --- Cargo.lock | 1 + crates/ui-egui/Cargo.toml | 5 +++++ crates/ui-egui/src/update.rs | 30 ++++++++++++++++++++++++++--- crates/ui-egui/src/update/tests.rs | 31 ++++++++++++++++++++++++++++-- docs/book/src/guide/updates.md | 2 +- 5 files changed, 63 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e325ee53..7aca27e2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4255,6 +4255,7 @@ dependencies = [ "toml", "ttf-parser", "web-time", + "winreg", "zip 3.0.0", ] diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 38c53a70..37563db2 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -28,6 +28,11 @@ zip = { workspace = true } # Rasterises the icon SVGs (assets/icons/ui/design/svg) at the size they are drawn. tiny-skia = { workspace = true } +[target.'cfg(windows)'.dependencies] +# Updates: the folder the Windows installer recorded, so an install outside Program Files is still +# updated by its installer (MIT; already in the tree as a build dependency). +winreg = "0.56" + [target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies] # The offscreen viewport render test opens a device without a window. pollster = { workspace = true } diff --git a/crates/ui-egui/src/update.rs b/crates/ui-egui/src/update.rs index d46d8931..09dd0568 100644 --- a/crates/ui-egui/src/update.rs +++ b/crates/ui-egui/src/update.rs @@ -144,7 +144,7 @@ pub enum InstallKind { Rpm, /// The plain `.tar.gz`, unpacked somewhere. LinuxTarball, - /// Installed by the `.msi` (under Program Files). + /// Installed by the `.msi` (under Program Files, or in the folder the installer recorded). WindowsMsi, /// The portable `.zip`, unpacked somewhere. WindowsPortable, @@ -165,6 +165,9 @@ pub struct Probe<'a> { /// The running executable. pub exe: Option, pub exists: &'a dyn Fn(&Path) -> bool, + /// Where the Windows installer put SolidCraft (`HKLM\Software\SolidCraft`, `InstallDir`): a + /// folder chosen in its wizard can be anywhere, not only under Program Files (#1870). + pub msi_dir: Option, } pub fn detect(p: &Probe) -> InstallKind { @@ -202,7 +205,8 @@ pub fn detect(p: &Probe) -> InstallKind { } "windows" => { let under = |k: &str| env(k).is_some_and(|dir| starts_with_ci(&exe, Path::new(&dir))); - if under("ProgramFiles") || under("ProgramW6432") || under("ProgramFiles(x86)") { + let installed = p.msi_dir.as_deref().is_some_and(|dir| starts_with_ci(&exe, dir)); + if installed || under("ProgramFiles") || under("ProgramW6432") || under("ProgramFiles(x86)") { return InstallKind::WindowsMsi; } if exe.parent().is_some_and(|d| (p.exists)(&d.join("solidcraft-cli.exe"))) { @@ -240,10 +244,30 @@ pub fn this_install() -> InstallKind { { let env = |k: &str| std::env::var(k).ok(); let exists = |p: &Path| p.exists(); - detect(&Probe { os: std::env::consts::OS, release_build: release_build(), env: &env, exe: std::env::current_exe().ok(), exists: &exists }) + let msi_dir = msi_install_dir(); + detect(&Probe { + os: std::env::consts::OS, + release_build: release_build(), + env: &env, + exe: std::env::current_exe().ok(), + exists: &exists, + msi_dir, + }) } } +/// The folder the Windows installer recorded (solidcraft.wxs writes it for upgrades), if any. +#[cfg(windows)] +fn msi_install_dir() -> Option { + let key = winreg::RegKey::predef(winreg::enums::HKEY_LOCAL_MACHINE).open_subkey(r"Software\SolidCraft").ok()?; + key.get_value::("InstallDir").ok().map(PathBuf::from) +} + +#[cfg(all(not(windows), not(target_arch = "wasm32")))] +fn msi_install_dir() -> Option { + None +} + impl InstallKind { /// The package manager updates it: Download isn't the way (the window shows the command). pub fn system_managed(&self) -> bool { diff --git a/crates/ui-egui/src/update/tests.rs b/crates/ui-egui/src/update/tests.rs index 3b014cdf..8eda32e7 100644 --- a/crates/ui-egui/src/update/tests.rs +++ b/crates/ui-egui/src/update/tests.rs @@ -32,7 +32,7 @@ fn probe(os: &str, release: bool, env: &[(&str, &str)], exe: &str, files: &[&str let files: Vec = files.iter().map(PathBuf::from).collect(); let get = |k: &str| env.get(k).cloned(); let exists = |p: &Path| files.iter().any(|f| f == p); - detect(&Probe { os, release_build: release, env: &get, exe: Some(PathBuf::from(exe)), exists: &exists }) + detect(&Probe { os, release_build: release, env: &get, exe: Some(PathBuf::from(exe)), exists: &exists, msi_dir: None }) } #[test] @@ -92,7 +92,34 @@ fn portable_windows_layout_is_recognised() { let exe = PathBuf::from("/portable/solidcraft.exe"); let get = |k: &str| (k == "ProgramFiles").then(|| "C:\\Program Files".to_string()); let exists = |p: &Path| p == Path::new("/portable/solidcraft-cli.exe"); - assert_eq!(detect(&Probe { os: "windows", release_build: true, env: &get, exe: Some(exe), exists: &exists }), InstallKind::WindowsPortable); + assert_eq!( + detect(&Probe { os: "windows", release_build: true, env: &get, exe: Some(exe), exists: &exists, msi_dir: None }), + InstallKind::WindowsPortable + ); +} + +/// #1870: the installer's wizard can put SolidCraft anywhere, and it puts solidcraft-cli.exe +/// beside it as the portable zip does. The folder it recorded is what tells its copy apart. +#[test] +fn an_msi_install_outside_program_files_is_recognised() { + let exe = PathBuf::from(r"D:\Program Files\SolidCraft\solidcraft.exe"); + let get = |k: &str| match k { + "ProgramFiles" | "ProgramW6432" => Some(r"C:\Program Files".to_string()), + "ProgramFiles(x86)" => Some(r"C:\Program Files (x86)".to_string()), + _ => None, + }; + // solidcraft-cli.exe is beside it. + let exists = |_: &Path| true; + let kind = |msi_dir: Option<&str>| { + detect(&Probe { os: "windows", release_build: true, env: &get, exe: Some(exe.clone()), exists: &exists, msi_dir: msi_dir.map(PathBuf::from) }) + }; + // The installer records [INSTALLFOLDER], which ends in a backslash; Windows paths ignore case. + assert_eq!(kind(Some(r"D:\Program Files\SolidCraft\")), InstallKind::WindowsMsi); + assert_eq!(kind(Some(r"d:\program files\solidcraft")), InstallKind::WindowsMsi); + // No installer copy here: the portable zip, as before. + assert_eq!(kind(None), InstallKind::WindowsPortable); + assert_eq!(kind(Some("")), InstallKind::WindowsPortable); + assert_eq!(kind(Some(r"D:\Program Files\SolidCraft2\")), InstallKind::WindowsPortable, "a folder whose name only starts the same"); } /// The assets of v0.10.0, as published. diff --git a/docs/book/src/guide/updates.md b/docs/book/src/guide/updates.md index 45307590..f871c29e 100644 --- a/docs/book/src/guide/updates.md +++ b/docs/book/src/guide/updates.md @@ -88,7 +88,7 @@ and SolidCraft picks the one matching how it was installed: | `.deb` | under `/usr`, dpkg knows `solidcraft` | `solidcraft-linux-.deb` | `sudo apt install ./solidcraft-linux-.deb` | | `.rpm` | under `/usr`, an rpm database exists | `solidcraft-linux-.rpm` | `sudo dnf install ./solidcraft-linux-.rpm` | | `.tar.gz` | anywhere else on Linux | `solidcraft--linux-.tar.gz` | Unpack over the old copy | -| Windows installer | under Program Files | `solidcraft-windows-.msi` | Run the installer | +| Windows installer | under Program Files, or in the folder the installer recorded | `solidcraft-windows-.msi` | Run the installer | | Windows portable | `solidcraft-cli.exe` beside it | `solidcraft-windows--portable.zip` | Unzip over the old copy | | Windows `.exe` | anywhere else on Windows | `SolidCraft--windows-x64.exe` | Replace the old `.exe` | | macOS | inside `SolidCraft.app` | `solidcraft-macos-universal.dmg` | Drag the new app to Applications |