diff --git a/cli/apple/build-mas.ts b/cli/apple/build-mas.ts index 6440af6..85de5d6 100644 --- a/cli/apple/build-mas.ts +++ b/cli/apple/build-mas.ts @@ -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 [--path-free] [--target universal-apple-darwin] -// [--version x.y.z] [--build-number N] [--profile ] [--out build/macos] [--unsigned] [--dry] [--json] +// [--entitlements ] [--allow-entitlements] [--version x.y.z] [--build-number N] [--profile ] [--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) : 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 | undefined; + if (entSrc) { + if (existsSync(entSrc.file)) existingEnt = parsePlist(readFileSync(entSrc.file)) as Record; + 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 , 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) { diff --git a/cli/apple/build.ts b/cli/apple/build.ts index 18093db..1655081 100644 --- a/cli/apple/build.ts +++ b/cli/apple/build.ts @@ -1,5 +1,5 @@ // Archive and export an App Store IPA with xcodebuild, no Xcode UI. -// storebound apple build --platform macos --tauri [--path-free] [--unsigned] [--target T] [--profile F] [--version x.y.z] [--build-number N] [--out D] +// storebound apple build --platform macos --tauri [--path-free] [--unsigned] [--target T] [--profile F] [--entitlements ] [--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] diff --git a/cli/data/flags.json b/cli/data/flags.json index 44ebcb1..9e5aed1 100644 --- a/cli/data/flags.json +++ b/cli/data/flags.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 [--path-free] [--unsigned] [--target T] [--profile F] [--version x.y.z] [--build-number N] [--out D]", + "storebound apple build --platform macos --tauri [--path-free] [--unsigned] [--target T] [--profile F] [--entitlements ] [--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": [ diff --git a/cli/lib/config.ts b/cli/lib/config.ts index 05b9fc0..01da2ed 100644 --- a/cli/lib/config.ts +++ b/cli/lib/config.ts @@ -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 ` 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 * //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): 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`); } } diff --git a/cli/lib/mas.ts b/cli/lib/mas.ts index 96346f6..b080e57 100644 --- a/cli/lib/mas.ts +++ b/cli/lib/mas.ts @@ -18,20 +18,52 @@ export interface TauriProject { category?: string; minOs?: string; entitlements?: string; + /** The overlay file merged over the base config (tauri..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; } +/** 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 = base !== null && typeof base === 'object' && !Array.isArray(base) ? { ...(base as Record) } : {}; + for (const [k, v] of Object.entries(patch as Record)) { + if (v === null) delete out[k]; + else out[k] = mergePatch(out[k], v); + } + return out; +} + +/** tauri..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..conf.json + * file) or the only tauri.conf.json found. An overlay file (tauri..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 ` : 'no tauri.conf.json found: pass --tauri '); confPath = hits[0]!; + autoOverlay(); } let conf: Record; 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; + } 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 `, 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 = { + '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 = { + '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 | 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 => 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; entitlements: Record | 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], diff --git a/skills/build-and-sign/SKILL.md b/skills/build-and-sign/SKILL.md index 844e24a..b8f6d47 100644 --- a/skills/build-and-sign/SKILL.md +++ b/skills/build-and-sign/SKILL.md @@ -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..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 `, 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 /Applications --sign ` and `pkgutil --check-signature`. 6. `--path-free`: rustc remaps the checkout, CARGO_HOME, RUSTUP_HOME and the home folder, no debug info, symbols stripped; diff --git a/tests/mas-entitlements.test.ts b/tests/mas-entitlements.test.ts new file mode 100644 index 0000000..4e62c5d --- /dev/null +++ b/tests/mas-entitlements.test.ts @@ -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..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 = {}): { 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 = {}): Record => ({ + '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, 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)['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)['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'); + }); +});