fix(launcher): preserve running macOS app bundles during updates

This commit is contained in:
Guilherme Jansen committed 2026-10-31 17:49:40 -03:00
1 parent 9658ee1fef
commit 4f1ffce47d
10 files changed
+883 -62

No files matched your search

+1
View File
@@ -2757,6 +2757,7 @@ dependencies = [
"proc-macro2",
"pulldown-cmark",
"raw-window-handle",
"security-framework 3.7.0",
"serde",
"serde_json",
"sha1",
+2
View File
@@ -65,6 +65,8 @@ oo7 = { version = "0.5", default-features = false, features = ["async-std", "nat
futures-lite = "2"
[target.'cfg(target_os = "macos")'.dependencies]
keyring = { version = "3", features = ["apple-native"] }
# Decode Keychain OSStatus errors without matching localized error strings (account.rs).
security-framework = "3"
# Painting the window's title bar dark or light (titlebar::apply_appearance), and the system's
# reduced motion (sky::moves): crates already in the lockfile through gpui and wgpu.
objc2-app-kit = { version = "0.3", default-features = false, features = ["NSAccessibility", "NSAppearance", "NSView", "NSWindow", "NSWorkspace"] }
+19
View File
@@ -850,9 +850,28 @@ fn credential_manager_says(code: u32) -> Option<&'static str> {
(code == ERROR_NOT_ENOUGH_MEMORY).then_some(CREDENTIAL_MANAGER_FULL)
}
/// A running executable removed by an older macOS updater cannot create Keychain credentials.
#[cfg(any(target_os = "macos", test))]
pub const MACOS_LAUNCHER_RESTART: &str = "The launcher was updated while it was running. Restart the launcher, then sign in with Microsoft again.";
#[cfg(any(target_os = "macos", test))]
fn macos_keychain_says(code: i32) -> Option<&'static str> {
// CSSM's Unix ENOENT: Keychain cannot identify an executable removed by an older updater.
(code == 100002).then_some(MACOS_LAUNCHER_RESTART)
}
/// The keyring crate's error as the player reads it.
#[cfg(any(target_os = "macos", target_os = "windows"))]
fn write_error(e: keyring::Error) -> String {
#[cfg(target_os = "macos")]
if let keyring::Error::PlatformFailure(inner) = &e {
if let Some(said) = inner
.downcast_ref::<security_framework::base::Error>()
.and_then(|error| macos_keychain_says(error.code()))
{
return said.to_string();
}
}
#[cfg(target_os = "windows")]
if let keyring::Error::PlatformFailure(inner) = &e {
if let Some(said) = inner
+36
View File
@@ -885,6 +885,42 @@ fn credential_manager_error_8_says_the_store_is_full() {
assert_eq!(credential_manager_says(1312), None);
}
#[test]
fn macos_removed_executable_error_requests_a_restart_and_new_sign_in() {
let said = macos_keychain_says(100002).unwrap();
assert_eq!(said, MACOS_LAUNCHER_RESTART);
assert!(said.contains("Restart the launcher"));
assert!(said.contains("sign in with Microsoft again"));
for code in [0, 2, -25300, -25293, -25291, 100001, 100003] {
assert_eq!(macos_keychain_says(code), None, "OSStatus {code}");
}
}
/// Constructs native errors, without creating or reading any Keychain entry.
#[cfg(target_os = "macos")]
#[test]
fn only_the_typed_macos_removed_executable_failure_gets_the_restart_message() {
let failure = |code| {
keyring::Error::PlatformFailure(Box::new(security_framework::base::Error::from_code(code)))
};
assert_eq!(write_error(failure(100002)), MACOS_LAUNCHER_RESTART);
for code in [-25300, -25293, -25291, 100003] {
let error = failure(code);
let original = error.to_string();
assert_ne!(original, MACOS_LAUNCHER_RESTART);
assert_eq!(write_error(error), original);
}
let unrelated = keyring::Error::PlatformFailure(Box::new(std::io::Error::other(
"OSStatus 100002: unrelated provider",
)));
let original = unrelated.to_string();
assert_eq!(write_error(unrelated), original, "not a string match");
assert_eq!(
write_error(keyring::Error::NoEntry),
keyring::Error::NoEntry.to_string()
);
}
#[test]
fn session_files_are_private_and_complete() {
let dir = std::env::temp_dir().join(format!("horizon-launcher-session-{}", std::process::id()));
+10 -62
View File
@@ -2824,71 +2824,19 @@ fn find_app(dir: &Path) -> Option<PathBuf> {
None
}
/// Whether the bundle's signature (Developer ID or ad hoc) is whole: every file it seals is there
/// and unchanged.
#[cfg(target_os = "macos")]
fn signature_holds(app: &Path) -> bool {
Command::new("codesign")
.args(["--verify", "--deep", "--strict"])
.arg(app)
.stderr(std::process::Stdio::null())
.status()
.is_ok_and(|s| s.success())
}
/// Puts the bundle `new` in place of `app`: copied beside it first (`ditto` keeps its links,
/// modes and signature) and checked, then the old one is moved aside and the new one renamed in,
/// so `app` is never half written, and checked again where it lands (the old one comes back if it
/// doesn't hold). The running launcher keeps its open files and works on.
/// Puts a verified app bundle in place, retaining old bundles until no process uses them.
#[cfg(target_os = "macos")]
fn replace_bundle(new: &Path, app: &Path) -> Result<(), String> {
let dir = app.parent().ok_or("The app has no folder.")?;
let name = app.file_name().unwrap_or_default().to_string_lossy();
let staged = dir.join(format!(".{name}.update-{}", std::process::id()));
let old = dir.join(format!(".{name}.old-{}", std::process::id()));
let _ = fs::remove_dir_all(&staged);
let copied = Command::new("ditto")
.arg(new)
.arg(&staged)
.status()
.map_err(|e| format!("ditto: {e}"))?;
if !copied.success() {
let _ = fs::remove_dir_all(&staged);
return Err(format!(
"Could not copy the new app next to {}.",
app.display()
));
crate::mac_update::replace_bundle(new, app)
}
/// Retired macOS launchers are removed only once their executable is no longer in use.
/// Run for every entry point (GUI, CLI and MCP), before a command can return or exec.
pub fn cleanup_launcher_updates() {
#[cfg(target_os = "macos")]
if let Some(app) = app_bundle(exe_path()) {
crate::mac_update::cleanup(app);
}
if !signature_holds(&staged) {
let _ = fs::remove_dir_all(&staged);
return Err(
"The downloaded app's signature doesn't hold; the launcher stays as it is.".into(),
);
}
fs::rename(app, &old).map_err(|e| {
let _ = fs::remove_dir_all(&staged);
format!("Could not move {} aside: {e}", app.display())
})?;
if let Err(e) = fs::rename(&staged, app) {
let _ = fs::rename(&old, app);
let _ = fs::remove_dir_all(&staged);
return Err(format!(
"Could not put the new app at {}: {e}",
app.display()
));
}
if !signature_holds(app) {
let failed = dir.join(format!(".{name}.failed-{}", std::process::id()));
if fs::rename(app, &failed).is_ok() && fs::rename(&old, app).is_ok() {
let _ = fs::remove_dir_all(&failed);
}
return Err(format!(
"The new app's signature doesn't hold at {}; the launcher stays as it was.",
app.display()
));
}
let _ = fs::remove_dir_all(&old);
Ok(())
}
#[cfg(not(target_os = "macos"))]
+2
View File
@@ -19,6 +19,8 @@ pub mod java;
pub mod java_launch;
pub mod launch;
pub mod loaders;
#[cfg(target_os = "macos")]
mod mac_update;
pub mod minecraft_versions;
pub mod modpacks;
pub mod mojang;
+168
View File
@@ -0,0 +1,168 @@
//! Retired app bundles must outlive every process using them. Removing a running macOS
//! executable breaks Keychain credential creation (OSStatus 100002), even though it keeps running.
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
const BINARY: &str = "horizon-launcher";
fn signature_holds(app: &Path) -> bool {
Command::new("/usr/bin/codesign")
.args(["--verify", "--deep", "--strict"])
.arg(app)
.stderr(std::process::Stdio::null())
.status()
.is_ok_and(|s| s.success())
}
/// lsof returns 1 with no output when there are no users of the file. Any warning, failure,
/// signal, or unexpected output means we cannot safely remove it. In particular, the updater's
/// PID alone is insufficient: other windows and MCP processes can run from the same old bundle.
fn definitely_unused(output: io::Result<Output>) -> bool {
output.is_ok_and(|out| {
out.status.code() == Some(1) && out.stdout.is_empty() && out.stderr.is_empty()
})
}
fn unused_by_processes(exe: &Path) -> bool {
definitely_unused(
Command::new("/usr/sbin/lsof")
// -t also suppresses warnings, which could turn an incomplete query into an
// apparently empty result. Keep warnings enabled and request only PID fields.
.args(["-nP", "+w", "-F", "p", "--"])
.arg(exe)
.output(),
)
}
fn is_retired(name: &str, app_name: &str) -> bool {
let prefix = format!(".{app_name}.old-");
let Some((pid, nonce)) = name.strip_prefix(&prefix).and_then(|s| s.split_once('-')) else {
return false;
};
pid.parse::<u32>()
.is_ok_and(|number| number != 0 && number.to_string() == pid)
&& nonce.len() == 32
&& nonce
.bytes()
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
}
/// Only consider a real bundle's executable, never a symlink or an incomplete/unknown folder.
fn retired_executable(app: &Path) -> Option<PathBuf> {
let mut path = app.to_path_buf();
for component in ["", "Contents", "MacOS", BINARY] {
if !component.is_empty() {
path.push(component);
}
let kind = fs::symlink_metadata(&path).ok()?.file_type();
if kind.is_symlink()
|| (component == BINARY && !kind.is_file())
|| (component != BINARY && !kind.is_dir())
{
return None;
}
}
Some(path)
}
/// Best effort: run on startup and after an update. Failed queries and inaccessible files stay
/// for a later start. A restart via exec releases the old mapping before the new process cleans.
pub(crate) fn cleanup(app: &Path) {
cleanup_with(app, unused_by_processes);
}
fn cleanup_with(app: &Path, mut unused: impl FnMut(&Path) -> bool) {
let (Some(dir), Some(name)) = (app.parent(), app.file_name().and_then(|s| s.to_str())) else {
return;
};
let Ok(entries) = fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
if !entry
.file_name()
.to_str()
.is_some_and(|s| is_retired(s, name))
{
continue;
}
let path = entry.path();
if retired_executable(&path).is_some_and(|exe| unused(&exe)) {
let _ = fs::remove_dir_all(path);
}
}
}
pub(crate) fn replace_bundle(new: &Path, app: &Path) -> Result<(), String> {
replace_bundle_with(new, app, signature_holds, unused_by_processes)
}
fn replace_bundle_with(
new: &Path,
app: &Path,
mut signature: impl FnMut(&Path) -> bool,
unused: impl FnMut(&Path) -> bool,
) -> Result<(), String> {
let dir = app.parent().ok_or("The app has no folder.")?;
let name = app.file_name().unwrap_or_default().to_string_lossy();
// A single process may update more than once, with older generations still in use.
let mut random = [0u8; 16];
getrandom::fill(&mut random).map_err(|e| e.to_string())?;
let nonce: String = random.iter().map(|b| format!("{b:02x}")).collect();
let suffix = format!("{}-{nonce}", std::process::id());
let staged = dir.join(format!(".{name}.update-{suffix}"));
let rollback = dir.join(format!(".{name}.rollback-{suffix}"));
let old = dir.join(format!(".{name}.old-{suffix}"));
fs::create_dir(&staged).map_err(|e| format!("Could not stage the new app: {e}"))?;
let copied = Command::new("/usr/bin/ditto")
.arg(new)
.arg(&staged)
.status();
if !copied.is_ok_and(|s| s.success()) {
let _ = fs::remove_dir_all(&staged);
return Err(format!(
"Could not copy the new app next to {}.",
app.display()
));
}
if !signature(&staged) {
let _ = fs::remove_dir_all(&staged);
return Err(
"The downloaded app's signature doesn't hold; the launcher stays as it is.".into(),
);
}
// Startup cleanup in another process must not collect an idle backup while this
// transaction can still need it. Publish it as retired only after the final check.
fs::rename(app, &rollback).map_err(|e| {
let _ = fs::remove_dir_all(&staged);
format!("Could not move {} aside: {e}", app.display())
})?;
if let Err(e) = fs::rename(&staged, app) {
let _ = fs::rename(&rollback, app);
let _ = fs::remove_dir_all(&staged);
return Err(format!(
"Could not put the new app at {}: {e}",
app.display()
));
}
if !signature(app) {
let failed = dir.join(format!(".{name}.failed-{suffix}"));
if fs::rename(app, &failed).is_ok() && fs::rename(&rollback, app).is_ok() {
let _ = fs::remove_dir_all(&failed);
}
return Err(format!(
"The new app's signature doesn't hold at {}; the launcher stays as it was.",
app.display()
));
}
// Failure to publish the now-retired bundle only leaves a conservative quarantine;
// the new installation is verified and the running old executable remains available.
let _ = fs::rename(&rollback, &old);
cleanup_with(app, unused);
Ok(())
}
#[cfg(test)]
mod tests;
+356
View File
@@ -0,0 +1,356 @@
//! Filesystem-only regression tests; process queries and signature decisions are injected.
//! Real executable mappings and Keychain writes belong to the opt-in Python harness.
use super::*;
use std::os::unix::{fs::symlink, process::ExitStatusExt};
struct Scratch(PathBuf);
impl Scratch {
fn new() -> Self {
let mut nonce = [0; 16];
getrandom::fill(&mut nonce).unwrap();
let root = std::env::temp_dir().join(format!(
"horizon-mac-update-{}-{:x?}",
std::process::id(),
nonce
));
fs::create_dir(&root).unwrap();
Self(root)
}
fn app(&self) -> PathBuf {
self.0.join("Horizon.app")
}
fn retired(&self, generation: u32) -> PathBuf {
self.0.join(format!(".Horizon.app.old-1-{generation:032x}"))
}
}
impl Drop for Scratch {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
fn bundle(app: &Path, version: &str) {
fs::create_dir_all(app.join("Contents/MacOS")).unwrap();
fs::write(app.join("Contents/MacOS/horizon-launcher"), b"fixture").unwrap();
fs::write(app.join("Contents/version"), version).unwrap();
}
fn version(app: &Path) -> String {
fs::read_to_string(app.join("Contents/version")).unwrap()
}
fn retired(work: &Scratch) -> Vec<PathBuf> {
let mut paths: Vec<_> = fs::read_dir(&work.0)
.unwrap()
.map(|e| e.unwrap().path())
.filter(|p| is_retired(p.file_name().unwrap().to_str().unwrap(), "Horizon.app"))
.collect();
paths.sort();
paths
}
#[test]
fn only_an_unambiguous_empty_lsof_exit_one_allows_deletion() {
for (code, stdout, stderr, unused) in [
(1, "", "", true),
(0, "123\n456\n", "", false),
(0, "", "", false),
(1, "123\n", "", false),
(1, "", "warning", false),
(1, "\n", "", false),
(1, "", "\n", false),
(2, "", "", false),
(127, "", "", false),
] {
assert_eq!(
definitely_unused(Ok(Output {
status: std::process::ExitStatus::from_raw(code << 8),
stdout: stdout.as_bytes().to_vec(),
stderr: stderr.as_bytes().to_vec(),
})),
unused,
"exit={code}, stdout={stdout:?}, stderr={stderr:?}"
);
}
assert!(!definitely_unused(Ok(Output {
status: std::process::ExitStatus::from_raw(15),
stdout: vec![],
stderr: vec![],
})));
assert!(!definitely_unused(Err(io::Error::new(
io::ErrorKind::NotFound,
"no lsof"
))));
}
#[test]
fn cleanup_names_are_exactly_our_app_positive_pid_and_lowercase_nonce() {
let hex = "0123456789abcdef0123456789abcdef";
for pid in ["1", "4294967295"] {
assert!(is_retired(
&format!(".Horizon.app.old-{pid}-{hex}"),
"Horizon.app"
));
}
for suffix in [
format!("0-{hex}"),
format!("4294967296-{hex}"),
format!("+1-{hex}"),
format!("01-{hex}"),
format!("-1-{hex}"),
format!("1-{}", hex.to_uppercase()),
"1-123".into(),
format!("1-{hex}0"),
format!("1-{hex}.app"),
format!("1-{}g", &hex[..31]),
"123".into(),
] {
assert!(
!is_retired(&format!(".Horizon.app.old-{suffix}"), "Horizon.app"),
"{suffix}"
);
}
assert!(!is_retired(
&format!(".Other.app.old-1-{hex}"),
"Horizon.app"
));
assert!(!is_retired(
&format!(".Horizon.app.update-1-{hex}"),
"Horizon.app"
));
}
#[test]
fn cleanup_preserves_unknown_names_incomplete_bundles_and_every_symlink_component() {
let work = Scratch::new();
let outside = work.0.join("unrelated");
bundle(&outside, "keep");
let safe = work.retired(1);
bundle(&safe, "unused");
symlink(&outside, work.retired(2)).unwrap();
for (n, component, target) in [
(3, "Contents", outside.join("Contents")),
(4, "Contents/MacOS", outside.join("Contents/MacOS")),
(
5,
"Contents/MacOS/horizon-launcher",
outside.join("Contents/MacOS/horizon-launcher"),
),
] {
let root = work.retired(n);
let link = root.join(component);
fs::create_dir_all(link.parent().unwrap()).unwrap();
symlink(target, link).unwrap();
}
let missing = work.retired(6);
fs::create_dir_all(missing.join("Contents/MacOS")).unwrap();
let directory = work.retired(7);
fs::create_dir_all(directory.join("Contents/MacOS/horizon-launcher")).unwrap();
for name in [
".Horizon.app.old-123",
".Horizon.app.old-1-not-a-nonce",
".Other.app.old-1-00000000000000000000000000000000",
] {
bundle(&work.0.join(name), "keep");
}
fs::write(work.retired(8), b"not a bundle").unwrap();
let before = fs::read_dir(&work.0).unwrap().count();
let mut queried = vec![];
cleanup_with(&work.app(), |exe| {
queried.push(exe.to_path_buf());
true
});
assert_eq!(queried, [safe.join("Contents/MacOS/horizon-launcher")]);
assert_eq!(fs::read_dir(&work.0).unwrap().count(), before - 1);
assert!(!safe.exists());
assert_eq!(version(&outside), "keep");
}
#[test]
fn multiple_users_and_ambiguous_queries_keep_the_bundle_until_the_last_user_leaves() {
let work = Scratch::new();
let old = work.retired(1);
bundle(&old, "v1");
for users in [2, 1] {
cleanup_with(&work.app(), |_| users == 0);
assert_eq!(version(&old), "v1");
}
cleanup_with(&work.app(), |_| {
definitely_unused(Err(io::Error::other("query failed")))
});
assert!(old.exists());
cleanup_with(&work.app(), |_| true);
assert!(!old.exists());
cleanup_with(&work.app(), |_| panic!("already cleaned"));
}
#[test]
fn repeated_updates_retain_distinct_generations_and_query_their_actual_executables() {
let work = Scratch::new();
let app = work.app();
let new = work.0.join("release.app");
bundle(&app, "v1");
bundle(&new, "v2");
replace_bundle_with(&new, &app, |_| true, |_| false).unwrap();
let first = retired(&work);
assert_eq!(first.len(), 1);
assert_eq!(version(&first[0]), "v1");
fs::write(new.join("Contents/version"), "v3").unwrap();
replace_bundle_with(&new, &app, |_| true, |_| false).unwrap();
let both = retired(&work);
assert_eq!(
both.len(),
2,
"same updater PID must not overwrite the previous generation"
);
assert_eq!(
fs::read_dir(&work.0).unwrap().count(),
4,
"no staging or failed residue"
);
assert_eq!(version(&app), "v3");
let second = both.iter().find(|p| **p != first[0]).unwrap();
assert_eq!(version(second), "v2");
cleanup_with(&app, |exe| {
assert_eq!(exe.file_name().unwrap(), "horizon-launcher");
assert_eq!(exe.parent().unwrap().file_name().unwrap(), "MacOS");
exe == second.join("Contents/MacOS/horizon-launcher")
});
assert_eq!(retired(&work), first);
assert_eq!(version(&first[0]), "v1");
cleanup_with(&app, |_| true);
assert!(retired(&work).is_empty());
assert_eq!(fs::read_dir(&work.0).unwrap().count(), 2);
}
#[test]
fn failed_signature_before_or_after_swap_keeps_the_original_and_cleans_only_its_own_staging() {
for fail_check in [1, 2] {
let work = Scratch::new();
let app = work.app();
let new = work.0.join("release.app");
bundle(&app, "original");
bundle(&new, "replacement");
let unrelated = work.0.join(".Horizon.app.update-someone-elses-work");
bundle(&unrelated, "preserve");
let previous = work.retired(1);
bundle(&previous, "previous generation still in use");
let mut checks = vec![];
let error = replace_bundle_with(
&new,
&app,
|path| {
checks.push(path.to_path_buf());
checks.len() != fail_check
},
|_| panic!("a failed update must not clean unrelated generations"),
)
.unwrap_err();
assert!(error.contains("signature"));
assert_eq!(checks.len(), fail_check);
if fail_check == 2 {
assert_eq!(checks[1], app);
}
assert_eq!(version(&app), "original");
assert_eq!(version(&new), "replacement");
assert_eq!(version(&unrelated), "preserve");
assert_eq!(version(&previous), "previous generation still in use");
assert_eq!(
fs::read_dir(&work.0).unwrap().count(),
4,
"no new staging/failed/old residue"
);
}
}
#[test]
fn a_concurrent_cleanup_during_final_signature_cannot_destroy_the_rollback() {
for final_signature_holds in [false, true] {
let work = Scratch::new();
let app = work.app();
let new = work.0.join("release.app");
// An older updater is replacing v2, which has no executable users at all.
bundle(&app, "v2");
bundle(&new, "v3");
let foreign = work
.0
.join(".Other.app.old-1-00000000000000000000000000000000");
let quarantine = work
.0
.join(".Horizon.app.rollback-1-00000000000000000000000000000000");
let other_staging = work.0.join(".Horizon.app.update-another-transaction");
for path in [&foreign, &quarantine, &other_staging] {
bundle(path, "unrelated");
}
let mut checks = 0;
let mut backup_survived_collection = false;
let result = replace_bundle_with(
&new,
&app,
|path| {
checks += 1;
assert_eq!(version(path), "v3");
if checks == 1 {
return true;
}
assert_eq!(path, app.as_path());
// A startup collector runs after installation, before the final verdict.
// Join fixes the interleaving; no timing assumptions or false lsof answer.
std::thread::scope(|scope| {
scope.spawn(|| cleanup_with(&app, |_| true)).join().unwrap();
});
// Identify the backup by its data, not by the fix's chosen namespace.
backup_survived_collection = fs::read_dir(&work.0).unwrap().any(|entry| {
fs::read_to_string(entry.unwrap().path().join("Contents/version"))
.is_ok_and(|v| v == "v2")
});
final_signature_holds
},
|_| false,
);
assert_eq!(checks, 2);
if !final_signature_holds {
assert!(result.unwrap_err().contains("signature"));
assert_eq!(
fs::read_to_string(app.join("Contents/version"))
.ok()
.as_deref(),
Some("v2"),
"a concurrent collector must not destroy the only rollback source"
);
assert!(retired(&work).is_empty());
} else {
result.unwrap();
assert_eq!(version(&app), "v3");
let published = retired(&work);
assert_eq!(
published.len(),
1,
"publish a collectable generation only after success"
);
assert_eq!(version(&published[0]), "v2");
cleanup_with(&app, |_| true);
assert!(
!published[0].exists(),
"the committed, unused generation is collectable"
);
}
assert!(
backup_survived_collection,
"the transaction still needs its backup before the verdict"
);
assert!(!is_retired(
quarantine.file_name().unwrap().to_str().unwrap(),
"Horizon.app"
));
assert_eq!(version(&new), "v3");
for path in [&foreign, &quarantine, &other_staging] {
assert_eq!(version(path), "unrelated");
}
assert_eq!(
fs::read_dir(&work.0).unwrap().count(),
5,
"no new transaction residue"
);
}
}
+1
View File
@@ -4330,6 +4330,7 @@ fn main() {
backend::exe_path();
#[cfg(windows)]
backend::remove_replaced_binaries();
backend::cleanup_launcher_updates();
// What the launcher downloads goes through the player's proxy, if they set one: the parts
// that build their own agents (`horizon-port-pack`) are told before anything starts.
horizon_launcher::proxy::announce(horizon_launcher::backend::Settings::load().proxy);
+288
View File
@@ -0,0 +1,288 @@
#!/usr/bin/env python3
"""Opt-in macOS updater regression, compiling the EXACT product module without GPUI.
python3 tools/test_launcher_macos_update.py --run
python3 tools/test_launcher_macos_update.py --run --keychain
Requires an approved build window, cached lockfile dependencies and macOS developer tools.
Without --run, only prints this help. --run tests filesystem/query/signature seams; adding
--keychain runs signed synthetic bundles, real lsof and real Keychain with UUID keys under
dev.playhorizon.launcher.diagnostic ONLY, removing and checking those keys afterwards.
No real launcher, OAuth, accounts, network or player data. Synthetic peers model multiple
GUI/MCP processes sharing a vnode; actual launcher startup/MCP wiring needs separate QA.
The scratch crate uses normal Rust module layout and byte-identical copies of the product
module and its tests, SHA-256 checked against the starting snapshot before invoking cargo.
"""
import argparse
import hashlib
import json
import os
from pathlib import Path
import re
import select
import shutil
import subprocess
import sys
import tempfile
import uuid
try:
from . import lab
except ImportError:
import lab
ROOT = Path(__file__).resolve().parents[1]
MODULE = ROOT / 'launcher/src/mac_update.rs'
LOCK = ROOT / 'launcher/Cargo.lock'
PROBE = r'''
#[allow(dead_code)]
mod mac_update;
use std::{io::{self, BufRead, Write}, path::PathBuf, process::Command};
use std::os::unix::process::CommandExt;
const SERVICE: &str = "dev.playhorizon.launcher.diagnostic";
const MARKER: &str = "horizon synthetic updater regression";
fn entry(key: &str) -> Result<keyring::Entry, String> {
if key.len() != 36 || !key.bytes().all(|b| b.is_ascii_hexdigit() || b == b'-') {
return Err("expected diagnostic UUID".into());
}
keyring::Entry::new(SERVICE, key).map_err(|e| e.to_string())
}
fn main() {
let app = PathBuf::from(std::env::args_os().nth(1).expect("scratch app path"));
mac_update::cleanup(&app);
println!("READY {}", std::process::id());
io::stdout().flush().unwrap();
for line in io::stdin().lock().lines() {
let line = line.unwrap();
let (op, arg) = line.split_once(' ').unwrap_or((&line, ""));
let result: Result<(), String> = (|| match op {
"update" => mac_update::replace_bundle(std::path::Path::new(arg), &app),
"cleanup" => { mac_update::cleanup(&app); Ok(()) },
"write" => {
let e = entry(arg)?;
match e.get_password() {
Err(keyring::Error::NoEntry) => e.set_password(MARKER).map_err(|e| e.to_string()),
_ => Err("diagnostic key was not absent before write".into()),
}
},
"read" => {
if entry(arg)?.get_password().map_err(|e| e.to_string())? != MARKER {
return Err("diagnostic marker differs".into());
}
Ok(())
},
"delete" => {
let e = entry(arg)?;
match e.get_password() {
Err(keyring::Error::NoEntry) => return Ok(()),
Ok(value) if value == MARKER => (),
Ok(_) => return Err("unexpected diagnostic value; preserved".into()),
Err(error) => return Err(error.to_string()),
}
match e.delete_credential() {
Ok(()) | Err(keyring::Error::NoEntry) => (),
Err(e) => return Err(e.to_string()),
}
match e.get_password() {
Err(keyring::Error::NoEntry) => Ok(()),
_ => Err("diagnostic key absence not verified".into()),
}
},
"exec" => Err(Command::new(app.join("Contents/MacOS/horizon-launcher"))
.arg(&app).exec().to_string()),
_ => Err("unknown probe command".into()),
})();
match result { Ok(()) => println!("OK"), Err(e) => println!("ERR {e}") }
io::stdout().flush().unwrap();
}
}
'''
def run(*args, env):
subprocess.run([str(a) for a in args], env=env, check=True, timeout=180)
def pins(text):
return {tuple(re.findall(r'^(?:name|version|source|checksum) = "([^"]*)"$', block, re.M))
for block in text.split('[[package]]')[1:] if '\nchecksum = ' in block}
class Peer:
def __init__(self, app, env):
self.proc = subprocess.Popen([str(app / 'Contents/MacOS/horizon-launcher'), str(app)],
env=env, stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True)
def line(self):
if not select.select([self.proc.stdout], [], [], 45)[0]:
raise TimeoutError(f'probe {self.proc.pid} did not reply (Keychain may need interaction)')
line = self.proc.stdout.readline().strip()
assert line, f'probe {self.proc.pid} exited without reply'
return line
def ask(self, command, expected='OK'):
self.proc.stdin.write(command + '\n')
self.proc.stdin.flush()
answer = self.line()
assert answer == expected, f'{command.split()[0]}: {answer}'
def close(self):
if not self.proc.stdin.closed:
self.proc.stdin.close()
try:
self.proc.wait(timeout=10)
except subprocess.TimeoutExpired:
self.proc.kill()
self.proc.wait(timeout=10)
def keychain_scenario(work, binary, env):
peers, keys = [], [str(uuid.uuid4()) for _ in range(3)]
app = work / 'Applications/Horizon.app'
# Recovery custody if an assertion or native permission prompt interrupts the run.
print(json.dumps({'diagnostic_service': 'dev.playhorizon.launcher.diagnostic',
'diagnostic_uuids': keys, 'scratch': str(work)}), flush=True)
def bundle(path, version):
(path / 'Contents/MacOS').mkdir(parents=True)
(path / 'Contents/Resources').mkdir()
shutil.copy2(binary, path / 'Contents/MacOS/horizon-launcher')
(path / 'Contents/Resources/version').write_text(version)
(path / 'Contents/Info.plist').write_text('''<?xml version="1.0"?><plist version="1.0"><dict>
<key>CFBundleExecutable</key><string>horizon-launcher</string>
<key>CFBundleIdentifier</key><string>dev.playhorizon.launcher.diagnostic</string>
<key>CFBundlePackageType</key><string>APPL</string></dict></plist>''')
run('/usr/bin/codesign', '--force', '--sign', '-', path, env=env)
return path
def start():
p = Peer(app, env)
peers.append(p)
assert p.line() == f'READY {p.proc.pid}'
return p
def retired():
return set(app.parent.glob('.Horizon.app.old-*'))
bundle(app, 'v1')
v2 = bundle(work / 'v2/Horizon.app', 'v2')
v3 = bundle(work / 'v3/Horizon.app', 'v3')
try:
a, b = start(), start() # two users of the same original executable, like GUI + MCP
a.ask(f'update {v2}')
a.ask(f'write {keys[0]}') # the causal failure occurs HERE if the old executable was deleted
reader = start()
reader.ask(f'read {keys[0]}')
old_v1 = retired()
assert len(old_v1) == 1
a.ask(f'update {v3}') # updater PID unchanged, while a new-generation reader is alive
b.ask(f'write {keys[1]}')
reader_v3 = start()
reader_v3.ask(f'read {keys[1]}')
assert len(retired()) == 2 and old_v1 < retired()
b.close()
a.ask('cleanup')
assert len(retired()) == 2, 'one original user remains, plus the v2 reader'
reader.close()
a.ask('cleanup')
assert retired() == old_v1, 'only the intermediate generation is unused'
a.ask('exec', f'READY {a.proc.pid}') # same PID, new vnode, startup cleanup
assert not retired(), 'exec must release the last original executable mapping'
a.ask(f'write {keys[2]}')
reader_v3.ask(f'read {keys[2]}')
(v2 / 'Contents/Resources/version').write_text('tampered')
a.proc.stdin.write(f'update {v2}\n')
a.proc.stdin.flush()
assert a.line().startswith('ERR '), 'tampered signed source must be refused'
assert (app / 'Contents/Resources/version').read_text() == 'v3'
run('/usr/bin/codesign', '--verify', '--deep', '--strict', app, env=env)
finally:
# A fresh process has a valid executable even if the test exposed an unsafe old updater.
removed, cleanup_errors = [], []
try:
try:
cleaner = start()
except Exception as error:
cleanup_errors = [{'uuid': key, 'error': str(error)} for key in keys]
else:
for key in keys:
try:
cleaner.ask(f'delete {key}')
removed.append(key)
except Exception as error:
cleanup_errors.append({'uuid': key, 'error': str(error)})
finally:
for p in peers:
p.close()
print(json.dumps({'diagnostic_uuids_removed_and_absent': removed,
'unverified_cleanup': cleanup_errors}), flush=True)
assert not cleanup_errors, 'diagnostic cleanup incomplete; reconcile UUIDs in the receipt'
return {'synthetic_keys_removed_and_absent': len(keys), 'shared_vnode_users': 2,
'updates': 2, 'exec_preserved_pid': True, 'tampered_source_refused': True}
def main():
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument('--run', action='store_true', help='explicitly build/run in an approved QA window')
parser.add_argument('--keychain', action='store_true', help='also access ONLY synthetic diagnostic UUIDs')
args = parser.parse_args()
if not args.run:
if args.keychain:
parser.error('--keychain requires --run')
parser.print_help()
return
if sys.platform != 'darwin':
parser.error('macOS is required')
sources = [MODULE, MODULE.parent / 'mac_update/tests.rs', LOCK, Path(__file__).resolve(),
Path(lab.__file__).resolve()]
before = {str(p.relative_to(ROOT)): hashlib.sha256(p.read_bytes()).hexdigest() for p in sources}
lock = LOCK.read_text()
print(json.dumps({'starting_snapshot_sha256': before}), flush=True)
with tempfile.TemporaryDirectory(prefix='horizon-macos-update-') as tmp:
work = Path(tmp)
env = dict(os.environ, CARGO_BUILD_JOBS='1', CARGO_TARGET_DIR=str(work / 'target'))
for key in ['HORIZON_HOME', 'HORIZON_LAUNCHER_HOME', 'XDG_DATA_HOME', 'XDG_CONFIG_HOME',
'HORIZON_LAUNCHER_TRASH', 'HORIZON_LAUNCHER_CLIPBOARD', 'HORIZON_LAUNCHER_DESKTOP']:
env[key] = str(work / key.lower())
Path(env[key]).mkdir()
# Force fresh homes before the official helper preserves caller-provided folders.
env = lab.game_env(env)
(work / 'src/mac_update').mkdir(parents=True)
(work / 'src/main.rs').write_text(PROBE)
# Normal `mod mac_update;` resolves its children under src/mac_update/.
# An absolute #[path] would instead make `mod tests;` seek a sibling tests.rs.
for source, target in [(MODULE, work / 'src/mac_update.rs'),
(sources[1], work / 'src/mac_update/tests.rs')]:
target.write_bytes(source.read_bytes())
expected = before[str(source.relative_to(ROOT))]
assert hashlib.sha256(target.read_bytes()).hexdigest() == expected, \
f'copied source differs from starting snapshot: {source}'
manifest = work / 'Cargo.toml'
manifest.write_text('''[package]
name = "horizon-macos-update-regression"
version = "0.0.0"
edition = "2021"
[workspace]
[dependencies]
getrandom = "=0.3.4"
keyring = { version = "=3.6.3", features = ["apple-native"] }
''')
(work / 'Cargo.lock').write_text(lock)
# Resolve only, offline; reject ANY transitive version/checksum drift before compiling.
subprocess.run(['cargo', 'metadata', '--offline', '--format-version', '1',
'--manifest-path', str(manifest)], env=env, check=True,
stdout=subprocess.DEVNULL, timeout=60)
resolved = pins((work / 'Cargo.lock').read_text())
assert resolved and resolved <= pins(lock), 'probe dependency drift from launcher/Cargo.lock'
run('cargo', 'test', '--offline', '--locked', '-j1', '--manifest-path', manifest, env=env)
result = {'module_tests': 'see cargo counts above', 'keychain': 'not requested'}
if args.keychain:
run('cargo', 'build', '--offline', '--locked', '-j1', '--manifest-path', manifest, env=env)
result['keychain'] = keychain_scenario(work, work / 'target/debug/horizon-macos-update-regression', env)
after = {str(p.relative_to(ROOT)): hashlib.sha256(p.read_bytes()).hexdigest() for p in sources}
assert before == after, 'source/lock snapshot changed during regression'
print(json.dumps({'snapshot_sha256': before, 'registry_pins': len(resolved), **result}, indent=2))
if __name__ == '__main__':
main()