guise-ui 1.5.2

A component library for gpui, Zed's GPU-accelerated UI framework: a themed palette, sizing tokens, 130+ composable components, a reactive state layer, and an in-app Safari-style inspector.
Documentation
//! macOS: mount the release `.dmg` and rsync the new bundle's contents onto the
//! installed `.app` — never replace the bundle directory itself.
//!
//! The "in place" is the point: the installed bundle keeps its path *and* its
//! directory inode, so LaunchServices' registration stays valid and the running
//! executable is only ever replaced-by-rename (its open inode lives on, which
//! macOS is fine with — it is in-place *modification* of a running binary that
//! gets a process killed). Swapping the whole bundle out from under
//! LaunchServices is what makes a relaunch fall back to running the bare Mach-O
//! inside Terminal.app: `open` on a path whose registration is stale resolves to
//! the inner executable, unbundled and broken.

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;

  /// Detach the mount on every exit path, success or error.
  struct Unmount(std::path::PathBuf);
  impl Drop for Unmount {
    fn drop(&mut self) {
      let _ = Command::new("hdiutil")
        .args(["detach", "-quiet"])
        .arg(&self.0)
        .status();
    }
  }

  // An unverifiable payload is not installable. See
  // `UpdateConfig::codesign_requirement`: without a requirement to pin the
  // signing identity to, `--verify` would only prove the bundle matches its own
  // seal, so *any* validly signed app would pass — which is not the question
  // being asked of something about to be executed as the user.
  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 })
  })?;

  // Belt and braces alongside the codesign check below: a digest catches a
  // swapped asset before the image is even mounted, and `hdiutil` parsing an
  // attacker-chosen file is a larger surface than a hash of it.
  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());

  // Verify the payload on the *mounted image*, before a byte of it reaches the
  // installed bundle. Verifying only afterwards means a bad update is already
  // committed by the time you find out — there is no rollback, and the next
  // launch runs it.
  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
    ));
  }

  // rsync the mounted bundle's *contents* (trailing slash) onto the installed
  // bundle; --delete drops files the new version no longer ships.
  // --delay-updates stages every changed file inside the bundle (per-directory
  // `.~tmp~` folders) and promotes it by rename only at the end, so a sync that
  // dies partway leaves the old files intact instead of a mixed bundle with a
  // broken signature. `Icon?` is the dmg's custom-icon file (`Icon\r`), not
  // part of the app.
  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());
  }

  // Re-verify what actually landed. The pre-flight check above established the
  // payload was genuine; this catches the sync itself having mangled it, which
  // Gatekeeper would otherwise turn into a dead next launch.
  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)
}

/// Remove the `.~tmp~` staging folders `rsync --delay-updates` leaves under
/// `dir` when a sync fails partway.
#[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);
    }
  }
}

/// The first `.app` bundle inside `dir` (the mounted update image).
#[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::*;

  /// A scratch dir that cleans up after itself.
  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);
  }

  /// Without a configured requirement there is nothing to verify the payload
  /// against, so the install must refuse rather than trust whatever mounts.
  #[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}");
  }
}