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

@ -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],