desktop-release: never run the x86_64 slice on the build Mac (Rosetta makes macOS warn the owner that the app will not open in the next macOS); every Mach-O in the bundle checked with lipo (arm64 + x86_64), self-test and sidecar version run natively only; pitfall 34

This commit is contained in:
CRTkafa 2026-10-13 01:59:23 +08:00
parent 7e09947e62
commit 3952716ef8
5 changed files with 64 additions and 22 deletions

View file

@ -8,8 +8,8 @@
// identity in the keychain: hardened runtime, notarization (notarytool, App Store Connect API key) and stapling.
// Without one: an ad-hoc signed build named `-unsigned`, and what the owner has to do. Must run in the owner's
// local GUI session: codesign over SSH fails (the keychain is locked there), so SSH sessions are refused.
// The built window is booted headless first (tools/desktop-boot-check.ts). Then, on the exact artifacts: codesign / Gatekeeper expectations, the self-test (sidecar handshake, both CPU
// types) from the mounted DMG and from the unpacked updater bundle, the privacy scan, minisign signatures with
// The built window is booted headless first (tools/desktop-boot-check.ts). Then, on the exact artifacts: codesign / Gatekeeper expectations, lipo (every executable universal; the x86_64 slice is
// never run, Rosetta), the native self-test (sidecar handshake) from the mounted DMG and from the unpacked updater bundle, the privacy scan, minisign signatures with
// the updater key (~/.storebound-keys/updater.key), SHA256SUMS, latest.json and evidence.json.
// bun tools/desktop-release.ts add --out <dir> <files...> [--code-signed]
// Adds Windows / Linux installers built by the Forgejo workflow (.forgejo/workflows/desktop.yml) to the version
@ -217,23 +217,36 @@ function args(argv: string[]) {
interface SelfTest { arch: string; result: Record<string, unknown> }
/** The x86_64 slice is never run on this Mac: launching it starts Rosetta, and macOS then tells the owner that
* "storebound" includes a component that will not open in the next major release. It is checked by structure only
* (`lipo -archs` on every Mach-O in the bundle, each must carry arm64 and x86_64); the self-test runs natively. */
export function sliceProblems(files: { file: string; archs: string[] }[]): string[] {
return files.filter(f => !(f.archs.includes('arm64') && f.archs.includes('x86_64'))).map(f => `${f.file} (${f.archs.join(' ') || 'no slices'})`);
}
function machOFiles(dir: string): string[] {
const out: string[] = [];
for (const e of readdirSync(dir, { withFileTypes: true })) {
const f = join(dir, e.name);
if (e.isDirectory()) out.push(...machOFiles(f));
else if (e.isFile() && /Mach-O/.test(run(['file', '-b', f]).out)) out.push(f);
}
return out;
}
function selfTest(appPath: string, label: string): SelfTest[] {
const bin = join(appPath, 'Contents/MacOS', MAIN_BIN);
const out: SelfTest[] = [];
const arches = run(['lipo', '-archs', bin]).out.trim().split(/\s+/).sort();
const sideArches = run(['lipo', '-archs', join(appPath, 'Contents/MacOS', PRODUCT)]).out.trim().split(/\s+/).sort();
if (arches.join(' ') !== 'arm64 x86_64' || sideArches.join(' ') !== 'arm64 x86_64') fail(`${label}: not universal (app ${arches}, sidecar ${sideArches})`);
const rosetta = run(['arch', '-x86_64', '/usr/bin/true'], { allowFail: true }).code === 0;
for (const arch of rosetta ? ['arm64', 'x86_64'] : [process.arch === 'arm64' ? 'arm64' : 'x86_64']) {
const r = run(['arch', `-${arch}`, bin, '--self-test'], { allowFail: true, timeout: 90_000 });
if (r.code !== 0) fail(`${label}: self-test (${arch}) failed: ${r.err.trim() || r.out.trim()}`);
const result = JSON.parse(r.out.trim().split('\n').at(-1)!) as Record<string, unknown>;
if (result.ok !== true || result.protocol !== 'storebound-desktop/1') fail(`${label}: self-test (${arch}) answered ${r.out}`);
say(`self-test ${label} (${arch}): sidecar ${result.sidecar}, ${result.methods} methods, ${result.ms} ms`);
out.push({ arch, result });
}
if (!rosetta) say('self-test: Rosetta is not installed, x86_64 slice not run (softwareupdate --install-rosetta to cover it)');
return out;
const slices = machOFiles(appPath).map(f => ({ file: f.slice(appPath.length + 1), archs: run(['lipo', '-archs', f]).out.trim().split(/\s+/).filter(Boolean) }));
const bad = sliceProblems(slices);
if (!slices.length || bad.length) fail(`${label}: not universal: ${bad.join(', ') || 'no executables'}`);
say(`${label}: every executable is universal (${slices.map(s => s.file.replace('Contents/MacOS/', '')).join(', ')}: arm64 x86_64); x86_64 checked by lipo, not run (no Rosetta)`);
if (process.arch !== 'arm64') fail('the self-test runs the arm64 slice natively: build on Apple silicon');
const r = run([bin, '--self-test'], { allowFail: true, timeout: 90_000 });
if (r.code !== 0) fail(`${label}: self-test (arm64) failed: ${r.err.trim() || r.out.trim()}`);
const result = JSON.parse(r.out.trim().split('\n').at(-1)!) as Record<string, unknown>;
if (result.ok !== true || result.protocol !== 'storebound-desktop/1') fail(`${label}: self-test (arm64) answered ${r.out}`);
say(`self-test ${label} (arm64): sidecar ${result.sidecar}, ${result.methods} methods, ${result.ms} ms`);
return [{ arch: 'arm64', result }];
}
export interface Gate { codesign: string; spctl: string; stapler: string; binaries: string[] }
@ -344,9 +357,11 @@ async function mac(argv: string[]): Promise<void> {
rmSync(uni, { force: true });
run(['lipo', '-create', '-output', uni, arm, x64]);
run(['codesign', '--force', '--sign', '-', uni]);
for (const f of [arm, x64, uni]) {
// native slices only: running the x64 sidecar would start Rosetta (see sliceProblems)
if (run(['lipo', '-archs', uni]).out.trim().split(/\s+/).sort().join(' ') !== 'arm64 x86_64') fail(`${basename(uni)} is not universal`);
for (const f of [arm, uni]) {
const v = run([f, 'version'], { allowFail: true, timeout: 60_000 });
if (f !== x64 && v.code !== 0) fail(`${basename(f)} does not run: ${v.err}`);
if (v.code !== 0) fail(`${basename(f)} does not run: ${v.err}`);
}
// the app: tauri build with the path-free environment, CARGO_TARGET_DIR outside home