From 201e055259d54b1311a86fe0a4901ffb6251a5f0 Mon Sep 17 00:00:00 2001 From: bherbruck Date: Fri, 16 Oct 2026 10:40:58 -0400 Subject: [PATCH] Windows relaunches after an update (#1748) After the MSI installer finishes, wait for msiexec and start the installed exe; relaunch is detached on Windows. --- apps/solidcraft/src/main.rs | 18 ++++++++++++++---- crates/ui-egui/src/update.rs | 9 +++++++-- crates/ui-egui/src/update/install.rs | 20 ++++++++++++++++++++ crates/ui-egui/src/update/install_tests.rs | 15 +++++++++++++++ 4 files changed, 56 insertions(+), 6 deletions(-) diff --git a/apps/solidcraft/src/main.rs b/apps/solidcraft/src/main.rs index 9d2b8c89..3d36ed26 100644 --- a/apps/solidcraft/src/main.rs +++ b/apps/solidcraft/src/main.rs @@ -94,10 +94,20 @@ impl eframe::App for App { solidcraft_ui_egui::recovery_ui::close(&mut self.solve); solidcraft_engine::crash::end_session(); // An update asked to restart into the new version or to run its installer. - if let Some((program, args)) = solidcraft_ui_egui::update::take_launch(&mut self.solve) - && let Err(e) = std::process::Command::new(&program).args(&args).spawn() - { - log::error!("update: starting {}: {e}", program.display()); + if let Some((program, args)) = solidcraft_ui_egui::update::take_launch(&mut self.solve) { + let mut command = std::process::Command::new(&program); + command.args(&args); + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + // Not tied to this process: its own process group, and no console (the PowerShell + // that waits for an installer is hidden; the app itself is detached). + let hidden_shell = program.file_stem().is_some_and(|n| n.eq_ignore_ascii_case("powershell")); + command.creation_flags(0x0000_0200 | if hidden_shell { 0x0800_0000 } else { 0x0000_0008 }); + } + if let Err(e) = command.spawn() { + log::error!("update: starting {}: {e}", program.display()); + } } } } diff --git a/crates/ui-egui/src/update.rs b/crates/ui-egui/src/update.rs index 508f72b4..85ae35d3 100644 --- a/crates/ui-egui/src/update.rs +++ b/crates/ui-egui/src/update.rs @@ -697,8 +697,13 @@ fn poll_install(app: &mut SolveApp) { app.updater.job = None; match &result { Ok(install::Outcome::Installer(program, args)) => { - // The installer replaces this program: start it once SolidCraft has closed. - app.updater.launch = Some((program.clone(), args.clone())); + // The installer replaces this program: start it once SolidCraft has closed. On + // Windows the installer is followed by the new version (see install::relaunch_after). + app.updater.launch = Some(match std::env::current_exe() { + #[cfg(windows)] + Ok(exe) => install::relaunch_after(program, args, &exe), + _ => (program.clone(), args.clone()), + }); crate::documents::request_quit(app); } Ok(install::Outcome::Opened(dmg)) => { diff --git a/crates/ui-egui/src/update/install.rs b/crates/ui-egui/src/update/install.rs index 8ec8325c..7f72cc10 100644 --- a/crates/ui-egui/src/update/install.rs +++ b/crates/ui-egui/src/update/install.rs @@ -107,6 +107,26 @@ pub fn sha256_file(path: &Path) -> Result { Ok(h.finalize().iter().map(|b| format!("{b:02x}")).collect()) } +/// The command that runs an installer, waits for it to finish and then starts `exe` (the +/// installed program), so the app comes back after an update. Windows has no `wait && start` in +/// a plain process spawn, so PowerShell does the waiting; the installer's exit code is ignored on +/// purpose: when the user cancels it, the old version is still there to start. +pub fn relaunch_after(installer: &Path, args: &[String], exe: &Path) -> (PathBuf, Vec) { + // PowerShell single-quoted string: only `'` needs doubling. + let q = |s: &str| format!("'{}'", s.replace('\'', "''")); + // msiexec splits its command line itself, so an argument with spaces needs its own quotes. + let list = args.iter().map(|a| if a.contains(' ') { q(&format!("\"{a}\"")) } else { q(a) }).collect::>().join(","); + let script = format!( + "Start-Process -FilePath {} -ArgumentList {list} -Wait; Start-Process -FilePath {}", + q(&installer.to_string_lossy()), + q(&exe.to_string_lossy()) + ); + ( + PathBuf::from("powershell"), + vec!["-NoProfile".into(), "-NonInteractive".into(), "-WindowStyle".into(), "Hidden".into(), "-Command".into(), script], + ) +} + /// `sums` is signed by `key` (minisign, prehashed) with a trusted comment naming `tag`. pub fn verify_signature(sums: &[u8], signature: &str, key: &str, tag: &str) -> Result<(), String> { let key = minisign_verify::PublicKey::from_base64(key.trim()).map_err(|e| format!("update signing key: {e}"))?; diff --git a/crates/ui-egui/src/update/install_tests.rs b/crates/ui-egui/src/update/install_tests.rs index cd1d8bc3..3829203d 100644 --- a/crates/ui-egui/src/update/install_tests.rs +++ b/crates/ui-egui/src/update/install_tests.rs @@ -203,3 +203,18 @@ fn installers_and_disk_images_are_handed_over() { assert_eq!(apply(&Method::Dmg, &pkg).unwrap(), Outcome::Opened(pkg.clone())); let _ = std::fs::remove_dir_all(&dir); } + +#[test] +fn windows_installer_is_followed_by_the_new_version() { + let args = vec!["/i".to_string(), r"C:\Users\a b\Temp\solidcraft-update\x.msi".to_string(), "/passive".to_string()]; + let (program, out) = relaunch_after(Path::new("msiexec"), &args, Path::new(r"C:\Program Files\SolidCraft\it's.exe")); + assert_eq!(program, PathBuf::from("powershell")); + assert_eq!( + out.last().unwrap(), + &concat!( + r#"Start-Process -FilePath 'msiexec' -ArgumentList '/i','"C:\Users\a b\Temp\solidcraft-update\x.msi"','/passive' -Wait; "#, + r"Start-Process -FilePath 'C:\Program Files\SolidCraft\it''s.exe'" + ) + ); + assert!(out.iter().any(|a| a == "-Command")); +}