use std::fs;
use std::path::Path;
use super::{InstallOptions, Installed, MsiUi};
use crate::error::{Error, Result};
pub(crate) fn install(
artifact: &Path,
install_root: &Path,
options: &InstallOptions,
) -> Result<Installed> {
if artifact.is_dir() {
return install_rebuilt_exe(artifact, install_root);
}
match artifact
.extension()
.and_then(|e| e.to_str())
.map(str::to_ascii_lowercase)
.as_deref()
{
Some("exe") => install_bare_exe(artifact, install_root),
Some("msi") => stage_msi(artifact, install_root, options.msi_ui),
_ => Err(Error::UnsupportedPlatform(
"windows update artifact (expected .exe or .msi)",
)),
}
}
fn install_bare_exe(new_exe: &Path, install_root: &Path) -> Result<Installed> {
let root_is_exe = install_root
.extension()
.and_then(|e| e.to_str())
.is_some_and(|e| e.eq_ignore_ascii_case("exe"));
if !root_is_exe {
return Err(Error::Install(format!(
"bare-exe update needs an .exe install root, got {}",
install_root.display()
)));
}
let old = install_root.with_extension("old.exe");
let _ = fs::remove_file(&old);
if install_root.exists() {
fs::rename(install_root, &old)
.map_err(|e| Error::install_io("could not rename running exe", &e))?;
}
if let Err(e) = fs::copy(new_exe, install_root) {
if old.exists() {
let _ = fs::rename(&old, install_root);
}
return Err(Error::install_io("could not place new exe", &e));
}
Ok(Installed {
restart_path: Some(install_root.to_path_buf()),
})
}
fn install_rebuilt_exe(dir: &Path, install_root: &Path) -> Result<Installed> {
let exe = install_root
.file_name()
.map(|name| dir.join(name))
.filter(|exe| fs::symlink_metadata(exe).is_ok_and(|m| m.is_file()))
.ok_or_else(|| {
Error::Install(format!(
"{} holds no rebuilt {}",
dir.display(),
install_root.display()
))
})?;
install_bare_exe(&exe, install_root)
}
#[cfg(feature = "delta")]
pub(crate) fn accepts_rebuilt(install_root: &Path, artifact_name: &str) -> bool {
let is_exe = |path: &Path| {
path.extension()
.is_some_and(|e| e.eq_ignore_ascii_case("exe"))
};
is_exe(install_root) && !install_root.is_dir() && is_exe(Path::new(artifact_name))
}
fn stage_msi(msi: &Path, install_root: &Path, ui: MsiUi) -> Result<Installed> {
let script_path = msi.with_extension("apply.cmd");
fs::write(&script_path, apply_script(msi, install_root, ui)?)
.map_err(|e| Error::install_io("could not stage MSI apply script", &e))?;
Ok(Installed {
restart_path: Some(script_path),
})
}
fn apply_script(msi: &Path, relaunch: &Path, ui: MsiUi) -> Result<String> {
let log = cmd_quoted(&msi.with_extension("log"))?;
let msi = cmd_quoted(msi)?;
let relaunch = cmd_quoted(relaunch)?;
let ui = ui.flag();
Ok(format!(
"@echo off\r\n\
rem Generated by gpui-updater: applies the staged MSI update and\r\n\
rem relaunches the app. Run automatically after the app exits.\r\n\
msiexec /i {msi} {ui} /norestart /l*v {log}\r\n\
set code=%errorlevel%\r\n\
if %code% equ 3010 set code=0\r\n\
if %code% neq 0 exit /b %code%\r\n\
start \"\" {relaunch}\r\n"
))
}
pub(crate) fn remove_leftovers(install_root: &Path) {
let root_is_exe = install_root
.extension()
.is_some_and(|e| e.eq_ignore_ascii_case("exe"));
let old = install_root.with_extension("old.exe");
if root_is_exe && old.is_file() {
if let Err(e) = fs::remove_file(&old) {
tracing::debug!(path = %old.display(), error = %e, "could not remove the previous exe");
}
}
}
fn cmd_quoted(path: &Path) -> Result<String> {
let s = path
.to_str()
.ok_or_else(|| Error::Install(format!("non-UTF-8 path: {}", path.display())))?;
if s.contains('"') || s.contains('%') || s.contains('\r') || s.contains('\n') {
return Err(Error::Install(format!(
"path cannot be quoted for cmd.exe: {s}"
)));
}
Ok(format!("\"{s}\""))
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
#[test]
fn dispatch_rejects_unknown_artifact_kinds() {
let err = install(
Path::new("C:\\dl\\app.zip"),
Path::new("C:\\app\\app.exe"),
&InstallOptions::new(),
)
.unwrap_err();
assert!(matches!(err, Error::UnsupportedPlatform(_)));
}
#[test]
fn bare_exe_requires_an_exe_install_root() {
let err = install(
Path::new("C:\\dl\\app.exe"),
Path::new("C:\\app"),
&InstallOptions::new(),
)
.unwrap_err();
assert!(matches!(err, Error::Install(_)));
}
#[test]
fn exe_rename_trick_swaps_and_keeps_the_old_copy() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().join("app.exe");
let new = dir.path().join("download.exe");
fs::write(&root, b"old").unwrap();
fs::write(&new, b"new").unwrap();
let installed = install(&new, &root, &InstallOptions::new()).unwrap();
assert_eq!(installed.restart_path.as_deref(), Some(root.as_path()));
assert_eq!(fs::read(&root).unwrap(), b"new");
assert_eq!(fs::read(root.with_extension("old.exe")).unwrap(), b"old");
super::super::remove_leftovers(&root);
assert!(!root.with_extension("old.exe").exists());
assert_eq!(fs::read(&root).unwrap(), b"new");
super::super::remove_leftovers(&root);
}
#[test]
fn a_rebuilt_directory_installs_the_exe_it_holds() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().join("App.exe");
let rebuilt = dir.path().join("delta-1.1.0");
fs::create_dir(&rebuilt).unwrap();
fs::write(&root, b"old").unwrap();
fs::write(rebuilt.join("App.exe"), b"new").unwrap();
let installed = install(&rebuilt, &root, &InstallOptions::new()).unwrap();
assert_eq!(installed.restart_path.as_deref(), Some(root.as_path()));
assert_eq!(fs::read(&root).unwrap(), b"new");
assert_eq!(fs::read(root.with_extension("old.exe")).unwrap(), b"old");
}
#[test]
fn a_rebuilt_directory_without_the_exe_is_refused() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().join("App.exe");
let rebuilt = dir.path().join("delta-1.1.0");
fs::create_dir_all(rebuilt.join("App.exe.d")).unwrap();
fs::write(&root, b"old").unwrap();
fs::write(rebuilt.join("Other.exe"), b"new").unwrap();
let err = install(&rebuilt, &root, &InstallOptions::new()).unwrap_err();
assert!(matches!(err, Error::Install(_)), "{err}");
assert_eq!(fs::read(&root).unwrap(), b"old");
assert!(!root.with_extension("old.exe").exists());
}
#[cfg(feature = "delta")]
#[test]
fn only_a_bare_exe_accepts_a_rebuilt_update() {
let dir = tempfile::tempdir().unwrap();
let exe = dir.path().join("App.exe");
fs::write(&exe, b"app").unwrap();
assert!(accepts_rebuilt(&exe, "App-1.1.0-x64.exe"));
assert!(accepts_rebuilt(&exe, "APP.EXE"));
assert!(!accepts_rebuilt(&exe, "App-1.1.0-x64.msi"));
assert!(!accepts_rebuilt(&exe, "App-1.1.0-x64.zip"));
assert!(!accepts_rebuilt(dir.path(), "App.exe"));
let folder = dir.path().join("Folder.exe");
fs::create_dir(&folder).unwrap();
assert!(!accepts_rebuilt(&folder, "App.exe"));
}
#[test]
fn msi_stages_an_apply_script_as_the_restart_path() {
let dir = tempfile::tempdir().unwrap();
let msi = dir.path().join("App-v2.msi");
fs::write(&msi, b"msi").unwrap();
let root = dir.path().join("App.exe");
let installed = install(&msi, &root, &InstallOptions::new()).unwrap();
let script_path = installed.restart_path.unwrap();
assert_eq!(script_path, msi.with_extension("apply.cmd"));
let script = fs::read_to_string(script_path).unwrap();
assert!(script.contains(&format!(
"msiexec /i \"{}\" /passive /norestart /l*v \"{}\"",
msi.display(),
msi.with_extension("log").display()
)));
assert!(script.contains(&format!("start \"\" \"{}\"", root.display())));
assert!(script.contains("if %code% equ 3010 set code=0"));
assert!(script.contains("if %code% neq 0 exit /b %code%"));
let quiet = InstallOptions::new().msi_ui(MsiUi::Quiet);
let installed = install(&msi, &root, &quiet).unwrap();
let script = fs::read_to_string(installed.restart_path.unwrap()).unwrap();
assert!(script.contains("/quiet /norestart"));
assert_eq!(fs::read(&msi).unwrap(), b"msi");
}
#[test]
fn quoting_refuses_paths_cmd_cannot_carry() {
for bad in ["C:\\a\"b\\x.msi", "C:\\a%b\\x.msi"] {
assert!(matches!(
apply_script(
Path::new(bad),
Path::new("C:\\app\\App.exe"),
MsiUi::Passive
),
Err(Error::Install(_))
));
}
}
}