craft-update: an app installed for one account moves to an install for everyone on its update

Temporary (craft-libs#422, step 4): new msi_migrate module, to be removed two releases after the
app packages become per-machine only.

The released packages refuse a per-machine install while a per-user copy exists, so the move
removes first and installs second: remove the per-user product (no permission prompt; if that
fails nothing has changed), install the new version for everyone, and if that fails or
permission is refused, install the new version for the account again in its old folder.
Settings and documents live outside the program folder and are never touched.

Which copies move: a per-user default folder goes to Program Files\ArtCraft\<App>; a chosen
folder outside the profile moves in place; a chosen folder inside the profile keeps updating
for the account. The launcher's own package never moves.

Tested with real Windows Installer runs of a renamed dual-scope package carrying the apps' own
install-policy.wxi: the move with the permission prompt accepted (twice), and with it refused
(the app came back for the account at the new version, the failed log kept). Also unit tests for
every plan and the order of steps, and a mutation check (9 of 9 killed).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
JeremyandClaude Opus 5.5 committed 2026-10-29 13:14:18 +00:00
1 parent cd7cc8eb39
commit 2e2e8f8daa
5 files changed
+322 -18

No files matched your search

+24 -4
View File
@@ -1656,7 +1656,7 @@ fn reinstall_command(program: &Path, msi: &Path, context: &[&str]) -> Result<std
/// Where `msi_command` asks Windows Installer to write its log.
#[cfg_attr(not(windows), allow(dead_code))]
fn msi_log(msi: &Path) -> PathBuf {
pub(crate) fn msi_log(msi: &Path) -> PathBuf {
msi.with_extension("log")
}
@@ -1713,9 +1713,15 @@ pub mod windows {
/// Runs an app's own new MSI for a self-update, with the same progress window and no restart
/// as [`run_msi`], and waits for it. `context` keeps the installed copy's Windows Installer
/// context. A failure names the installer's log, which is the only place 1603 is explained.
pub fn reinstall(msi: &Path, context: &[&str]) -> Result<(), String> {
let status = reinstall_command(&msiexec(), msi, context)?.status().map_err(|e| format!("couldn't start the Windows Installer ({e})"))?;
/// context; `folder`, when given, is where it goes. A failure names the installer's log,
/// which is the only place 1603 is explained.
pub fn reinstall(msi: &Path, context: &[&str], folder: Option<&Path>) -> Result<(), String> {
use std::os::windows::process::CommandExt;
let mut command = reinstall_command(&msiexec(), msi, context)?;
if let Some(dir) = folder {
command.raw_arg(msi_folder_argument(dir)?);
}
let status = command.status().map_err(|e| format!("couldn't start the Windows Installer ({e})"))?;
explain(status.code()).map_err(|e| match status.code() {
Some(1602) | None => e,
_ => format!("{e}. Its log is at {}", msi_log(msi).display()),
@@ -1728,6 +1734,20 @@ pub mod windows {
INSTALLER.alone(&cancelled(stop), &mut turn(phase, working), work).unwrap_or_else(|| Err(CANCELLED.into()))
}
/// Removes one installed product by its code, with a progress window and no restart, and waits.
/// A per-user product asks for no permission. `log` receives the installer's verbose log.
pub fn remove_product(product_code: &str, log: &Path) -> Result<(), String> {
if !crate::msi_context::valid_product_code(product_code) {
return Err(format!("{product_code} isn't a Windows Installer product code"));
}
let status = process::command(msiexec())
.args(["/x", product_code, "/passive", "/norestart", "/l*v"])
.arg(log)
.status()
.map_err(|e| format!("couldn't start the Windows Installer ({e})"))?;
explain(status.code()).map_err(|e| format!("{e}. Its log is at {}", log.display()))
}
pub fn uninstall_msi(app: &App, stop: &Stop, phase: &mut dyn FnMut(JobPhase)) -> Result<(), String> {
let installs = crate::detect::windows::msi_installs(app);
if installs.is_empty() {
+1
View File
@@ -12,6 +12,7 @@ pub mod install;
pub mod manifest;
pub mod msi_context;
mod msi_copy;
mod msi_migrate;
pub mod packages;
pub mod paths;
pub mod platform;
+19 -6
View File
@@ -42,20 +42,32 @@ const ANOTHER_COPY: &str = "Windows lists this app for a different account setti
const NOT_ONE_PRODUCT: &str =
"Windows lists this app's installation more than once or not at all, so it can't tell which copy to update. Use the app's installer instead.";
/// The `msiexec` properties for `copy`'s update. `product_codes` are the app's Windows Installer
/// The `msiexec` properties for `copy`'s update (as [`crate::msi_migrate`] uses them). `product_codes` are the app's Windows Installer
/// registrations; `probe` asks Windows Installer for one product's context, and `record_scopes`
/// lists the context of every uninstall record of that product, read only when the lookup cannot
/// run. Exactly one product must be registered: with two, either could be the copy that runs. The
/// records must agree on one context. Whichever answers, it must be the context of the hive that
/// named this exe, or the registration is another copy's.
#[cfg(test)]
pub(crate) fn update_arguments(
copy: MsiCopy,
product_codes: &[String],
probe: impl FnOnce(&str) -> Result<MsiScope, crate::msi_context::ProbeError>,
record_scopes: impl FnOnce(&str) -> Vec<MsiScope>,
) -> Result<&'static [&'static str], String> {
Ok(resolve(copy, product_codes, probe, record_scopes)?.map_or(&[][..], |(scope, _)| crate::msi_context::scope_arguments(scope)))
}
/// The confirmed context and product code of `copy`, by the rules of [`update_arguments`]. `None`
/// for the launcher's own package, which keeps its package default and is never moved.
pub(crate) fn resolve(
copy: MsiCopy,
product_codes: &[String],
probe: impl FnOnce(&str) -> Result<MsiScope, crate::msi_context::ProbeError>,
record_scopes: impl FnOnce(&str) -> Vec<MsiScope>,
) -> Result<Option<(MsiScope, String)>, String> {
let hive = match copy {
MsiCopy::Legacy => return Ok(&[]),
MsiCopy::Legacy => return Ok(None),
MsiCopy::Conflicting => return Err(CONFLICTING.into()),
MsiCopy::Registered { hive } => hive,
};
@@ -65,7 +77,7 @@ pub(crate) fn update_arguments(
if scope != hive {
return Err(ANOTHER_COPY.into());
}
Ok(crate::msi_context::scope_arguments(scope))
Ok(Some((scope, code.clone())))
}
/// How a copy was installed, from what is known about it. The launcher's own per-user package in
@@ -156,15 +168,16 @@ pub(crate) mod windows {
super::classify(is_launcher, is_legacy(app, &exe), || match_registration(&exe, &registered_exes(app)))
}
/// The `msiexec` properties for `exe`'s update, asking Windows Installer for its context.
pub(crate) fn update_arguments(app: &App, exe: &Path) -> Result<&'static [&'static str], String> {
/// The confirmed context and product code of `exe`'s copy, asking Windows Installer; `None`
/// for the launcher's own package. See [`super::resolve`].
pub(crate) fn resolve(app: &App, exe: &Path) -> Result<Option<(MsiScope, String)>, String> {
let copy = find(app, exe).ok_or_else(|| format!("{} isn't registered with the Windows Installer", app.name))?;
let codes: Vec<String> = crate::detect::windows::msi_installs(app)
.into_iter()
.map(|m| m.product_code)
.filter(|code| crate::msi_context::valid_product_code(code))
.collect();
super::update_arguments(copy, &codes, crate::msi_context::probe, record_scopes)
super::resolve(copy, &codes, crate::msi_context::probe, record_scopes)
}
/// The context of every uninstall record of `code`: the machine's (both registry views) are
+270
View File
@@ -0,0 +1,270 @@
//! TEMPORARY: moves an app installed for one account to an install for everyone during its own
//! update. Remove this module two releases after the app packages become per-machine only
//! (craft-libs#422, step 4); from then on there are no per-user copies left to move.
//!
//! The released packages refuse a per-machine install while a per-user copy exists, so the move
//! removes first and installs second:
//!
//! 1. Remove the per-user copy (no permission prompt). If that fails nothing has changed.
//! 2. Install the new version for everyone (Windows asks for permission).
//! 3. If that fails or permission is refused, install the new version for the account again, in
//! the folder it had. The person always keeps a working app; settings and documents live
//! outside the program folder and are never touched.
//!
//! Which copies move: one in a per-user default folder goes to `Program Files\ArtCraft\<App>`; one
//! in a folder the person chose outside their profile moves in place; one in a chosen folder
//! inside the profile keeps updating for the account, since program files for everyone must not
//! live in one person's profile. The launcher's own package never moves.
#![cfg_attr(not(windows), allow(dead_code))]
use std::path::{Path, PathBuf};
use crate::msi_context::{MsiScope, scope_arguments};
/// What an MSI self-update does.
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) enum Step {
/// Update in place with these `msiexec` properties.
Update(&'static [&'static str]),
/// Remove the per-user product, install for everyone into `folder`, and fall back to the
/// account in `old_folder` if that fails.
Move { product_code: String, folder: PathBuf, old_folder: PathBuf },
}
/// Where this account's files live: `USERPROFILE`, `LOCALAPPDATA` and `APPDATA`, which may be
/// redirected away from one another.
#[derive(Clone, Debug, Default)]
pub(crate) struct Profile {
pub roots: Vec<PathBuf>,
pub local_app_data: Option<PathBuf>,
}
/// The step for a copy whose exe is in `dir`. `resolved` is its confirmed context and product
/// code (`None` for the launcher's own package); `program_files` is the root a new install for
/// everyone goes under, for this package's architecture.
pub(crate) fn plan(resolved: Option<(MsiScope, String)>, dir: &Path, app_name: &str, profile: &Profile, program_files: Option<&Path>) -> Step {
let Some((scope, product_code)) = resolved else { return Step::Update(&[]) };
if scope == MsiScope::PerMachine {
return Step::Update(scope_arguments(scope));
}
let stay = Step::Update(scope_arguments(MsiScope::PerUser));
let defaults = profile.local_app_data.iter().flat_map(|local| {
let programs = local.join("Programs");
[programs.join("ArtCraft").join(app_name), programs.join(app_name)]
});
let folder = if defaults.into_iter().any(|default| same_path(&default, dir)) {
match program_files {
Some(root) => root.join("ArtCraft").join(app_name),
None => return stay,
}
} else if profile.roots.iter().any(|root| is_within(dir, root)) {
return stay;
} else {
dir.to_path_buf()
};
Step::Move { product_code, folder, old_folder: dir.to_path_buf() }
}
/// Windows paths compare without regard to case or a trailing separator.
fn normal(path: &Path) -> String {
path.as_os_str().to_string_lossy().replace('/', "\\").trim_end_matches('\\').to_ascii_lowercase()
}
fn same_path(a: &Path, b: &Path) -> bool {
normal(a) == normal(b)
}
/// Whether `path` is `root` or inside it, by whole folder names.
fn is_within(path: &Path, root: &Path) -> bool {
let (path, root) = (normal(path), normal(root));
!root.is_empty() && (path == root || path.strip_prefix(&root).is_some_and(|rest| rest.starts_with('\\')))
}
/// Moves a copy in the only safe order: `remove` the per-user copy (stop if that fails, nothing has
/// changed), install `to_everyone`, and only if that fails `keep_log` of it and install
/// `back_to_account`. The error says where the app is now.
pub(crate) fn run_move(
remove: impl FnOnce() -> Result<(), String>,
to_everyone: impl FnOnce() -> Result<(), String>,
keep_log: impl FnOnce(),
back_to_account: impl FnOnce() -> Result<(), String>,
) -> Result<(), String> {
remove()?;
let Err(installed) = to_everyone() else { return Ok(()) };
keep_log();
Err(move_failure(&installed, back_to_account()))
}
/// The error a failed move reports, saying where the app now is.
pub(crate) fn move_failure(installed: &str, rollback: Result<(), String>) -> String {
match rollback {
Ok(()) => format!("couldn't move the app to an install for everyone ({installed}); it was updated for your account instead"),
Err(again) => format!(
"couldn't move the app to an install for everyone ({installed}), and reinstalling it for your account failed too ({again}). Run its installer to get it back; your settings and documents are untouched"
),
}
}
#[cfg(windows)]
pub(crate) mod windows {
use std::path::{Path, PathBuf};
use super::{Profile, Step, plan, run_move};
use crate::app::App;
use crate::install::windows::{reinstall, remove_product};
use crate::msi_context::{MsiScope, scope_arguments};
fn env_path(name: &str) -> Option<PathBuf> {
std::env::var_os(name).map(PathBuf::from).filter(|p| p.is_absolute())
}
/// The step for `exe`'s update, asking Windows Installer for its context.
pub(crate) fn step(app: &App, exe: &Path) -> Result<Step, String> {
let resolved = crate::msi_copy::windows::resolve(app, exe)?;
let dir = exe.parent().ok_or_else(|| format!("{} isn't in a folder", app.name))?;
let dir = dunce::canonicalize(dir).unwrap_or_else(|_| dir.to_path_buf());
let profile = Profile {
roots: ["USERPROFILE", "LOCALAPPDATA", "APPDATA"].into_iter().filter_map(env_path).collect(),
local_app_data: crate::folders::data_local_dir(),
};
// This process has the package's architecture (the helper is a copy of the app), so its
// own `ProgramFiles` is the right root: `Program Files (x86)` for a 32-bit app.
Ok(plan(resolved, &dir, app.name, &profile, env_path("ProgramFiles").as_deref()))
}
/// Runs `step` with the downloaded package `msi`.
pub(crate) fn run(msi: &Path, step: &Step) -> Result<(), String> {
match step {
Step::Update(context) => reinstall(msi, context, None),
Step::Move { product_code, folder, old_folder } => run_move(
|| remove_product(product_code, &msi.with_extension("remove.log")),
|| reinstall(msi, scope_arguments(MsiScope::PerMachine), Some(folder)),
// The fallback writes a fresh log; keep the failed install's beside it.
|| {
let _ = std::fs::rename(crate::install::msi_log(msi), msi.with_extension("everyone.log"));
},
|| reinstall(msi, scope_arguments(MsiScope::PerUser), Some(old_folder)),
),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
const CODE: &str = "{500F5254-26F4-4AAA-B90D-6F3FD9947D14}";
fn p(s: &str) -> PathBuf {
PathBuf::from(s)
}
fn profile() -> Profile {
Profile {
roots: vec![p(r"C:\Users\Ann"), p(r"C:\Users\Ann\AppData\Local"), p(r"C:\Users\Ann\AppData\Roaming")],
local_app_data: Some(p(r"C:\Users\Ann\AppData\Local")),
}
}
fn user() -> Option<(MsiScope, String)> {
Some((MsiScope::PerUser, CODE.to_string()))
}
fn moved(folder: &str, old: &str) -> Step {
Step::Move { product_code: CODE.to_string(), folder: p(folder), old_folder: p(old) }
}
fn pf() -> Option<&'static Path> {
Some(Path::new(r"C:\Program Files"))
}
#[test]
fn a_copy_for_everyone_and_the_launcher_update_in_place() {
let machine = Some((MsiScope::PerMachine, CODE.to_string()));
assert_eq!(plan(machine, &p(r"C:\Program Files\GridCraft"), "GridCraft", &profile(), pf()), Step::Update(&["ALLUSERS=1"]));
assert_eq!(
plan(None, &p(r"C:\Users\Ann\AppData\Local\Programs\ArtCraft\Launcher"), "ArtCraft Launcher", &profile(), pf()),
Step::Update(&[])
);
}
#[test]
fn a_per_user_copy_in_a_default_folder_moves_to_the_suite_folder() {
for old in [r"C:\Users\Ann\AppData\Local\Programs\ArtCraft\GridCraft", r"c:\users\ann\appdata\local\programs\gridcraft\"] {
assert_eq!(plan(user(), &p(old), "GridCraft", &profile(), pf()), moved(r"C:\Program Files\ArtCraft\GridCraft", old));
}
}
#[test]
fn a_chosen_folder_outside_the_profile_moves_in_place() {
let old = r"D:\Apps\GridCraft";
assert_eq!(plan(user(), &p(old), "GridCraft", &profile(), pf()), moved(old, old));
}
#[test]
fn a_chosen_folder_inside_the_profile_keeps_updating_for_the_account() {
for dir in [r"C:\Users\Ann\Tools\GridCraft", r"C:\Users\Ann\AppData\Local\Other\GridCraft", r"C:\Users\Ann\AppData\Roaming\GridCraft"] {
assert_eq!(plan(user(), &p(dir), "GridCraft", &profile(), pf()), Step::Update(&["ALLUSERS=2", "MSIINSTALLPERUSER=1"]), "{dir}");
}
}
#[test]
fn a_folder_that_only_shares_a_prefix_with_the_profile_is_outside_it() {
let old = r"C:\Users\Anna\GridCraft";
assert_eq!(plan(user(), &p(old), "GridCraft", &profile(), pf()), moved(old, old));
}
#[test]
fn without_a_program_files_folder_a_default_copy_stays_for_the_account() {
let dir = p(r"C:\Users\Ann\AppData\Local\Programs\ArtCraft\GridCraft");
assert_eq!(plan(user(), &dir, "GridCraft", &profile(), None), Step::Update(&["ALLUSERS=2", "MSIINSTALLPERUSER=1"]));
}
fn steps(results: [Result<(), String>; 3]) -> (Result<(), String>, Vec<&'static str>) {
let done = std::cell::RefCell::new(Vec::new());
let [remove, everyone, account] = results;
let result = run_move(
|| {
done.borrow_mut().push("remove");
remove
},
|| {
done.borrow_mut().push("everyone");
everyone
},
|| done.borrow_mut().push("keep log"),
|| {
done.borrow_mut().push("account");
account
},
);
(result, done.into_inner())
}
#[test]
fn a_move_removes_first_and_installs_for_everyone() {
assert_eq!(steps([Ok(()), Ok(()), Ok(())]), (Ok(()), vec!["remove", "everyone"]));
}
#[test]
fn a_failed_removal_changes_nothing_else() {
let (result, done) = steps([Err("error 1603".into()), Ok(()), Ok(())]);
assert_eq!(result, Err("error 1603".into()));
assert_eq!(done, ["remove"]);
}
#[test]
fn a_refused_install_for_everyone_puts_the_app_back_for_the_account() {
let (result, done) = steps([Ok(()), Err("cancelled".into()), Ok(())]);
assert_eq!(done, ["remove", "everyone", "keep log", "account"]);
assert!(result.is_err_and(|e| e.contains("updated for your account instead")));
}
#[test]
fn a_failed_move_says_where_the_app_is() {
assert!(move_failure("error 1602", Ok(())).contains("updated for your account instead"));
let both = move_failure("error 1602", Err("error 1603".into()));
assert!(both.contains("error 1603") && both.contains("Run its installer"), "{both}");
}
}
+8 -8
View File
@@ -201,11 +201,11 @@ fn detect(app: &App, exe: &Path) -> Option<Install> {
None
}
/// The `msiexec` properties that keep `exe`'s Windows Installer context, or why its update stops.
/// Asked while the app still runs (so it can show the error) and again by the helper.
/// What `exe`'s MSI update does (update in place, or move a per-user copy to everyone), or why it
/// stops. Asked while the app still runs (so it can show the error) and again by the helper.
#[cfg(windows)]
fn msi_arguments(app: &App, exe: &Path) -> Result<&'static [&'static str], String> {
crate::msi_copy::windows::update_arguments(app, exe)
fn msi_step(app: &App, exe: &Path) -> Result<crate::msi_migrate::Step, String> {
crate::msi_migrate::windows::step(app, exe)
}
/// The exe to start after an MSI update of a registered copy: the one its registrations name now
@@ -697,7 +697,7 @@ fn windows_precheck(app: &App, install: &Install, paths: &Paths, download: &Path
let me = std::env::current_exe().map_err(|e| format!("couldn't find this program ({e})"))?;
match install {
Install::Msi(exe) => signed_like(app, &me, exe)
.and_then(|()| msi_arguments(app, exe).map(drop))
.and_then(|()| msi_step(app, exe).map(drop))
.and_then(|()| signed_like(app, &me, download))
.and_then(|()| own_executable(app, exe))
.and_then(|()| same_cache_as_helper(app, paths, exe)),
@@ -834,9 +834,9 @@ fn run_step(app: &App, reference: &Path, pid: u32, exe: &Path, step: &Finish, wa
wait_watch::returned(pid, waited);
let result = if waited {
match step {
Finish::Msi(msi) => signed_like(app, reference, msi)
.and_then(|()| msi_arguments(app, exe))
.and_then(|arguments| install::windows::reinstall(msi, arguments)),
Finish::Msi(msi) => {
signed_like(app, reference, msi).and_then(|()| msi_step(app, exe)).and_then(|step| crate::msi_migrate::windows::run(msi, &step))
}
Finish::Portable { zip, tmp } => match (exe.parent(), exe.file_name()) {
(Some(dir), Some(executable)) => crate::portable::unpack_over(zip, tmp, dir, executable, &|file| {
// The expected executable must be signed even when its contents aren't a PE file.