From 9a937b5512f4a47fc90ff35c8c8fcd2e64d16675 Mon Sep 17 00:00:00 2001 From: CRT Date: Sat, 24 Oct 2026 12:04:26 +0800 Subject: [PATCH] apple build --platform macos --tauri: Mac App Store build of a Tauri app (sandbox entitlements, profile, productbuild pkg, path-free, checks); apple upload accepts the pkg --- cli/apple/build-mas.ts | 201 +++++++++++++++++++++++++++ cli/apple/build.ts | 3 + cli/apple/signing.ts | 2 +- cli/apple/upload.ts | 37 +++-- cli/lib/mas.ts | 299 ++++++++++++++++++++++++++++++++++++++++ cli/lib/signing.ts | 4 +- cli/lib/xcode.ts | 8 +- tests/mas-build.test.ts | 194 ++++++++++++++++++++++++++ 8 files changed, 726 insertions(+), 22 deletions(-) create mode 100644 cli/apple/build-mas.ts create mode 100644 cli/lib/mas.ts create mode 100644 tests/mas-build.test.ts diff --git a/cli/apple/build-mas.ts b/cli/apple/build-mas.ts new file mode 100644 index 0000000..d1bb17c --- /dev/null +++ b/cli/apple/build-mas.ts @@ -0,0 +1,201 @@ +// 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] +// Steps: Tauri project -> version / build number (next free one on App Store Connect, platform MAC_OS) -> 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 +// from the API) -> entitlements (app sandbox + application / team identifier, the project's own keys kept) -> a +// tauri.conf overlay (signingIdentity, entitlements, embedded.provisionprofile, bundleVersion) -> `tauri build` -> +// checks of the .app (sandbox entitlement, category, minimum OS, version / build, profile inside, no local paths) -> +// productbuild --sign -> pkgutil --check-signature. --path-free remaps the checkout, CARGO_HOME, RUSTUP_HOME and the home +// folder in rustc, strips debug info and symbols, and fails when the home folder or user name is still in the app. +// --unsigned builds without identities, profile and installer signature (a pipeline check on any Mac; not uploadable). +// Result: /export/-/.pkg + /latest.json; then `storebound apple upload --platform macos`. +// codesign needs the Mac's logged-in GUI session (SSH: errSecInternalComponent), same as apple build. +import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { homedir, userInfo } from 'node:os'; +import { dirname, join, relative } from 'node:path'; +import type { Ctx } from '../lib/ctx'; +import { need } from '../lib/ctx'; +import { done, log, printJson, warn } from '../lib/log'; +import { parseMobileprovision, parsePlist, type PlistValue } from '../lib/plist'; +import { nextBuildNumber } from '../lib/release'; +import { parseIdentities, profileDirs, readProfiles, type ApiProfile } from '../lib/signing'; +import { remoteBuildSession, showCmd } from '../lib/xcode'; +import { + appProblems, entitlementKeysOf, entitlementsXml, findBuiltApp, findTauri, leakNeedles, masEntitlements, type MasProfile, masSteps, pathFreeEnv, pickMasIdentities, + pickMasProfiles, pkgProblems, parsePkgSignature, readAppInfo, scanPathLeaks, tauriOverlay, categoryId, +} from '../lib/mas'; +import { readdirSync } from 'node:fs'; +import { ascClient, type Asc } from './asc'; +import { findBundleId } from './signing'; +import { runLogged } from './build'; +import * as rt from '../lib/rt'; +import { shipPhase } from '../lib/ship-step'; + +const TAG = 'build'; +const run1 = (argv: string[], timeout = 120_000): { ok: boolean; out: string } => { + const r = rt.spawnSync(argv, { stdout: 'pipe', stderr: 'pipe', timeout }); + return { ok: r.exitCode === 0, out: `${r.stdout.toString()}${r.stderr.toString()}` }; +}; + +/** The Tauri CLI: the project's node_modules/.bin/tauri (walking up), `cargo tauri`, else bunx. */ +export function tauriCommand(cwd: string, exists: (p: string) => boolean = existsSync, which: (c: string) => string | null = rt.which): string[] { + for (let d = cwd, prev = ''; d !== prev; prev = d, d = dirname(d)) { const p = join(d, 'node_modules', '.bin', 'tauri'); if (exists(p)) return [p]; } + if (which('cargo-tauri')) return ['cargo', 'tauri']; + return ['bunx', '--bun', '@tauri-apps/cli']; +} + +/** Mac App Store profiles installed for Xcode (both folders, .provisionprofile and .mobileprovision). */ +export function installedMasProfiles(bundleId: string, teamId: string | null, home = homedir()): MasProfile[] { + const files: { path: string; profile: Record }[] = []; + for (const d of profileDirs(home)) { + let names: string[] = []; try { names = readdirSync(d).filter(f => /\.(provisionprofile|mobileprovision)$/.test(f)); } catch { continue; } + for (const f of names) { try { files.push({ path: join(d, f), profile: parseMobileprovision(readFileSync(join(d, f))) }); } catch { /* not a profile */ } } + } + return pickMasProfiles(files, bundleId, teamId); +} + +/** The ACTIVE MAC_APP_STORE profile of the bundle id from the API, written to `dest`. Null when there is none. */ +async function fetchMasProfile(c: Asc, bundleId: string, dest: string): Promise { + const b = await findBundleId(c, bundleId); + if (!b) return null; + const list: ApiProfile[] = await readProfiles(p => c.all(p), b.id, 'MAC_APP_STORE'); + const ok = list.filter(p => p.attributes.profileState === 'ACTIVE' && Date.parse(p.attributes.expirationDate ?? '') - Date.now() > 86_400_000) + .sort((x, y) => Date.parse(y.attributes.expirationDate!) - Date.parse(x.attributes.expirationDate!))[0]; + if (!ok) return null; + const content = ok.attributes.profileContent ?? (await c.asc('GET', `/v1/profiles/${ok.id}`)).data.attributes.profileContent; + if (!content) return null; + mkdirSync(dirname(dest), { recursive: true }); + writeFileSync(dest, Buffer.from(content, 'base64')); + return dest; +} + +export async function run(ctx: Ctx): Promise { + const { config, root, resolve } = ctx.loaded; + const a = ctx.args; + const bundleId = need(config.apple?.bundleId, 'apple.bundleId'); + const dry = a.has('dry'), json = a.has('json'); + const unsigned = a.has('unsigned'); + const pathFree = a.has('path-free'); + const tauri = findTauri(root, a.get('tauri')); + if (tauri.identifier !== bundleId) throw new Error(`${relative(root, tauri.confPath)}: identifier is "${tauri.identifier}", the config's apple.bundleId is "${bundleId}": they must be the same`); + const target = a.str('target', 'universal-apple-darwin'); + const out = resolve(a.get('out') ?? (config.apple?.build?.out ? `${config.apple.build.out.replace(/\/ios$/, '')}/macos` : 'build/macos')); + const version = a.get('version') ?? tauri.version; + if (!version || !/^\d+(\.\d+){0,2}$/.test(version)) throw new Error('version: x.y.z from --version or tauri.conf.json "version"'); + let build = a.get('build-number'); + let teamId = config.apple?.signing?.teamId ?? null; + let client: Asc | null = null; + if (!unsigned || !build) { + try { + client = await ascClient(root, bundleId, config.apple?.appId); + if (!build) { + const builds = await client.all(`/v1/builds?filter[app]=${await client.app()}&filter[preReleaseVersion.platform]=MAC_OS&limit=200&fields[builds]=version`); + build = nextBuildNumber(builds.map(b => String(b.attributes.version))); + log(TAG, `${builds.length} macOS builds on App Store Connect: next build number ${build} (CFBundleVersion must increase)`); + } + if (!teamId) teamId = (await findBundleId(client, bundleId))?.attributes?.seedId ?? null; + } catch (e) { + if (!dry) throw new Error(`${(e as Error).message}\n(pass --build-number N and set apple.signing.teamId to build without App Store Connect reads)`); + warn(TAG, `App Store Connect not readable (${(e as Error).message.split('\n')[0]?.slice(0, 120)}): dry run continues with placeholders`); + } + } + if (build && !/^\d+(\.\d+){0,2}$/.test(build)) throw new Error('--build-number: digits, e.g. 42 or 1.0.42'); + const buildNo = build ?? ''; + const team = teamId ?? ''; + + // identities and profile (a real signed run needs them; a dry run shows placeholders) + const mac = process.platform === 'darwin'; + let appIdentity: string | null = null, installerIdentity: string | null = null; + if (!unsigned && mac) { + const picked = pickMasIdentities(parseIdentities(run1(['security', 'find-identity', '-v']).out), teamId); + appIdentity = picked.app?.name ?? null; installerIdentity = picked.installer?.name ?? null; + const missing = [!appIdentity && 'an "Apple Distribution" / "3rd Party Mac Developer Application" identity', !installerIdentity && 'a "Mac Installer Distribution" / "3rd Party Mac Developer Installer" identity'].filter(Boolean); + if (missing.length && !dry) throw new Error(`no ${missing.join(' and no ')} for team ${team} in the keychain. Create them with apple.signing.certificateType DISTRIBUTION / MAC_INSTALLER_DISTRIBUTION and \`storebound apple signing ensure\`, then import the certificate into the login keychain.`); + if (missing.length) warn(TAG, `missing in the keychain: ${missing.join(', ')}`); + } + const profileFile = join(out, 'embedded.provisionprofile'); + let profile: string | null = null; + if (!unsigned) { + const given = a.get('profile'); + if (given) { profile = resolve(given); if (!existsSync(profile)) throw new Error(`--profile ${given}: no such file`); } + else { + const have = installedMasProfiles(bundleId, teamId)[0]; + if (have) { profile = have.path; log(TAG, `Mac App Store profile "${have.name}" (installed, expires ${have.expires.slice(0, 10)})`); } + else if (client && !dry) profile = await fetchMasProfile(client, bundleId, profileFile); + else if (client) { try { profile = await fetchMasProfile(client, bundleId, profileFile); } catch (e) { warn(TAG, `profile not readable: ${(e as Error).message.split('\n')[0]}`); } } + if (!profile && !dry) throw new Error(`no active MAC_APP_STORE provisioning profile for ${bundleId}: set apple.signing.profileType to MAC_APP_STORE and run \`storebound apple signing ensure\`, or pass --profile `); + if (!profile) warn(TAG, `no MAC_APP_STORE profile for ${bundleId} yet (storebound apple signing ensure with apple.signing.profileType MAC_APP_STORE)`); + } + } + + const home = homedir(), user = userInfo().username; + const env = pathFree ? pathFreeEnv({ home, user, root: process.env.STOREBOUND_PATHFREE_ROOT ?? root, cargoHome: process.env.CARGO_HOME, rustupHome: process.env.RUSTUP_HOME, rustflags: process.env.RUSTFLAGS }) : {}; + const entFile = join(out, 'mas.entitlements'); + const configFile = join(out, 'tauri.mas.conf.json'); + const exportPath = join(out, 'export', `${version}-${buildNo}`); + const pkg = join(exportPath, `${tauri.productName}.pkg`); + const tauriCmd = tauriCommand(tauri.cwd); + const category = tauri.category && !categoryId(tauri.category) ? undefined : tauri.category; + const overlay = tauriOverlay({ appIdentity, entitlementsFile: entFile, profile, version, build: buildNo, unsigned, minOs: tauri.minOs, category }); + const input = { tauri, tauriCmd, target, out, configFile, entitlementsFile: entFile, profile, appIdentity, installerIdentity: installerIdentity ?? '', version, build: buildNo, unsigned, pathFree, pkg }; + const steps = masSteps(input); + const existingEnt = tauri.entitlements && existsSync(tauri.entitlements) ? (parsePlist(readFileSync(tauri.entitlements)) as Record) : undefined; + 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])), + 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, `app identity: ${appIdentity ?? '(none found)'}; installer identity: ${installerIdentity ?? '(none found)'}; profile: ${plan.profile ?? '(none)'}`); + log(TAG, `entitlements: ${plan.entitlements.join(', ')}`); + if (pathFree) log(TAG, `RUSTFLAGS: ${plan.env.RUSTFLAGS}`); + for (const s of plan.steps) log(TAG, `would run: ${s}`); + done(TAG, 'macOS build planned (dry run)'); + return; + } + 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(`apple build: ${remote}; codesign there fails with errSecInternalComponent (locked keychain). Run it in the Mac's logged-in session (write the command into a .command file and \`open -a Terminal \`), or add --unsigned for a pipeline check.`); + mkdirSync(out, { recursive: true }); + writeFileSync(entFile, entitlementsXml(ent)); + writeFileSync(configFile, `${JSON.stringify(overlay, null, 2)}\n`); + shipPhase('build', `tauri build ${target}, build ${buildNo}`); + await runLogged(steps.tauri, tauri.cwd, join(out, 'tauri-build.log'), showCmd(steps.tauri, null), env); + const appDir = findBuiltApp(tauri.cwd, root, target, tauri.productName); + if (!appDir) throw new Error(`tauri build exited 0 but no ${tauri.productName}.app is under ${join(tauri.cwd, 'target', target, 'release/bundle/macos')} (see ${join(out, 'tauri-build.log')})`); + shipPhase('verify', `checking ${basenameOf(appDir)}`); + if (!unsigned) { + const v = run1(steps.codesignVerify.map(x => (x.endsWith('.app') ? appDir : x))); + if (!v.ok) throw new Error(`codesign --verify failed:\n${v.out.slice(-600)}`); + } + 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 }); + 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')}`); + shipPhase('sign', 'productbuild'); + rmSync(exportPath, { recursive: true, force: true }); + mkdirSync(exportPath, { recursive: true }); + const pbArgv = masSteps({ ...input, installerIdentity }).productbuild.map(x => (x.endsWith('.app') ? appDir : x)); + await runLogged(pbArgv, tauri.cwd, join(out, 'productbuild.log'), showCmd(pbArgv, null)); + if (!existsSync(pkg)) throw new Error(`productbuild exited 0 but left no ${pkg}`); + if (!unsigned) { + const sig = parsePkgSignature(run1(steps.pkgCheck).out); + const bad = pkgProblems(sig); + if (bad.length) throw new Error(`${relative(root, pkg)}:\n${bad.map(x => ` ${x}`).join('\n')}`); + log(TAG, `pkg signed with "${sig.certificate}"`); + } + copyIfAny(profile, profileFile); + const result = { pkg, platform: 'MAC_OS', bundleId, version, build: buildNo, unsigned, pathFree, leaks: leaks.length }; + writeFileSync(join(out, 'latest.json'), `${JSON.stringify(result, null, 2)}\n`); + writeFileSync(`${pkg}.json`, `${JSON.stringify(result, null, 2)}\n`); + if (json) printJson(result); + done(TAG, `${relative(root, pkg)}: ${bundleId} ${version} (${buildNo})${unsigned ? ' UNSIGNED, not uploadable' : `. Next: storebound apple upload ${relative(root, pkg)} --platform macos --dry`}`); +} + +const basenameOf = (p: string): string => p.split(/[\\/]/).pop() ?? p; +function copyIfAny(src: string | null, dest: string): void { if (src && src !== dest && existsSync(src) && !existsSync(dest)) { try { copyFileSync(src, dest); } catch { /* optional */ } } } diff --git a/cli/apple/build.ts b/cli/apple/build.ts index 277bfce..b5df488 100644 --- a/cli/apple/build.ts +++ b/cli/apple/build.ts @@ -26,6 +26,7 @@ import { capacitorProject, webStep } from '../lib/web-assets'; import { archiveArgs, type AscAuth, distributionErrors, exportArgs, foreignRsync, remoteBuildSession, xcodePath, flutterConfigArgs, ipaInfo, ipaVersionProblem, pickXcode, scanXcode, showCmd } from '../lib/xcode'; import { bundleTrackingIssues, ipaManifests } from '../lib/privacy-files'; import { ascClient } from './asc'; +import { parsePlatform } from './platform'; import { findBundleId } from './signing'; import * as rt from '../lib/rt'; import { shipPhase } from '../lib/ship-step'; @@ -98,6 +99,8 @@ export async function exportIpa(o: { root: string; out: string; archivePath: str export async function run(ctx: Ctx): Promise { const { config, root, resolve } = ctx.loaded; const a = ctx.args; + // Mac App Store build of a Tauri app (cli/apple/build-mas.ts) + if (a.has('tauri') || parsePlatform(a.get('platform')) === 'MAC_OS') return (await import('./build-mas')).run(ctx); const bundleId = need(config.apple?.bundleId, 'apple.bundleId'); const cfg = config.apple?.build ?? {}; const signing = config.apple?.signing ?? {}; diff --git a/cli/apple/signing.ts b/cli/apple/signing.ts index 30ce943..cea09e0 100644 --- a/cli/apple/signing.ts +++ b/cli/apple/signing.ts @@ -55,7 +55,7 @@ function localKeys(root: string): Map { /** SHA-1s of code signing identities in the keychain search list (macOS; empty elsewhere). Read-only. */ function keychainIdentities(): Set { if (process.platform !== 'darwin' || !rt.which('security')) return new Set(); - const r = rt.spawnSync(['security', 'find-identity', '-v', '-p', 'codesigning'], { stdout: 'pipe', stderr: 'pipe' }); + const r = rt.spawnSync(['security', 'find-identity', '-v'], { stdout: 'pipe', stderr: 'pipe' }); return new Set(parseIdentities(r.stdout.toString()).map(i => i.sha1)); } diff --git a/cli/apple/upload.ts b/cli/apple/upload.ts index 966019f..03b19c6 100644 --- a/cli/apple/upload.ts +++ b/cli/apple/upload.ts @@ -17,6 +17,7 @@ // timeout (exit 7). // An upload is a write: --dry shows the plan; a real run asks first (--yes in scripts). import { runPreCheck } from '../lib/pre-check'; +import { pkgInfo } from '../lib/mas'; import { appleSdkProblem } from '../lib/store-minimums'; import { closeSync, existsSync, openSync, readdirSync, readSync, statSync } from 'node:fs'; import { basename, join, relative } from 'node:path'; @@ -29,6 +30,7 @@ import { altoolArgs, buildUploadReqs, exportedIpas, ipaInfo, ipaVersionProblem, import { bundleTrackingIssues, ipaManifests } from '../lib/privacy-files'; import { BUILD_WAIT_EXIT } from '../lib/build-ops'; import { type Asc, ascClient, type J } from './asc'; +import { parsePlatform } from './platform'; import { waitForBuild, waitMessage } from './testflight'; import { describeReq } from './jsonapi'; import { createOnce } from '../lib/upsert'; @@ -139,13 +141,13 @@ export async function sendParts(o: SendPartsOptions): Promise { } finally { beat.stop(); } } -async function apiUpload(c: Asc, appId: string, ipa: string, info: ReturnType, deadline: number, o: HeartbeatOptions = {}): Promise { +async function apiUpload(c: Asc, appId: string, ipa: string, info: ReturnType, deadline: number, o: HeartbeatOptions = {}, platform: 'IOS' | 'MAC_OS' = 'IOS'): Promise { const size = statSync(ipa).size; - const reqs = buildUploadReqs(appId, info, basename(ipa), size); + const reqs = buildUploadReqs(appId, info, basename(ipa), size, platform); // one build upload per version + build number: an earlier one that did not fail is resumed, never doubled - const prior = (await uploadsOf(c, appId, info)).find((u: J) => u.attributes?.state?.state !== 'FAILED'); + const prior = (await uploadsOf(c, appId, info, platform)).find((u: J) => u.attributes?.state?.state !== 'FAILED'); const made = prior ? { value: prior, created: false } : await createOnce({ what: `build upload ${info.version} (${info.build})`, tag: TAG, - find: async () => (await uploadsOf(c, appId, info)).find((u: J) => u.attributes?.state?.state !== 'FAILED') ?? null, + find: async () => (await uploadsOf(c, appId, info, platform)).find((u: J) => u.attributes?.state?.state !== 'FAILED') ?? null, create: async () => (await c.asc('POST', reqs.upload.path, reqs.upload.body)).data }); const up = made.value; log(TAG, made.created ? `build upload ${up.id} created` : `build upload ${up.id} exists (${up.attributes?.state?.state ?? '?'}): resuming it`); @@ -179,8 +181,8 @@ async function apiUpload(c: Asc, appId: string, ipa: string, info: ReturnType => - c.all(`/v1/apps/${appId}/buildUploads?filter[cfBundleShortVersionString]=${encodeURIComponent(info.version)}&filter[cfBundleVersion]=${encodeURIComponent(info.build)}&filter[platform]=IOS&limit=20`); +const uploadsOf = (c: Asc, appId: string, info: { version: string; build: string }, platform = 'IOS'): Promise => + c.all(`/v1/apps/${appId}/buildUploads?filter[cfBundleShortVersionString]=${encodeURIComponent(info.version)}&filter[cfBundleVersion]=${encodeURIComponent(info.build)}&filter[platform]=${platform}&limit=20`); /** * `xcrun altool --upload-package`: runs in the background with a heartbeat (it prints nothing until it is done) and is @@ -232,25 +234,30 @@ export async function run(ctx: Ctx): Promise { const via = a.str('via', 'api'); if (via !== 'api' && via !== 'altool') throw new Error('--via api | altool'); const want = a.get('build'); - let ipa = a._.find(x => x.endsWith('.ipa')); + const macos = parsePlatform(a.get('platform')) === 'MAC_OS'; + let ipa = a._.find(x => x.endsWith('.ipa') || x.endsWith('.pkg')); if (!ipa) { const dir = join(resolve(config.apple?.build?.out ?? 'build/ios'), 'export'); let found = exportedIpas(dir); if (want) found = found.filter(f => { try { return ipaInfo(f).build === want; } catch { return false; } }); + if (!found[0] && macos) throw new Error('usage: storebound apple upload --platform macos (storebound apple build --platform macos --tauri makes one)'); if (!found[0]) throw new Error(`usage: storebound apple upload (no ${want ? `build ${want} ` : ''}IPA in ${relative(root, dir)}; storebound apple build makes one)`); ipa = found[0]; } else ipa = resolve(ipa); shipPhase('verify', `checking ${relative(root, ipa)}`); - const info = ipaInfo(ipa); + const isPkg = ipa.endsWith('.pkg'); + const platform: 'IOS' | 'MAC_OS' = isPkg ? 'MAC_OS' : 'IOS'; + if (macos && !isPkg) throw new Error('--platform macos uploads a signed .pkg (storebound apple build --platform macos --tauri ), not an .ipa'); + const info: ReturnType = isPkg ? pkgInfo(ipa, bundleId) : ipaInfo(ipa); shipReport({ version: info.version, build: info.build, url: consoleUrl('asc.testflight') }); if (want && info.build !== want) throw new Error(`${relative(root, ipa)} is build ${info.build}, not ${want} (--build): did the last apple build fail?`); if (info.bundleId !== bundleId) throw new Error(`${relative(root, ipa)} is ${info.bundleId}, the config says ${bundleId}`); // App Store Connect refuses IPAs from an Xcode / SDK older than its current minimum (cli/lib/store-minimums.ts) - const sdkWhy = appleSdkProblem(info); + const sdkWhy = isPkg ? null : appleSdkProblem(info); if (sdkWhy) throw new Error(`${relative(root, ipa)}: not uploaded, ${sdkWhy}`); // every PrivacyInfo.xcprivacy in the app (root, Frameworks, bundles): Apple refuses the build minutes after the // upload when NSPrivacyTracking and NSPrivacyTrackingDomains disagree (ITMS-91064), so stop before any network call - const privacy = bundleTrackingIssues(ipaManifests(ipa)); + const privacy = isPkg ? [] : bundleTrackingIssues(ipaManifests(ipa)); if (privacy.length) throw new Error(`${relative(root, ipa)}: not uploaded, Apple would refuse it as Invalid Binary:\n${privacy.map(x => ` ${x}`).join('\n')}\nFix the manifest, rebuild (storebound apple build) and upload the new IPA.`); const pre = await runPreCheck({ args: a, config, root, resolve, file: ipa, shown: relative(root, ipa), tag: TAG }); const size = statSync(ipa).size; @@ -263,14 +270,14 @@ export async function run(ctx: Ctx): Promise { const appId = await c.app(); // the IPA must belong to the version train being prepared (the editable App Store version): MARKETING_VERSION 1.0 // from a project that ignored the intended version otherwise lands in the wrong train - const editable = await c.editableVersion().catch(() => null); + const editable = await c.editableVersion(platform).catch(() => null); const trainWhy = ipaVersionProblem(info.version, editable?.attributes?.versionString as string | undefined, 'the editable App Store version'); if (trainWhy) { if (a.has('allow-version-mismatch')) warn(TAG, trainWhy); else throw new Error(`${relative(root, ipa)}: not uploaded, ${trainWhy}`); } - const builds = await c.all(`/v1/builds?filter[app]=${appId}&limit=200&fields[builds]=version,processingState`); + const builds = await c.all(`/v1/builds?filter[app]=${appId}${isPkg ? '&filter[preReleaseVersion.platform]=MAC_OS' : ''}&limit=200&fields[builds]=version,processingState`); const numbers = builds.map(b => String(b.attributes.version)); const next = nextBuildNumber(numbers); // the same IPA uploaded by an earlier run: nothing to send again, only the wait - const already = via === 'api' && !dry ? (await uploadsOf(c, appId, info).catch(() => [])).find((u: J) => u.attributes?.state?.state === 'COMPLETE') : undefined; + const already = via === 'api' && !dry ? (await uploadsOf(c, appId, info, platform).catch(() => [])).find((u: J) => u.attributes?.state?.state === 'COMPLETE') : undefined; const onAsc = builds.find(b => String(b.attributes.version) === info.build); const decision = existingBuild({ ipa: relative(root, ipa), build: info.build, onAsc: !!onAsc, state: onAsc?.attributes?.processingState, uploadComplete: !!already, allowExisting: a.has('allow-existing'), next }); // the ship-step contract: a build number App Store Connect has already is "nothing to do" (exit 7), never a second upload @@ -291,7 +298,7 @@ export async function run(ctx: Ctx): Promise { stop(r.outcome === 'failed' ? BUILD_WAIT_EXIT.failed : BUILD_WAIT_EXIT.timeout, waitMessage(info.build, r)); } if (numbers.length && compareBuild(info.build, next) < 0) warn(TAG, `build ${info.build} is lower than the highest uploaded one (next free: ${next}); Apple accepts it only if this version train has no higher build`); - const reqs = buildUploadReqs(appId, info, basename(ipa), size); + const reqs = buildUploadReqs(appId, info, basename(ipa), size, platform); const plan = via === 'api' ? [describeReq(reqs.upload), describeReq(reqs.file('')), `PUT the file parts to the upload URLs Apple returns`, describeReq(reqs.commit('', '')), 'GET /v1/buildUploads/ until COMPLETE'] : [showCmd(altoolArgs(ipa, { keyPath: '$ASC_P8', keyId: '$ASC_KEY_ID', issuerId: '$ASC_ISSUER_ID' }), null)]; @@ -309,7 +316,7 @@ export async function run(ctx: Ctx): Promise { shipPhase('upload', `uploading ${mb(size)} via ${via}`, 0); log(TAG, `${relative(root, ipa)}: ${info.bundleId} ${info.version} (${info.build}), ${mb(size)}, via ${via}, timeout ${a.str('timeout', '60m')}`); if (via === 'api') { - try { await apiUpload(c, appId, ipa, info, deadline); } catch (e) { if ((e as { exit?: number }).exit) { warn(TAG, (e as Error).message); stop((e as { exit: number }).exit, (e as Error).message); } throw e; } + try { await apiUpload(c, appId, ipa, info, deadline, {}, platform); } catch (e) { if ((e as { exit?: number }).exit) { warn(TAG, (e as Error).message); stop((e as { exit: number }).exit, (e as Error).message); } throw e; } } else { try { await altoolUpload(root, ipa, deadline); } catch (e) { if ((e as { exit?: number }).exit) { warn(TAG, (e as Error).message); stop((e as { exit: number }).exit, (e as Error).message); } throw e; } } diff --git a/cli/lib/mas.ts b/cli/lib/mas.ts new file mode 100644 index 0000000..cc5d15b --- /dev/null +++ b/cli/lib/mas.ts @@ -0,0 +1,299 @@ +// 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, '/home/build'], + ]; + 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', + CARGO_PROFILE_RELEASE_STRIP: 'symbols', + CARGO_PROFILE_RELEASE_TRIM_PATHS: 'all', + }; +} + +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.cwd, '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; +} + +/** 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. */ +export function pkgInfo(pkg: string, expectBundleId?: string): { bundleId: string; version: string; build: string } { + if (rt.which('pkgutil')) { + const sig = parsePkgSignature(rt.spawnSync(['pkgutil', '--check-signature', pkg], { stdout: 'pipe', stderr: 'pipe' }).stdout.toString()); + const bad = pkgProblems(sig); + 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()}`); + try { + if (rt.spawnSync(['pkgutil', '--expand', pkg, tmp], { stdout: 'pipe', stderr: 'pipe' }).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 */ } } + } + const side = `${pkg}.json`; + 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 }; + } + throw new Error(`${basename(pkg)}: cannot read bundle id / version / build (needs pkgutil on a Mac, or the ${basename(side)} file apple build wrote)`); +} diff --git a/cli/lib/signing.ts b/cli/lib/signing.ts index 8abefab..70019c9 100644 --- a/cli/lib/signing.ts +++ b/cli/lib/signing.ts @@ -20,7 +20,7 @@ export type CapabilityType = typeof CAPABILITY_TYPES[number]; /** Certificate types storebound creates: Apple Distribution (DISTRIBUTION, all platforms), the older iOS Distribution, * and Developer ID Application (Mac apps outside the App Store; Account Holder only). */ -export const CERT_TYPES = ['DISTRIBUTION', 'IOS_DISTRIBUTION', 'DEVELOPER_ID_APPLICATION', 'DEVELOPER_ID_APPLICATION_G2'] as const; +export const CERT_TYPES = ['DISTRIBUTION', 'IOS_DISTRIBUTION', 'DEVELOPER_ID_APPLICATION', 'DEVELOPER_ID_APPLICATION_G2', 'MAC_APP_DISTRIBUTION', 'MAC_INSTALLER_DISTRIBUTION'] as const; export type CertType = typeof CERT_TYPES[number]; /** Profile types for store builds (Profile.profileType). */ @@ -105,7 +105,7 @@ export function pickCertificate(certs: ApiCert[], type: CertType, hasKey: (c: Ap } /** Apple caps distribution certificates per team (Apple Distribution: 3 per team). */ -export const CERT_LIMIT: Partial> = { DISTRIBUTION: 3, IOS_DISTRIBUTION: 3, DEVELOPER_ID_APPLICATION: 5, DEVELOPER_ID_APPLICATION_G2: 5 }; +export const CERT_LIMIT: Partial> = { DISTRIBUTION: 3, IOS_DISTRIBUTION: 3, DEVELOPER_ID_APPLICATION: 5, DEVELOPER_ID_APPLICATION_G2: 5, MAC_APP_DISTRIBUTION: 3, MAC_INSTALLER_DISTRIBUTION: 3 }; export function certificateReq(type: CertType, csr: string): Req { return { method: 'POST', path: '/v1/certificates', note: `create ${type} certificate (key in .storebound/signing, never printed)`, diff --git a/cli/lib/xcode.ts b/cli/lib/xcode.ts index 6d01360..fe0dd52 100644 --- a/cli/lib/xcode.ts +++ b/cli/lib/xcode.ts @@ -187,13 +187,13 @@ export function ipaVersionProblem(ipaVersion: string, wanted: string | null | un export const UPLOAD_DONE = new Set(['COMPLETE', 'FAILED']); /** Requests of an App Store Connect API build upload (Build Uploads, API 4.1): the upload, then the file reservation. */ -export function buildUploadReqs(appId: string, info: IpaInfo, fileName: string, fileSize: number) { +export function buildUploadReqs(appId: string, info: IpaInfo, fileName: string, fileSize: number, platform: 'IOS' | 'MAC_OS' = 'IOS') { return { upload: { method: 'POST' as const, path: '/v1/buildUploads', note: `build ${info.build} (${info.version})`, - body: { data: { type: 'buildUploads', attributes: { cfBundleShortVersionString: info.version, cfBundleVersion: info.build, platform: 'IOS' }, + body: { data: { type: 'buildUploads', attributes: { cfBundleShortVersionString: info.version, cfBundleVersion: info.build, platform }, relationships: { app: { data: { type: 'apps', id: appId } } } } } }, file: (uploadId: string) => ({ method: 'POST' as const, path: '/v1/buildUploadFiles', note: `${fileName} (${(fileSize / 1048576).toFixed(1)} MB)`, - body: { data: { type: 'buildUploadFiles', attributes: { assetType: 'ASSET', fileName, fileSize, uti: 'com.apple.ipa' }, + body: { data: { type: 'buildUploadFiles', attributes: { assetType: 'ASSET', fileName, fileSize, uti: platform === 'MAC_OS' ? 'com.apple.pkg' : 'com.apple.ipa' }, relationships: { buildUpload: { data: { type: 'buildUploads', id: uploadId } } } } } }), commit: (fileId: string, md5: string) => ({ method: 'PATCH' as const, path: `/v1/buildUploadFiles/${fileId}`, note: 'commit', body: { data: { type: 'buildUploadFiles', id: fileId, attributes: { uploaded: true, sourceFileChecksums: { file: { hash: md5, algorithm: 'MD5' } } } } } }), @@ -202,7 +202,7 @@ export function buildUploadReqs(appId: string, info: IpaInfo, fileName: string, /** altool upload (Xcode's command line uploader) with the API key. */ export const altoolArgs = (ipa: string, auth: AscAuth): string[] => - ['xcrun', 'altool', '--upload-package', ipa, '--api-key', auth.keyId, '--api-issuer', auth.issuerId, '--p8-file-path', auth.keyPath, '--output-format', 'json']; + ['xcrun', 'altool', '--upload-package', ipa, ...(ipa.endsWith('.pkg') ? ['--type', 'macos'] : []), '--api-key', auth.keyId, '--api-issuer', auth.issuerId, '--p8-file-path', auth.keyPath, '--output-format', 'json']; /** Errors and warnings of a build upload's state (StateDetail: code, message). */ export function uploadProblems(state: { errors?: { code?: string; message?: string; description?: string }[]; warnings?: { code?: string; message?: string; description?: string }[] } | undefined): { errors: string[]; warnings: string[] } { diff --git a/tests/mas-build.test.ts b/tests/mas-build.test.ts new file mode 100644 index 0000000..b689b02 --- /dev/null +++ b/tests/mas-build.test.ts @@ -0,0 +1,194 @@ +// Mac App Store build of a Tauri app (cli/lib/mas.ts, cli/apple/build-mas.ts): plan, entitlements, identities, path-free +// flags, leak scan, app / pkg checks, and the pkg variant of the build upload requests. +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 { tauriCommand } from '../cli/apple/build-mas'; +import { appProblems, categoryId, entitlementKeysOf, entitlementsXml, findTauri, masEntitlements, masSteps, parsePackageInfo, parsePkgSignature, pathFreeEnv, pickMasIdentities, pickMasProfiles, pkgProblems, scanPathLeaks, tauriOverlay } from '../cli/lib/mas'; +import { altoolArgs, buildUploadReqs } from '../cli/lib/xcode'; + +const tmp = mkdtempSync(join(tmpdir(), 'sb-mas-')); +afterAll(() => rmSync(tmp, { recursive: true, force: true })); + +const conf = { productName: 'Demo App', identifier: 'com.example.demo', version: '1.2.3', bundle: { category: 'Utility', macOS: { minimumSystemVersion: '12.0' } } }; +function fixture(): string { + const root = mkdtempSync(join(tmp, 'p-')); + mkdirSync(join(root, 'apps/desktop/src-tauri'), { recursive: true }); + writeFileSync(join(root, 'apps/desktop/src-tauri/tauri.conf.json'), JSON.stringify(conf)); + return root; +} + +describe('tauri project', () => { + test('found by scan and by hint; fields read', () => { + const root = fixture(); + const t = findTauri(root); + expect(t.productName).toBe('Demo App'); + expect(t.identifier).toBe('com.example.demo'); + expect(t.cwd).toBe(join(root, 'apps/desktop')); + expect(t.minOs).toBe('12.0'); + expect(findTauri(root, 'apps/desktop/src-tauri').dir).toBe(t.dir); + expect(() => findTauri(root, 'nope')).toThrow('no tauri.conf.json'); + }); + test('none or several found', () => { + const empty = mkdtempSync(join(tmp, 'e-')); + expect(() => findTauri(empty)).toThrow('no tauri.conf.json found'); + const two = fixture(); + mkdirSync(join(two, 'other/src-tauri'), { recursive: true }); + writeFileSync(join(two, 'other/src-tauri/tauri.conf.json'), JSON.stringify(conf)); + expect(() => findTauri(two)).toThrow('several Tauri projects'); + }); + test('tauri command: project binary, cargo-tauri, else bunx', () => { + expect(tauriCommand('/a/b', p => /a.node_modules..bin.tauri$/.test(p), () => null)).toHaveLength(1); + expect(tauriCommand('/a/b', () => false, c => (c === 'cargo-tauri' ? '/x' : null))).toEqual(['cargo', 'tauri']); + expect(tauriCommand('/a/b', () => false, () => null)[0]).toBe('bunx'); + }); + test('category ids', () => { + expect(categoryId('Utility')).toBe('public.app-category.utilities'); + expect(categoryId('public.app-category.games')).toBe('public.app-category.games'); + expect(categoryId('Nope')).toBeNull(); + }); +}); + +describe('entitlements', () => { + test('sandbox and identifiers forced, project keys kept', () => { + const e = masEntitlements({ teamId: 'ABCDE12345', bundleId: 'com.example.demo', existing: { 'com.apple.security.app-sandbox': false, 'com.apple.security.files.user-selected.read-write': true } }); + expect(e['com.apple.security.app-sandbox']).toBe(true); + expect(e['com.apple.application-identifier']).toBe('ABCDE12345.com.example.demo'); + expect(e['com.apple.developer.team-identifier']).toBe('ABCDE12345'); + expect(e['com.apple.security.files.user-selected.read-write']).toBe(true); + expect(e['com.apple.security.network.client']).toBe(true); + }); + test('xml round trip', () => { + const e = masEntitlements({ teamId: 'ABCDE12345', bundleId: 'com.example.demo' }); + expect(entitlementKeysOf(entitlementsXml(e))?.['com.apple.security.app-sandbox']).toBe(true); + expect(entitlementKeysOf('garbage')).toBeNull(); + }); +}); + +describe('identities and profiles', () => { + const ids = [ + { sha1: 'A', name: 'Apple Development: X (AAAAAAAAAA)' }, + { sha1: 'B', name: 'Apple Distribution: Co (ABCDE12345)' }, + { sha1: 'C', name: '3rd Party Mac Developer Installer: Co (ABCDE12345)' }, + { sha1: 'D', name: 'Apple Distribution: Other (ZZZZZZZZZZ)' }, + ]; + test('app and installer identity of the team', () => { + const p = pickMasIdentities(ids, 'ABCDE12345'); + expect(p.app?.sha1).toBe('B'); + expect(p.installer?.sha1).toBe('C'); + expect(pickMasIdentities(ids, 'QQQQQQQQQQ')).toEqual({ app: null, installer: null }); + expect(pickMasIdentities([{ sha1: 'E', name: '3rd Party Mac Developer Application: Co (ABCDE12345)' }, ids[1]!], 'ABCDE12345').app?.sha1).toBe('E'); + expect(pickMasIdentities([{ sha1: 'F', name: 'Mac Installer Distribution: Co (ABCDE12345)' }, ids[2]!], 'ABCDE12345').installer?.sha1).toBe('F'); + }); + const prof = (o: Record) => ({ path: '/p', profile: { UUID: 'u1', Name: 'n', TeamIdentifier: ['ABCDE12345'], Platform: ['OSX'], ExpirationDate: '2099-01-01T00:00:00Z', + Entitlements: { 'com.apple.application-identifier': 'ABCDE12345.com.example.demo' }, ...o } as never }); + test('Mac App Store profile: macOS, no devices, matching id, not expired', () => { + expect(pickMasProfiles([prof({})], 'com.example.demo', 'ABCDE12345')).toHaveLength(1); + expect(pickMasProfiles([prof({ Platform: ['iOS'] })], 'com.example.demo', null)).toHaveLength(0); + expect(pickMasProfiles([prof({ ProvisionedDevices: ['x'] })], 'com.example.demo', null)).toHaveLength(0); + expect(pickMasProfiles([prof({})], 'com.example.other', null)).toHaveLength(0); + expect(pickMasProfiles([prof({ ExpirationDate: '2000-01-01T00:00:00Z' })], 'com.example.demo', null)).toHaveLength(0); + }); +}); + +describe('path-free', () => { + test('remaps checkout, cargo, rustup, home; no debug info', () => { + const e = pathFreeEnv({ home: '/Users/dev', user: 'dev', root: '/Users/dev/work/app', rustflags: '-C target-cpu=native' }); + expect(e.RUSTFLAGS).toContain('-C target-cpu=native'); + expect(e.RUSTFLAGS).toContain('--remap-path-prefix=/Users/dev/work/app=/src'); + expect(e.RUSTFLAGS).toContain('--remap-path-prefix=/Users/dev/.cargo=/cargo'); + expect(e.RUSTFLAGS).toContain('--remap-path-prefix=/Users/dev/.rustup=/rustup'); + expect(e.RUSTFLAGS).toContain('--remap-path-prefix=/Users/dev=/home/build'); + expect(e.CARGO_PROFILE_RELEASE_DEBUG).toBe('0'); + + }); + test('leak scan finds the home folder in a binary and a plist', () => { + const app = mkdtempSync(join(tmp, 'app-')); + mkdirSync(join(app, 'Contents/MacOS'), { recursive: true }); + writeFileSync(join(app, 'Contents/MacOS/bin'), Buffer.concat([Buffer.from([0, 1, 2]), Buffer.from('panic at /Users/dev/.cargo/registry/x.rs'), Buffer.from([0])])); + writeFileSync(join(app, 'Contents/Info.plist'), 'clean'); + const leaks = scanPathLeaks(app, ['/Users/dev', '/Users/dev/']); + expect(leaks.map(l => `${l.file}:${l.needle}`)).toEqual(['Contents/MacOS/bin:/Users/dev', 'Contents/MacOS/bin:/Users/dev/']); + expect(scanPathLeaks(app, ['/Users/nobody'])).toEqual([]); + }); +}); + +describe('app and pkg checks', () => { + const good = { info: { CFBundleIdentifier: 'com.example.demo', CFBundleShortVersionString: '1.2.3', CFBundleVersion: '7', LSApplicationCategoryType: 'public.app-category.utilities', LSMinimumSystemVersion: '12.0', ITSAppUsesNonExemptEncryption: false }, + entitlements: masEntitlements({ teamId: 'ABCDE12345', bundleId: 'com.example.demo' }), hasProfile: true, leaks: [] }; + const o = { bundleId: 'com.example.demo', version: '1.2.3', build: '7', signed: true, pathFree: true }; + test('a good app passes', () => { expect(appProblems(good, o)).toEqual({ errors: [], warnings: [] }); }); + test('missing sandbox, category, min OS, profile, wrong build', () => { + const bad = { ...good, info: { ...good.info, CFBundleVersion: '6', LSApplicationCategoryType: undefined, LSMinimumSystemVersion: undefined } as never, entitlements: { 'com.apple.security.app-sandbox': false }, hasProfile: false }; + const e = appProblems(bad, o).errors.join('\n'); + for (const s of ['CFBundleVersion is 6', 'LSApplicationCategoryType', 'LSMinimumSystemVersion', 'app-sandbox is not true', 'application-identifier', 'embedded.provisionprofile']) expect(e).toContain(s); + expect(appProblems({ ...good, entitlements: null }, o).errors[0]).toContain('no entitlements'); + }); + test('get-task-allow refused; unsigned skips entitlement checks', () => { + expect(appProblems({ ...good, entitlements: { ...good.entitlements!, 'com.apple.security.get-task-allow': true } }, o).errors.join()).toContain('get-task-allow'); + expect(appProblems({ ...good, entitlements: null, hasProfile: false }, { ...o, signed: false }).errors).toEqual([]); + }); + test('path leaks are errors with --path-free, warnings without', () => { + const leaks = [{ file: 'Contents/MacOS/bin', needle: '/Users/dev', count: 3 }]; + expect(appProblems({ ...good, leaks }, o).errors[0]).toContain('local paths'); + const w = appProblems({ ...good, leaks }, { ...o, pathFree: false }); + expect(w.errors).toEqual([]); + expect(w.warnings.join()).toContain('--path-free'); + }); + test('pkgutil --check-signature', () => { + const ok = parsePkgSignature('Package "a.pkg":\n Status: signed by a certificate trusted by macOS\n Certificate Chain:\n 1. 3rd Party Mac Developer Installer: Co (ABCDE12345)\n Expires: 2027'); + expect(ok).toMatchObject({ signed: true, installerCert: true, certificate: '3rd Party Mac Developer Installer: Co (ABCDE12345)' }); + expect(pkgProblems(ok)).toEqual([]); + expect(pkgProblems(parsePkgSignature('Package "a.pkg":\n Status: no signature'))[0]).toContain('not signed'); + const dev = parsePkgSignature(' Status: signed by a certificate trusted by macOS\n 1. Developer ID Installer: Co (ABCDE12345)'); + expect(pkgProblems(dev)[0]).toContain('not a "3rd Party Mac Developer Installer"'); + }); + test('PackageInfo', () => { + const x = ''; + expect(parsePackageInfo(x)).toEqual({ bundleId: 'com.example.demo', version: '1.2.3', build: '7' }); + expect(parsePackageInfo('')).toBeNull(); + }); +}); + +describe('plan', () => { + const t = findTauri(fixture()); + const base = { tauri: t, tauriCmd: ['cargo', 'tauri'], target: 'universal-apple-darwin', out: '/o', configFile: '/o/c.json', entitlementsFile: '/o/e', profile: '/o/p', appIdentity: 'Apple Distribution: Co (ABCDE12345)', + installerIdentity: '3rd Party Mac Developer Installer: Co (ABCDE12345)', version: '1.2.3', build: '7', unsigned: false, pathFree: true, pkg: '/o/Demo App.pkg' }; + test('signed steps: tauri app bundle, productbuild with the installer identity', () => { + const s = masSteps(base); + expect(s.tauri).toEqual(['cargo', 'tauri', 'build', '--bundles', 'app', '--target', 'universal-apple-darwin', '--config', '/o/c.json']); + expect(s.productbuild.slice(0, 2)).toEqual(['productbuild', '--component']); + expect(s.productbuild).toContain('--sign'); + expect(s.productbuild.at(-1)).toBe('/o/Demo App.pkg'); + expect(s.pkgCheck).toEqual(['pkgutil', '--check-signature', '/o/Demo App.pkg']); + }); + test('unsigned: no --sign', () => { expect(masSteps({ ...base, unsigned: true }).productbuild).not.toContain('--sign'); }); + test('tauri overlay: identity, entitlements, embedded profile, bundleVersion', () => { + const o = tauriOverlay({ ...base, minOs: '12.0' }) as { version: string; bundle: { macOS: Record } }; + expect(o.version).toBe('1.2.3'); + expect(o.bundle.macOS.bundleVersion).toBe('7'); + expect(o.bundle.macOS.signingIdentity).toBe(base.appIdentity); + expect(o.bundle.macOS.hardenedRuntime).toBe(true); + expect(o.bundle.macOS.files).toEqual({ 'embedded.provisionprofile': '/o/p' }); + const u = tauriOverlay({ ...base, unsigned: true }) as { bundle: { macOS: Record } }; + expect(u.bundle.macOS.signingIdentity).toBeUndefined(); + }); +}); + +describe('build upload of a pkg', () => { + const info = { bundleId: 'com.example.demo', version: '1.2.3', build: '7' }; + test('platform MAC_OS and the pkg uti; iOS unchanged', () => { + const m = buildUploadReqs('app1', info, 'Demo.pkg', 10, 'MAC_OS'); + expect((m.upload.body.data.attributes as Record).platform).toBe('MAC_OS'); + expect((m.file('u1').body.data.attributes as Record).uti).toBe('com.apple.pkg'); + const i = buildUploadReqs('app1', info, 'Demo.ipa', 10); + expect((i.upload.body.data.attributes as Record).platform).toBe('IOS'); + expect((i.file('u1').body.data.attributes as Record).uti).toBe('com.apple.ipa'); + }); + test('altool gets --type macos for a pkg', () => { + const k = { keyPath: 'k', keyId: 'i', issuerId: 's' }; + expect(altoolArgs('/o/Demo.pkg', k)).toContain('macos'); + expect(altoolArgs('/o/Demo.ipa', k)).not.toContain('--type'); + }); +});