From a44920f6858b2daffa724fe4d140976a1a57001e Mon Sep 17 00:00:00 2001 From: Zhihui Song Date: Thu, 12 Nov 2026 23:05:31 +0800 Subject: [PATCH] craft-update: allow opted-in quiet per-user MSI self-updates * Add opt-in quiet per-user MSI self-updates --------- Co-authored-by: songzhihui --- crates/update/src/install.rs | 48 +++++++++++++++++++---- crates/update/src/msi_migrate.rs | 8 ++++ crates/update/src/self_update.rs | 65 +++++++++++++++++++++++++------- 3 files changed, 100 insertions(+), 21 deletions(-) diff --git a/crates/update/src/install.rs b/crates/update/src/install.rs index 6094fdbc6..4b65e3af0 100644 --- a/crates/update/src/install.rs +++ b/crates/update/src/install.rs @@ -2201,19 +2201,30 @@ mod freebsd { /// without starting an installer. #[cfg_attr(not(windows), allow(dead_code))] fn msi_command(program: &Path, msi: &Path) -> Result { + msi_command_ui(program, msi, false) +} + +#[cfg_attr(not(windows), allow(dead_code))] +fn msi_command_ui(program: &Path, msi: &Path, quiet: bool) -> Result { let msi = msi_path(msi)?; let mut command = process::command(program); // A verbose log beside the package: Windows Installer's own errors (1603 above all) say // nothing without it. - command.arg("/i").arg(&msi).args(["/passive", "/norestart", "/l*v"]).arg(msi_log(&msi)); + command.arg("/i").arg(&msi).args([if quiet { "/qn" } else { "/passive" }, "/norestart", "/l*v"]).arg(msi_log(&msi)); Ok(command) } /// The self-update command: [`msi_command`] plus the properties that keep the installed copy's /// Windows Installer context (`msi_copy::update_arguments`). -#[cfg_attr(not(windows), allow(dead_code))] +#[cfg(test)] fn reinstall_command(program: &Path, msi: &Path, context: &[&str]) -> Result { - let mut command = msi_command(program, msi)?; + reinstall_command_ui(program, msi, context, false) +} + +#[cfg_attr(not(windows), allow(dead_code))] +fn reinstall_command_ui(program: &Path, msi: &Path, context: &[&str], quiet: bool) -> Result { + // A per-machine /qn install cannot request elevation. Keep its existing visible route. + let mut command = msi_command_ui(program, msi, quiet && !context.contains(&"ALLUSERS=1"))?; command.args(context); Ok(command) } @@ -2240,9 +2251,7 @@ pub(crate) fn msi_path(msi: &Path) -> Result { pub mod windows { use crate::protocol::CANCELLED; - use super::{ - App, INSTALLER, JobPhase, Path, PathBuf, Stop, cancelled, msi_command, msi_folder_argument, msi_log, process, reinstall_command, turn, - }; + use super::{App, INSTALLER, JobPhase, Path, PathBuf, Stop, cancelled, msi_command, msi_folder_argument, msi_log, process, turn}; fn msiexec() -> PathBuf { std::env::var_os("SystemRoot").map_or_else(|| PathBuf::from(r"C:\Windows"), PathBuf::from).join(r"System32\msiexec.exe") @@ -2280,8 +2289,12 @@ pub mod windows { /// 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> { + reinstall_with_ui(msi, context, folder, false) + } + + pub(crate) fn reinstall_with_ui(msi: &Path, context: &[&str], folder: Option<&Path>, quiet: bool) -> Result<(), String> { use std::os::windows::process::CommandExt; - let mut command = reinstall_command(&msiexec(), msi, context)?; + let mut command = super::reinstall_command_ui(&msiexec(), msi, context, quiet)?; if let Some(dir) = folder { command.raw_arg(msi_folder_argument(dir)?); } @@ -2334,6 +2347,27 @@ pub mod windows { mod tests { use super::*; + #[test] + fn quiet_self_update_keeps_logs_context_and_required_permission_ui() { + let dir = tempfile::tempdir().unwrap(); + let file = dir.path().join("launcher update.msi"); + std::fs::write(&file, b"test package").unwrap(); + for (context, quiet, expected) in [ + (&[][..], false, "/passive"), + (&[][..], true, "/qn"), + (&["ALLUSERS=2", "MSIINSTALLPERUSER=1"][..], true, "/qn"), + (&["ALLUSERS=1"][..], true, "/passive"), + ] { + let cmd = reinstall_command_ui(Path::new("msiexec.exe"), &file, context, quiet).unwrap(); + let args: Vec<_> = cmd.get_args().map(|a| a.to_string_lossy().into_owned()).collect(); + assert_eq!(args[2], expected); + assert_eq!(&args[3..5], ["/norestart", "/l*v"]); + assert!(args[5].ends_with("launcher update.log")); + assert_eq!(&args[6..], context); + assert_eq!(args.iter().filter(|a| a.as_str() == "/qn" || a.as_str() == "/passive").count(), 1); + } + } + #[cfg(target_os = "linux")] #[test] fn a_system_flatpak_bundle_is_reinstalled_with_pkexec_on_that_installation() { diff --git a/crates/update/src/msi_migrate.rs b/crates/update/src/msi_migrate.rs index 5ceb90020..f0d981c9c 100644 --- a/crates/update/src/msi_migrate.rs +++ b/crates/update/src/msi_migrate.rs @@ -143,6 +143,14 @@ pub(crate) mod windows { Ok(plan(resolved, &dir, app.name, &profile, env_path("ProgramFiles").as_deref())) } + /// Quiet is limited to in-place per-user updates; migrations retain their permission UI. + pub(crate) fn run_with_ui(msi: &Path, step: &Step, quiet: bool) -> Result<(), String> { + if let Step::Update(context) = step { + return crate::install::windows::reinstall_with_ui(msi, context, None, quiet); + } + run(msi, step) + } + /// Runs `step` with the downloaded package `msi`. pub(crate) fn run(msi: &Path, step: &Step) -> Result<(), String> { match step { diff --git a/crates/update/src/self_update.rs b/crates/update/src/self_update.rs index 702bc9cfb..5356db4fa 100644 --- a/crates/update/src/self_update.rs +++ b/crates/update/src/self_update.rs @@ -768,8 +768,16 @@ impl std::fmt::Debug for Restart { } } -/// [`update`] without starting the helper: the caller starts the returned [`Restart`] when the -/// app's exit is certain. On macOS and Linux the update is in place when this returns. +/// Options for an explicitly opted-in background update. Other platforms keep their existing +/// installation behaviour; permission prompts are never suppressed. +#[derive(Clone, Copy, Debug, Default)] +pub struct UpdateOptions { + /// Hide progress for a per-user Windows MSI. Machine installs retain their permission UI. + pub quiet_msi: bool, +} + +/// [`update`] without starting the helper: start the returned [`Restart`] when exit is certain. +/// On macOS and Linux the update is already in place when this returns. #[allow(clippy::too_many_arguments)] pub fn prepare_update( app: &App, @@ -783,6 +791,27 @@ pub fn prepare_update( last: &dyn Fn() -> Result<(), String>, running_version: Option<&str>, ) -> Result { + prepare_update_with_options(app, install, release, platform, http, paths, stop, phase, last, running_version, UpdateOptions::default()) +} + +/// Like [`prepare_update`], with an explicit choice of Windows Installer progress UI. +#[allow(clippy::too_many_arguments)] +pub fn prepare_update_with_options( + app: &App, + install: &Install, + release: &Release, + platform: Platform, + http: &Http, + paths: &Paths, + stop: &Stop, + phase: &mut dyn FnMut(JobPhase), + last: &dyn Fn() -> Result<(), String>, + running_version: Option<&str>, + options: UpdateOptions, +) -> Result { + // The option only changes Windows MSI helpers. + #[cfg(not(windows))] + let _ = options; let cur_v = running_version.and_then(|v| current_version(app, v)); if let (Some(rel_v), Some(cur_v)) = (release.version.as_ref(), cur_v.as_ref()) && !version::offers_update(rel_v, cur_v) @@ -855,7 +884,7 @@ pub fn prepare_update( relaunch_flatpak_after_exit(app) } #[cfg(windows)] - Install::Msi(exe) => windows_helper(app, paths, release, pid, exe, &msi_finish(&file)?), + Install::Msi(exe) => windows_helper(app, paths, release, pid, exe, &msi_finish(&file, options.quiet_msi)?), #[cfg(windows)] Install::Portable(exe) => { exe.parent().ok_or_else(|| format!("{} isn't in a folder", app.name))?; @@ -1354,6 +1383,8 @@ pub const FINISH_FLAG: &str = "--finish-update"; enum Finish { /// Run the new MSI, which reinstalls into the same folder. Msi(PathBuf), + /// Per-user MSI with no installer progress window. + QuietMsi(PathBuf), /// Unpack the new portable zip into `tmp` and copy it over the launcher's folder. Portable { zip: PathBuf, tmp: PathBuf }, } @@ -1365,6 +1396,7 @@ fn helper_args(pid: u32, exe: &Path, finish: &Finish) -> Vec { let mut args: Vec = vec![FINISH_FLAG.into(), pid.to_string().into(), exe.into()]; match finish { Finish::Msi(msi) => args.extend(["msi".into(), msi.into()]), + Finish::QuietMsi(msi) => args.extend(["msi-quiet".into(), msi.into()]), Finish::Portable { zip, tmp } => args.extend(["portable".into(), zip.into(), tmp.into()]), } args @@ -1380,6 +1412,7 @@ fn parse_helper_args(args: &[OsString]) -> Option<(u32, PathBuf, Finish)> { let pid = pid.to_str()?.parse().ok()?; let finish = match step { [kind, msi] if kind == "msi" => Finish::Msi(msi.into()), + [kind, msi] if kind == "msi-quiet" => Finish::QuietMsi(msi.into()), [kind, zip, tmp] if kind == "portable" => Finish::Portable { zip: zip.into(), tmp: tmp.into() }, _ => return None, }; @@ -1387,8 +1420,8 @@ fn parse_helper_args(args: &[OsString]) -> Option<(u32, PathBuf, Finish)> { } #[cfg_attr(not(windows), allow(dead_code))] -fn msi_finish(file: &Path) -> Result { - install::msi_path(file).map(Finish::Msi) +fn msi_finish(file: &Path, quiet: bool) -> Result { + install::msi_path(file).map(|file| if quiet { Finish::QuietMsi(file) } else { Finish::Msi(file) }) } /// The cache folder the update helper is copied into. @@ -1442,8 +1475,8 @@ pub fn finish(app: &App, args: &[OsString]) -> Option> { return Some(Err(e)); } // Read before the step: the update may move or replace the old exe. - let registered = - matches!(step, Finish::Msi(_)) && matches!(crate::msi_copy::windows::find(app, &exe), Some(crate::msi_copy::MsiCopy::Registered { .. })); + let registered = matches!(step, Finish::Msi(_) | Finish::QuietMsi(_)) + && matches!(crate::msi_copy::windows::find(app, &exe), Some(crate::msi_copy::MsiCopy::Registered { .. })); let wait = |pid| process::wait_for_exit(pid, std::time::Duration::from_secs(60)); let start = |exe: &Path| { signed_like(app, &me, exe) @@ -1667,7 +1700,7 @@ fn check_helper(app: &App, me: &Path, paths: &Paths, exe: &Path, step: &Finish) let downloads = canonical(&paths.downloads())?; let in_downloads = |file: &Path| canonical(file).map(|f| f.parent() == Some(downloads.as_path())); let allowed = match (detect(app, &exe), step) { - (Some(Install::Msi(installed)), Finish::Msi(msi)) => installed == exe && in_downloads(msi)?, + (Some(Install::Msi(installed)), Finish::Msi(msi) | Finish::QuietMsi(msi)) => installed == exe && in_downloads(msi)?, (Some(Install::Portable(installed) | Install::Standalone(installed)), Finish::Portable { zip, tmp }) => { installed == exe && in_downloads(zip)? @@ -1701,8 +1734,11 @@ fn step_after_wait(app: &App, reference: &Path, pid: u32, exe: &Path, step: &Fin let waited = wait(pid); let result = if waited { match step { - 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::Msi(msi) | Finish::QuietMsi(msi) => { + let quiet = matches!(step, Finish::QuietMsi(_)); + signed_like(app, reference, msi) + .and_then(|()| msi_step(app, exe)) + .and_then(|step| crate::msi_migrate::windows::run_with_ui(msi, &step, quiet)) } Finish::Portable { zip, tmp } => match (exe.parent(), exe.file_name()) { (Some(dir), Some(executable)) => crate::portable::unpack_over(zip, tmp, dir, executable, &|file| { @@ -1716,7 +1752,7 @@ fn step_after_wait(app: &App, reference: &Path, pid: u32, exe: &Path, step: &Fin Err(format!("{} was still running after a minute, so the update wasn't installed", app.name)) }; match step { - Finish::Msi(msi) => { + Finish::Msi(msi) | Finish::QuietMsi(msi) => { let _ = std::fs::remove_file(msi); } // `unpack_over` removes the staging folder, unless it holds files it couldn't put back. @@ -2525,13 +2561,13 @@ mod tests { std::fs::write(&file, b"test package").unwrap(); let link = dir.path().join("cached.msi"); std::os::unix::fs::symlink(&file, &link).unwrap(); - assert_eq!(msi_finish(&link).unwrap(), Finish::Msi(file.canonicalize().unwrap())); + assert_eq!(msi_finish(&link, false).unwrap(), Finish::Msi(file.canonicalize().unwrap())); } #[test] fn a_missing_msi_cannot_schedule_a_self_update() { let dir = tempfile::tempdir().unwrap(); - assert!(msi_finish(&dir.path().join("missing.msi")).is_err()); + assert!(msi_finish(&dir.path().join("missing.msi"), false).is_err()); } /// The helper reads back exactly what the launcher passed it, paths with spaces and @@ -2542,7 +2578,8 @@ mod tests { let msi = Finish::Msi(PathBuf::from(r"C:\Users\O'Brien\AppData\Local\ArtCraft Launcher\cache\artcraft-launcher-0.7.0-windows-x64.msi")); let portable = Finish::Portable { zip: PathBuf::from(r"D:\Tools\x 1.zip"), tmp: PathBuf::from(r"D:\Tools\ArtCraftLauncherData\cache\launcher-update") }; - for finish in [msi, portable] { + let quiet = Finish::QuietMsi(PathBuf::from(r"C:\Updates\a b.msi")); + for finish in [msi, quiet, portable] { let args = helper_args(4242, exe, &finish); assert_eq!(args.first().map(OsString::as_os_str), Some(std::ffi::OsStr::new(FINISH_FLAG))); assert_eq!(parse_helper_args(&args), Some((4242, exe.to_path_buf(), finish)));