// Mac App Store builds of a Tauri app: detection, entitlements, signing identities, path-free build flags, and the // checks of the built .app and .pkg. Pure except findTauri / scanPathLeaks / readAppInfo (bounded file reads). // storebound apple build --platform macos --tauri [--path-free] (cli/apple/build-mas.ts runs it) import { existsSync, readdirSync, readFileSync, rmSync, statSync } from 'node:fs'; import { basename, dirname, join, relative, sep } from 'node:path'; import { parsePlist, toXmlPlist, type PlistValue } from './plist'; import * as rt from './rt'; export interface TauriProject { /** Folder with tauri.conf.json (usually src-tauri). */ dir: string; confPath: string; /** Folder `tauri build` runs in: the parent of `dir`. */ cwd: string; productName: string; identifier: string; version?: string; category?: string; minOs?: string; entitlements?: string; conf: Record; } 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. */ export function findTauri(root: string, hint?: string): TauriProject { const names = ['tauri.conf.json', 'tauri.conf.json5']; let confPath: string | undefined; 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`); } else { const hits: string[] = []; const walk = (dir: string, d: number): void => { let ns: string[]; try { ns = readdirSync(dir); } catch { return; } for (const n of ns) if (names.includes(n)) hits.push(join(dir, n)); if (d <= 0) return; for (const n of ns) { if (SKIP.has(n) || n.startsWith('.')) continue; const p = join(dir, n); try { if (statSync(p).isDirectory()) walk(p, d - 1); } catch { /* unreadable */ } } }; 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]!; } 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`); } 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 }; } /** Tauri's category names (and Apple's public.app-category.* ids) -> the LSApplicationCategoryType Tauri writes; null when unknown. */ export function categoryId(c: string | undefined): string | null { if (!c) return null; if (c.startsWith('public.app-category.')) return c; const map: Record = { Business: 'business', DeveloperTool: 'developer-tools', Education: 'education', Entertainment: 'entertainment', Finance: 'finance', Game: 'games', GraphicsAndDesign: 'graphics-design', HealthcareAndFitness: 'healthcare-fitness', Lifestyle: 'lifestyle', Medical: 'medical', Music: 'music', News: 'news', Photography: 'photography', Productivity: 'productivity', Reference: 'reference', SocialNetworking: 'social-networking', Sports: 'sports', Travel: 'travel', Utility: 'utilities', Video: 'video', Weather: 'weather' }; return map[c] ? `public.app-category.${map[c]}` : null; } /** Entitlements a Mac App Store build needs: the sandbox plus the application / team identifiers of the profile. */ export const MAS_REQUIRED = ['com.apple.security.app-sandbox', 'com.apple.application-identifier', 'com.apple.developer.team-identifier'] as const; /** The entitlements file: the project's own keys kept, the required ones added (the project's values win for the optional ones). */ export function masEntitlements(o: { teamId: string; bundleId: string; existing?: Record; network?: boolean }): Record { return { ...(o.network === false ? {} : { 'com.apple.security.network.client': true }), ...(o.existing ?? {}), 'com.apple.security.app-sandbox': true, 'com.apple.application-identifier': `${o.teamId}.${o.bundleId}`, 'com.apple.developer.team-identifier': o.teamId, }; } export const entitlementsXml = (e: Record): string => toXmlPlist(e); /** Keys of an entitlements plist (XML or what `codesign -d --entitlements :-` prints). */ export function entitlementKeysOf(text: string): Record | null { const start = text.indexOf('= 0 ? start : text.indexOf(' : null; } catch { return null; } } export interface Identity { sha1: string; name: string } /** The signing identities for a Mac App Store build on this team: the app one ("Apple Distribution" or the older * "3rd Party Mac Developer Application" / "Mac App Distribution") and the installer one ("3rd Party Mac Developer * Installer" / "Mac Installer Distribution"). `all` is the parsed `security find-identity -v` list. */ export function pickMasIdentities(all: Identity[], teamId?: string | null): { app: Identity | null; installer: Identity | null } { const mine = all.filter(i => !teamId || i.name.includes(`(${teamId})`)); const first = (...prefixes: string[]): Identity | null => { for (const p of prefixes) { const h = mine.find(i => i.name.startsWith(p)); if (h) return h; } return null; }; return { app: first('3rd Party Mac Developer Application', 'Apple Distribution', 'Mac App Distribution'), installer: first('Mac Installer Distribution', '3rd Party Mac Developer Installer') }; } export interface MasProfile { path: string; uuid: string; name: string; team: string; expires: string } /** Mac App Store profiles (.provisionprofile / .mobileprovision) among `files`: macOS platform, no device list, this * application identifier, not expired; newest expiry first. `parse` reads one profile file. Pure. */ export function pickMasProfiles(files: { path: string; profile: Record }[], bundleId: string, teamId: string | null, now = Date.now()): MasProfile[] { const out: MasProfile[] = []; for (const { path, profile: p } of files) { const ent = (p.Entitlements ?? {}) as Record; const team = String((p.TeamIdentifier as string[] | undefined)?.[0] ?? ''); const appId = String(ent['com.apple.application-identifier'] ?? ent['application-identifier'] ?? ''); const platforms = (p.Platform as string[] | undefined) ?? []; if (platforms.length && !platforms.includes('OSX') && !platforms.includes('macOS')) continue; if (p.ProvisionedDevices || p.ProvisionsAllDevices) continue; if (appId !== `${team}.${bundleId}` || (teamId && team !== teamId)) continue; if (Date.parse(String(p.ExpirationDate)) < now) continue; if (!out.some(x => x.uuid === p.UUID)) out.push({ path, uuid: String(p.UUID), name: String(p.Name), team, expires: String(p.ExpirationDate) }); } return out.sort((a, b) => Date.parse(b.expires) - Date.parse(a.expires)); } export interface PathFreeInput { home: string; user: string; root: string; cargoHome?: string; rustupHome?: string; rustflags?: string } /** Environment of a build that leaves no local path in the binary: rustc remaps the checkout, CARGO_HOME, RUSTUP_HOME * and the home folder; no debug info; symbols stripped. Pure. */ export function pathFreeEnv(i: PathFreeInput): Record { const maps: [string, string][] = [ [i.root, '/src'], [i.cargoHome ?? `${i.home}/.cargo`, '/cargo'], [i.rustupHome ?? `${i.home}/.rustup`, '/rustup'], [i.home, '/build-home'], ]; const seen = new Set(); const flags = maps.filter(([from]) => from && !seen.has(from) && seen.add(from)).map(([from, to]) => `--remap-path-prefix=${from}=${to}`); return { RUSTFLAGS: [i.rustflags ?? '', ...flags].filter(Boolean).join(' '), CARGO_PROFILE_RELEASE_DEBUG: '0', // no CARGO_PROFILE_RELEASE_TRIM_PATHS: still nightly-only in cargo 1.98 ("feature trim-paths is required"); the remaps do the job CARGO_PROFILE_RELEASE_STRIP: 'symbols', }; } export interface Leak { file: string; needle: string; count: number } const MAX_SCAN = 400 * 1048576; /** Every file under `dir` that contains one of the needles (home folder, user name as /name/, checkout path). Reads each * file whole (an .app is some tens of MB). */ export function scanPathLeaks(dir: string, needles: string[]): Leak[] { const ns = [...new Set(needles.filter(n => n.length >= 4))].map(n => ({ n, b: Buffer.from(n) })); const out: Leak[] = []; const walk = (d: string): void => { let names: string[]; try { names = readdirSync(d); } catch { return; } for (const name of names) { const p = join(d, name); let st; try { st = statSync(p); } catch { continue; } if (st.isDirectory()) { walk(p); continue; } if (!st.isFile() || st.size > MAX_SCAN) continue; const buf = readFileSync(p); for (const { n, b } of ns) { let count = 0; for (let at = buf.indexOf(b); at >= 0; at = buf.indexOf(b, at + b.length)) count++; if (count) out.push({ file: relative(dir, p).split(sep).join('/'), needle: n, count }); } } }; walk(dir); return out; } /** The needles for a leak scan: the home folder, /Users/ and the checkout. */ export const leakNeedles = (home: string, user: string, root: string): string[] => [home, `/Users/${user}/`, `/home/${user}/`, root]; 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[] } { 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}`); if (s('CFBundleShortVersionString') !== o.version) errors.push(`CFBundleShortVersionString is ${s('CFBundleShortVersionString') ?? 'missing'}, not ${o.version}`); if (s('CFBundleVersion') !== o.build) errors.push(`CFBundleVersion is ${s('CFBundleVersion') ?? 'missing'}, not ${o.build} (Tauri: bundle.macOS.bundleVersion)`); if (!s('LSApplicationCategoryType')) errors.push('LSApplicationCategoryType is missing: set bundle.category in tauri.conf.json (e.g. "Utility", "Productivity")'); if (!s('LSMinimumSystemVersion')) errors.push('LSMinimumSystemVersion is missing: set bundle.macOS.minimumSystemVersion in tauri.conf.json'); if (f.info.ITSAppUsesNonExemptEncryption === undefined) warnings.push('ITSAppUsesNonExemptEncryption is not in Info.plist: the build will ask for export compliance in App Store Connect (storebound apple encryption answers it)'); if (o.signed) { if (!f.entitlements) errors.push('the app has no entitlements (codesign -d --entitlements): the Mac App Store requires the app sandbox'); 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'); } if (!f.hasProfile) errors.push('Contents/embedded.provisionprofile is missing: a Mac App Store app needs its provisioning profile inside (Tauri bundle.macOS.files)'); } if (f.leaks.length) { const lines = f.leaks.slice(0, 8).map(l => ` ${l.file}: ${l.count}x "${l.needle}"`); (o.pathFree ? errors : warnings).push(`local paths in the app:\n${lines.join('\n')}${f.leaks.length > 8 ? `\n ... ${f.leaks.length - 8} more` : ''}${o.pathFree ? '' : '\n (build with --path-free to remap them)'}`); } return { errors, warnings }; } export interface PkgSignature { signed: boolean; trusted: boolean; certificate: string | null; installerCert: boolean } /** `pkgutil --check-signature` output -> what matters. Pure. */ export function parsePkgSignature(out: string): PkgSignature { if (/no signature/i.test(out)) return { signed: false, trusted: false, certificate: null, installerCert: false }; const cert = /^\s*1\.\s+(.+)$/m.exec(out)?.[1]?.trim() ?? null; return { signed: /Status:\s+signed/i.test(out), trusted: /trusted (certificate|timestamp)|signed by a certificate trusted/i.test(out), certificate: cert, installerCert: !!cert && /(3rd Party Mac Developer Installer|Mac Installer Distribution)/.test(cert) }; } export function pkgProblems(sig: PkgSignature): string[] { if (!sig.signed) return ['the .pkg is not signed (pkgutil --check-signature): productbuild --sign ""']; const e: string[] = []; if (!sig.installerCert) e.push(`the .pkg is signed with "${sig.certificate ?? '?'}", not a "3rd Party Mac Developer Installer" / "Mac Installer Distribution" certificate: the Mac App Store refuses it`); return e; } /** bundle id, version and build from a component package's PackageInfo (`pkgutil --expand` writes it). Pure. */ export function parsePackageInfo(xml: string): { bundleId: string; version: string; build: string } | null { const tag = /]*>/.exec(xml)?.[0]; if (!tag) return null; const attr = (n: string): string | undefined => new RegExp(`\\b${n}="([^"]*)"`).exec(tag)?.[1]; const bundleId = attr('id'), version = attr('CFBundleShortVersionString'), build = attr('CFBundleVersion'); return bundleId && version && build ? { bundleId, version, build } : null; } export interface MasPlanInput { tauri: TauriProject; tauriCmd: string[]; target: string; out: string; configFile: string; entitlementsFile: string; profile: string | null; appIdentity: string | null; installerIdentity: string | null; version: string; build: string; unsigned: boolean; pathFree: boolean; pkg: string; } /** The commands of one build, as shown by --dry and run by build-mas. Pure. */ 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], 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], }; } /** The tauri.conf.json overlay for the Mac App Store: identity, entitlements, profile, version, build. */ export function tauriOverlay(i: Pick & { minOs?: string; category?: string }): Record { return { version: i.version, bundle: { ...(i.category ? { category: i.category } : {}), macOS: { bundleVersion: i.build, ...(i.minOs ? { minimumSystemVersion: i.minOs } : {}), ...(i.unsigned ? {} : { signingIdentity: i.appIdentity, entitlements: i.entitlementsFile, hardenedRuntime: true, ...(i.profile ? { files: { 'embedded.provisionprofile': i.profile } } : {}) }), }, }, }; } export interface AppInfo { appDir: string; info: Record } /** The .app Tauri built: the first *.app in /target//release/bundle/macos (or the workspace's target). */ export function findBuiltApp(cwd: string, root: string, target: string, productName: string): string | null { for (const base of [join(cwd, 'target'), join(root, 'target'), join(dirname(cwd), 'target')]) { const d = join(base, target, 'release', 'bundle', 'macos'); const p = join(d, `${productName}.app`); if (existsSync(p)) return p; try { const any = readdirSync(d).find(f => f.endsWith('.app')); if (any) return join(d, any); } catch { /* none */ } } return null; } export function readAppInfo(appDir: string): Record { const p = join(appDir, 'Contents', 'Info.plist'); if (!existsSync(p)) throw new Error(`${basename(appDir)}: no Contents/Info.plist`); return parsePlist(readFileSync(p)) as Record; } /** What bounds and reports the .pkg reading (tests inject a fake spawnSync and short limits). */ export interface PkgInfoOptions { say?: (msg: string) => void; spawnSync?: typeof rt.spawnSync; checkMs?: number; expandMs?: number } /** pkgutil --check-signature evaluates the certificate chain (revocation lookups over the network): never unbounded. */ export const PKG_CHECK_MS = 90_000; /** pkgutil --expand copies the whole archive to a temp folder: minutes for a big app, never unbounded. */ export const PKG_EXPAND_MS = 5 * 60_000; /** bundle id, version and build of a .pkg: pkgutil --expand on a Mac (PackageInfo), else the .json next to it that * `apple build --platform macos` wrote. Refuses an unsigned or wrongly signed .pkg when pkgutil is there. Both pkgutil * calls have a timeout (no stdin, so no prompt) and say what they do first; one that times out falls back to the * .json file, else it fails with the reason (a silent hang over SSH, 10 min, was this read). */ export function pkgInfo(pkg: string, expectBundleId?: string, o: PkgInfoOptions = {}): { bundleId: string; version: string; build: string } { const say = o.say ?? (() => {}); const spawnSync = o.spawnSync ?? rt.spawnSync; const side = `${pkg}.json`; let timedOut = ''; if (rt.which('pkgutil') || o.spawnSync) { const checkMs = o.checkMs ?? PKG_CHECK_MS, expandMs = o.expandMs ?? PKG_EXPAND_MS; say(`checking the signature of ${basename(pkg)} (pkgutil --check-signature, up to ${Math.round(checkMs / 1000)} s)`); const chk = spawnSync(['pkgutil', '--check-signature', pkg], { stdout: 'pipe', stderr: 'pipe', timeout: checkMs }); if (chk.exitCode === null && chk.signalCode) timedOut = `pkgutil --check-signature gave no answer in ${Math.round(checkMs / 1000)} s (it checks the certificate chain over the network)`; else { const bad = pkgProblems(parsePkgSignature(chk.stdout.toString())); if (bad.length) throw new Error(`${basename(pkg)}: not uploaded:\n${bad.map(x => ` ${x}`).join('\n')}`); const tmp = join(process.env.TMPDIR ?? '/tmp', `sb-pkg-${process.pid}-${Date.now()}`); say(`reading bundle id, version and build (pkgutil --expand, up to ${Math.round(expandMs / 1000)} s; a big package takes a while)`); try { const ex = spawnSync(['pkgutil', '--expand', pkg, tmp], { stdout: 'pipe', stderr: 'pipe', timeout: expandMs }); if (ex.exitCode === null && ex.signalCode) timedOut = `pkgutil --expand gave no answer in ${Math.round(expandMs / 1000)} s`; else if (ex.exitCode === 0) { for (const d of readdirSync(tmp)) { const p = join(tmp, d, 'PackageInfo'); const info = existsSync(p) ? parsePackageInfo(readFileSync(p, 'utf8')) : null; if (info && (!expectBundleId || info.bundleId === expectBundleId)) return info; } } } finally { try { rmSync(tmp, { recursive: true, force: true }); } catch { /* temp */ } } } } if (timedOut) say(`${timedOut}: using ${basename(side)} (written by apple build) instead`); if (existsSync(side)) { const j = JSON.parse(readFileSync(side, 'utf8')) as { bundleId?: string; version?: string; build?: string; unsigned?: boolean }; if (j.unsigned) throw new Error(`${basename(pkg)} was built with --unsigned: the Mac App Store refuses it`); if (j.bundleId && j.version && j.build) return { bundleId: j.bundleId, version: j.version, build: j.build }; } if (timedOut) throw new Error(`${basename(pkg)}: ${timedOut}, and there is no ${basename(side)} file from apple build to read instead: run it in the Mac's logged-in GUI session, or check the Mac's network`); throw new Error(`${basename(pkg)}: cannot read bundle id / version / build (needs pkgutil on a Mac, or the ${basename(side)} file apple build wrote)`); }