258 lines
20 KiB
TypeScript
258 lines
20 KiB
TypeScript
// Mac App Store build of a Tauri app: `tauri build --bundles app`, signed for the Mac App Store, then productbuild to a .pkg.
|
|
// storebound apple build --platform macos --tauri <src-tauri> [--path-free] [--target universal-apple-darwin]
|
|
// [--entitlements <plist>] [--allow-entitlements] [--version x.y.z] [--build-number N] [--profile <file.provisionprofile>] [--out build/macos] [--unsigned] [--dry] [--json]
|
|
// Steps: Tauri project -> version / build number (next free one on App Store Connect, every platform) -> signing
|
|
// identities from the keychain ("Apple Distribution" or "3rd Party Mac Developer Application"; "Mac Installer
|
|
// Distribution" or "3rd Party Mac Developer Installer") -> Mac App Store provisioning profile (--profile, installed, or
|
|
// 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: <out>/export/<version>-<build>/<Product>.pkg + <out>/latest.json; then `storebound apple upload <pkg> --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 { appNextBuild } from './next-build';
|
|
import { parseIdentities, profileDirs, readProfiles, type ApiProfile } from '../lib/signing';
|
|
import { sshBuildRefusal } from '../lib/keychain';
|
|
import { remoteBuildSession, showCmd } from '../lib/xcode';
|
|
import { prebuildFor, prebuildsDone, runPrebuild } from '../lib/web-assets';
|
|
import { preCheckCommand } from '../lib/pre-check';
|
|
import { pump } from '../lib/runner';
|
|
import {
|
|
appProblems, entitlementKeysOf, entitlementsSource, entitlementsXml, findBuiltApp, findTauri, leakNeedles, masEntitlementProblems, masEntitlements, type MasProfile, masSteps, pathFreeEnv, pickMasIdentities,
|
|
pickMasProfiles, pkgProblems, parsePkgSignature, readAppInfo, scanPathLeaks, tauriOverlay, categoryId,
|
|
} from '../lib/mas';
|
|
import { readdirSync } from 'node:fs';
|
|
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. */
|
|
/** One sh -c command in root, its output streamed; the exit code and the last lines. */
|
|
async function runCheck(cmd: string, root: string): Promise<{ ok: boolean; code: number; tail: string }> {
|
|
const p = rt.spawn(['sh', '-c', cmd], { cwd: root, stdin: 'ignore', stdout: 'pipe', stderr: 'pipe', env: process.env });
|
|
const tail: string[] = [];
|
|
const each = (line: string, kind: 'stdout' | 'stderr'): void => { tail.push(line); if (tail.length > 10) tail.shift(); (kind === 'stderr' ? console.error : console.log)(` ${line}`); };
|
|
await Promise.all([pump(p.stdout, 'stdout', each), pump(p.stderr, 'stderr', each)]);
|
|
const code = await p.exited;
|
|
return { ok: code === 0, code, tail: tail.join('\n') };
|
|
}
|
|
|
|
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<string, PlistValue> }[] = [];
|
|
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<string | null> {
|
|
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;
|
|
}
|
|
|
|
/** The dry-run line about signing identities: on a machine that is not a Mac, say so instead of "none found". */
|
|
export function identityLine(o: { platform: string; appIdentity: string | null; installerIdentity: string | null; profile: string | null }): string {
|
|
if (o.platform !== 'darwin') return `this is not a build machine: Mac App Store builds run on a Mac (the signing identities live in its keychain), so none were looked up here; profile: ${o.profile ?? '(none)'}`;
|
|
return `app identity: ${o.appIdentity ?? '(none found)'}; installer identity: ${o.installerIdentity ?? '(none found)'}; profile: ${o.profile ?? '(none)'}`;
|
|
}
|
|
|
|
export async function run(ctx: Ctx, platform: string = process.platform): Promise<void> {
|
|
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) {
|
|
// every platform of the app record, not only macOS: CFBundleVersion must be unique across all its builds
|
|
const found = await appNextBuild(client);
|
|
build = found.next;
|
|
log(TAG, `${found.builds} builds (all platforms) 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 ?? '<next build number>';
|
|
const team = teamId ?? '<TEAM_ID>';
|
|
|
|
// identities and profile (a real signed run needs them; a dry run shows placeholders)
|
|
const mac = 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 <file.provisionprofile>`);
|
|
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 ?? '<Mac Installer Distribution identity>', version, build: buildNo, unsigned, pathFree, pkg };
|
|
const steps = masSteps(input);
|
|
// the same hooks as apple build: build.prebuild / apple.prebuild first (a project computes its build-time env there),
|
|
// apple.macAppCheck on the built .app before productbuild (e.g. a project's own check that a value was stamped in)
|
|
const prebuild = prebuildFor(config, 'apple') ?? null;
|
|
const prebuildSkip = !prebuild ? undefined : a.has('no-prebuild') ? '--no-prebuild' : prebuildsDone().has(prebuild) ? 'already ran in this flow run' : undefined;
|
|
const appCheck = config.apple?.macAppCheck?.trim() || null;
|
|
// entitlements: --entitlements, apple.macos.entitlements, the merged Tauri config (the first one that is set)
|
|
const entFlag = a.get('entitlements'), entCfg = config.apple?.macos?.entitlements;
|
|
const entSrc = entitlementsSource({ flag: entFlag ? resolve(entFlag) : undefined, config: entCfg ? resolve(entCfg) : undefined, tauri: tauri.entitlements });
|
|
let existingEnt: Record<string, PlistValue> | undefined;
|
|
if (entSrc) {
|
|
if (existsSync(entSrc.file)) existingEnt = parsePlist(readFileSync(entSrc.file)) as Record<string, PlistValue>;
|
|
else if (entSrc.source !== 'tauri config bundle.macOS.entitlements') throw new Error(`${entSrc.source}: ${entSrc.file} does not exist`);
|
|
else warn(TAG, `${entSrc.source}: ${relative(root, entSrc.file)} does not exist, using the sandbox defaults only`);
|
|
}
|
|
const ent = masEntitlements({ teamId: team, bundleId, existing: existingEnt });
|
|
const entCheck = masEntitlementProblems(ent);
|
|
const allowEnt = a.has('allow-entitlements');
|
|
const plan = { platform: 'MAC_OS', tauri: relative(root, tauri.confPath), tauriOverlayFile: tauri.overlayPath ? relative(root, tauri.overlayPath) : null, target, version, build: buildNo, teamId: teamId, unsigned, pathFree, appIdentity, installerIdentity,
|
|
profile: profile ? basenameOf(profile) : null, entitlements: Object.keys(ent), entitlementsSource: entSrc ? { source: entSrc.source, file: relative(root, entSrc.file) } : null,
|
|
entitlementProblems: entCheck, tauriOverlay: overlay, env: Object.fromEntries(Object.entries(env).map(([k, v]) => [k, k === 'RUSTFLAGS' ? v.replaceAll(home, '~') : v])),
|
|
prebuild, ...(prebuildSkip ? { prebuildSkip } : {}), appCheck,
|
|
steps: [showCmd(steps.tauri, null), showCmd(steps.codesignVerify, null), showCmd(steps.productbuild, null), showCmd(steps.pkgCheck, null)], out: relative(root, out) || '.', pkg: relative(root, pkg) };
|
|
if (dry) {
|
|
if (json) { printJson(plan); return; }
|
|
log(TAG, `Tauri ${tauri.productName} (${bundleId}) ${version} (${buildNo}) for ${target}, ${unsigned ? 'UNSIGNED' : 'Mac App Store signing'}${pathFree ? ', path-free' : ''}`);
|
|
log(TAG, identityLine({ platform, appIdentity, installerIdentity, profile: plan.profile }));
|
|
if (tauri.overlayPath) log(TAG, `Tauri overlay ${plan.tauriOverlayFile}${tauri.overlayAuto ? ' (found next to tauri.conf.json)' : ''}: merged over tauri.conf.json, passed to tauri build --config`);
|
|
log(TAG, `entitlements from ${entSrc?.source ?? 'the sandbox defaults'}${entSrc ? ` (${plan.entitlementsSource!.file})` : ''}: ${plan.entitlements.join(', ')}`);
|
|
for (const e of entCheck.errors) warn(TAG, `entitlement ${e}${allowEnt ? '' : ' (the real build stops here; --allow-entitlements builds anyway)'}`);
|
|
for (const w of entCheck.warnings) warn(TAG, `entitlement ${w}`);
|
|
if (pathFree) log(TAG, `RUSTFLAGS: ${plan.env.RUSTFLAGS}`);
|
|
if (prebuild) log(TAG, prebuildSkip ? `prebuild skipped (${prebuildSkip}): ${prebuild}` : `would run the prebuild: ${prebuild}`);
|
|
for (const s of plan.steps) log(TAG, `would run: ${s}`);
|
|
if (appCheck) log(TAG, `would check the built .app: ${appCheck}`);
|
|
done(TAG, 'macOS build planned (dry run)');
|
|
return;
|
|
}
|
|
if (tauri.overlayPath) log(TAG, `Tauri overlay ${plan.tauriOverlayFile}${tauri.overlayAuto ? ' (found next to tauri.conf.json)' : ''}: merged over tauri.conf.json, passed to tauri build --config`);
|
|
log(TAG, `entitlements from ${entSrc?.source ?? 'the sandbox defaults'}${entSrc ? ` (${plan.entitlementsSource!.file})` : ''}: ${plan.entitlements.join(', ')}`);
|
|
for (const w of entCheck.warnings) warn(TAG, `entitlement ${w}`);
|
|
for (const e of entCheck.errors) if (allowEnt) warn(TAG, `entitlement ${e} (--allow-entitlements)`);
|
|
if (entCheck.errors.length && !allowEnt) throw new Error(`the entitlements (from ${entSrc?.source ?? 'the sandbox defaults'}) do not belong in a Mac App Store build:\n${entCheck.errors.map(x => ` ${x}`).join('\n')}\n Point the build at the App Store plist: --entitlements <plist>, apple.macos.entitlements, or a tauri.appstore.conf.json overlay next to tauri.conf.json (--allow-entitlements builds anyway).`);
|
|
if (!mac) throw new Error('apple build --platform macos needs macOS (tauri build, codesign, productbuild)');
|
|
const remote = remoteBuildSession(process.env, run1(['launchctl', 'managername'], 5000).out.trim());
|
|
if (remote && !unsigned && !a.has('allow-ssh')) throw new Error(`${sshBuildRefusal(remote, { unlockFlag: false })}
|
|
Or add --unsigned for a pipeline check.`);
|
|
mkdirSync(out, { recursive: true });
|
|
writeFileSync(entFile, entitlementsXml(ent));
|
|
writeFileSync(configFile, `${JSON.stringify(overlay, null, 2)}\n`);
|
|
if (prebuild && !prebuildSkip) await runPrebuild(prebuild, root, TAG);
|
|
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.dir, root, target, tauri.productName);
|
|
if (!appDir) throw new Error(`tauri build exited 0 but no ${tauri.productName}.app is under ${join(tauri.dir, '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, allowEntitlements: allowEnt });
|
|
for (const w of checks.warnings) warn(TAG, w);
|
|
if (checks.errors.length) throw new Error(`${basenameOf(appDir)} was built, but the Mac App Store would refuse it:\n${checks.errors.map(x => ` ${x}`).join('\n')}`);
|
|
if (appCheck) {
|
|
const cmd = preCheckCommand(appCheck, appDir);
|
|
log(TAG, `apple.macAppCheck: $ ${cmd}`);
|
|
const r = await runCheck(cmd, root);
|
|
if (!r.ok) throw new Error(`apple.macAppCheck failed (exit ${r.code}) on ${basenameOf(appDir)}; nothing packaged:\n${r.tail}`);
|
|
log(TAG, 'apple.macAppCheck: ok');
|
|
}
|
|
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 */ } } }
|