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

@ -84,7 +84,13 @@ What it does, in order:
the script notarizes the DMG.
- **None found**: an ad-hoc signed build whose files end in `-unsigned`.
3. Builds the sidecars for arm64 and x64, merges them into one universal file with `lipo`, then runs
`tauri build --target universal-apple-darwin --bundles app` with the path-free environment.
`tauri build --target universal-apple-darwin --bundles app --no-sign` with the path-free environment. Then the
**architecture guard** on that unsigned app: every Mach-O file in the bundle (Contents/MacOS, Frameworks,
Resources: main binary, sidecar, helpers, dylibs) must contain arm64 (`lipo -archs`), nothing may be Intel-only,
and Info.plist must not set `LSRequiresNativeExecution` false or put x86_64 first in `LSArchitecturePriority`.
Users must never see macOS's "App Update Required ... will not open in macOS 28" (Rosetta) notice. Only then does
`tauri bundle` sign, notarize and staple the same binaries. The guard runs again on the signed app, the app in the
DMG and the one in the updater bundle, and its lines go into evidence.json.
4. Makes the updater bundle (`.app.tar.gz`, root-owned entries, no macOS metadata) and the DMG. The DMG is made with
`hdiutil` only (no Finder scripting, nothing comes to the front).
5. **Checks the exact artifacts:**

View file

@ -467,5 +467,6 @@ that fails without the fix (see CONTRIBUTING.md).
starts Rosetta; macOS warns about every app that does.
- **Do:** check the x86_64 slice only by structure (`lipo -archs`, `file`, `codesign`) and run self-tests natively
(arm64). The same holds for any universal app or game you build: never launch the Intel slice to test it.
- **Guard:** `tools/desktop-release.ts` (`sliceProblems`: every Mach-O in the bundle must carry arm64 and x86_64,
- **Guard:** `tools/desktop-release.ts` (`archProblems` before signing: every Mach-O must contain arm64, no Intel-only
file, no Info.plist that forces Intel; `sliceProblems`: every Mach-O in the bundle must carry arm64 and x86_64,
nothing x86_64 is run); `tests/desktop-release.test.ts`.

View file

@ -3,7 +3,7 @@ import { describe, expect, test } from 'bun:test';
import { cpSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { artifactName, developerIdIdentity, mergeManifest, pathFreeEnv, releaseUrl, remoteSessionReason, sliceProblems, sumsText, uploadable } from '../tools/desktop-release';
import { artifactName, developerIdIdentity, mergeManifest, pathFreeEnv, releaseUrl, remoteSessionReason, sliceProblems, sumsText, uploadable, archProblems } from '../tools/desktop-release';
import { COPIES, setVersion, versions } from '../tools/desktop-version';
describe('desktop version authority', () => {
@ -99,3 +99,27 @@ describe('universal slices (never run under Rosetta)', () => {
}
});
});
describe('architecture guard (no Rosetta notice for users)', () => {
const ok = [{ file: 'Contents/MacOS/storebound-desktop', lipo: 'x86_64 arm64\n' }, { file: 'Contents/MacOS/storebound', lipo: 'x86_64 arm64' }, { file: 'Contents/Frameworks/libx.dylib', lipo: 'arm64' }];
test('every Mach-O with arm64 and a neutral Info.plist pass', () => {
expect(archProblems(ok, { CFBundleIdentifier: 'dev.example' })).toEqual([]);
expect(archProblems(ok, { LSRequiresNativeExecution: true, LSArchitecturePriority: ['arm64', 'x86_64'] })).toEqual([]);
});
test('an Intel-only helper, a file without slices or one without arm64 fail', () => {
expect(archProblems([...ok, { file: 'Contents/Resources/helper', lipo: 'x86_64' }], {})).toEqual(['Contents/Resources/helper: no arm64 slice (x86_64, Intel-only: needs Rosetta)']);
expect(archProblems([{ file: 'a', lipo: '' }], {})).toEqual(['a: no arm64 slice (no slices)']);
expect(archProblems([{ file: 'b', lipo: 'x86_64h i386' }], {})).toEqual(['b: no arm64 slice (x86_64h i386, Intel-only: needs Rosetta)']);
});
test('an Info.plist that forces Intel fails', () => {
expect(archProblems(ok, { LSRequiresNativeExecution: false })).toEqual(['Info.plist: LSRequiresNativeExecution is false']);
expect(archProblems(ok, { LSArchitecturePriority: ['x86_64', 'arm64'] })).toEqual(['Info.plist: LSArchitecturePriority puts x86_64 first']);
});
test('the release runs the guard before signing (tauri build --no-sign, then tauri bundle)', () => {
const src = readFileSync(join(import.meta.dir, '..', 'tools/desktop-release.ts'), 'utf8');
const build = src.indexOf("'--no-sign'"), guard = src.indexOf("archGuard(join(targetDir"), bundle = src.indexOf("['tauri', 'bundle'");
expect(build).toBeGreaterThan(0);
expect(guard).toBeGreaterThan(build);
expect(bundle).toBeGreaterThan(guard);
});
});

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`);