desktop-release: architecture guard before signing (tauri build --no-sign, guard, then tauri bundle signs / notarizes): every Mach-O in the app must contain arm64, nothing Intel-only, Info.plist must not force Intel (LSRequiresNativeExecution false, LSArchitecturePriority x86_64 first); guard repeated on the signed app, the DMG and the updater bundle, recorded in evidence.json

This commit is contained in:
CRTkafa 2026-10-13 02:06:53 +08:00
parent 9ba8471002
commit 12b01f065c
4 changed files with 82 additions and 7 deletions

View file

@ -224,6 +224,36 @@ export function sliceProblems(files: { file: string; archs: string[] }[]): strin
return files.filter(f => !(f.archs.includes('arm64') && f.archs.includes('x86_64'))).map(f => `${f.file} (${f.archs.join(' ') || 'no slices'})`);
}
/** Architecture guard (pure): users must never get macOS's "App Update Required ... will not open in macOS 28"
* notice, which macOS shows for any app that needs Rosetta. Every Mach-O in the bundle (main binary, sidecar,
* helpers, dylibs, frameworks, anything executable) must contain arm64; the Info.plist must not force Intel
* (LSRequiresNativeExecution false, LSArchitecturePriority with x86_64 / i386 first). `lipo` is `lipo -archs` output. */
export function archProblems(files: { file: string; lipo: string }[], plist: Record<string, unknown>): string[] {
const out: string[] = [];
for (const f of files) {
const archs = f.lipo.trim().split(/\s+/).filter(Boolean);
if (!archs.includes('arm64')) out.push(`${f.file}: no arm64 slice (${archs.join(' ') || 'no slices'}${archs.length && archs.every(a => /^(x86_64h?|i386)$/.test(a)) ? ', Intel-only: needs Rosetta' : ''})`);
}
if (plist.LSRequiresNativeExecution === false) out.push('Info.plist: LSRequiresNativeExecution is false');
const prio = plist.LSArchitecturePriority;
if (Array.isArray(prio) && prio.length && /^(x86_64h?|i386)$/.test(String(prio[0]))) out.push(`Info.plist: LSArchitecturePriority puts ${prio[0]} first`);
return out;
}
export interface ArchGuard { files: { file: string; archs: string }[]; plist: string }
/** The architecture guard on one app: walks the whole bundle for Mach-O files and reads Info.plist (plutil). */
function archGuard(appPath: string, label: string): ArchGuard {
const files = machOFiles(appPath).map(f => ({ file: f.slice(appPath.length + 1), lipo: run(['lipo', '-archs', f]).out.trim() }));
const plist = JSON.parse(run(['plutil', '-convert', 'json', '-o', '-', join(appPath, 'Contents/Info.plist')]).out) as Record<string, unknown>;
const bad = archProblems(files, plist);
if (!files.length) bad.push('no Mach-O files found');
if (bad.length) fail(`architecture guard (${label}): ${bad.join('; ')}`);
const plistNote = `LSRequiresNativeExecution ${plist.LSRequiresNativeExecution ?? 'unset'}, LSArchitecturePriority ${Array.isArray(plist.LSArchitecturePriority) ? plist.LSArchitecturePriority.join(' > ') : 'unset'}`;
say(`architecture guard (${label}): ${files.length} Mach-O files, each contains arm64: ${files.map(f => `${f.file} [${f.lipo}]`).join(', ')}; Info.plist ${plistNote}`);
return { files: files.map(f => ({ file: f.file, archs: f.lipo })), plist: plistNote };
}
function machOFiles(dir: string): string[] {
const out: string[] = [];
for (const e of readdirSync(dir, { withFileTypes: true })) {
@ -359,6 +389,8 @@ async function mac(argv: string[]): Promise<void> {
run(['codesign', '--force', '--sign', '-', 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`);
const sideBad = archProblems([{ file: basename(uni), lipo: run(['lipo', '-archs', uni]).out }], {});
if (sideBad.length) fail(`architecture guard: ${sideBad.join('; ')}`);
for (const f of [arm, uni]) {
const v = run([f, 'version'], { allowFail: true, timeout: 60_000 });
if (v.code !== 0) fail(`${basename(f)} does not run: ${v.err}`);
@ -372,10 +404,19 @@ async function mac(argv: string[]): Promise<void> {
const override = { bundle: { macOS: signed ? { signingIdentity: identity, hardenedRuntime: true, entitlements: './entitlements.plist' } : { signingIdentity: '-' } } };
const overrideFile = join(mkdtempSync(join(tmpdir(), 'sb-release-')), 'override.json');
writeFileSync(overrideFile, JSON.stringify(override));
say(`tauri build --target universal-apple-darwin --bundles app (target dir ${targetDir}; this takes a while)`);
const tb = spawnSync('bunx', ['tauri', 'build', '--target', 'universal-apple-darwin', '--bundles', 'app', '--ci', '--config', overrideFile],
{ cwd: APP_DIR, env: env as Record<string, string>, stdio: ['ignore', 'inherit', 'inherit'] });
// 1. build and bundle WITHOUT signing (no notarization either), 2. the architecture guard on that unsigned app,
// 3. tauri bundle signs, notarizes and staples the very binaries that passed the guard (no recompile)
const unsignedEnv: Record<string, string | undefined> = { ...env };
delete unsignedEnv.APPLE_API_KEY; delete unsignedEnv.APPLE_API_ISSUER; delete unsignedEnv.APPLE_API_KEY_PATH;
say(`tauri build --target universal-apple-darwin --bundles app --no-sign (target dir ${targetDir}; this takes a while)`);
const tb = spawnSync('bunx', ['tauri', 'build', '--target', 'universal-apple-darwin', '--bundles', 'app', '--ci', '--no-sign', '--config', overrideFile],
{ cwd: APP_DIR, env: unsignedEnv as Record<string, string>, stdio: ['ignore', 'inherit', 'inherit'] });
if (tb.status !== 0) fail(`tauri build failed (exit ${tb.status})`);
const preSignGuard = archGuard(join(targetDir, 'universal-apple-darwin/release/bundle/macos', APP_NAME), 'unsigned app (before signing)');
say(`tauri bundle --target universal-apple-darwin --bundles app (${signed ? `sign${notarized ? ', notarize, staple' : ''}` : 'ad-hoc sign'})`);
const tbs = spawnSync('bunx', ['tauri', 'bundle', '--target', 'universal-apple-darwin', '--bundles', 'app', '--ci', '--config', overrideFile],
{ cwd: APP_DIR, env: env as Record<string, string>, stdio: ['ignore', 'inherit', 'inherit'] });
if (tbs.status !== 0) fail(`tauri bundle (signing) failed (exit ${tbs.status})`);
// the frontend tauri just embedded (apps/desktop/dist): it must draw under the release CSP and the frozen
// Object.prototype with old saved states (tools/desktop-boot-check.ts); a blank window never ships again
say('boot check of the built window (empty, D1 with folders gone, D3 saved state; missing bundle panel)');
@ -389,6 +430,7 @@ async function mac(argv: string[]): Promise<void> {
const dsyms = readdirSync(bundleDir).filter(n => n.endsWith('.dSYM'));
if (dsyms.length) fail(`debug symbols next to the app: ${dsyms.join(', ')}`);
const arch: Record<string, ArchGuard> = { preSign: preSignGuard, app: archGuard(app, 'signed app') };
const checks: Record<string, Gate> = { app: gatekeeper(app, signed, notarized, 'built app') };
const tests: Record<string, SelfTest[]> = { app: selfTest(app, 'built app') };
@ -424,6 +466,7 @@ async function mac(argv: string[]): Promise<void> {
const mnt = mkdtempSync(join(tmpdir(), 'sb-mnt-'));
run(['hdiutil', 'attach', '-readonly', '-nobrowse', '-noautoopen', '-mountpoint', mnt, dmg]);
try {
arch.dmgApp = archGuard(join(mnt, APP_NAME), 'app in the DMG');
checks.dmgApp = gatekeeper(join(mnt, APP_NAME), signed, notarized, 'app in the DMG');
tests.dmg = selfTest(join(mnt, APP_NAME), 'DMG');
} finally {
@ -442,6 +485,7 @@ async function mac(argv: string[]): Promise<void> {
say(`DMG: spctl -t open ${dmgSpctl}; ${dmgStapler}`);
const unpacked = mkdtempSync(join(tmpdir(), 'sb-upd-'));
run(['tar', '-xzf', updater, '-C', unpacked]);
arch.updaterApp = archGuard(join(unpacked, APP_NAME), 'app in the updater bundle');
checks.updaterApp = gatekeeper(join(unpacked, APP_NAME), signed, notarized, 'app in the updater bundle');
tests.updater = selfTest(join(unpacked, APP_NAME), 'updater bundle');
rmSync(unpacked, { recursive: true, force: true });
@ -471,7 +515,7 @@ async function mac(argv: string[]): Promise<void> {
rustc: run(['rustc', '--version'], { env: { ...process.env, PATH } }).out.trim(), bun: Bun.version,
tauri: run(['bunx', 'tauri', '--version'], { cwd: APP_DIR }).out.trim(), macos: run(['sw_vers', '-productVersion']).out.trim(), darwin: osRelease(),
},
checks: { ...checks, dmg: { spctl: dmgSpctl, stapler: dmgStapler }, selfTest: tests, bootCheck, privacy },
checks: { ...checks, dmg: { spctl: dmgSpctl, stapler: dmgStapler }, architecture: arch, selfTest: tests, bootCheck, privacy },
artifacts,
};
writeFileSync(join(out, `evidence-macos-${version}.json`), `${JSON.stringify(evidence, null, 2)}\n`);