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
This commit is contained in:
parent
2e8593ed5c
commit
9a937b5512
8 changed files with 726 additions and 22 deletions
201
cli/apple/build-mas.ts
Normal file
201
cli/apple/build-mas.ts
Normal file
|
|
@ -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 <src-tauri> [--path-free] [--target universal-apple-darwin]
|
||||
// [--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, 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: <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 { 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<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;
|
||||
}
|
||||
|
||||
export async function run(ctx: Ctx): 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) {
|
||||
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 ?? '<next build number>';
|
||||
const team = teamId ?? '<TEAM_ID>';
|
||||
|
||||
// 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 <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);
|
||||
const existingEnt = tauri.entitlements && existsSync(tauri.entitlements) ? (parsePlist(readFileSync(tauri.entitlements)) as Record<string, PlistValue>) : 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 <file>\`), 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 */ } } }
|
||||
|
|
@ -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<void> {
|
||||
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 ?? {};
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ function localKeys(root: string): Map<string, string> {
|
|||
/** SHA-1s of code signing identities in the keychain search list (macOS; empty elsewhere). Read-only. */
|
||||
function keychainIdentities(): Set<string> {
|
||||
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));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
|||
} finally { beat.stop(); }
|
||||
}
|
||||
|
||||
async function apiUpload(c: Asc, appId: string, ipa: string, info: ReturnType<typeof ipaInfo>, deadline: number, o: HeartbeatOptions = {}): Promise<void> {
|
||||
async function apiUpload(c: Asc, appId: string, ipa: string, info: ReturnType<typeof ipaInfo>, deadline: number, o: HeartbeatOptions = {}, platform: 'IOS' | 'MAC_OS' = 'IOS'): Promise<void> {
|
||||
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<J>({ 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<ty
|
|||
}
|
||||
|
||||
/** Build uploads of this version + build number (any state), newest first. */
|
||||
const uploadsOf = (c: Asc, appId: string, info: { version: string; build: string }): Promise<J[]> =>
|
||||
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<J[]> =>
|
||||
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<void> {
|
|||
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 <app.pkg> --platform macos (storebound apple build --platform macos --tauri <src-tauri> makes one)');
|
||||
if (!found[0]) throw new Error(`usage: storebound apple upload <app.ipa> (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 <src-tauri>), not an .ipa');
|
||||
const info: ReturnType<typeof ipaInfo> = 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<void> {
|
|||
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<void> {
|
|||
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('<upload id>')), `PUT the file parts to the upload URLs Apple returns`, describeReq(reqs.commit('<file id>', '<md5>')), 'GET /v1/buildUploads/<id> 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<void> {
|
|||
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; }
|
||||
}
|
||||
|
|
|
|||
299
cli/lib/mas.ts
Normal file
299
cli/lib/mas.ts
Normal file
|
|
@ -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 <src-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<string, unknown>;
|
||||
}
|
||||
|
||||
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 <folder with tauri.conf.json>` : 'no tauri.conf.json found: pass --tauri <folder with tauri.conf.json>');
|
||||
confPath = hits[0]!;
|
||||
}
|
||||
let conf: Record<string, any>;
|
||||
try { conf = JSON.parse(readFileSync(confPath, 'utf8')); } catch (e) { throw new Error(`${confPath}: not valid JSON (${(e as Error).message}); tauri.conf.json5 / Tauri.toml are not read, use tauri.conf.json`); }
|
||||
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<string, string> = { 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<string, PlistValue>; network?: boolean }): Record<string, PlistValue> {
|
||||
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, PlistValue>): string => toXmlPlist(e);
|
||||
|
||||
/** Keys of an entitlements plist (XML or what `codesign -d --entitlements :-` prints). */
|
||||
export function entitlementKeysOf(text: string): Record<string, PlistValue> | null {
|
||||
const start = text.indexOf('<?xml'); const i = start >= 0 ? start : text.indexOf('<plist');
|
||||
if (i < 0) return null;
|
||||
try { const v = parsePlist(text.slice(i)); return v && typeof v === 'object' && !Array.isArray(v) && !Buffer.isBuffer(v) ? v as Record<string, PlistValue> : 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<string, PlistValue> }[], 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<string, PlistValue>;
|
||||
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<string, string> {
|
||||
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<string>();
|
||||
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/<user> and the checkout. */
|
||||
export const leakNeedles = (home: string, user: string, root: string): string[] => [home, `/Users/${user}/`, `/home/${user}/`, root];
|
||||
|
||||
export interface AppFacts { info: Record<string, PlistValue>; entitlements: Record<string, PlistValue> | null; hasProfile: boolean; leaks: Leak[] }
|
||||
|
||||
/** Problems (stop) and warnings of a built .app before it goes into a .pkg. Pure. */
|
||||
export function appProblems(f: AppFacts, o: { bundleId: string; version: string; build: string; signed: boolean; pathFree: boolean }): { errors: string[]; warnings: string[] } {
|
||||
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 "<Mac Installer Distribution identity>"'];
|
||||
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 = /<bundle\b[^>]*>/.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<MasPlanInput, 'appIdentity' | 'entitlementsFile' | 'profile' | 'version' | 'build' | 'unsigned'> & { minOs?: string; category?: string }): Record<string, unknown> {
|
||||
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<string, PlistValue> }
|
||||
|
||||
/** The .app Tauri built: the first *.app in <cwd>/target/<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<string, PlistValue> {
|
||||
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<string, PlistValue>;
|
||||
}
|
||||
|
||||
/** bundle id, version and build of a .pkg: pkgutil --expand on a Mac (PackageInfo), else the <pkg>.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)`);
|
||||
}
|
||||
|
|
@ -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<Record<CertType, number>> = { DISTRIBUTION: 3, IOS_DISTRIBUTION: 3, DEVELOPER_ID_APPLICATION: 5, DEVELOPER_ID_APPLICATION_G2: 5 };
|
||||
export const CERT_LIMIT: Partial<Record<CertType, number>> = { 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)`,
|
||||
|
|
|
|||
|
|
@ -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[] } {
|
||||
|
|
|
|||
194
tests/mas-build.test.ts
Normal file
194
tests/mas-build.test.ts
Normal file
|
|
@ -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<string, unknown>) => ({ 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 = '<pkg-info identifier="com.example.demo" version="1.2.3"><bundle path="./Demo.app" id="com.example.demo" CFBundleShortVersionString="1.2.3" CFBundleVersion="7"/></pkg-info>';
|
||||
expect(parsePackageInfo(x)).toEqual({ bundleId: 'com.example.demo', version: '1.2.3', build: '7' });
|
||||
expect(parsePackageInfo('<pkg-info/>')).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<string, unknown> } };
|
||||
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<string, unknown> } };
|
||||
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<string, unknown>).platform).toBe('MAC_OS');
|
||||
expect((m.file('u1').body.data.attributes as Record<string, unknown>).uti).toBe('com.apple.pkg');
|
||||
const i = buildUploadReqs('app1', info, 'Demo.ipa', 10);
|
||||
expect((i.upload.body.data.attributes as Record<string, unknown>).platform).toBe('IOS');
|
||||
expect((i.file('u1').body.data.attributes as Record<string, unknown>).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');
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue