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:
parent
9ba8471002
commit
12b01f065c
4 changed files with 82 additions and 7 deletions
|
|
@ -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:**
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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`);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue