fix(updater): offer the native package to an install Rosetta is translating

An x86_64 build on Apple Silicon asked for the x86_64 package on every
update, because the architecture came from `cfg!(target_arch)` — a
compile-time constant that says `x86_64` whether the binary is on an
Intel Mac or under translation. So an install that arrived as Intel by
any route (a mis-picked download, a migration off an Intel Mac) renewed
itself as Intel forever. The updater was the only thing that could have
carried it across, and it was the thing holding it back. Nothing said so
until macOS began warning that the app "includes a component that will
not work with a future release of macOS" (#687).

`sysctl.proc_translated` distinguishes the two: 1 under Rosetta, 0
native, absent on Intel, where the question does not arise. An x86_64
build that reads 1 is running on Apple Silicon, so it is offered the
arm64 package and migrates on its next update.

Safe unasked: the swap is the unzip-and-rename the updater already
performs, with no architecture check anywhere in that path, and it runs
in a separate process that only moves files. Both packages ship in every
release, and a release missing the arm64 zip fails loudly in
`select_release_asset_for` rather than falling back to the wrong one.

The policy is split from the probing, the way the AppImage answer
already is, so it is testable without a Rosetta process. The probe is
split in turn into a raw `Option<i32>`: every way of misusing
`sysctlbyname` returns non-zero and reads as an ordinary "not
translated", which is also the right answer on the untranslated machines
the tests run on — so a broken probe would have passed a bool-only
assertion while leaving the bug in place.

The published arm64 bundle was never at fault: its three Mach-O files
are single-architecture arm64, and `bundle-macos.sh` copies only out of
the matching target directory.
This commit is contained in:
l0ng-ai
2026-08-19 18:26:34 +08:00
parent 958d8b7442
commit bdd9a6ccf1
+140 -5
View File
@@ -1997,6 +1997,71 @@ impl PackageOffer {
}
}
/// The architecture this binary was built for, as `target_arch` spells it.
/// Named so the macOS policy below can be handed an architecture rather than
/// read one, which is what makes it testable on either kind of Mac.
#[cfg(target_os = "macos")]
const BUILD_ARCH: &str = std::env::consts::ARCH;
/// Whether this process is an x86_64 binary Rosetta is translating on Apple
/// Silicon.
///
/// `sysctl.proc_translated` answers 1 exactly then and 0 for a native
/// process. On an Intel Mac the key does not exist at all, which is the same
/// answer for our purposes: nothing to migrate to.
#[cfg(target_os = "macos")]
fn running_translated() -> bool {
proc_translated() == Some(1)
}
/// The raw `sysctl.proc_translated` reading: `None` when the key does not
/// exist, which is an Intel Mac.
///
/// Split from [`running_translated`] so a test can tell "the kernel says no"
/// from "we asked the wrong question" — every way of getting `sysctlbyname`
/// wrong returns non-zero and would otherwise read as an ordinary no.
#[cfg(target_os = "macos")]
fn proc_translated() -> Option<i32> {
let mut translated: i32 = 0;
let mut len = std::mem::size_of::<i32>();
// SAFETY: the name is a NUL-terminated literal, and the out pointer and
// its length describe the same `i32` on the stack.
let queried = unsafe {
libc::sysctlbyname(
c"sysctl.proc_translated".as_ptr(),
(&raw mut translated).cast(),
&raw mut len,
std::ptr::null_mut(),
0,
)
};
(queried == 0).then_some(translated)
}
/// The macOS package a build should replace itself with, split from the
/// probing so the policy is testable without a Rosetta process — the same
/// split [`linux_package_for`] makes for the AppImage answer.
#[cfg(target_os = "macos")]
fn macos_package_arch(build_arch: &str, translated: bool) -> Option<&'static str> {
match (build_arch, translated) {
// Rosetta only ever translates x86_64, and only on Apple Silicon, so
// this is the one build that is running somewhere other than where it
// was compiled for. Offering it the native package is what carries an
// install across (#687); offering it its own architecture is what kept
// it Intel through every update it ever took.
//
// Safe to do unasked: the swap is the same unzip-and-rename it already
// performs, the updater doing it is a separate process that only moves
// files, and both packages ship in every release — a release missing
// the arm64 zip fails loudly in `select_release_asset_for` rather than
// falling back to the wrong one.
("x86_64", true) => Some("arm64"),
("aarch64", _) => Some("arm64"),
("x86_64", false) => Some("x86_64"),
_ => None,
}
}
/// The release package this installation can replace itself with, or the
/// reason it cannot.
fn package_for_current_install(version: &str) -> Result<PackageOffer, UpdateInstallHint> {
@@ -2008,11 +2073,7 @@ fn package_for_current_install(version: &str) -> Result<PackageOffer, UpdateInst
if !is_macos_update_writable(&app) || bundled_updater().is_none() {
return Err(UpdateInstallHint::UnsupportedMacos);
}
let arch = if cfg!(target_arch = "aarch64") {
"arm64"
} else if cfg!(target_arch = "x86_64") {
"x86_64"
} else {
let Some(arch) = macos_package_arch(BUILD_ARCH, running_translated()) else {
return Err(UpdateInstallHint::UnsupportedMacos);
};
return Ok(PackageOffer::plain(format!(
@@ -2949,6 +3010,80 @@ mod tests {
);
}
/// #687: an Intel build on Apple Silicon has to be offered the *native*
/// package, not another copy of itself.
///
/// The architecture was read from `cfg!(target_arch)`, which an x86_64
/// binary answers `x86_64` whether it is running on an Intel Mac or under
/// Rosetta. So an install that arrived as Intel — a mis-picked download, a
/// migration off an Intel Mac — asked for the Intel package on every
/// update and stayed Intel forever, silently, until macOS started warning
/// that the app "includes a component that will not work with a future
/// release of macOS". The updater is the only thing that could have moved
/// them across, and it was the thing keeping them.
#[cfg(target_os = "macos")]
#[test]
fn a_translated_install_is_offered_the_native_package() {
assert_eq!(
macos_package_arch("x86_64", true),
Some("arm64"),
"an x86_64 build under Rosetta is on Apple Silicon, and updating is the one \
moment it can become native"
);
assert_eq!(
macos_package_arch("x86_64", false),
Some("x86_64"),
"an untranslated x86_64 build is a real Intel Mac, which arm64 would not run"
);
assert_eq!(
macos_package_arch("aarch64", false),
Some("arm64"),
"a native build keeps the answer it always had"
);
assert_eq!(
macos_package_arch("powerpc", false),
None,
"an architecture the release does not publish has no filename worth naming"
);
}
/// The probe the policy above is fed, checked against the system's own
/// answer.
///
/// Both halves are needed. Every way of getting `sysctlbyname` wrong — a
/// misspelled key, the wrong out-size — returns non-zero, which reads as
/// an ordinary "not translated"; on the untranslated machines this test
/// mostly runs on, that is also the right answer, so comparing only the
/// `bool` would pass a probe that never worked and leave #687 unfixed with
/// every policy test above still green. Asserting the key *resolves* on
/// Apple Silicon is what actually pins the call.
#[cfg(target_os = "macos")]
#[test]
fn the_translation_probe_agrees_with_the_system() {
let sysctl = std::process::Command::new("/usr/sbin/sysctl")
.args(["-n", "sysctl.proc_translated"])
.output()
.expect("sysctl is part of the base system");
let reading = sysctl
.status
.success()
.then(|| String::from_utf8_lossy(&sysctl.stdout).trim().to_string());
assert_eq!(
proc_translated().map(|t| t.to_string()),
reading,
"the probe has to read what the kernel reads, including whether the key is there"
);
if cfg!(target_arch = "aarch64") {
assert!(
proc_translated().is_some(),
"every Apple Silicon kernel carries sysctl.proc_translated; `None` here means \
the name or the out-size is wrong, not that nothing is being translated"
);
}
assert_eq!(running_translated(), reading.as_deref() == Some("1"));
}
/// The one Linux shape that installs itself: an AppImage whose directory
/// takes a staging directory and whose image carries the helper.
#[cfg(target_os = "linux")]