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:
parent
d88ed3ec55
commit
68467ffe9b
7 changed files with 312 additions and 21 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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": [
|
||||
|
|
|
|||
|
|
@ -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`);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
182
tests/mas-entitlements.test.ts
Normal file
182
tests/mas-entitlements.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue