From 12b01f065cd34e72f6e7cdc4b66370406fd6ccf5 Mon Sep 17 00:00:00 2001 From: CRT Date: Tue, 13 Oct 2026 02:06:53 +0800 Subject: [PATCH] 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 --- docs/desktop-release.md | 8 +++++- skills/store-pitfalls/SKILL.md | 3 +- tests/desktop-release.test.ts | 26 ++++++++++++++++- tools/desktop-release.ts | 52 +++++++++++++++++++++++++++++++--- 4 files changed, 82 insertions(+), 7 deletions(-) diff --git a/docs/desktop-release.md b/docs/desktop-release.md index fed660a..7ee8743 100644 --- a/docs/desktop-release.md +++ b/docs/desktop-release.md @@ -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:** diff --git a/skills/store-pitfalls/SKILL.md b/skills/store-pitfalls/SKILL.md index 14a3a6c..a0b0765 100644 --- a/skills/store-pitfalls/SKILL.md +++ b/skills/store-pitfalls/SKILL.md @@ -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`. diff --git a/tests/desktop-release.test.ts b/tests/desktop-release.test.ts index 1778165..84b1545 100644 --- a/tests/desktop-release.test.ts +++ b/tests/desktop-release.test.ts @@ -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); + }); +}); diff --git a/tools/desktop-release.ts b/tools/desktop-release.ts index f9de4d8..72252ab 100644 --- a/tools/desktop-release.ts +++ b/tools/desktop-release.ts @@ -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[] { + 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; + 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 { 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 { 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, 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 = { ...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, 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, 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 { const dsyms = readdirSync(bundleDir).filter(n => n.endsWith('.dSYM')); if (dsyms.length) fail(`debug symbols next to the app: ${dsyms.join(', ')}`); + const arch: Record = { preSign: preSignGuard, app: archGuard(app, 'signed app') }; const checks: Record = { app: gatekeeper(app, signed, notarized, 'built app') }; const tests: Record = { app: selfTest(app, 'built app') }; @@ -424,6 +466,7 @@ async function mac(argv: string[]): Promise { 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 { 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 { 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`);