apple build --platform macos: --tauri takes tauri.<name>.conf.json (tauri.appstore.conf.json found by itself), entitlements from --entitlements / apple.macos.entitlements / the merged config, Developer-ID-only keys refused or warned

This commit is contained in:
CRTkafa 2026-11-03 23:40:06 +07:00
parent d88ed3ec55
commit 68467ffe9b
7 changed files with 312 additions and 21 deletions

View file

@ -1,6 +1,6 @@
// Mac App Store build of a Tauri app: `tauri build --bundles app`, signed for the Mac App Store, then productbuild to a .pkg. // Mac App Store build of a Tauri app: `tauri build --bundles app`, signed for the Mac App Store, then productbuild to a .pkg.
// storebound apple build --platform macos --tauri <src-tauri> [--path-free] [--target universal-apple-darwin] // storebound apple build --platform macos --tauri <src-tauri> [--path-free] [--target universal-apple-darwin]
// [--version x.y.z] [--build-number N] [--profile <file.provisionprofile>] [--out build/macos] [--unsigned] [--dry] [--json] // [--entitlements <plist>] [--allow-entitlements] [--version x.y.z] [--build-number N] [--profile <file.provisionprofile>] [--out build/macos] [--unsigned] [--dry] [--json]
// Steps: Tauri project -> version / build number (next free one on App Store Connect, every platform) -> signing // Steps: Tauri project -> version / build number (next free one on App Store Connect, every platform) -> signing
// identities from the keychain ("Apple Distribution" or "3rd Party Mac Developer Application"; "Mac Installer // identities from the keychain ("Apple Distribution" or "3rd Party Mac Developer Application"; "Mac Installer
// Distribution" or "3rd Party Mac Developer Installer") -> Mac App Store provisioning profile (--profile, installed, or // Distribution" or "3rd Party Mac Developer Installer") -> Mac App Store provisioning profile (--profile, installed, or
@ -27,7 +27,7 @@ import { prebuildFor, prebuildsDone, runPrebuild } from '../lib/web-assets';
import { preCheckCommand } from '../lib/pre-check'; import { preCheckCommand } from '../lib/pre-check';
import { pump } from '../lib/runner'; import { pump } from '../lib/runner';
import { import {
appProblems, entitlementKeysOf, entitlementsXml, findBuiltApp, findTauri, leakNeedles, masEntitlements, type MasProfile, masSteps, pathFreeEnv, pickMasIdentities, appProblems, entitlementKeysOf, entitlementsSource, entitlementsXml, findBuiltApp, findTauri, leakNeedles, masEntitlementProblems, masEntitlements, type MasProfile, masSteps, pathFreeEnv, pickMasIdentities,
pickMasProfiles, pkgProblems, parsePkgSignature, readAppInfo, scanPathLeaks, tauriOverlay, categoryId, pickMasProfiles, pkgProblems, parsePkgSignature, readAppInfo, scanPathLeaks, tauriOverlay, categoryId,
} from '../lib/mas'; } from '../lib/mas';
import { readdirSync } from 'node:fs'; import { readdirSync } from 'node:fs';
@ -167,17 +167,31 @@ export async function run(ctx: Ctx, platform: string = process.platform): Promis
const prebuild = prebuildFor(config, 'apple') ?? null; const prebuild = prebuildFor(config, 'apple') ?? null;
const prebuildSkip = !prebuild ? undefined : a.has('no-prebuild') ? '--no-prebuild' : prebuildsDone().has(prebuild) ? 'already ran in this flow run' : undefined; const prebuildSkip = !prebuild ? undefined : a.has('no-prebuild') ? '--no-prebuild' : prebuildsDone().has(prebuild) ? 'already ran in this flow run' : undefined;
const appCheck = config.apple?.macAppCheck?.trim() || null; const appCheck = config.apple?.macAppCheck?.trim() || null;
const existingEnt = tauri.entitlements && existsSync(tauri.entitlements) ? (parsePlist(readFileSync(tauri.entitlements)) as Record<string, PlistValue>) : undefined; // entitlements: --entitlements, apple.macos.entitlements, the merged Tauri config (the first one that is set)
const entFlag = a.get('entitlements'), entCfg = config.apple?.macos?.entitlements;
const entSrc = entitlementsSource({ flag: entFlag ? resolve(entFlag) : undefined, config: entCfg ? resolve(entCfg) : undefined, tauri: tauri.entitlements });
let existingEnt: Record<string, PlistValue> | undefined;
if (entSrc) {
if (existsSync(entSrc.file)) existingEnt = parsePlist(readFileSync(entSrc.file)) as Record<string, PlistValue>;
else if (entSrc.source !== 'tauri config bundle.macOS.entitlements') throw new Error(`${entSrc.source}: ${entSrc.file} does not exist`);
else warn(TAG, `${entSrc.source}: ${relative(root, entSrc.file)} does not exist, using the sandbox defaults only`);
}
const ent = masEntitlements({ teamId: team, bundleId, existing: existingEnt }); const ent = masEntitlements({ teamId: team, bundleId, existing: existingEnt });
const plan = { platform: 'MAC_OS', tauri: relative(root, tauri.confPath), target, version, build: buildNo, teamId: teamId, unsigned, pathFree, appIdentity, installerIdentity, const entCheck = masEntitlementProblems(ent);
profile: profile ? basenameOf(profile) : null, entitlements: Object.keys(ent), tauriOverlay: overlay, env: Object.fromEntries(Object.entries(env).map(([k, v]) => [k, k === 'RUSTFLAGS' ? v.replaceAll(home, '~') : v])), const allowEnt = a.has('allow-entitlements');
const plan = { platform: 'MAC_OS', tauri: relative(root, tauri.confPath), tauriOverlayFile: tauri.overlayPath ? relative(root, tauri.overlayPath) : null, target, version, build: buildNo, teamId: teamId, unsigned, pathFree, appIdentity, installerIdentity,
profile: profile ? basenameOf(profile) : null, entitlements: Object.keys(ent), entitlementsSource: entSrc ? { source: entSrc.source, file: relative(root, entSrc.file) } : null,
entitlementProblems: entCheck, tauriOverlay: overlay, env: Object.fromEntries(Object.entries(env).map(([k, v]) => [k, k === 'RUSTFLAGS' ? v.replaceAll(home, '~') : v])),
prebuild, ...(prebuildSkip ? { prebuildSkip } : {}), appCheck, prebuild, ...(prebuildSkip ? { prebuildSkip } : {}), appCheck,
steps: [showCmd(steps.tauri, null), showCmd(steps.codesignVerify, null), showCmd(steps.productbuild, null), showCmd(steps.pkgCheck, null)], out: relative(root, out) || '.', pkg: relative(root, pkg) }; steps: [showCmd(steps.tauri, null), showCmd(steps.codesignVerify, null), showCmd(steps.productbuild, null), showCmd(steps.pkgCheck, null)], out: relative(root, out) || '.', pkg: relative(root, pkg) };
if (dry) { if (dry) {
if (json) { printJson(plan); return; } if (json) { printJson(plan); return; }
log(TAG, `Tauri ${tauri.productName} (${bundleId}) ${version} (${buildNo}) for ${target}, ${unsigned ? 'UNSIGNED' : 'Mac App Store signing'}${pathFree ? ', path-free' : ''}`); log(TAG, `Tauri ${tauri.productName} (${bundleId}) ${version} (${buildNo}) for ${target}, ${unsigned ? 'UNSIGNED' : 'Mac App Store signing'}${pathFree ? ', path-free' : ''}`);
log(TAG, identityLine({ platform, appIdentity, installerIdentity, profile: plan.profile })); log(TAG, identityLine({ platform, appIdentity, installerIdentity, profile: plan.profile }));
log(TAG, `entitlements: ${plan.entitlements.join(', ')}`); if (tauri.overlayPath) log(TAG, `Tauri overlay ${plan.tauriOverlayFile}${tauri.overlayAuto ? ' (found next to tauri.conf.json)' : ''}: merged over tauri.conf.json, passed to tauri build --config`);
log(TAG, `entitlements from ${entSrc?.source ?? 'the sandbox defaults'}${entSrc ? ` (${plan.entitlementsSource!.file})` : ''}: ${plan.entitlements.join(', ')}`);
for (const e of entCheck.errors) warn(TAG, `entitlement ${e}${allowEnt ? '' : ' (the real build stops here; --allow-entitlements builds anyway)'}`);
for (const w of entCheck.warnings) warn(TAG, `entitlement ${w}`);
if (pathFree) log(TAG, `RUSTFLAGS: ${plan.env.RUSTFLAGS}`); if (pathFree) log(TAG, `RUSTFLAGS: ${plan.env.RUSTFLAGS}`);
if (prebuild) log(TAG, prebuildSkip ? `prebuild skipped (${prebuildSkip}): ${prebuild}` : `would run the prebuild: ${prebuild}`); if (prebuild) log(TAG, prebuildSkip ? `prebuild skipped (${prebuildSkip}): ${prebuild}` : `would run the prebuild: ${prebuild}`);
for (const s of plan.steps) log(TAG, `would run: ${s}`); for (const s of plan.steps) log(TAG, `would run: ${s}`);
@ -185,6 +199,11 @@ export async function run(ctx: Ctx, platform: string = process.platform): Promis
done(TAG, 'macOS build planned (dry run)'); done(TAG, 'macOS build planned (dry run)');
return; return;
} }
if (tauri.overlayPath) log(TAG, `Tauri overlay ${plan.tauriOverlayFile}${tauri.overlayAuto ? ' (found next to tauri.conf.json)' : ''}: merged over tauri.conf.json, passed to tauri build --config`);
log(TAG, `entitlements from ${entSrc?.source ?? 'the sandbox defaults'}${entSrc ? ` (${plan.entitlementsSource!.file})` : ''}: ${plan.entitlements.join(', ')}`);
for (const w of entCheck.warnings) warn(TAG, `entitlement ${w}`);
for (const e of entCheck.errors) if (allowEnt) warn(TAG, `entitlement ${e} (--allow-entitlements)`);
if (entCheck.errors.length && !allowEnt) throw new Error(`the entitlements (from ${entSrc?.source ?? 'the sandbox defaults'}) do not belong in a Mac App Store build:\n${entCheck.errors.map(x => ` ${x}`).join('\n')}\n Point the build at the App Store plist: --entitlements <plist>, apple.macos.entitlements, or a tauri.appstore.conf.json overlay next to tauri.conf.json (--allow-entitlements builds anyway).`);
if (!mac) throw new Error('apple build --platform macos needs macOS (tauri build, codesign, productbuild)'); if (!mac) throw new Error('apple build --platform macos needs macOS (tauri build, codesign, productbuild)');
const remote = remoteBuildSession(process.env, run1(['launchctl', 'managername'], 5000).out.trim()); const remote = remoteBuildSession(process.env, run1(['launchctl', 'managername'], 5000).out.trim());
if (remote && !unsigned && !a.has('allow-ssh')) throw new Error(`${sshBuildRefusal(remote, { unlockFlag: false })} if (remote && !unsigned && !a.has('allow-ssh')) throw new Error(`${sshBuildRefusal(remote, { unlockFlag: false })}
@ -205,7 +224,7 @@ export async function run(ctx: Ctx, platform: string = process.platform): Promis
const entOut = unsigned ? null : entitlementKeysOf(run1(['codesign', '-d', '--entitlements', ':-', appDir]).out); const entOut = unsigned ? null : entitlementKeysOf(run1(['codesign', '-d', '--entitlements', ':-', appDir]).out);
const leaks = pathFree || a.has('scan-paths') ? scanPathLeaks(appDir, leakNeedles(home, user, root)) : []; const leaks = pathFree || a.has('scan-paths') ? scanPathLeaks(appDir, leakNeedles(home, user, root)) : [];
const checks = appProblems({ info: readAppInfo(appDir), entitlements: entOut, hasProfile: existsSync(join(appDir, 'Contents', 'embedded.provisionprofile')), leaks }, const checks = appProblems({ info: readAppInfo(appDir), entitlements: entOut, hasProfile: existsSync(join(appDir, 'Contents', 'embedded.provisionprofile')), leaks },
{ bundleId, version, build: buildNo, signed: !unsigned, pathFree }); { bundleId, version, build: buildNo, signed: !unsigned, pathFree, allowEntitlements: allowEnt });
for (const w of checks.warnings) warn(TAG, w); for (const w of checks.warnings) warn(TAG, w);
if (checks.errors.length) throw new Error(`${basenameOf(appDir)} was built, but the Mac App Store would refuse it:\n${checks.errors.map(x => ` ${x}`).join('\n')}`); if (checks.errors.length) throw new Error(`${basenameOf(appDir)} was built, but the Mac App Store would refuse it:\n${checks.errors.map(x => ` ${x}`).join('\n')}`);
if (appCheck) { if (appCheck) {

View file

@ -1,5 +1,5 @@
// Archive and export an App Store IPA with xcodebuild, no Xcode UI. // Archive and export an App Store IPA with xcodebuild, no Xcode UI.
// storebound apple build --platform macos --tauri <src-tauri> [--path-free] [--unsigned] [--target T] [--profile F] [--version x.y.z] [--build-number N] [--out D] // storebound apple build --platform macos --tauri <src-tauri> [--path-free] [--unsigned] [--target T] [--profile F] [--entitlements <plist>] [--allow-entitlements] [--version x.y.z] [--build-number N] [--out D]
// (Mac App Store build of a Tauri app: cli/apple/build-mas.ts) // (Mac App Store build of a Tauri app: cli/apple/build-mas.ts)
// storebound apple build [--version 1.2.0] [--build-number N] [--scheme S] [--workspace W | --project P] // storebound apple build [--version 1.2.0] [--build-number N] [--scheme S] [--workspace W | --project P]
// [--configuration Release] [--out build/ios] [--no-prebuild] [--allow-stale-web] [--dry] [--json] // [--configuration Release] [--out build/ios] [--no-prebuild] [--allow-stale-web] [--dry] [--json]

View file

@ -69,12 +69,14 @@
"apple build": [ "apple build": [
"allow-app-group-gap", "allow-app-group-gap",
"allow-device-gap", "allow-device-gap",
"allow-entitlements",
"allow-ssh", "allow-ssh",
"allow-stale-web", "allow-stale-web",
"allow-version-mismatch", "allow-version-mismatch",
"build-number", "build-number",
"configuration", "configuration",
"derived-data", "derived-data",
"entitlements",
"env-file", "env-file",
"evidence", "evidence",
"keychain", "keychain",
@ -1181,6 +1183,7 @@
"allow-app-group-gap", "allow-app-group-gap",
"allow-availability-changes", "allow-availability-changes",
"allow-device-gap", "allow-device-gap",
"allow-entitlements",
"allow-existing", "allow-existing",
"allow-origin", "allow-origin",
"allow-price-changes", "allow-price-changes",
@ -1243,6 +1246,7 @@
"download", "download",
"enable", "enable",
"end", "end",
"entitlements",
"env-file", "env-file",
"every", "every",
"evidence", "evidence",
@ -1506,7 +1510,7 @@
"storebound apple appinfo [--dry] [--json] [--allow-price-changes] [--allow-availability-changes]" "storebound apple appinfo [--dry] [--json] [--allow-price-changes] [--allow-availability-changes]"
], ],
"apple build": [ "apple build": [
"storebound apple build --platform macos --tauri <src-tauri> [--path-free] [--unsigned] [--target T] [--profile F] [--version x.y.z] [--build-number N] [--out D]", "storebound apple build --platform macos --tauri <src-tauri> [--path-free] [--unsigned] [--target T] [--profile F] [--entitlements <plist>] [--allow-entitlements] [--version x.y.z] [--build-number N] [--out D]",
"storebound apple build [--version 1.2.0] [--build-number N] [--scheme S] [--workspace W | --project P]" "storebound apple build [--version 1.2.0] [--build-number N] [--scheme S] [--workspace W | --project P]"
], ],
"apple bundle": [ "apple bundle": [

View file

@ -714,8 +714,10 @@ export interface ShipConfig {
* --platform macos`; `reviewNotes`: used instead of apple.info.reviewNotes by `apple review-details --platform macos`. * --platform macos`; `reviewNotes`: used instead of apple.info.reviewNotes by `apple review-details --platform macos`.
* `gameCenter: true`: the Mac app has Game Center (silences the Game Center check of `validate --platform macos`). * `gameCenter: true`: the Mac app has Game Center (silences the Game Center check of `validate --platform macos`).
* `noIap: true`: the Mac app sells nothing (validate then flags in-app purchase lines in the Mac texts). * `noIap: true`: the Mac app sells nothing (validate then flags in-app purchase lines in the Mac texts).
* `entitlements`: the Mac App Store entitlements plist of `apple build --platform macos --tauri` (e.g. "src-tauri/Entitlements.appstore.plist"),
* used instead of the Tauri config's bundle.macOS.entitlements (a project with a Developer ID channel keeps that one there); `--entitlements <plist>` wins over it.
*/ */
macos?: { listing?: string; reviewNotes?: string; gameCenter?: boolean; noIap?: boolean }; macos?: { listing?: string; reviewNotes?: string; gameCenter?: boolean; noIap?: boolean; entitlements?: string };
/** "Header and Search Results" creative assets (`storebound apple creative-assets`, iOS / iPadOS 27+). Files under /** "Header and Search Results" creative assets (`storebound apple creative-assets`, iOS / iPadOS 27+). Files under
* <renders>/<lang>/creative/: header.{png,jpg,mov,m4v,mp4} (3840x1646, 21:9), search.{...} (3:2, 1920x1280 to * <renders>/<lang>/creative/: header.{png,jpg,mov,m4v,mp4} (3840x1646, 21:9), search.{...} (3:2, 1920x1280 to
* 3840x2560) or universal.png (5244x2950) for both. `header` / `search` false skip that slot; `universal` is a * 3840x2560) or universal.png (5244x2950) for both. `header` / `search` false skip that slot; `universal` is a
@ -1047,9 +1049,9 @@ export function validateExtras(cfg: Partial<ShipConfig>): string[] {
if (cfg.play?.free !== undefined && typeof cfg.play.free !== 'boolean') e.push('play.free: true (free) | false (paid)'); if (cfg.play?.free !== undefined && typeof cfg.play.free !== 'boolean') e.push('play.free: true (free) | false (paid)');
const mo = cfg.apple?.macos; const mo = cfg.apple?.macos;
if (mo !== undefined) { if (mo !== undefined) {
if (typeof mo !== 'object' || mo === null || Array.isArray(mo)) e.push('apple.macos: { listing?: "store/appstore-macos.md", reviewNotes?: "...", gameCenter?: true, noIap?: true }'); if (typeof mo !== 'object' || mo === null || Array.isArray(mo)) e.push('apple.macos: { listing?: "store/appstore-macos.md", reviewNotes?: "...", gameCenter?: true, noIap?: true, entitlements?: "src-tauri/Entitlements.appstore.plist" }');
else { else {
for (const k of ['listing', 'reviewNotes'] as const) if (mo[k] !== undefined && (typeof mo[k] !== 'string' || !mo[k]!.trim())) e.push(`apple.macos.${k}: a non-empty string${k === 'listing' ? ' (path of the macOS App Store listing file)' : ''}`); for (const k of ['listing', 'reviewNotes', 'entitlements'] as const) if (mo[k] !== undefined && (typeof mo[k] !== 'string' || !mo[k]!.trim())) e.push(`apple.macos.${k}: a non-empty string${k === 'listing' ? ' (path of the macOS App Store listing file)' : k === 'entitlements' ? ' (path of the Mac App Store entitlements plist)' : ''}`);
for (const k of ['gameCenter', 'noIap'] as const) if (mo[k] !== undefined && typeof mo[k] !== 'boolean') e.push(`apple.macos.${k}: true | false`); for (const k of ['gameCenter', 'noIap'] as const) if (mo[k] !== undefined && typeof mo[k] !== 'boolean') e.push(`apple.macos.${k}: true | false`);
} }
} }

View file

@ -18,20 +18,52 @@ export interface TauriProject {
category?: string; category?: string;
minOs?: string; minOs?: string;
entitlements?: string; entitlements?: string;
/** The overlay file merged over the base config (tauri.<name>.conf.json), absolute; passed to `tauri build --config` too. */
overlayPath?: string;
/** True when the overlay was found next to tauri.conf.json without being named (tauri.appstore.conf.json / tauri.mas.conf.json). */
overlayAuto?: boolean;
conf: Record<string, unknown>; conf: Record<string, unknown>;
} }
/** Overlay files that are picked up on their own for a Mac App Store build, in this order. */
export const MAS_OVERLAY_NAMES = ['tauri.appstore.conf.json', 'tauri.mas.conf.json'] as const;
/** JSON merge patch (RFC 7386, what Tauri's --config does): objects merge, null removes a key, anything else (arrays too) replaces. */
export function mergePatch(base: unknown, patch: unknown): unknown {
if (patch === null || typeof patch !== 'object' || Array.isArray(patch)) return patch;
const out: Record<string, unknown> = base !== null && typeof base === 'object' && !Array.isArray(base) ? { ...(base as Record<string, unknown>) } : {};
for (const [k, v] of Object.entries(patch as Record<string, unknown>)) {
if (v === null) delete out[k];
else out[k] = mergePatch(out[k], v);
}
return out;
}
/** tauri.<name>.conf.json (an overlay), not tauri.conf.json itself. */
export const isOverlayFile = (p: string): boolean => /(^|[\\/])tauri\.[^\\/]+\.conf\.json$/.test(p);
const SKIP = new Set(['node_modules', 'target', '.git', 'dist', 'build', '.storebound', 'Pods']); const SKIP = new Set(['node_modules', 'target', '.git', 'dist', 'build', '.storebound', 'Pods']);
/** The Tauri project: `hint` (a folder with tauri.conf.json, or the file itself) or the only tauri.conf.json found. */ /** The Tauri project: `hint` (a folder with tauri.conf.json, or a tauri.conf.json / tauri.conf.json5 / tauri.<name>.conf.json
* file) or the only tauri.conf.json found. An overlay file (tauri.<name>.conf.json) is merged over the base
* tauri.conf.json of its folder; tauri.appstore.conf.json / tauri.mas.conf.json next to the base config is picked up on its own. */
export function findTauri(root: string, hint?: string): TauriProject { export function findTauri(root: string, hint?: string): TauriProject {
const names = ['tauri.conf.json', 'tauri.conf.json5']; const names = ['tauri.conf.json', 'tauri.conf.json5'];
let confPath: string | undefined; let confPath: string | undefined, overlayPath: string | undefined, overlayAuto = false;
const autoOverlay = (): void => { const o = MAS_OVERLAY_NAMES.map(n => join(dirname(confPath!), n)).find(existsSync); if (o) { overlayPath = o; overlayAuto = true; } };
if (hint && hint !== 'true') { if (hint && hint !== 'true') {
const p = join(root, hint); const p = join(root, hint);
if (/tauri\.conf\.json5?$/.test(p) && existsSync(p)) confPath = p; if (isOverlayFile(p)) {
else confPath = names.map(n => join(p, n)).find(existsSync); if (!existsSync(p)) throw new Error(`--tauri ${hint}: no such file`);
if (!confPath) throw new Error(`--tauri ${hint}: no tauri.conf.json in it`); overlayPath = p;
confPath = names.map(n => join(dirname(p), n)).find(existsSync);
if (!confPath) throw new Error(`--tauri ${hint}: no tauri.conf.json next to this overlay (the base config it is merged over)`);
} else {
if (/tauri\.conf\.json5?$/.test(p) && existsSync(p)) confPath = p;
else confPath = names.map(n => join(p, n)).find(existsSync);
if (!confPath) throw new Error(`--tauri ${hint}: no tauri.conf.json in it`);
autoOverlay();
}
} else { } else {
const hits: string[] = []; const hits: string[] = [];
const walk = (dir: string, d: number): void => { const walk = (dir: string, d: number): void => {
@ -43,16 +75,22 @@ export function findTauri(root: string, hint?: string): TauriProject {
walk(root, 4); walk(root, 4);
if (hits.length !== 1) throw new Error(hits.length ? `several Tauri projects (${hits.map(h => relative(root, h)).join(', ')}): pass --tauri <folder with tauri.conf.json>` : 'no tauri.conf.json found: pass --tauri <folder with tauri.conf.json>'); if (hits.length !== 1) throw new Error(hits.length ? `several Tauri projects (${hits.map(h => relative(root, h)).join(', ')}): pass --tauri <folder with tauri.conf.json>` : 'no tauri.conf.json found: pass --tauri <folder with tauri.conf.json>');
confPath = hits[0]!; confPath = hits[0]!;
autoOverlay();
} }
let conf: Record<string, any>; let conf: Record<string, any>;
try { conf = JSON.parse(readFileSync(confPath, 'utf8')); } catch (e) { throw new Error(`${confPath}: not valid JSON (${(e as Error).message}); tauri.conf.json5 / Tauri.toml are not read, use tauri.conf.json`); } try { conf = JSON.parse(readFileSync(confPath, 'utf8')); } catch (e) { throw new Error(`${confPath}: not valid JSON (${(e as Error).message}); tauri.conf.json5 / Tauri.toml are not read, use tauri.conf.json`); }
if (overlayPath) {
let patch: unknown;
try { patch = JSON.parse(readFileSync(overlayPath, 'utf8')); } catch (e) { throw new Error(`${overlayPath}: not valid JSON (${(e as Error).message})`); }
conf = mergePatch(conf, patch) as Record<string, any>;
}
const mac = conf.bundle?.macOS ?? {}; const mac = conf.bundle?.macOS ?? {};
const productName = String(conf.productName ?? ''); const productName = String(conf.productName ?? '');
if (!productName) throw new Error(`${confPath}: productName is missing`); if (!productName) throw new Error(`${confPath}: productName is missing`);
const dir = dirname(confPath); const dir = dirname(confPath);
return { dir, confPath, cwd: dirname(dir), productName, identifier: String(conf.identifier ?? ''), version: typeof conf.version === 'string' ? conf.version : undefined, return { dir, confPath, cwd: dirname(dir), productName, identifier: String(conf.identifier ?? ''), version: typeof conf.version === 'string' ? conf.version : undefined,
category: typeof conf.bundle?.category === 'string' ? conf.bundle.category : undefined, minOs: typeof mac.minimumSystemVersion === 'string' ? mac.minimumSystemVersion : undefined, category: typeof conf.bundle?.category === 'string' ? conf.bundle.category : undefined, minOs: typeof mac.minimumSystemVersion === 'string' ? mac.minimumSystemVersion : undefined,
entitlements: typeof mac.entitlements === 'string' ? join(dir, mac.entitlements) : undefined, conf }; entitlements: typeof mac.entitlements === 'string' ? join(dir, mac.entitlements) : undefined, ...(overlayPath ? { overlayPath, overlayAuto } : {}), conf };
} }
/** Tauri's category names (and Apple's public.app-category.* ids) -> the LSApplicationCategoryType Tauri writes; null when unknown. */ /** Tauri's category names (and Apple's public.app-category.* ids) -> the LSApplicationCategoryType Tauri writes; null when unknown. */
@ -79,6 +117,43 @@ export function masEntitlements(o: { teamId: string; bundleId: string; existing?
}; };
} }
/** Where the entitlements of a Mac App Store build come from, first hit wins: `--entitlements <plist>`, the config's
* `apple.macos.entitlements`, the merged Tauri config's `bundle.macOS.entitlements`. Paths are already absolute. Pure. */
export function entitlementsSource(o: { flag?: string; config?: string; tauri?: string }): { file: string; source: string } | null {
if (o.flag) return { file: o.flag, source: '--entitlements' };
if (o.config) return { file: o.config, source: 'apple.macos.entitlements' };
if (o.tauri) return { file: o.tauri, source: 'tauri config bundle.macOS.entitlements' };
return null;
}
/** Entitlements that App Store Connect refuses in a Mac App Store build (a Developer ID / development leftover). */
export const MAS_REFUSED_KEYS: Record<string, string> = {
'com.apple.security.get-task-allow': 'a development entitlement, App Store Connect refuses it',
'com.apple.security.cs.disable-library-validation': 'a Developer ID hardened-runtime exception, App Store Connect refuses it',
};
/** Hardened-runtime exceptions and Developer ID habits that do not belong in a Mac App Store build. App Review may
* question them or the sandbox ignores them, but they are not an automatic upload refusal: a warning, not a stop. */
export const MAS_SUSPECT_KEYS: Record<string, string> = {
'com.apple.security.cs.allow-jit': 'hardened-runtime exception for Developer ID builds; App Review asks why a sandboxed app needs it',
'com.apple.security.cs.allow-unsigned-executable-memory': 'hardened-runtime exception for Developer ID builds; App Review asks why',
'com.apple.security.cs.disable-executable-page-protection': 'hardened-runtime exception for Developer ID builds',
'com.apple.security.cs.allow-dyld-environment-variables': 'hardened-runtime exception for Developer ID builds',
'com.apple.security.cs.debugger': 'hardened-runtime exception for Developer ID builds (a debugger cannot be sandboxed)',
'com.apple.security.automation.apple-events': 'needs NSAppleEventsUsageDescription in Info.plist and a reason in the review notes; usually a Developer ID leftover',
};
/** The entitlements of a Mac App Store build against the two lists above (a key set to false is fine). Pure. */
export function masEntitlementProblems(ent: Record<string, PlistValue> | null | undefined): { errors: string[]; warnings: string[] } {
const errors: string[] = [], warnings: string[] = [];
for (const [k, v] of Object.entries(ent ?? {})) {
if (v === false) continue;
if (MAS_REFUSED_KEYS[k]) errors.push(`${k} is set: ${MAS_REFUSED_KEYS[k]}`);
else if (MAS_SUSPECT_KEYS[k]) warnings.push(`${k} is set: ${MAS_SUSPECT_KEYS[k]}`);
}
return { errors, warnings };
}
export const entitlementsXml = (e: Record<string, PlistValue>): string => toXmlPlist(e); export const entitlementsXml = (e: Record<string, PlistValue>): string => toXmlPlist(e);
/** Keys of an entitlements plist (XML or what `codesign -d --entitlements :-` prints). */ /** Keys of an entitlements plist (XML or what `codesign -d --entitlements :-` prints). */
@ -170,7 +245,7 @@ export const leakNeedles = (home: string, user: string, root: string): string[]
export interface AppFacts { info: Record<string, PlistValue>; entitlements: Record<string, PlistValue> | null; hasProfile: boolean; leaks: Leak[] } export interface AppFacts { info: Record<string, PlistValue>; entitlements: Record<string, PlistValue> | null; hasProfile: boolean; leaks: Leak[] }
/** Problems (stop) and warnings of a built .app before it goes into a .pkg. Pure. */ /** Problems (stop) and warnings of a built .app before it goes into a .pkg. Pure. */
export function appProblems(f: AppFacts, o: { bundleId: string; version: string; build: string; signed: boolean; pathFree: boolean }): { errors: string[]; warnings: string[] } { export function appProblems(f: AppFacts, o: { bundleId: string; version: string; build: string; signed: boolean; pathFree: boolean; allowEntitlements?: boolean }): { errors: string[]; warnings: string[] } {
const errors: string[] = [], warnings: string[] = []; const errors: string[] = [], warnings: string[] = [];
const s = (k: string): string | undefined => (typeof f.info[k] === 'string' ? f.info[k] as string : undefined); const s = (k: string): string | undefined => (typeof f.info[k] === 'string' ? f.info[k] as string : undefined);
if (s('CFBundleIdentifier') !== o.bundleId) errors.push(`CFBundleIdentifier is ${s('CFBundleIdentifier') ?? 'missing'}, not ${o.bundleId}`); if (s('CFBundleIdentifier') !== o.bundleId) errors.push(`CFBundleIdentifier is ${s('CFBundleIdentifier') ?? 'missing'}, not ${o.bundleId}`);
@ -184,7 +259,9 @@ export function appProblems(f: AppFacts, o: { bundleId: string; version: string;
else { else {
for (const k of MAS_REQUIRED) if (f.entitlements[k] === undefined) errors.push(`entitlement ${k} is missing`); for (const k of MAS_REQUIRED) if (f.entitlements[k] === undefined) errors.push(`entitlement ${k} is missing`);
if (f.entitlements['com.apple.security.app-sandbox'] !== true) errors.push('com.apple.security.app-sandbox is not true'); if (f.entitlements['com.apple.security.app-sandbox'] !== true) errors.push('com.apple.security.app-sandbox is not true');
if (f.entitlements['com.apple.security.get-task-allow'] === true) errors.push('com.apple.security.get-task-allow is true: a development entitlement, App Store Connect refuses it'); const ep = masEntitlementProblems(f.entitlements);
(o.allowEntitlements ? warnings : errors).push(...ep.errors);
warnings.push(...ep.warnings);
} }
if (!f.hasProfile) errors.push('Contents/embedded.provisionprofile is missing: a Mac App Store app needs its provisioning profile inside (Tauri bundle.macOS.files)'); if (!f.hasProfile) errors.push('Contents/embedded.provisionprofile is missing: a Mac App Store app needs its provisioning profile inside (Tauri bundle.macOS.files)');
} }
@ -230,7 +307,7 @@ export interface MasPlanInput {
export function masSteps(i: MasPlanInput): { tauri: string[]; productbuild: string[]; codesignVerify: string[]; pkgCheck: string[] } { export function masSteps(i: MasPlanInput): { tauri: string[]; productbuild: string[]; codesignVerify: string[]; pkgCheck: string[] } {
const appPath = join(i.tauri.dir, 'target', i.target, 'release', 'bundle', 'macos', `${i.tauri.productName}.app`); const appPath = join(i.tauri.dir, 'target', i.target, 'release', 'bundle', 'macos', `${i.tauri.productName}.app`);
return { return {
tauri: [...i.tauriCmd, 'build', '--bundles', 'app', '--target', i.target, '--config', i.configFile], tauri: [...i.tauriCmd, 'build', '--bundles', 'app', '--target', i.target, ...(i.tauri.overlayPath ? ['--config', i.tauri.overlayPath] : []), '--config', i.configFile],
codesignVerify: ['codesign', '--verify', '--deep', '--strict', '--verbose=2', appPath], codesignVerify: ['codesign', '--verify', '--deep', '--strict', '--verbose=2', appPath],
productbuild: ['productbuild', '--component', appPath, '/Applications', ...(i.unsigned || !i.installerIdentity ? [] : ['--sign', i.installerIdentity]), i.pkg], productbuild: ['productbuild', '--component', appPath, '/Applications', ...(i.unsigned || !i.installerIdentity ? [] : ['--sign', i.installerIdentity]), i.pkg],
pkgCheck: ['pkgutil', '--check-signature', i.pkg], pkgCheck: ['pkgutil', '--check-signature', i.pkg],

View file

@ -112,6 +112,13 @@ when the owner said so for this run). The MCP server offers only `apple_signing_
the generated entitlements (app sandbox, application / team identifier, your own keys kept), the embedded profile, the generated entitlements (app sandbox, application / team identifier, your own keys kept), the embedded profile,
hardened runtime and `bundleVersion`. Set `bundle.category` and `bundle.macOS.minimumSystemVersion` in hardened runtime and `bundleVersion`. Set `bundle.category` and `bundle.macOS.minimumSystemVersion` in
tauri.conf.json (the build stops without LSApplicationCategoryType / LSMinimumSystemVersion). tauri.conf.json (the build stops without LSApplicationCategoryType / LSMinimumSystemVersion).
A project with a Developer ID and a Mac App Store channel keeps two plists: `--tauri` takes the folder or a
`tauri.<name>.conf.json` overlay (merged over tauri.conf.json like Tauri's `--config`, and passed to `tauri build`
too; `tauri.appstore.conf.json` / `tauri.mas.conf.json` next to tauri.conf.json is picked up on its own). The
entitlements plist comes from `--entitlements <plist>`, else `apple.macos.entitlements`, else the merged
`bundle.macOS.entitlements`; the plan shows the source and the final keys. `get-task-allow` and
`cs.disable-library-validation` stop the build (App Store Connect refuses them); other hardened-runtime exceptions
(`cs.allow-jit`, `cs.allow-unsigned-executable-memory`, `automation.apple-events`...) warn. `--allow-entitlements` builds anyway.
5. Checks of the .app (sandbox, no get-task-allow, profile inside, version / build), `codesign --verify`, then 5. Checks of the .app (sandbox, no get-task-allow, profile inside, version / build), `codesign --verify`, then
`productbuild --component <app> /Applications --sign <installer identity>` and `pkgutil --check-signature`. `productbuild --component <app> /Applications --sign <installer identity>` and `pkgutil --check-signature`.
6. `--path-free`: rustc remaps the checkout, CARGO_HOME, RUSTUP_HOME and the home folder, no debug info, symbols stripped; 6. `--path-free`: rustc remaps the checkout, CARGO_HOME, RUSTUP_HOME and the home folder, no debug info, symbols stripped;

View file

@ -0,0 +1,182 @@
// Mac App Store entitlements of a Tauri build (cli/lib/mas.ts, cli/apple/build-mas.ts): the overlay config
// (tauri.<name>.conf.json) merged over tauri.conf.json, the source order of the entitlements plist, and the check of
// Developer ID / development keys that do not belong in a Mac App Store build.
import { afterAll, describe, expect, test } from 'bun:test';
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { run as buildMas } from '../cli/apple/build-mas';
import type { ShipConfig } from '../cli/lib/config';
import { appProblems, entitlementsSource, entitlementsXml, findTauri, masEntitlementProblems, masSteps, mergePatch, tauriOverlay } from '../cli/lib/mas';
import { BASE, capture, project } from './helpers/store-mock';
const tmp = mkdtempSync(join(tmpdir(), 'sb-masent-'));
afterAll(() => rmSync(tmp, { recursive: true, force: true }));
const BASE_CONF = { productName: 'Demo App', identifier: 'com.example.notes', version: '1.2.3', bundle: { category: 'Utility', macOS: { entitlements: 'Entitlements.plist', minimumSystemVersion: '12.0' } } };
const OVERLAY = { bundle: { macOS: { entitlements: 'Entitlements.appstore.plist' } } };
const DEVID = { 'com.apple.security.cs.disable-library-validation': true, 'com.apple.security.cs.allow-jit': true, 'com.apple.security.network.client': true };
const APPSTORE = { 'com.apple.security.files.user-selected.read-only': true, 'com.apple.security.network.client': true };
function fixture(files: Record<string, unknown> = {}): { root: string; dir: string } {
const root = mkdtempSync(join(tmp, 'p-'));
const dir = join(root, 'src-tauri');
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, 'tauri.conf.json'), JSON.stringify(BASE_CONF));
for (const [n, v] of Object.entries(files)) writeFileSync(join(dir, n), typeof v === 'string' ? v : JSON.stringify(v));
return { root, dir };
}
describe('merge patch', () => {
test('objects merge, null removes, arrays and scalars replace', () => {
expect(mergePatch({ a: { b: 1, c: 2 }, d: [1, 2], e: 1 }, { a: { b: 9 }, d: [3], e: null })).toEqual({ a: { b: 9, c: 2 }, d: [3] });
expect(mergePatch({ a: 1 }, { a: { x: 1 } })).toEqual({ a: { x: 1 } });
expect(mergePatch('x', 5)).toBe(5);
});
});
describe('overlay config', () => {
test('an overlay file path is merged over the base config in its folder', () => {
const { root, dir } = fixture({ 'tauri.appstore.conf.json': OVERLAY });
const t = findTauri(root, 'src-tauri/tauri.appstore.conf.json');
expect(t.overlayPath).toBe(join(dir, 'tauri.appstore.conf.json'));
expect(t.overlayAuto).toBe(false);
expect(t.entitlements).toBe(join(dir, 'Entitlements.appstore.plist'));
expect(t.productName).toBe('Demo App');
expect(t.minOs).toBe('12.0');
});
test('a folder picks up tauri.appstore.conf.json, then tauri.mas.conf.json, on its own; a scan does too', () => {
const a = fixture({ 'tauri.appstore.conf.json': OVERLAY });
expect(findTauri(a.root, 'src-tauri')).toMatchObject({ overlayPath: join(a.dir, 'tauri.appstore.conf.json'), overlayAuto: true });
expect(findTauri(a.root).overlayAuto).toBe(true);
const m = fixture({ 'tauri.mas.conf.json': OVERLAY });
expect(findTauri(m.root, 'src-tauri').overlayPath).toBe(join(m.dir, 'tauri.mas.conf.json'));
const both = fixture({ 'tauri.appstore.conf.json': OVERLAY, 'tauri.mas.conf.json': { bundle: { macOS: { entitlements: 'other.plist' } } } });
expect(findTauri(both.root, 'src-tauri').entitlements).toBe(join(both.dir, 'Entitlements.appstore.plist'));
});
test('naming the base file directly still auto-detects; no overlay means none', () => {
const { root, dir } = fixture({ 'tauri.appstore.conf.json': OVERLAY });
expect(findTauri(root, 'src-tauri/tauri.conf.json').entitlements).toBe(join(dir, 'Entitlements.appstore.plist'));
const plain = fixture();
const t = findTauri(plain.root, 'src-tauri');
expect(t.overlayPath).toBeUndefined();
expect(t.entitlements).toBe(join(plain.dir, 'Entitlements.plist'));
});
test('an overlay without a base config, a missing file, bad JSON', () => {
const root = mkdtempSync(join(tmp, 'o-'));
mkdirSync(join(root, 'x'), { recursive: true });
writeFileSync(join(root, 'x/tauri.appstore.conf.json'), '{}');
expect(() => findTauri(root, 'x/tauri.appstore.conf.json')).toThrow('no tauri.conf.json next to this overlay');
const { root: r2 } = fixture();
expect(() => findTauri(r2, 'src-tauri/tauri.nope.conf.json')).toThrow('no such file');
const bad = fixture({ 'tauri.appstore.conf.json': '{ nope' });
expect(() => findTauri(bad.root, 'src-tauri')).toThrow('not valid JSON');
});
test('tauri build gets the overlay first and storebound\'s own overlay last', () => {
const { root, dir } = fixture({ 'tauri.appstore.conf.json': OVERLAY });
const tauri = findTauri(root, 'src-tauri');
const input = { tauri, tauriCmd: ['tauri'], target: 'universal-apple-darwin', out: join(root, 'out'), configFile: join(root, 'out/tauri.mas.conf.json'), entitlementsFile: 'e', profile: null,
appIdentity: null, installerIdentity: null, version: '1.0.0', build: '1', unsigned: true, pathFree: false, pkg: 'x.pkg' };
const argv = masSteps(input).tauri;
const cfgs = argv.flatMap((x, i) => (x === '--config' ? [argv[i + 1]] : []));
expect(cfgs).toEqual([join(dir, 'tauri.appstore.conf.json'), input.configFile]);
const plain = masSteps({ ...input, tauri: findTauri(fixture().root, 'src-tauri') }).tauri;
expect(plain.filter(x => x === '--config').length).toBe(1);
expect(tauriOverlay({ appIdentity: 'id', entitlementsFile: 'e', profile: null, version: '1', build: '1', unsigned: false })).toBeTruthy();
});
});
describe('entitlements source order', () => {
test('flag, then config, then the Tauri config', () => {
expect(entitlementsSource({ flag: '/f', config: '/c', tauri: '/t' })).toEqual({ file: '/f', source: '--entitlements' });
expect(entitlementsSource({ config: '/c', tauri: '/t' })).toEqual({ file: '/c', source: 'apple.macos.entitlements' });
expect(entitlementsSource({ tauri: '/t' })).toEqual({ file: '/t', source: 'tauri config bundle.macOS.entitlements' });
expect(entitlementsSource({})).toBeNull();
});
});
describe('entitlement key check', () => {
test('refused keys are errors, hardened-runtime exceptions warnings, false values and fine keys pass', () => {
const r = masEntitlementProblems({
...DEVID, 'com.apple.security.get-task-allow': true, 'com.apple.security.cs.allow-unsigned-executable-memory': true,
'com.apple.security.cs.disable-executable-page-protection': true, 'com.apple.security.cs.allow-dyld-environment-variables': true,
'com.apple.security.automation.apple-events': true, 'com.apple.security.cs.debugger': false, 'com.apple.security.files.user-selected.read-only': true,
});
expect(r.errors.map(e => e.split(' ')[0])).toEqual(['com.apple.security.cs.disable-library-validation', 'com.apple.security.get-task-allow']);
expect(r.warnings.map(e => e.split(' ')[0]).sort()).toEqual([
'com.apple.security.automation.apple-events', 'com.apple.security.cs.allow-dyld-environment-variables', 'com.apple.security.cs.allow-jit',
'com.apple.security.cs.allow-unsigned-executable-memory', 'com.apple.security.cs.disable-executable-page-protection',
]);
expect(masEntitlementProblems(APPSTORE)).toEqual({ errors: [], warnings: [] });
expect(masEntitlementProblems(null)).toEqual({ errors: [], warnings: [] });
});
test('the built app is checked too; --allow-entitlements turns the errors into warnings', () => {
const facts = {
info: { CFBundleIdentifier: 'com.example.notes', CFBundleShortVersionString: '1.0.0', CFBundleVersion: '5', LSApplicationCategoryType: 'public.app-category.utilities', LSMinimumSystemVersion: '12.0', ITSAppUsesNonExemptEncryption: false },
entitlements: { 'com.apple.security.app-sandbox': true, 'com.apple.application-identifier': 'T.com.example.notes', 'com.apple.developer.team-identifier': 'T', ...DEVID },
hasProfile: true, leaks: [],
};
const o = { bundleId: 'com.example.notes', version: '1.0.0', build: '5', signed: true, pathFree: false };
const r = appProblems(facts, o);
expect(r.errors.join()).toContain('disable-library-validation');
expect(r.warnings.join()).toContain('allow-jit');
const allowed = appProblems(facts, { ...o, allowEntitlements: true });
expect(allowed.errors).toEqual([]);
expect(allowed.warnings.join()).toContain('disable-library-validation');
});
});
// the whole command, as a dry run (no store, no Mac)
describe('apple build --platform macos --dry', () => {
const CONFIG: ShipConfig = { ...BASE };
const files = (extra: Record<string, string> = {}): Record<string, string> => ({
'src-tauri/tauri.conf.json': JSON.stringify(BASE_CONF),
'src-tauri/tauri.appstore.conf.json': JSON.stringify(OVERLAY),
'src-tauri/Entitlements.plist': entitlementsXml(DEVID),
'src-tauri/Entitlements.appstore.plist': entitlementsXml(APPSTORE),
...extra,
});
const dry = async (cfg: ShipConfig, f: Record<string, string>, argv: string[]): Promise<{ plan: any; err: string }> => {
const p = project(cfg, f);
const r = await capture(() => buildMas(p.ctx(['--tauri', 'src-tauri', '--unsigned', '--build-number', '7', '--dry', '--json', ...argv]), 'linux'));
return { plan: JSON.parse(r.out.slice(r.out.indexOf('{'))), err: r.err };
};
test('the App Store overlay is found and its plist used, not the Developer ID one', async () => {
const { plan } = await dry(CONFIG, files(), []);
expect(String(plan.tauriOverlayFile).replaceAll("\\", "/")).toBe('src-tauri/tauri.appstore.conf.json');
expect({ ...plan.entitlementsSource, file: plan.entitlementsSource.file.replaceAll("\\", "/") }).toEqual({ source: 'tauri config bundle.macOS.entitlements', file: 'src-tauri/Entitlements.appstore.plist' });
expect(plan.entitlements).toContain('com.apple.security.files.user-selected.read-only');
expect(plan.entitlements).not.toContain('com.apple.security.cs.disable-library-validation');
expect(plan.entitlementProblems).toEqual({ errors: [], warnings: [] });
expect(plan.steps[0]).toContain('tauri.appstore.conf.json');
});
test('the Developer ID plist is reported (error under a real build, a warning here)', async () => {
const f = files(); delete (f as Record<string, string | undefined>)['src-tauri/tauri.appstore.conf.json'];
const { plan, err } = await dry(CONFIG, f, []);
expect(plan.entitlementsSource.file.replaceAll("\\", "/")).toBe('src-tauri/Entitlements.plist');
expect(plan.entitlementProblems.errors.join()).toContain('disable-library-validation');
expect(plan.entitlementProblems.warnings.join()).toContain('allow-jit');
expect(err).toBe(''); // --json prints only the plan
const pj = project(CONFIG, f);
const text = await capture(() => buildMas(pj.ctx(['--tauri', 'src-tauri', '--unsigned', '--build-number', '7', '--dry']), 'linux'));
expect(text.err).toContain('disable-library-validation');
expect(text.err).toContain('--allow-entitlements');
expect(text.out).toContain('entitlements from tauri config');
});
test('--entitlements beats apple.macos.entitlements beats the Tauri config', async () => {
const f = files({ 'a.plist': entitlementsXml({ 'com.apple.security.files.downloads.read-only': true }), 'b.plist': entitlementsXml({ 'com.apple.security.files.user-selected.read-write': true }) });
delete (f as Record<string, string | undefined>)['src-tauri/tauri.appstore.conf.json'];
const cfg: ShipConfig = { ...CONFIG, apple: { ...CONFIG.apple!, macos: { entitlements: 'a.plist' } } };
const viaConfig = await dry(cfg, f, []);
expect(viaConfig.plan.entitlementsSource).toEqual({ source: 'apple.macos.entitlements', file: 'a.plist' });
expect(viaConfig.plan.entitlements).toContain('com.apple.security.files.downloads.read-only');
const viaFlag = await dry(cfg, f, ['--entitlements', 'b.plist']);
expect(viaFlag.plan.entitlementsSource).toEqual({ source: '--entitlements', file: 'b.plist' });
expect(viaFlag.plan.entitlements).toContain('com.apple.security.files.user-selected.read-write');
expect(viaFlag.plan.entitlements).not.toContain('com.apple.security.files.downloads.read-only');
});
test('a named plist that does not exist stops the run', async () => {
const p = project(CONFIG, files());
await expect(buildMas(p.ctx(['--tauri', 'src-tauri', '--unsigned', '--build-number', '7', '--dry', '--entitlements', 'nope.plist']), 'linux')).rejects.toThrow('does not exist');
});
});