diff --git a/Cargo.lock b/Cargo.lock index bb6f9d6..9f2d410 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1221,6 +1221,7 @@ dependencies = [ "patois", "pulldown-cmark", "serde", + "tempfile", "ureq", "wxdragon", ] diff --git a/Cargo.toml b/Cargo.toml index ab9b0cb..8a98faf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,7 @@ repository = "https://github.com/trypsynth/ship-shape" readme = "README.md" keywords = ["updater", "wxdragon", "auto-update", "desktop", "github-releases"] categories = ["gui"] +exclude = ["examples/"] [package.metadata.patois] translatable = true @@ -23,6 +24,9 @@ ureq = { version = "3.4.2", default-features = false, features = ["json", "rustl # which parts of wxWidgets get built; nothing here needs any of them. wxdragon = { version = "0.9.22", default-features = false } +[target.'cfg(target_os = "macos")'.dependencies] +tempfile = "3.27.0" + [lints.clippy] all = { level = "warn", priority = -1 } cargo = { level = "warn", priority = -1 } diff --git a/README.md b/README.md index ddb6c15..dad3108 100644 --- a/README.md +++ b/README.md @@ -9,8 +9,9 @@ Auto-updater library for wxDragon desktop apps, supporting Windows, macOS, and L - Minisign signature verification before applying - `ui` module: update/progress dialogs, plus platform-specific install flows: - Windows: PowerShell install/extract scripts that relaunch the app afterward - - macOS: downloads and mounts a signed, notarized `.dmg` for the user to drag into - Applications; the app must be quit and relaunched manually + - macOS: privately mounts the verified `.dmg`, stages and validates its matching app bundle, + then replaces and relaunches writable installations; protected, mounted-image, or translocated + installations retain manual DMG instructions - Linux: a shell script that extracts the tarball or replaces the running `.AppImage`, then relaunches the app, the same self-updating flow as Windows - Other platforms: downloads the file and tells the user where it is @@ -62,6 +63,58 @@ ui::run_update_check(config, &frame, UpdateChannel::Stable, CheckTrigger::Manual - `UpdateError` variants dropped their `Error` suffix (`HttpError` is now `Http(u16)`), gained `Io`, and the enum is `#[non_exhaustive]`. +## macOS installation + +Updates replace the running app at its existing location, including custom folders and renamed +bundles. The DMG must contain exactly one top-level `.app` matching the running bundle identifier, +with a valid code signature and executable. Both the running app and its replacement must carry +the same Apple developer Team ID, verified against an Apple-issued signing chain. Unsigned and +ad-hoc signed development builds use the manual flow. Mounted images and Gatekeeper-translocated +apps are not modified. No administrator privileges are requested. + +Mounting, validation, copying, and the helper handshake run off the UI thread. Canceling before +shutdown drops the staged update. The detached helper waits up to 60 seconds for the app to exit, +then swaps bundles using same-filesystem renames. A failed replacement restores the old bundle; +a failed restore or launch preserves the backup and reports its location. Failure logs survive +cleanup under `~/Library/Logs/ship-shape-update-*.log`; successful and canceled updates remove +their logs. A successful Launch Services request does not detect subsequent application crashes. + +Use `ui::run_update_check_with_exit_handler` to save state and terminate normally. The existing +`run_update_check` entry point keeps its immediate process-exit behavior. The handler runs on the +UI thread only after successful installer preparation. It must terminate the process within +60 seconds; simply hiding the main window is insufficient. + +`UpdaterConfig::with_macos_relaunch_env(name, value)` preserves an explicit environment override +when launching through macOS `open`, such as an isolated settings directory. It is ignored on +other platforms; values are passed as arguments and never interpolated into shell code. + +From a repository checkout, build `cargo build --example macos_update_demo`. Run the example with +`--setup /tmp/ship-shape-demo` using a new directory, then run the generated +`Installed Demo.app/Contents/MacOS/demo` executable with `--cancel` or `--apply` followed by the +fixture's `demo.dmg` path. For automatic installation, set `SHIP_SHAPE_DEMO_SIGNING_ID` to an +Apple-issued signing identity when running `--setup`. Applying must display version 2; canceling must retain version 1. Without +that identity, fixtures are ad-hoc signed and demonstrate the manual fallback instead. The demo +trusts only its generated local fixture and keeps the production signer checks. It is a repository +test harness, excluded from the published crate package. + +The download gauge stays below 100 until verification and preparation finish. Native progress +updates can yield to the event loop, so completion waits for the active Update/Pulse call to +return before destroying the dialog or calling the shutdown handler. Regression tests cover this +ordering without requiring a graphical session. + +Run `cargo test` on macOS for coverage of cancellation, helper timeouts, replacement and rollback, +failed restore and launch, competing destination changes, protected locations, bundle selection, +signature tampering, and paths with spaces and apostrophes. Tests substitute launch commands and +alerts and only modify temporary fixture apps. The demo above additionally exercises actual DMG +mounting and Launch Services; testing a signed, notarized release establishes the Gatekeeper path. +For a read-only signer check with an existing release, set `SHIP_SHAPE_TEST_SIGNED_APP` to its +app path and run `cargo test developer_signed_release_accepts_only_its_team -- --ignored`. + +On the next automatic update, stages older than 24 hours are swept only when they belong to the +same bundle and user and neither the host nor helper process is alive. Unregistered stages, +symlinks, and stages containing `old.app` recovery backups are preserved. This avoids deleting +another app's staging area, an active update, or the only recoverable copy after a failed swap. + ## License MIT diff --git a/examples/macos_update_demo.rs b/examples/macos_update_demo.rs new file mode 100644 index 0000000..bc69bc8 --- /dev/null +++ b/examples/macos_update_demo.rs @@ -0,0 +1,129 @@ +//! Disposable macOS updater demo. It includes the actual installer implementation, without +//! adding a production API for applying arbitrary local files. No network or key bypass is +//! added to the updater. This demo trusts ONLY its own locally generated fixture image. +//! Set `SHIP_SHAPE_DEMO_SIGNING_ID` to an Apple-issued signing identity for automatic installation. +#![allow(dead_code, reason = "the shared platform module has APIs unused by the demo")] + +#[cfg(target_os = "macos")] +#[path = "../src/platform/macos.rs"] +mod macos; + +#[cfg(target_os = "macos")] +use std::{env, fs, path::Path, process::Command}; + +#[cfg(target_os = "macos")] +use ship_shape::{InstallKind, UpdateError, UpdaterConfig}; + +#[cfg(target_os = "macos")] +enum InstallOutcome { + Prepared(macos::PreparedUpdate), + ManualStep(String), +} + +#[cfg(target_os = "macos")] +fn main() -> Result<(), Box> { + let args: Vec = env::args().collect(); + if args.get(1).is_some_and(|arg| arg == "--setup") { + let root = Path::new(args.get(2).ok_or("Pass a NEW destination directory after --setup")?); + // Refuse reuse or an existing destination. The demo only replaces copies it creates. + fs::create_dir(root)?; + let running = root.join("Installed Demo.app"); + let image_root = root.join("image"); + fs::create_dir(&image_root)?; + make_bundle(&running, "1")?; + make_bundle(&image_root.join("Update Demo.app"), "2")?; + let dmg = root.join("demo.dmg"); + let status = Command::new("/usr/bin/hdiutil") + .args(["create", "-quiet", "-fs", "HFS+", "-srcfolder"]) + .arg(&image_root) + .arg(&dmg) + .status()?; + if !status.success() { + return Err("Failed to create demo DMG".into()); + } + println!("Created disposable demo at {}", root.display()); + println!("Run the installed bundle's Contents/MacOS/demo executable with --apply and the demo.dmg path."); + return Ok(()); + } + let exe = env::current_exe()?; + let bundle = exe.parent().and_then(Path::parent).and_then(Path::parent).ok_or("Not in a demo bundle")?; + if bundle.join("Contents/Resources/fixture-version").is_file() { + if args.get(1).is_some_and(|arg| arg == "--apply" || arg == "--cancel") { + let path = Path::new(args.get(2).ok_or("Pass demo.dmg after --apply or --cancel")?); + // Bind the test harness to the destination and DMG produced by --setup. + let root = bundle.parent().ok_or("Missing fixture directory")?; + if bundle.file_name().is_none_or(|name| name != "Installed Demo.app") + || path.canonicalize()? != root.join("demo.dmg").canonicalize()? + { + return Err("Use only the fixture's Installed Demo.app and demo.dmg".into()); + } + let config = UpdaterConfig::new("fixture/demo", "demo", "Updater Demo", "unused-fixture-key", "1"); + match macos::install(&config, path)? { + InstallOutcome::Prepared(update) if args[1] == "--cancel" => { + drop(update); + println!("Cancelled prepared update; version 1 must remain installed."); + } + InstallOutcome::Prepared(update) => { + update.commit()?; + println!("Helper ready. Exiting so it can replace this disposable bundle."); + } + InstallOutcome::ManualStep(message) => println!("{message}"), + } + return Ok(()); + } + let version = fs::read_to_string(bundle.join("Contents/Resources/fixture-version"))?; + if let Some(root) = bundle.parent() + && root.join(".demo-headless").is_file() + { + fs::write(root.join("launched-version"), &version)?; + return Ok(()); + } + let message = format!("Updater demo version {version} launched. This is a disposable test app."); + Command::new("/usr/bin/osascript") + .args([ + "-e", + "on run argv", + "-e", + "display dialog (item 1 of argv) buttons {\"OK\"} default button 1", + "-e", + "end run", + &message, + ]) + .status()?; + return Ok(()); + } + Err("Use --setup with a new disposable directory first".into()) +} + +#[cfg(target_os = "macos")] +fn make_bundle(bundle: &Path, version: &str) -> Result<(), Box> { + let macos = bundle.join("Contents/MacOS"); + fs::create_dir_all(&macos)?; + fs::create_dir_all(bundle.join("Contents/Resources"))?; + fs::copy(env::current_exe()?, macos.join("demo"))?; + fs::write(bundle.join("Contents/Resources/fixture-version"), version)?; + fs::write( + bundle.join("Contents/Info.plist"), + format!( + r#" + + +CFBundleIdentifierorg.ship-shape.disposable-demo +CFBundleNameUpdater Demo +CFBundleExecutabledemo +CFBundlePackageTypeAPPL +CFBundleVersion{version} +"# + ), + )?; + let identity = env::var("SHIP_SHAPE_DEMO_SIGNING_ID").unwrap_or_else(|_| "-".to_owned()); + if !Command::new("/usr/bin/codesign").args(["--force", "--sign", &identity]).arg(bundle).status()?.success() { + return Err("Failed to sign fixture".into()); + } + Ok(()) +} + +#[cfg(not(target_os = "macos"))] +fn main() { + eprintln!("This disposable demo requires macOS."); +} diff --git a/src/config.rs b/src/config.rs index e3aa693..2a0bd58 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,3 +1,5 @@ +use std::ffi::OsString; + /// How the running copy of the app was installed. Selects which release asset to download and how /// to apply it. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] @@ -56,6 +58,10 @@ pub struct UpdaterConfig { /// Command-line arguments passed to a downloaded installer on Windows. Defaults to /// `["/silent"]` (Inno Setup); NSIS installers need `["/S"]`. pub installer_args: Vec, + /// Environment overrides passed to macOS `open` when relaunching the app. Empty by default. + /// Use this for an explicit configuration directory that must survive a Launch Services launch. + /// Ignored on other platforms. Values are passed as arguments, never as shell source. + pub macos_relaunch_env: Vec<(String, OsString)>, } impl UpdaterConfig { @@ -81,6 +87,7 @@ impl UpdaterConfig { asset_suffix: String::new(), installer_asset_suffix: None, installer_args: vec!["/silent".into()], + macos_relaunch_env: Vec::new(), } } @@ -133,6 +140,13 @@ impl UpdaterConfig { self } + /// Preserve an environment override when macOS relaunches the updated app. + #[must_use] + pub fn with_macos_relaunch_env(mut self, name: impl Into, value: impl Into) -> Self { + self.macos_relaunch_env.push((name.into(), value.into())); + self + } + pub(crate) fn effective_asset_suffix(&self) -> &str { match self.install_kind { InstallKind::Installer => self.installer_asset_suffix.as_deref().unwrap_or(&self.asset_suffix), diff --git a/src/download.rs b/src/download.rs index 9888772..0855504 100644 --- a/src/download.rs +++ b/src/download.rs @@ -70,7 +70,15 @@ pub fn download_update_file( .and_then(|v| v.parse::().ok()) .unwrap_or(0); let file_name = url.rsplit('/').next().unwrap_or("update.bin"); - let final_path = platform::download_dir(config)?.join(file_name); + let download_dir = platform::download_dir(config)?; + #[cfg(target_os = "macos")] + let private_dir = tempfile::Builder::new() + .prefix("ship-shape-download-") + .tempdir_in(&download_dir) + .map_err(|e| UpdateError::Io(format!("Failed to create private download directory: {e}")))?; + #[cfg(target_os = "macos")] + let download_dir = private_dir.path(); + let final_path = download_dir.join(file_name); let temp = TempDownload(final_path.with_file_name(format!("{file_name}.tmp"))); let mut file = File::create(&temp.0).map_err(|e| UpdateError::Io(format!("Failed to create file: {e}")))?; let mut downloaded: u64 = 0; @@ -93,6 +101,8 @@ pub fn download_update_file( .verify(&data, &signature, true) .map_err(|e| UpdateError::Verification(format!("Signature verification failed: {e}")))?; fs::rename(&temp.0, &final_path).map_err(|e| UpdateError::Io(format!("Failed to rename verified file: {e}")))?; + #[cfg(target_os = "macos")] + let _ = private_dir.keep(); Ok(final_path) } diff --git a/src/platform.rs b/src/platform.rs index 132abe6..d3a07c6 100644 --- a/src/platform.rs +++ b/src/platform.rs @@ -13,7 +13,8 @@ mod windows; #[cfg(target_os = "linux")] pub use linux::{asset_name_parts, download_dir, install}; #[cfg(target_os = "macos")] -pub use macos::{asset_name_parts, download_dir, install}; +#[allow(unused_imports, reason = "macOS UI uses cancellable preparation")] +pub use macos::{asset_name_parts, download_dir, install, install_with_cancel, remove_private_download}; #[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))] pub use other::{asset_name_parts, download_dir, install}; #[cfg(target_os = "windows")] @@ -22,6 +23,9 @@ pub use windows::{asset_name_parts, download_dir, install}; /// What the caller must do after a successful [`install`]. #[allow(dead_code, reason = "each platform constructs only some variants")] pub enum InstallOutcome { + /// macOS preparation succeeded; commit on the UI thread before requesting shutdown. + #[cfg(target_os = "macos")] + Prepared(macos::PreparedUpdate), /// The installer is waiting for this process to exit. Exit, /// The user must finish the install by hand. Holds the instructions to show. diff --git a/src/platform/macos.rs b/src/platform/macos.rs index 7ac49ab..a1f1ad2 100644 --- a/src/platform/macos.rs +++ b/src/platform/macos.rs @@ -1,14 +1,23 @@ use std::{ env, + ffi::OsString, + fs, + os::unix::fs::MetadataExt, path::{Path, PathBuf}, - process::Command, + process::{self, Command, Stdio}, + sync::atomic::{AtomicBool, Ordering}, + thread, + time::{Duration, Instant, SystemTime}, }; use patois::t; +use tempfile::TempDir; use super::InstallOutcome; use crate::{InstallKind, UpdateError, UpdaterConfig}; +const STALE_STAGE_AGE: Duration = Duration::from_hours(24); + /// macOS has a single asset kind, a disk image, so `install_kind` is ignored. pub const fn asset_name_parts(_install_kind: InstallKind) -> (&'static str, &'static str) { ("", "dmg") @@ -19,23 +28,352 @@ pub fn download_dir(_config: &UpdaterConfig) -> Result { Ok(env::temp_dir()) } -/// Mount the downloaded disk image and prompt the user to finish installing by hand. -/// -/// There's no way to self-replace a running, Gatekeeper-checked `.app` bundle the way the -/// Windows install script does, so this stops at opening the mounted image; dragging the app -/// into Applications is left to the user. +/// A staged update with a helper waiting for an explicit commit. Dropping it cancels the helper +/// and removes the staging directory; accepting it transfers cleanup to the helper. +pub struct PreparedUpdate { + stage: TempDir, +} + +impl PreparedUpdate { + pub fn commit(self) -> Result<(), String> { + fs::write(self.stage.path().join("commit"), b"install") + .map_err(|e| format!("{}: {e}", t("Failed to start installation")))?; + let _ = self.stage.keep(); + Ok(()) + } +} + +/// Prepare an in-place bundle update while the application is still running. A detached helper +/// waits for both an explicit commit and process termination before replacing the bundle. +/// This must only receive a downloaded disk image whose Minisign signature has been verified. +#[allow(dead_code, reason = "UI uses the cancellable entry point; the demo uses install")] pub fn install(config: &UpdaterConfig, path: &Path) -> Result { - // `open` mounts the disk image and shows it in a Finder window, the same as - // double-clicking it. - Command::new("open").arg(path).spawn().map_err(|e| format!("{}: {e}", t("Failed to open disk image")))?; + install_with_cancel(config, path, &AtomicBool::new(false)) +} + +pub fn install_with_cancel( + config: &UpdaterConfig, + path: &Path, + cancelled: &AtomicBool, +) -> Result { + check_cancelled(cancelled)?; + let current = env::current_exe().map_err(|e| e.to_string())?; + let Some(bundle) = bundle_for_executable(¤t) else { + return manual_install(config, path); + }; + if !can_update(&bundle) { + return manual_install(config, path); + } + let Some(parent) = bundle.parent() else { + return manual_install(config, path); + }; + let Some(team) = signing_team(&bundle) else { + return manual_install(config, path); + }; + validate_bundle(&bundle, &team)?; + let Ok(stage) = tempfile::Builder::new().prefix(".ship-shape-").tempdir_in(parent) else { + return manual_install(config, path); + }; + register_stage(stage.path(), &bundle)?; + sweep_stale_stages(parent, &bundle, stage.path().metadata().map_err(|e| e.to_string())?.uid(), SystemTime::now()); + let original_stamp = bundle_stamp(&bundle)?; + let identity = plist_value(&bundle, "CFBundleIdentifier")?; + let mount = Mount::attach(path)?; + check_cancelled(cancelled)?; + let replacement = find_replacement(&mount.point, &identity)?; + validate_bundle(&replacement, &team)?; + let staged = stage.path().join("new.app"); + run(Command::new("/usr/bin/ditto").arg(&replacement).arg(&staged), &t("Failed to stage the application"))?; + check_cancelled(cancelled)?; + validate_bundle(&staged, &team)?; + if plist_value(&staged, "CFBundleIdentifier")? != identity { + return Err(t("The update contains a different application.")); + } + mount.detach()?; + check_cancelled(cancelled)?; + // Failure diagnostics survive cleanup. The helper removes the log after a successful launch. + let logs = env::var_os("HOME") + .map(|home| PathBuf::from(home).join("Library/Logs")) + .ok_or_else(|| t("Could not determine the update log directory."))?; + fs::create_dir_all(&logs).map_err(|e| e.to_string())?; + let log = tempfile::Builder::new() + .prefix("ship-shape-update-") + .suffix(".log") + .tempfile_in(logs) + .map_err(|e| e.to_string())?; + let (log_file, log_path) = log.keep().map_err(|e| e.to_string())?; + write_messages(stage.path())?; + let script_path = stage.path().join("install.sh"); + fs::write(&script_path, include_str!("macos_install.sh")).map_err(|e| e.to_string())?; + let mut command = Command::new("/bin/sh"); + command + .arg(&script_path) + .arg(process::id().to_string()) + .arg(&bundle) + .arg(stage.path()) + .arg(&log_path) + .arg(original_stamp) + .stdin(Stdio::null()) + .stdout(Stdio::from(log_file.try_clone().map_err(|e| e.to_string())?)) + .stderr(Stdio::from(log_file)); + for (name, value) in &config.macos_relaunch_env { + let mut assignment = OsString::from(name); + assignment.push("="); + assignment.push(value); + command.arg("--env").arg(assignment); + } + let mut helper = command.spawn().map_err(|e| format!("{}: {e}", t("Failed to launch update helper")))?; + let deadline = Instant::now() + Duration::from_secs(5); + while !stage.path().join("ready").is_file() { + if helper.try_wait().map_err(|e| e.to_string())?.is_some() || Instant::now() >= deadline { + let _ = helper.kill(); + let _ = helper.wait(); + return Err(format!("{} {}", t("The update helper did not start. See the log:"), log_path.display())); + } + thread::sleep(Duration::from_millis(20)); + } + // The helper is intentionally independent of the host process. It exits itself on cancellation + // or timeout; a detached reaper prevents a zombie if this application stays open after cancel. + thread::spawn(move || { + let _ = helper.wait(); + }); + Ok(InstallOutcome::Prepared(PreparedUpdate { stage })) +} + +fn write_messages(stage: &Path) -> Result<(), String> { + let messages = [ + t("The application did not quit within 60 seconds. The current app was kept."), + t("The installation changed while waiting. The current app was kept."), + t("Could not move the current application. The current app was kept."), + t("Could not install the update. The previous version was restored."), + t("Could not restore the previous version. Recover it from:"), + t("macOS could not launch the update. The previous version is preserved at:"), + ]; + for (index, message) in messages.iter().enumerate() { + fs::write(stage.join(format!("message-{index}")), message).map_err(|e| e.to_string())?; + } + fs::write(stage.join("message-title"), t("Application update failed")).map_err(|e| e.to_string())?; + fs::write(stage.join("message-log"), t("Log:")).map_err(|e| e.to_string())?; + Ok(()) +} + +fn check_cancelled(cancelled: &AtomicBool) -> Result<(), String> { + if cancelled.load(Ordering::Relaxed) { + return Err(t("Update cancelled.")); + } + Ok(()) +} + +fn bundle_stamp(bundle: &Path) -> Result { + let metadata = fs::symlink_metadata(bundle).map_err(|e| e.to_string())?; + Ok(format!("{}:{}", metadata.dev(), metadata.ino())) +} + +fn manual_install(config: &UpdaterConfig, path: &Path) -> Result { + run(Command::new("/usr/bin/open").arg(path), &t("Failed to open disk image"))?; Ok(InstallOutcome::ManualStep( - t( - "The update has been downloaded and its disk image opened. Quit %s and drag the new version into Applications to finish installing.", - ) - .replace("%s", &config.app_display_name), + t("The update has been downloaded and its disk image opened. Quit %s and drag the new version into Applications to finish installing.") + .replace("%s", &config.app_display_name), )) } +fn bundle_for_executable(exe: &Path) -> Option { + let macos = exe.parent()?; + let contents = macos.parent()?; + let bundle = contents.parent()?; + (macos.file_name()? == "MacOS" && contents.file_name()? == "Contents" && bundle.extension()? == "app") + .then(|| bundle.to_path_buf()) +} + +fn can_update(bundle: &Path) -> bool { + !bundle.components().any(|part| part.as_os_str() == "AppTranslocation") + && !fs::symlink_metadata(bundle).is_ok_and(|meta| meta.file_type().is_symlink()) + && Command::new("/bin/test").arg("-w").arg(bundle).status().is_ok_and(|s| s.success()) +} + +fn plist_value(bundle: &Path, key: &str) -> Result { + let output = Command::new("/usr/libexec/PlistBuddy") + .args(["-c", &format!("Print :{key}")]) + .arg(bundle.join("Contents/Info.plist")) + .output() + .map_err(|e| e.to_string())?; + let value = String::from_utf8(output.stdout).map_err(|e| e.to_string())?; + if !output.status.success() || value.trim().is_empty() { + return Err(format!("{}: {key}", t("Invalid application metadata"))); + } + Ok(value.trim().to_owned()) +} + +fn find_replacement(root: &Path, identity: &str) -> Result { + let mut matches = Vec::new(); + for entry in fs::read_dir(root).map_err(|e| e.to_string())? { + let entry = entry.map_err(|e| e.to_string())?; + if entry.file_type().map_err(|e| e.to_string())?.is_dir() + && entry.path().extension().is_some_and(|ext| ext == "app") + && plist_value(&entry.path(), "CFBundleIdentifier").is_ok_and(|id| id == identity) + { + matches.push(entry.path()); + } + } + if matches.len() != 1 { + return Err(t("The disk image must contain exactly one matching application.")); + } + Ok(matches.remove(0)) +} + +fn signing_team(bundle: &Path) -> Option { + let output = Command::new("/usr/bin/codesign").args(["--display", "--verbose=4"]).arg(bundle).output().ok()?; + if !output.status.success() { + return None; + } + let metadata = String::from_utf8(output.stderr).ok()?; + let team = metadata.lines().find_map(|line| line.strip_prefix("TeamIdentifier="))?; + // Apple Team IDs contain ten ASCII letters/digits. This also rejects "not set" and + // keeps the value safe to embed in the native code requirement language. + (team.len() == 10 && team.bytes().all(|byte| byte.is_ascii_uppercase() || byte.is_ascii_digit())) + .then(|| team.to_owned()) +} + +fn validate_bundle(bundle: &Path, team: &str) -> Result<(), String> { + validate_bundle_integrity(bundle)?; + // Checking TeamIdentifier metadata alone could trust a self-signed certificate. Require + // an Apple-issued signing chain and the running app's team in the certificate itself. + let requirement = format!("anchor apple generic and certificate leaf[subject.OU] = \"{team}\""); + run( + Command::new("/usr/bin/codesign") + .args(["--verify", "--deep", "--strict"]) + .arg(format!("-R={requirement}")) + .arg(bundle), + &t("The update must be signed by the application's developer team"), + ) +} + +fn validate_bundle_integrity(bundle: &Path) -> Result<(), String> { + let name = plist_value(bundle, "CFBundleExecutable")?; + if Path::new(&name).file_name().is_none_or(|n| n != name.as_str()) { + return Err(t("Invalid application executable.")); + } + let exe = bundle.join("Contents/MacOS").join(name); + let resolved = exe.canonicalize().map_err(|e| e.to_string())?; + if !resolved.starts_with(bundle.canonicalize().map_err(|e| e.to_string())?) || !resolved.is_file() { + return Err(t("Invalid application executable.")); + } + run(Command::new("/bin/test").arg("-x").arg(exe), &t("Invalid application executable"))?; + run( + Command::new("/usr/bin/codesign").args(["--verify", "--deep", "--strict"]).arg(bundle), + &t("Application signature verification failed"), + ) +} + +fn register_stage(stage: &Path, bundle: &Path) -> Result<(), String> { + let bundle = bundle.canonicalize().map_err(|e| e.to_string())?; + fs::write(stage.join("owner-bundle"), bundle.as_os_str().as_encoded_bytes()).map_err(|e| e.to_string())?; + fs::write(stage.join("host-pid"), process::id().to_string()).map_err(|e| e.to_string()) +} + +/// Only remove abandoned stages belonging to this app and user. Recovery backups are kept. +fn sweep_stale_stages(parent: &Path, bundle: &Path, owner: u32, now: SystemTime) { + let Ok(bundle) = bundle.canonicalize() else { return }; + let Ok(entries) = fs::read_dir(parent) else { return }; + for entry in entries.flatten() { + if !entry.file_name().to_string_lossy().starts_with(".ship-shape-") { + continue; + } + let stage = entry.path(); + let Ok(metadata) = fs::symlink_metadata(&stage) else { continue }; + if !metadata.is_dir() || metadata.uid() != owner { + continue; + } + match fs::symlink_metadata(stage.join("old.app")) { + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + _ => continue, + } + let marker = stage.join("owner-bundle"); + let Ok(metadata) = fs::symlink_metadata(&marker) else { continue }; + if !metadata.is_file() || metadata.uid() != owner { + continue; + } + let old_enough = metadata + .modified() + .ok() + .and_then(|time| now.duration_since(time).ok()) + .is_some_and(|age| age >= STALE_STAGE_AGE); + if !old_enough || !fs::read(&marker).is_ok_and(|path| path == bundle.as_os_str().as_encoded_bytes()) { + continue; + } + if stage_process_alive(&stage.join("host-pid"), owner, false) + || stage_process_alive(&stage.join("helper-pid"), owner, true) + { + continue; + } + let _ = fs::remove_dir_all(stage); + } +} + +fn stage_process_alive(path: &Path, owner: u32, optional: bool) -> bool { + let metadata = match fs::symlink_metadata(path) { + Ok(metadata) => metadata, + Err(error) if optional && error.kind() == std::io::ErrorKind::NotFound => return false, + Err(_) => return true, + }; + if !metadata.is_file() || metadata.uid() != owner { + return true; + } + let Some(pid) = + fs::read_to_string(path).ok().and_then(|text| text.trim().parse::().ok()).filter(|pid| *pid > 0) + else { + return true; + }; + // Fail closed if process inspection itself cannot run. A reused PID only delays cleanup. + Command::new("/bin/kill").args(["-0", &pid.to_string()]).output().map_or(true, |output| output.status.success()) +} + +fn run(command: &mut Command, message: &str) -> Result<(), String> { + let output = command.output().map_err(|e| format!("{message}: {e}"))?; + if !output.status.success() { + return Err(format!("{}: {}", message, String::from_utf8_lossy(&output.stderr).trim())); + } + Ok(()) +} + +struct Mount { + point: PathBuf, + _directory: TempDir, + attached: bool, +} + +impl Mount { + fn attach(dmg: &Path) -> Result { + let directory = tempfile::Builder::new().prefix("ship-shape-mount-").tempdir().map_err(|e| e.to_string())?; + let point = directory.path().join("image"); + fs::create_dir(&point).map_err(|e| e.to_string())?; + // A failed attach can still partially mount a volume; Drop attempts detach. + let mount = Self { point, _directory: directory, attached: true }; + run( + Command::new("/usr/bin/hdiutil") + .args(["attach", "-readonly", "-nobrowse", "-noautoopen", "-mountpoint"]) + .arg(&mount.point) + .arg(dmg), + &t("Failed to mount disk image"), + )?; + Ok(mount) + } + + fn detach(mut self) -> Result<(), String> { + run(Command::new("/usr/bin/hdiutil").arg("detach").arg(&self.point), &t("Failed to unmount disk image"))?; + self.attached = false; + Ok(()) + } +} + +impl Drop for Mount { + fn drop(&mut self) { + if self.attached { + let _ = Command::new("/usr/bin/hdiutil").args(["detach", "-force"]).arg(&self.point).output(); + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -45,4 +383,33 @@ mod tests { assert_eq!(asset_name_parts(InstallKind::Installer), ("", "dmg")); assert_eq!(asset_name_parts(InstallKind::Portable), ("", "dmg")); } + + #[test] + fn discovers_only_bundle_executables() { + assert_eq!( + bundle_for_executable(Path::new("/tmp/O'Brien App.app/Contents/MacOS/app")), + Some(PathBuf::from("/tmp/O'Brien App.app")) + ); + assert!(bundle_for_executable(Path::new("/tmp/app")).is_none()); + assert!(bundle_for_executable(Path::new("/tmp/App.app/Other/MacOS/app")).is_none()); + } + + #[test] + fn translocated_bundle_is_never_updated() { + assert!(!can_update(Path::new("/private/var/AppTranslocation/id/d/App.app"))); + } } + +/// Only delete directories created by our download pipeline, never a caller's arbitrary DMG. +pub fn remove_private_download(path: &Path) { + if let Some(parent) = path.parent() + && parent.parent() == Some(env::temp_dir().as_path()) + && parent.file_name().is_some_and(|name| name.to_string_lossy().starts_with("ship-shape-download-")) + { + let _ = fs::remove_dir_all(parent); + } +} + +#[cfg(test)] +#[path = "macos_tests.rs"] +mod integration_tests; diff --git a/src/platform/macos_install.sh b/src/platform/macos_install.sh new file mode 100644 index 0000000..78a0967 --- /dev/null +++ b/src/platform/macos_install.sh @@ -0,0 +1,91 @@ +#!/bin/sh +# Arguments are passed directly by Rust; paths never become shell source. +pid=$1 +bundle=$2 +stage=$3 +log=$4 +original_stamp=$5 +shift 5 +launch_app() { + /usr/bin/open -n "$bundle" "$@" +} +backup="$stage/old.app" +new="$stage/new.app" +# This helper remains outside both app bundles and survives their replacement. +trap '' HUP +report() { + message="$(/bin/cat "$stage/message-$1") ${2:-} $(/bin/cat "$stage/message-log") $log" + title=$(/bin/cat "$stage/message-title") + printf '%s\n' "$message" + # Pass the message as argv, rather than interpolating it into AppleScript. + /usr/bin/osascript - "$title" "$message" <<'APPLESCRIPT' +on run argv + display alert (item 1 of argv) message (item 2 of argv) as warning +end run +APPLESCRIPT +} +cleanup() { + /bin/rm -rf "$stage" +} +printf '%s\n' "$$" > "$stage/helper-pid" || exit 1 +: > "$stage/ready" || exit 1 +# Cancelled UI flows drop the staging directory. Never touch the app without commit. +count=0 +while [ ! -f "$stage/commit" ]; do + if [ ! -d "$stage" ]; then + /bin/rm -f "$log" + exit 0 + fi + count=$((count + 1)) + if [ "$count" -ge 300 ]; then + printf '%s\n' 'Installation was not committed; keeping the current app.' + cleanup + exit 1 + fi + /bin/sleep 0.2 +done +count=0 +while /bin/kill -0 "$pid" 2>/dev/null; do + count=$((count + 1)) + if [ "$count" -ge 300 ]; then + report 0 + cleanup + exit 1 + fi + /bin/sleep 0.2 +done +printf '%s\n' 'Application exited; replacing the bundle.' +rename_bundle() { + # rename(2) fails on an occupied, nonempty destination instead of nesting the source + # inside it. /usr/bin/perl ships with our supported macOS versions. + /usr/bin/perl -e 'rename $ARGV[0], $ARGV[1] or die "rename: $!\n"' "$1" "$2" +} +# Recheck after waiting: another installer may have changed the destination. +if [ ! -d "$bundle" ] || [ -L "$bundle" ] || [ ! -d "$new" ] || [ "$(/usr/bin/stat -f '%d:%i' "$bundle")" != "$original_stamp" ]; then + report 1 + cleanup + exit 1 +fi +if ! rename_bundle "$bundle" "$backup"; then + report 2 + launch_app "$@" + cleanup + exit 1 +fi +if ! rename_bundle "$new" "$bundle"; then + if rename_bundle "$backup" "$bundle"; then + report 3 + launch_app "$@" + cleanup + else + report 4 "$backup" + fi + exit 1 +fi +if ! launch_app "$@"; then + report 5 "$backup" + exit 1 +fi +printf '%s\n' 'Replacement completed and macOS accepted the launch request.' +cleanup +/bin/rm -f "$log" diff --git a/src/platform/macos_tests.rs b/src/platform/macos_tests.rs new file mode 100644 index 0000000..53824f3 --- /dev/null +++ b/src/platform/macos_tests.rs @@ -0,0 +1,325 @@ +//! Exercise the actual helper script with disposable directories. Only launch/report commands +//! and fault injection are substituted, so tests cannot open apps or display desktop alerts. +use std::{ + fs, + path::PathBuf, + process::{Child, Command}, + thread, + time::{Duration, Instant, SystemTime}, +}; + +use tempfile::TempDir; + +use super::{ + PreparedUpdate, STALE_STAGE_AGE, bundle_stamp, check_cancelled, find_replacement, register_stage, signing_team, + sweep_stale_stages, validate_bundle, validate_bundle_integrity, +}; + +struct Fixture { + root: TempDir, + bundle: PathBuf, + stage: PathBuf, +} + +impl Fixture { + fn new() -> Self { + let root = tempfile::Builder::new().prefix("ship-shape-O'Brien test-").tempdir().unwrap(); + let bundle = root.path().join("Current App.app"); + let stage = root.path().join("stage"); + fs::create_dir(&bundle).unwrap(); + fs::create_dir(&stage).unwrap(); + super::write_messages(&stage).unwrap(); + fs::create_dir(stage.join("new.app")).unwrap(); + fs::write(bundle.join("version"), "old").unwrap(); + fs::write(stage.join("new.app/version"), "new").unwrap(); + fs::write(root.path().join("log"), "diagnostic").unwrap(); + Self { root, bundle, stage } + } + + fn launch(&self, pid: u32, commit: bool, fault: &str) -> Child { + let mut script = include_str!("macos_install.sh").to_string(); + // Replace desktop alerts with a successful noninteractive command. + script = script.replace("/usr/bin/osascript -", "/usr/bin/true"); + let launcher = self.root.path().join("launch.sh"); + fs::write( + &launcher, + if fault == "launch" { + "exit 1\n" + } else { + "printf '%s\\n' \"$@\" > \"$(dirname \"$0\")/launch-args\"\nexit 0\n" + }, + ) + .unwrap(); + script = script.replace("/usr/bin/open -n", &format!("/bin/sh {}", quote(launcher.to_str().unwrap()))); + if fault == "rename" || fault == "restore" { + let renamer = self.root.path().join("rename.sh"); + let count = self.root.path().join("renames"); + let restore = if fault == "restore" { " -o \"$n\" -eq 3" } else { "" }; + fs::write(&renamer, format!("count={}\nn=0\n[ ! -f \"$count\" ] || n=$(cat \"$count\")\nn=$((n+1))\nprintf '%s' \"$n\" > \"$count\"\n[ \"$n\" -ne 2{restore} ] || exit 1\n/usr/bin/perl -e 'rename $ARGV[0], $ARGV[1] or die $!' \"$1\" \"$2\"\n", quote(count.to_str().unwrap()))).unwrap(); + // Use an explicit condition for the restore failure (both calls 2 and 3 fail). + if fault == "restore" { + let contents = fs::read_to_string(&renamer) + .unwrap() + .replace("[ \"$n\" -ne 2 -o \"$n\" -eq 3 ]", "[ \"$n\" -eq 1 ]"); + fs::write(&renamer, contents).unwrap(); + } + script = script.replace( + "/usr/bin/perl -e 'rename $ARGV[0], $ARGV[1] or die \"rename: $!\\n\"'", + &format!("/bin/sh {}", quote(renamer.to_str().unwrap())), + ); + } + if fault == "timeout" { + script = script.replace("-ge 300", "-ge 3").replace("/bin/sleep 0.2", "/bin/sleep 0.01"); + } + let stamp = bundle_stamp(&self.bundle).unwrap(); + if fault == "changed" { + fs::rename(&self.bundle, self.root.path().join("another-installer-backup.app")).unwrap(); + fs::create_dir(&self.bundle).unwrap(); + fs::write(self.bundle.join("version"), "other").unwrap(); + } + let script_path = self.root.path().join("helper.sh"); + fs::write(&script_path, script).unwrap(); + if commit { + fs::write(self.stage.join("commit"), "yes").unwrap(); + } + Command::new("/bin/sh") + .arg(script_path) + .arg(pid.to_string()) + .arg(&self.bundle) + .arg(&self.stage) + .arg(self.root.path().join("log")) + .arg(stamp) + .arg("--env") + .arg("TEST_CONFIG_PATH=O'Brien test/settings") + .spawn() + .unwrap() + } + + fn version(&self) -> String { + fs::read_to_string(self.bundle.join("version")).unwrap() + } +} + +fn quote(path: &str) -> String { + format!("'{}'", path.replace('\'', "'\\''")) +} + +fn wait(mut child: Child) -> bool { + let deadline = Instant::now() + Duration::from_secs(5); + loop { + if let Some(status) = child.try_wait().unwrap() { + return status.success(); + } + if Instant::now() > deadline { + child.kill().unwrap(); + child.wait().unwrap(); + panic!("helper did not exit"); + } + thread::sleep(Duration::from_millis(10)); + } +} + +#[test] +fn helper_replaces_and_cleans_up_with_quoted_paths() { + let fixture = Fixture::new(); + assert!(wait(fixture.launch(u32::MAX, true, ""))); + assert_eq!(fixture.version(), "new"); + assert!(!fixture.stage.exists()); + assert!(!fixture.root.path().join("log").exists()); + let args = fs::read_to_string(fixture.root.path().join("launch-args")).unwrap(); + assert!(args.ends_with("--env\nTEST_CONFIG_PATH=O'Brien test/settings\n")); +} + +#[test] +fn helper_restores_old_bundle_after_install_failure() { + let fixture = Fixture::new(); + assert!(!wait(fixture.launch(u32::MAX, true, "rename"))); + assert_eq!(fixture.version(), "old"); + assert!(!fixture.stage.exists()); + assert!(fixture.root.path().join("log").exists()); +} + +#[test] +fn helper_preserves_backup_when_restore_fails() { + let fixture = Fixture::new(); + assert!(!wait(fixture.launch(u32::MAX, true, "restore"))); + assert_eq!(fs::read_to_string(fixture.stage.join("old.app/version")).unwrap(), "old"); + assert_eq!(fs::read_to_string(fixture.stage.join("new.app/version")).unwrap(), "new"); +} + +#[test] +fn helper_preserves_backup_when_launch_fails() { + let fixture = Fixture::new(); + assert!(!wait(fixture.launch(u32::MAX, true, "launch"))); + assert_eq!(fixture.version(), "new"); + assert_eq!(fs::read_to_string(fixture.stage.join("old.app/version")).unwrap(), "old"); + assert!(fixture.root.path().join("log").exists()); +} + +#[test] +fn helper_does_not_replace_an_installation_changed_by_another_installer() { + let fixture = Fixture::new(); + assert!(!wait(fixture.launch(u32::MAX, true, "changed"))); + assert_eq!(fixture.version(), "other"); +} + +#[test] +fn helper_times_out_without_commit_or_host_exit() { + for commit in [false, true] { + let fixture = Fixture::new(); + assert!(!wait(fixture.launch(std::process::id(), commit, "timeout"))); + assert_eq!(fixture.version(), "old"); + assert!(!fixture.stage.exists()); + } +} + +#[test] +fn dropping_prepared_update_cancels_before_replacement() { + let fixture = Fixture::new(); + let child = fixture.launch(u32::MAX, false, ""); + let deadline = Instant::now() + Duration::from_secs(2); + while !fixture.stage.join("ready").exists() { + assert!(Instant::now() < deadline); + thread::sleep(Duration::from_millis(10)); + } + fs::remove_dir_all(&fixture.stage).unwrap(); + assert!(wait(child)); + assert_eq!(fixture.version(), "old"); + assert!(!fixture.root.path().join("log").exists()); + let stage = tempfile::tempdir().unwrap(); + let path = stage.path().to_owned(); + drop(PreparedUpdate { stage }); + assert!(!path.exists()); +} + +fn metadata_bundle(root: &std::path::Path, name: &str, identity: &str, executable: &str) -> PathBuf { + let bundle = root.join(name); + fs::create_dir_all(bundle.join("Contents/MacOS")).unwrap(); + fs::write(bundle.join("Contents/Info.plist"), format!("CFBundleIdentifier{identity}CFBundleExecutable{executable}")).unwrap(); + bundle +} + +#[test] +fn replacement_requires_exactly_one_matching_bundle_and_ignores_symlinks() { + let root = tempfile::tempdir().unwrap(); + assert!(find_replacement(root.path(), "correct.id").is_err()); + metadata_bundle(root.path(), "Wrong.app", "wrong.id", "app"); + let first = metadata_bundle(root.path(), "First.app", "correct.id", "app"); + std::os::unix::fs::symlink(&first, root.path().join("Link.app")).unwrap(); + assert_eq!(find_replacement(root.path(), "correct.id").unwrap(), first); + metadata_bundle(root.path(), "Duplicate.app", "correct.id", "app"); + assert!(find_replacement(root.path(), "correct.id").is_err()); +} + +#[test] +fn rejects_missing_escaping_and_unsigned_executables() { + use std::os::unix::fs::PermissionsExt; + let root = tempfile::tempdir().unwrap(); + let bundle = metadata_bundle(root.path(), "Missing.app", "id", "app"); + assert!(validate_bundle(&bundle, "TESTTEAM01").is_err()); + let bundle = metadata_bundle(root.path(), "Escaping.app", "id", "../../outside"); + assert!(validate_bundle(&bundle, "TESTTEAM01").is_err()); + let bundle = metadata_bundle(root.path(), "Unsigned.app", "id", "app"); + let executable = bundle.join("Contents/MacOS/app"); + fs::write(&executable, "#!/bin/sh\nexit 0\n").unwrap(); + fs::set_permissions(&executable, fs::Permissions::from_mode(0o755)).unwrap(); + assert!(validate_bundle(&bundle, "TESTTEAM01").is_err()); +} + +#[test] +fn cancellation_stops_preparation() { + assert!(check_cancelled(&std::sync::atomic::AtomicBool::new(true)).is_err()); +} + +#[test] +fn protected_and_symlinked_bundles_use_manual_fallback() { + use std::os::unix::fs::PermissionsExt; + let root = tempfile::tempdir().unwrap(); + let bundle = metadata_bundle(root.path(), "Protected.app", "id", "app"); + fs::set_permissions(&bundle, fs::Permissions::from_mode(0o555)).unwrap(); + assert!(!super::can_update(&bundle)); + fs::set_permissions(&bundle, fs::Permissions::from_mode(0o755)).unwrap(); + let link = root.path().join("Link.app"); + std::os::unix::fs::symlink(&bundle, &link).unwrap(); + assert!(!super::can_update(&link)); +} + +#[test] +fn signed_bundle_passes_but_tampering_fails() { + let root = tempfile::tempdir().unwrap(); + let bundle = metadata_bundle(root.path(), "Signed.app", "id", "app"); + fs::create_dir(bundle.join("Contents/Resources")).unwrap(); + let resource = bundle.join("Contents/Resources/content.txt"); + fs::write(&resource, "original").unwrap(); + // A Mach-O executable, copied into a disposable fixture, avoids signing shell scripts. + fs::copy("/usr/bin/true", bundle.join("Contents/MacOS/app")).unwrap(); + let status = Command::new("/usr/bin/codesign").args(["--force", "--sign", "-"]).arg(&bundle).output().unwrap(); + assert!(status.status.success(), "{}", String::from_utf8_lossy(&status.stderr)); + validate_bundle_integrity(&bundle).unwrap(); + assert!(signing_team(&bundle).is_none()); + let error = validate_bundle(&bundle, "TESTTEAM01").unwrap_err(); + assert!(error.contains("developer team"), "{error}"); + fs::write(&resource, "tampered").unwrap(); + let error = validate_bundle_integrity(&bundle).unwrap_err(); + assert!(error.contains("signature verification failed"), "{error}"); +} + +#[test] +fn unsigned_host_has_no_signing_team() { + let root = tempfile::tempdir().unwrap(); + let bundle = metadata_bundle(root.path(), "Unsigned.app", "id", "app"); + fs::copy("/usr/bin/true", bundle.join("Contents/MacOS/app")).unwrap(); + assert!(signing_team(&bundle).is_none()); +} + +#[test] +#[ignore = "requires SHIP_SHAPE_TEST_SIGNED_APP pointing to an Apple Developer-signed app"] +fn developer_signed_release_accepts_only_its_team() { + let bundle = PathBuf::from(std::env::var_os("SHIP_SHAPE_TEST_SIGNED_APP").expect("provide a signed app path")); + let team = signing_team(&bundle).expect("the fixture must have an Apple developer team"); + validate_bundle(&bundle, &team).unwrap(); + let other = if team == "TESTTEAM01" { "TESTTEAM02" } else { "TESTTEAM01" }; + let error = validate_bundle(&bundle, other).unwrap_err(); + assert!(error.contains("developer team"), "{error}"); +} + +#[test] +fn stale_stage_cleanup_preserves_live_helpers_backups_and_other_apps() { + use std::os::unix::fs::MetadataExt; + let root = tempfile::tempdir().unwrap(); + let bundle = metadata_bundle(root.path(), "Current.app", "id", "app"); + let other = metadata_bundle(root.path(), "Other.app", "other.id", "app"); + let make_stage = |name: &str, app: &std::path::Path| { + let stage = root.path().join(format!(".ship-shape-{name}")); + fs::create_dir(&stage).unwrap(); + register_stage(&stage, app).unwrap(); + fs::write(stage.join("host-pid"), "999999999").unwrap(); + fs::create_dir(stage.join("new.app")).unwrap(); + stage + }; + let abandoned = make_stage("abandoned", &bundle); + let live_host = make_stage("host", &bundle); + fs::write(live_host.join("host-pid"), std::process::id().to_string()).unwrap(); + let live_helper = make_stage("helper", &bundle); + fs::write(live_helper.join("helper-pid"), std::process::id().to_string()).unwrap(); + let recovery = make_stage("recovery", &bundle); + fs::create_dir(recovery.join("old.app")).unwrap(); + let unrelated = make_stage("other", &other); + let malformed = make_stage("malformed", &bundle); + fs::write(malformed.join("host-pid"), "-1").unwrap(); + let unregistered = root.path().join(".ship-shape-unregistered"); + fs::create_dir(&unregistered).unwrap(); + let symlink = root.path().join(".ship-shape-link"); + std::os::unix::fs::symlink(&abandoned, &symlink).unwrap(); + let owner = abandoned.metadata().unwrap().uid(); + sweep_stale_stages(root.path(), &bundle, owner, SystemTime::now()); + assert!(abandoned.exists(), "recent stages must not be swept"); + sweep_stale_stages(root.path(), &bundle, owner.wrapping_add(1), SystemTime::now() + STALE_STAGE_AGE * 2); + assert!(abandoned.exists(), "stages owned by another user must not be swept"); + sweep_stale_stages(root.path(), &bundle, owner, SystemTime::now() + STALE_STAGE_AGE * 2); + assert!(!abandoned.exists()); + for stage in [live_host, live_helper, recovery, unrelated, malformed, unregistered] { + assert!(stage.exists(), "must preserve {}", stage.display()); + } + assert!(fs::symlink_metadata(symlink).unwrap().file_type().is_symlink()); +} diff --git a/src/ui.rs b/src/ui.rs index e5bd354..7ce57cb 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -1,6 +1,4 @@ use std::{ - cell::RefCell, - path::PathBuf, process, ptr, sync::{ Arc, @@ -21,11 +19,14 @@ use crate::{ }; mod markdown; +mod progress; + +use progress::{ProgressLifecycle, download_percent}; const PROGRESS_INTERVAL: Duration = Duration::from_millis(200); thread_local! { - static ACTIVE_PROGRESS: RefCell> = const { RefCell::new(None) }; + static ACTIVE_PROGRESS: ProgressLifecycle = const { ProgressLifecycle::new() }; } /// Guards against a second update-check flow (silent startup check, manual "Check for @@ -82,6 +83,8 @@ struct DownloadProgress { downloaded: AtomicU64, total: AtomicU64, finished: AtomicBool, + #[cfg(target_os = "macos")] + preparing: AtomicBool, cancelled: AtomicBool, } @@ -143,6 +146,21 @@ pub fn run_update_check( channel: UpdateChannel, trigger: CheckTrigger, ) { + run_update_check_with_exit_handler(config, parent, channel, trigger, || process::exit(0)); +} + +/// Like [`run_update_check`], but calls `on_exit` on the UI thread when an installer is ready. +/// +/// The handler must save application state and terminate the process. macOS installation is +/// cancelled if the host does not exit within 60 seconds. It is not called on failure or cancel. +pub fn run_update_check_with_exit_handler( + config: Arc, + parent: &dyn WxWidget, + channel: UpdateChannel, + trigger: CheckTrigger, + on_exit: impl FnOnce() + Send + 'static, +) { + let on_exit: Box = Box::new(on_exit); let Some(check) = ActiveCheck::acquire() else { return; }; @@ -150,7 +168,7 @@ pub fn run_update_check( thread::spawn(move || { let outcome = check_for_updates(&config, channel); wxdragon::call_after(Box::new(move || match outcome { - Ok(UpdateCheckOutcome::UpdateAvailable(result)) => offer_update(config, check, parent, result), + Ok(UpdateCheckOutcome::UpdateAvailable(result)) => offer_update(config, check, parent, result, on_exit), Ok(UpdateCheckOutcome::UpToDate(version)) if trigger == CheckTrigger::Manual => { show_up_to_date(parent, &version); } @@ -163,15 +181,27 @@ pub fn run_update_check( }); } -fn offer_update(config: Arc, check: ActiveCheck, parent: ParentWindow, result: UpdateAvailableResult) { +fn offer_update( + config: Arc, + check: ActiveCheck, + parent: ParentWindow, + result: UpdateAvailableResult, + on_exit: Box, +) { let plain_notes = markdown_to_text(&result.release_notes); let release_notes = if plain_notes.is_empty() { t("No release notes provided.") } else { plain_notes }; if show_update_dialog(&parent, &result.latest_version, &release_notes, &config.app_display_name) { - start_download(config, check, parent, result); + start_download(config, check, parent, result, on_exit); } } -fn start_download(config: Arc, check: ActiveCheck, parent: ParentWindow, result: UpdateAvailableResult) { +fn start_download( + config: Arc, + check: ActiveCheck, + parent: ParentWindow, + result: UpdateAvailableResult, + on_exit: Box, +) { let title = t("%s Update").replace("%s", &config.app_display_name); let progress_dialog = ProgressDialog::builder(&parent, &title, &t("Downloading update..."), 100) .with_style( @@ -181,7 +211,7 @@ fn start_download(config: Arc, check: ActiveCheck, parent: Parent | ProgressDialogStyle::CanAbort, ) .build(); - ACTIVE_PROGRESS.with(|p| *p.borrow_mut() = Some(progress_dialog)); + ACTIVE_PROGRESS.with(|p| p.set(progress_dialog)); let progress = Arc::new(DownloadProgress::default()); spawn_progress_heartbeat(Arc::clone(&progress)); thread::spawn(move || { @@ -190,13 +220,39 @@ fn start_download(config: Arc, check: ActiveCheck, parent: Parent progress.downloaded.store(d, Ordering::Relaxed); progress.total.store(t, Ordering::Relaxed); }); + #[cfg(target_os = "macos")] + let download = if progress.cancelled.load(Ordering::Relaxed) { + if let Ok(ref path) = download { + platform::remove_private_download(path); + } + Err(t("Update cancelled.")) + } else { + download.map_err(|e| format!("{}: {e}", t("Update failed"))).and_then(|path| { + progress.preparing.store(true, Ordering::Relaxed); + let result = platform::install_with_cancel(&config, &path, &progress.cancelled); + // A manual fallback still needs its DMG. Automatic preparation has copied the + // bundle, so its private verified download can be removed. + if !matches!(result, Ok(InstallOutcome::ManualStep(_))) { + platform::remove_private_download(&path); + } + result + }) + }; progress.finished.store(true, Ordering::Relaxed); wxdragon::call_after(Box::new(move || { - ACTIVE_PROGRESS.with(|p| *p.borrow_mut() = None); - if !progress.cancelled.load(Ordering::Relaxed) { - install_update(&config, parent, download); - } - drop(check); + ACTIVE_PROGRESS.with(|p| { + p.finish(Box::new(move || { + ACTIVE_PROGRESS.with(ProgressLifecycle::clear); + if !progress.cancelled.load(Ordering::Relaxed) { + #[cfg(not(target_os = "macos"))] + let download = download + .map_err(|e| format!("{}: {e}", t("Update failed"))) + .and_then(|path| platform::install(&config, &path)); + install_update(parent, download, on_exit); + } + drop(check); + })); + }); })); wxdragon::wake_up_idle(); }); @@ -219,32 +275,37 @@ fn spawn_progress_heartbeat(progress: Arc) { fn update_progress_dialog(progress: &DownloadProgress, downloaded: u64, total: u64) { ACTIVE_PROGRESS.with(|p| { - let Some(keep_going) = p.borrow().as_ref().map(|dialog| { - downloaded - .saturating_mul(100) - .checked_div(total) - .and_then(|percent| i32::try_from(percent).ok()) - .map_or_else(|| dialog.pulse(None), |percent| dialog.update(percent, None)) - }) else { + let Some(update) = p.begin_update() else { return; }; + let dialog = update.dialog(); + #[cfg(target_os = "macos")] + let preparing = progress.preparing.load(Ordering::Relaxed); + #[cfg(not(target_os = "macos"))] + let preparing = false; + let keep_going = if preparing { + dialog.pulse(Some(&t("Preparing update..."))) + } else { + download_percent(downloaded, total) + .map_or_else(|| dialog.pulse(None), |percent| dialog.update(percent, None)) + }; if !keep_going { - // Signal the download thread to abort immediately instead of letting the transfer - // run to completion (or its 10-minute timeout) unattended in the background. progress.cancelled.store(true, Ordering::Relaxed); - if let Some(dialog) = p.borrow().as_ref() { - dialog.update(100, None); - } - *p.borrow_mut() = None; + p.clear(); } + // Dropping `update` invokes any completion queued during the native event-loop yield, + // after Update/Pulse returns and its reference to the dialog is released. }); } -fn install_update(config: &UpdaterConfig, parent: ParentWindow, download: Result) { - let outcome = - download.map_err(|e| format!("{}: {e}", t("Update failed"))).and_then(|path| platform::install(config, &path)); +fn install_update(parent: ParentWindow, outcome: Result, on_exit: Box) { match outcome { - Ok(InstallOutcome::Exit) => process::exit(0), + Ok(InstallOutcome::Exit) => on_exit(), + #[cfg(target_os = "macos")] + Ok(InstallOutcome::Prepared(update)) => match update.commit() { + Ok(()) => on_exit(), + Err(msg) => show_message(parent, &msg, &t("Error"), MessageDialogStyle::IconError), + }, Ok(InstallOutcome::ManualStep(msg)) => { show_message(parent, &msg, &t("Update Ready"), MessageDialogStyle::IconInformation); } diff --git a/src/ui/progress.rs b/src/ui/progress.rs new file mode 100644 index 0000000..2fded34 --- /dev/null +++ b/src/ui/progress.rs @@ -0,0 +1,127 @@ +//! wx progress updates yield to the event loop. Keep dialog ownership and completion separate +//! so a nested callback cannot destroy a dialog, or borrow its storage, during Update/Pulse. +use std::{ + cell::{Cell, RefCell}, + rc::Rc, +}; + +pub(super) struct ProgressLifecycle { + dialog: RefCell>>, + updating: Cell, + completion: RefCell>>, +} + +impl ProgressLifecycle { + pub(super) const fn new() -> Self { + Self { dialog: RefCell::new(None), updating: Cell::new(false), completion: RefCell::new(None) } + } + + pub(super) fn set(&self, dialog: D) { + *self.dialog.borrow_mut() = Some(Rc::new(dialog)); + } + + pub(super) fn clear(&self) { + // Native dialog destruction can process events too. Release the borrow before dropping. + let dialog = self.dialog.borrow_mut().take(); + drop(dialog); + } + + pub(super) fn begin_update(&self) -> Option> { + if self.updating.get() { + return None; + } + let dialog = self.dialog.borrow().as_ref().map(Rc::clone)?; + self.updating.set(true); + Some(ProgressUpdate { state: self, dialog: Some(dialog) }) + } + + pub(super) fn finish(&self, completion: Box) { + if self.updating.get() { + *self.completion.borrow_mut() = Some(completion); + } else { + completion(); + } + } +} + +pub(super) struct ProgressUpdate<'a, D> { + state: &'a ProgressLifecycle, + dialog: Option>, +} + +impl ProgressUpdate<'_, D> { + pub(super) fn dialog(&self) -> &D { + self.dialog.as_ref().expect("progress update owns its dialog") + } +} + +impl Drop for ProgressUpdate<'_, D> { + fn drop(&mut self) { + // Native Update/Pulse has returned. Release its reference before handing off shutdown. + drop(self.dialog.take()); + self.state.updating.set(false); + let completion = self.state.completion.borrow_mut().take(); + if let Some(completion) = completion { + completion(); + } + } +} + +/// Keep the gauge below its native completion value until preparation and verification finish. +/// `Update(100)` without `AutoHide` opens a nested modal loop and waits for the user to close it. +pub(super) fn download_percent(downloaded: u64, total: u64) -> Option { + downloaded.saturating_mul(100).checked_div(total).map(|value| i32::try_from(value.min(99)).unwrap_or(99)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn completed_download_never_enters_native_completion_loop() { + assert_eq!(download_percent(50, 100), Some(50)); + assert_eq!(download_percent(100, 100), Some(99)); + assert_eq!(download_percent(200, 100), Some(99)); + assert_eq!(download_percent(u64::MAX, 1), Some(99)); + assert_eq!(download_percent(0, 0), None); + } + + #[test] + fn nested_completion_waits_for_native_update_to_return_and_destroy_dialog() { + struct Dialog(Rc>>); + impl Drop for Dialog { + fn drop(&mut self) { + self.0.borrow_mut().push("destroyed"); + } + } + let events = Rc::new(RefCell::new(Vec::new())); + let state = Rc::new(ProgressLifecycle::new()); + state.set(Dialog(Rc::clone(&events))); + let guard = state.begin_update().unwrap(); + assert!(state.begin_update().is_none()); + let complete_state = Rc::clone(&state); + let complete_events = Rc::clone(&events); + state.finish(Box::new(move || { + complete_state.clear(); + complete_events.borrow_mut().push("shutdown"); + })); + assert!(events.borrow().is_empty()); + // A reentrant callback can borrow the storage while Update/Pulse is active. + assert!(state.dialog.try_borrow_mut().is_ok()); + let _ = guard.dialog(); + drop(guard); + assert_eq!(*events.borrow(), ["destroyed", "shutdown"]); + assert!(state.begin_update().is_none()); + } + + #[test] + fn cancellation_keeps_native_dialog_alive_until_update_returns() { + let state = ProgressLifecycle::new(); + state.set(42); + let guard = state.begin_update().unwrap(); + state.clear(); + assert_eq!(*guard.dialog(), 42); + drop(guard); + assert!(state.begin_update().is_none()); + } +}