diff --git a/crates/update/src/install.rs b/crates/update/src/install.rs index 7c9e773e..5f1f7ff3 100644 --- a/crates/update/src/install.rs +++ b/crates/update/src/install.rs @@ -1656,7 +1656,7 @@ fn reinstall_command(program: &Path, msi: &Path, context: &[&str]) -> Result 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() { diff --git a/crates/update/src/lib.rs b/crates/update/src/lib.rs index 58bb0e94..40a5825a 100644 --- a/crates/update/src/lib.rs +++ b/crates/update/src/lib.rs @@ -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; diff --git a/crates/update/src/msi_copy.rs b/crates/update/src/msi_copy.rs index 05e1ca3f..a78ad435 100644 --- a/crates/update/src/msi_copy.rs +++ b/crates/update/src/msi_copy.rs @@ -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, record_scopes: impl FnOnce(&str) -> Vec, ) -> 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, + record_scopes: impl FnOnce(&str) -> Vec, +) -> Result, 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, ®istered_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, String> { let copy = find(app, exe).ok_or_else(|| format!("{} isn't registered with the Windows Installer", app.name))?; let codes: Vec = 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 diff --git a/crates/update/src/msi_migrate.rs b/crates/update/src/msi_migrate.rs new file mode 100644 index 00000000..a043781e --- /dev/null +++ b/crates/update/src/msi_migrate.rs @@ -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\`; 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, + pub local_app_data: Option, +} + +/// 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 { + 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 { + 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}"); + } +} diff --git a/crates/update/src/self_update.rs b/crates/update/src/self_update.rs index a3b3bc4c..394e65f0 100644 --- a/crates/update/src/self_update.rs +++ b/crates/update/src/self_update.rs @@ -201,11 +201,11 @@ fn detect(app: &App, exe: &Path) -> Option { 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::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.