use super::UpdateConfig;
#[cfg(target_os = "macos")]
pub(crate) fn install(
config: &UpdateConfig,
release: &crate::update::Release,
app: &std::path::Path,
on_stage: &dyn Fn(crate::update::UpdateStage),
) -> Result<crate::update::Relaunch, String> {
use crate::update::{InstallKind, Relaunch, UpdateStage};
use std::process::Command;
struct Unmount(std::path::PathBuf);
impl Drop for Unmount {
fn drop(&mut self) {
let _ = Command::new("hdiutil")
.args(["detach", "-quiet"])
.arg(&self.0)
.status();
}
}
let requirement = config
.requirement
.as_deref()
.ok_or("no codesign requirement is configured, so this update can't be verified")?;
let asset = release
.asset_for(&InstallKind::MacApp(app.to_path_buf()))
.ok_or("this release hasn't published a macOS build yet")?;
let dir = std::env::temp_dir().join(format!("{}-update-{}", config.slug, release.version));
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
let dmg = dir.join("update.dmg");
let total = asset.size;
on_stage(UpdateStage::Downloading { done: 0, total });
super::fetch::file(&asset.url, &dmg, total, &config.user_agent, &|done| {
on_stage(UpdateStage::Downloading { done, total })
})?;
on_stage(UpdateStage::Verifying);
config.verify_checksum(release, asset, &dmg)?;
on_stage(UpdateStage::Preparing);
let mount = dir.join("mnt");
std::fs::create_dir_all(&mount).map_err(|e| e.to_string())?;
let attach = Command::new("hdiutil")
.args(["attach", "-nobrowse", "-quiet", "-mountpoint"])
.arg(&mount)
.arg(&dmg)
.status()
.map_err(|e| format!("hdiutil attach: {e}"))?;
if !attach.success() {
return Err("could not mount the update image".to_string());
}
let unmount = Unmount(mount.clone());
on_stage(UpdateStage::Verifying);
let src = app_in(&mount)?;
let trusted = Command::new("codesign")
.args(["--verify", "--deep"])
.arg(format!("-R={requirement}"))
.arg(&src)
.status()
.map_err(|e| format!("codesign: {e}"))?;
if !trusted.success() {
return Err(format!(
"the update isn't signed by {} — refusing to install it",
config.slug
));
}
on_stage(UpdateStage::Installing);
let mut contents = std::ffi::OsString::from(src);
contents.push("/");
let synced = Command::new("rsync")
.args(["-a", "--delete", "--delay-updates", "--exclude", "Icon?"])
.arg(&contents)
.arg(app)
.status()
.map_err(|e| format!("rsync: {e}"))?;
if !synced.success() {
scrub_staging(app);
return Err("could not copy the update into place".to_string());
}
let verified = Command::new("codesign")
.args(["--verify", "--deep"])
.arg(format!("-R={requirement}"))
.arg(app)
.status()
.map_err(|e| format!("codesign: {e}"))?;
if !verified.success() {
return Err("the updated app failed signature verification".to_string());
}
drop(unmount);
let _ = std::fs::remove_dir_all(&dir);
Ok(Relaunch::Current)
}
#[cfg(target_os = "macos")]
fn scrub_staging(dir: &std::path::Path) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
if !path.is_dir() {
continue;
}
if path.file_name().is_some_and(|n| n == ".~tmp~") {
let _ = std::fs::remove_dir_all(&path);
} else {
scrub_staging(&path);
}
}
}
#[cfg(any(target_os = "macos", test))]
fn app_in(dir: &std::path::Path) -> Result<std::path::PathBuf, String> {
std::fs::read_dir(dir)
.ok()
.and_then(|mut entries| {
entries.find_map(|entry| {
entry
.ok()
.map(|e| e.path())
.filter(|p| p.extension().is_some_and(|x| x == "app"))
})
})
.ok_or_else(|| "no .app in the update image".to_string())
}
#[cfg(not(target_os = "macos"))]
pub(crate) fn install(
_config: &UpdateConfig,
_release: &crate::update::Release,
_app: &std::path::Path,
_on_stage: &dyn Fn(crate::update::UpdateStage),
) -> Result<crate::update::Relaunch, String> {
Err("not macOS".to_string())
}
#[cfg(test)]
mod tests {
use super::*;
fn scratch(name: &str) -> std::path::PathBuf {
let dir =
std::env::temp_dir().join(format!("guise-update-mac-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn finds_the_app_in_a_mounted_image() {
let dir = scratch("appin");
std::fs::create_dir_all(dir.join("Acme.app/Contents")).unwrap();
std::fs::write(dir.join(".background"), b"").unwrap();
assert_eq!(app_in(&dir).unwrap(), dir.join("Acme.app"));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn empty_image_is_an_error() {
let dir = scratch("empty");
assert!(app_in(&dir).is_err());
let _ = std::fs::remove_dir_all(&dir);
}
#[cfg(target_os = "macos")]
#[test]
fn scrub_removes_only_rsync_staging_dirs() {
let dir = scratch("scrub");
std::fs::create_dir_all(dir.join("Contents/MacOS/.~tmp~")).unwrap();
std::fs::write(dir.join("Contents/MacOS/.~tmp~/acme"), b"half").unwrap();
std::fs::create_dir_all(dir.join("Contents/.~tmp~")).unwrap();
std::fs::write(dir.join("Contents/Info.plist"), b"keep").unwrap();
scrub_staging(&dir);
assert!(!dir.join("Contents/MacOS/.~tmp~").exists());
assert!(!dir.join("Contents/.~tmp~").exists());
assert_eq!(
std::fs::read(dir.join("Contents/Info.plist")).unwrap(),
b"keep"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[cfg(target_os = "macos")]
#[test]
fn an_unconfigured_requirement_refuses_to_install() {
let release = crate::update::Release {
version: "9.9.9".to_string(),
url: String::new(),
assets: vec![crate::update::ReleaseAsset {
name: "Acme.dmg".to_string(),
url: "https://d/x".to_string(),
size: 0,
}],
};
let config = UpdateConfig::new("Acme", "1.0.0", crate::update::UpdateSource::github("a/b"));
let err = install(
&config,
&release,
std::path::Path::new("/Applications/Acme.app"),
&|_| {},
)
.unwrap_err();
assert!(err.contains("codesign requirement"), "{err}");
}
}