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.
// 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
// 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
@ -27,7 +27,7 @@ import { prebuildFor, prebuildsDone, runPrebuild } from '../lib/web-assets';
import { preCheckCommand } from '../lib/pre-check';
import { pump } from '../lib/runner';
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,
} from '../lib/mas';
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 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 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 plan = { platform: 'MAC_OS', tauri: relative(root, tauri.confPath), target, version, build: buildNo, teamId: teamId, unsigned, pathFree, appIdentity, installerIdentity,
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 entCheck = masEntitlementProblems(ent);
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,
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 (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, 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 (prebuild) log(TAG, prebuildSkip ? `prebuild skipped (${prebuildSkip}): ${prebuild}` : `would run the prebuild: ${prebuild}`);
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)');
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)');
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 })}
@ -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 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 },
{ 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);
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) {

View file

@ -1,5 +1,5 @@
// 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)
// 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]

View file

@ -69,12 +69,14 @@
"apple build": [
"allow-app-group-gap",
"allow-device-gap",
"allow-entitlements",
"allow-ssh",
"allow-stale-web",
"allow-version-mismatch",
"build-number",
"configuration",
"derived-data",
"entitlements",
"env-file",
"evidence",
"keychain",
@ -1181,6 +1183,7 @@
"allow-app-group-gap",
"allow-availability-changes",
"allow-device-gap",
"allow-entitlements",
"allow-existing",
"allow-origin",
"allow-price-changes",
@ -1243,6 +1246,7 @@
"download",
"enable",
"end",
"entitlements",
"env-file",
"every",
"evidence",
@ -1506,7 +1510,7 @@
"storebound apple appinfo [--dry] [--json] [--allow-price-changes] [--allow-availability-changes]"
],
"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]"
],
"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`.
* `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).
* `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
* <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
@ -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)');
const mo = cfg.apple?.macos;
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 {
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`);
}
}

View file

@ -18,20 +18,52 @@ export interface TauriProject {
category?: string;
minOs?: 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>;
}
/** 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']);
/** 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 {
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') {
const p = join(root, hint);
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`);
if (isOverlayFile(p)) {
if (!existsSync(p)) throw new Error(`--tauri ${hint}: no such file`);
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 {
const hits: string[] = [];
const walk = (dir: string, d: number): void => {
@ -43,16 +75,22 @@ export function findTauri(root: string, hint?: string): TauriProject {
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>');
confPath = hits[0]!;
autoOverlay();
}
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`); }
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 productName = String(conf.productName ?? '');
if (!productName) throw new Error(`${confPath}: productName is missing`);
const dir = dirname(confPath);
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,
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. */
@ -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);
/** 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[] }
/** 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 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}`);
@ -184,7 +259,9 @@ export function appProblems(f: AppFacts, o: { bundleId: string; version: string;
else {
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.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)');
}
@ -230,7 +307,7 @@ export interface MasPlanInput {
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`);
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],
productbuild: ['productbuild', '--component', appPath, '/Applications', ...(i.unsigned || !i.installerIdentity ? [] : ['--sign', i.installerIdentity]), 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,
hardened runtime and `bundleVersion`. Set `bundle.category` and `bundle.macOS.minimumSystemVersion` in
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
`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;

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');
});
});