use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use super::Installed;
use crate::error::{Error, Result};
pub(crate) fn bundle_root(exe: &Path) -> Option<PathBuf> {
exe.ancestors()
.find(|p| p.extension().is_some_and(|e| e == "app"))
.map(Path::to_path_buf)
}
pub(crate) fn install(artifact: &Path, app_root: &Path) -> Result<Installed> {
with_app(artifact, |new_app| swap_in(new_app, app_root))
}
pub(crate) fn with_app<T>(artifact: &Path, f: impl FnOnce(&Path) -> Result<T>) -> Result<T> {
if artifact.is_dir() {
return f(&find_app(artifact)?);
}
ensure_disk_image(artifact)?;
let mount = Mount::attach(artifact)?;
f(&find_app(&mount.dir)?)
}
fn swap_in(new_app: &Path, app_root: &Path) -> Result<Installed> {
let parent = app_root
.parent()
.ok_or_else(|| Error::Install("install root has no parent directory".to_string()))?;
let name = app_root
.file_name()
.ok_or_else(|| Error::Install("install root has no file name".to_string()))?;
let staging = parent.join(format!(".{}.new", name.to_string_lossy()));
let _ = fs::remove_dir_all(&staging);
ditto(new_app, &staging)?;
let backup = parent.join(format!(".{}.old", name.to_string_lossy()));
let _ = fs::remove_dir_all(&backup);
if app_root.exists() {
fs::rename(app_root, &backup)
.map_err(|e| Error::install_io("could not move current app aside", &e))?;
}
if let Err(e) = fs::rename(&staging, app_root) {
if backup.exists() {
let _ = fs::rename(&backup, app_root);
}
let _ = fs::remove_dir_all(&staging);
return Err(Error::install_io("could not move new app into place", &e));
}
let _ = fs::remove_dir_all(&backup);
Ok(Installed {
restart_path: Some(app_root.to_path_buf()),
})
}
fn ensure_disk_image(artifact: &Path) -> Result<()> {
if artifact
.extension()
.is_some_and(|e| e.eq_ignore_ascii_case("dmg"))
{
return Ok(());
}
Err(Error::Install(format!(
"cannot install `{}` on macOS: only .dmg disk images are supported",
artifact.file_name().map_or_else(
|| artifact.display().to_string(),
|n| n.to_string_lossy().into_owned()
)
)))
}
struct Mount {
dir: PathBuf,
}
impl Mount {
fn attach(dmg: &Path) -> Result<Self> {
let dir = crate::staging::private_temp_dir("gpui-updater-dmg")?;
let out = Command::new("hdiutil")
.args([
"attach",
"-nobrowse",
"-readonly",
"-noautoopen",
"-mountpoint",
])
.arg(&dir)
.arg(dmg)
.output()?;
if !out.status.success() {
let _ = fs::remove_dir_all(&dir);
return Err(Error::Install(format!(
"hdiutil attach failed: {}",
String::from_utf8_lossy(&out.stderr).trim()
)));
}
Ok(Self { dir })
}
}
impl Drop for Mount {
fn drop(&mut self) {
let _ = Command::new("hdiutil")
.args(["detach", "-force"])
.arg(&self.dir)
.output();
let _ = fs::remove_dir_all(&self.dir);
}
}
fn find_app(mount: &Path) -> Result<PathBuf> {
for entry in fs::read_dir(mount)? {
let path = entry?.path();
if path.extension().is_some_and(|e| e == "app") {
return Ok(path);
}
}
Err(Error::Install(
"no .app found in the mounted disk image".to_string(),
))
}
fn ditto(src: &Path, dst: &Path) -> Result<()> {
let out = Command::new("ditto").arg(src).arg(dst).output()?;
if !out.status.success() {
return Err(Error::Install(format!(
"ditto failed: {}",
String::from_utf8_lossy(&out.stderr).trim()
)));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::{bundle_root, install};
use crate::error::Error;
use crate::staging::private_temp_dir;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
#[test]
fn finds_enclosing_app_bundle() {
let exe = Path::new("/Applications/OpenLogi.app/Contents/MacOS/openlogi-gui");
assert_eq!(
bundle_root(exe),
Some(PathBuf::from("/Applications/OpenLogi.app"))
);
}
#[test]
fn returns_none_when_not_in_a_bundle() {
assert_eq!(bundle_root(Path::new("/usr/local/bin/tool")), None);
}
#[test]
fn install_restart_path_is_the_app_bundle_root() {
let work = private_temp_dir("gpui-updater-test").unwrap();
let payload = work.join("payload");
fs::create_dir_all(payload.join("Demo.app/Contents/MacOS")).unwrap();
fs::write(payload.join("Demo.app/Contents/MacOS/demo"), "new").unwrap();
let dmg = work.join("update.dmg");
let status = Command::new("hdiutil")
.args(["create", "-quiet", "-srcfolder"])
.arg(&payload)
.arg(&dmg)
.status()
.unwrap();
assert!(status.success(), "hdiutil create failed");
let app_root = work.join("Applications/Demo.app");
fs::create_dir_all(app_root.join("Contents/MacOS")).unwrap();
fs::write(app_root.join("Contents/MacOS/demo"), "old").unwrap();
let installed = install(&dmg, &app_root).unwrap();
assert_eq!(installed.restart_path, Some(app_root.clone()));
let swapped = fs::read_to_string(app_root.join("Contents/MacOS/demo")).unwrap();
assert_eq!(swapped, "new");
let _ = fs::remove_dir_all(&work);
}
#[test]
fn installs_an_unpacked_payload_directory() {
let work = private_temp_dir("gpui-updater-test").unwrap();
let payload = work.join("rebuilt");
fs::create_dir_all(payload.join("Demo.app/Contents/MacOS")).unwrap();
fs::write(payload.join("Demo.app/Contents/MacOS/demo"), "rebuilt").unwrap();
let app_root = work.join("Applications/Demo.app");
fs::create_dir_all(app_root.join("Contents/MacOS")).unwrap();
fs::write(app_root.join("Contents/MacOS/demo"), "old").unwrap();
fs::write(app_root.join("Contents/stale"), "removed by the swap").unwrap();
let installed = install(&payload, &app_root).unwrap();
assert_eq!(installed.restart_path, Some(app_root.clone()));
let swapped = fs::read_to_string(app_root.join("Contents/MacOS/demo")).unwrap();
assert_eq!(swapped, "rebuilt");
assert!(!app_root.join("Contents/stale").exists());
assert!(!work.join("Applications/.Demo.app.new").exists());
assert!(!work.join("Applications/.Demo.app.old").exists());
let _ = fs::remove_dir_all(&work);
}
#[test]
fn rejects_archives_that_are_not_disk_images() {
let target = Path::new("/nonexistent/App.app");
for artifact in ["/tmp/App-1.0.zip", "/tmp/App-1.0.tar.xz", "/tmp/App"] {
match install(Path::new(artifact), target) {
Err(Error::Install(message)) => assert!(message.contains(".dmg"), "{message}"),
other => panic!("{artifact}: expected an install error, got {other:?}"),
}
}
}
}