398 lines
26 KiB
TypeScript
398 lines
26 KiB
TypeScript
// 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;
|
|
/** The overlay file merged over the base config (tauri.<name>.conf.json), absolute; passed to `tauri build --config` too. */
|
|
overlayPath?: string;
|
|
/** True when the overlay was found next to tauri.conf.json without being named (tauri.appstore.conf.json / tauri.mas.conf.json). */
|
|
overlayAuto?: boolean;
|
|
conf: Record<string, unknown>;
|
|
}
|
|
|
|
/** Overlay files that are picked up on their own for a Mac App Store build, in this order. */
|
|
export const MAS_OVERLAY_NAMES = ['tauri.appstore.conf.json', 'tauri.mas.conf.json'] as const;
|
|
|
|
/** JSON merge patch (RFC 7386, what Tauri's --config does): objects merge, null removes a key, anything else (arrays too) replaces. */
|
|
export function mergePatch(base: unknown, patch: unknown): unknown {
|
|
if (patch === null || typeof patch !== 'object' || Array.isArray(patch)) return patch;
|
|
const out: Record<string, unknown> = base !== null && typeof base === 'object' && !Array.isArray(base) ? { ...(base as Record<string, unknown>) } : {};
|
|
for (const [k, v] of Object.entries(patch as Record<string, unknown>)) {
|
|
if (v === null) delete out[k];
|
|
else out[k] = mergePatch(out[k], v);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/** tauri.<name>.conf.json (an overlay), not tauri.conf.json itself. */
|
|
export const isOverlayFile = (p: string): boolean => /(^|[\\/])tauri\.[^\\/]+\.conf\.json$/.test(p);
|
|
|
|
const SKIP = new Set(['node_modules', 'target', '.git', 'dist', 'build', '.storebound', 'Pods']);
|
|
|
|
/** The Tauri project: `hint` (a folder with tauri.conf.json, or a tauri.conf.json / tauri.conf.json5 / tauri.<name>.conf.json
|
|
* file) or the only tauri.conf.json found. An overlay file (tauri.<name>.conf.json) is merged over the base
|
|
* tauri.conf.json of its folder; tauri.appstore.conf.json / tauri.mas.conf.json next to the base config is picked up on its own. */
|
|
export function findTauri(root: string, hint?: string): TauriProject {
|
|
const names = ['tauri.conf.json', 'tauri.conf.json5'];
|
|
let confPath: string | undefined, overlayPath: string | undefined, overlayAuto = false;
|
|
const autoOverlay = (): void => { const o = MAS_OVERLAY_NAMES.map(n => join(dirname(confPath!), n)).find(existsSync); if (o) { overlayPath = o; overlayAuto = true; } };
|
|
if (hint && hint !== 'true') {
|
|
const p = join(root, hint);
|
|
if (isOverlayFile(p)) {
|
|
if (!existsSync(p)) throw new Error(`--tauri ${hint}: no such file`);
|
|
overlayPath = p;
|
|
confPath = names.map(n => join(dirname(p), n)).find(existsSync);
|
|
if (!confPath) throw new Error(`--tauri ${hint}: no tauri.conf.json next to this overlay (the base config it is merged over)`);
|
|
} else {
|
|
if (/tauri\.conf\.json5?$/.test(p) && existsSync(p)) confPath = p;
|
|
else confPath = names.map(n => join(p, n)).find(existsSync);
|
|
if (!confPath) throw new Error(`--tauri ${hint}: no tauri.conf.json in it`);
|
|
autoOverlay();
|
|
}
|
|
} else {
|
|
const hits: string[] = [];
|
|
const walk = (dir: string, d: number): void => {
|
|
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]!;
|
|
autoOverlay();
|
|
}
|
|
let conf: Record<string, any>;
|
|
try { conf = JSON.parse(readFileSync(confPath, 'utf8')); } catch (e) { throw new Error(`${confPath}: not valid JSON (${(e as Error).message}); tauri.conf.json5 / Tauri.toml are not read, use tauri.conf.json`); }
|
|
if (overlayPath) {
|
|
let patch: unknown;
|
|
try { patch = JSON.parse(readFileSync(overlayPath, 'utf8')); } catch (e) { throw new Error(`${overlayPath}: not valid JSON (${(e as Error).message})`); }
|
|
conf = mergePatch(conf, patch) as Record<string, any>;
|
|
}
|
|
const mac = conf.bundle?.macOS ?? {};
|
|
const productName = String(conf.productName ?? '');
|
|
if (!productName) throw new Error(`${confPath}: productName is missing`);
|
|
const dir = dirname(confPath);
|
|
return { dir, confPath, cwd: dirname(dir), productName, identifier: String(conf.identifier ?? ''), version: typeof conf.version === 'string' ? conf.version : undefined,
|
|
category: typeof conf.bundle?.category === 'string' ? conf.bundle.category : undefined, minOs: typeof mac.minimumSystemVersion === 'string' ? mac.minimumSystemVersion : undefined,
|
|
entitlements: typeof mac.entitlements === 'string' ? join(dir, mac.entitlements) : undefined, ...(overlayPath ? { overlayPath, overlayAuto } : {}), 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,
|
|
};
|
|
}
|
|
|
|
/** Where the entitlements of a Mac App Store build come from, first hit wins: `--entitlements <plist>`, the config's
|
|
* `apple.macos.entitlements`, the merged Tauri config's `bundle.macOS.entitlements`. Paths are already absolute. Pure. */
|
|
export function entitlementsSource(o: { flag?: string; config?: string; tauri?: string }): { file: string; source: string } | null {
|
|
if (o.flag) return { file: o.flag, source: '--entitlements' };
|
|
if (o.config) return { file: o.config, source: 'apple.macos.entitlements' };
|
|
if (o.tauri) return { file: o.tauri, source: 'tauri config bundle.macOS.entitlements' };
|
|
return null;
|
|
}
|
|
|
|
/** Entitlements that App Store Connect refuses in a Mac App Store build (a Developer ID / development leftover). */
|
|
export const MAS_REFUSED_KEYS: Record<string, string> = {
|
|
'com.apple.security.get-task-allow': 'a development entitlement, App Store Connect refuses it',
|
|
'com.apple.security.cs.disable-library-validation': 'a Developer ID hardened-runtime exception, App Store Connect refuses it',
|
|
};
|
|
|
|
/** Hardened-runtime exceptions and Developer ID habits that do not belong in a Mac App Store build. App Review may
|
|
* question them or the sandbox ignores them, but they are not an automatic upload refusal: a warning, not a stop. */
|
|
export const MAS_SUSPECT_KEYS: Record<string, string> = {
|
|
'com.apple.security.cs.allow-jit': 'hardened-runtime exception for Developer ID builds; App Review asks why a sandboxed app needs it',
|
|
'com.apple.security.cs.allow-unsigned-executable-memory': 'hardened-runtime exception for Developer ID builds; App Review asks why',
|
|
'com.apple.security.cs.disable-executable-page-protection': 'hardened-runtime exception for Developer ID builds',
|
|
'com.apple.security.cs.allow-dyld-environment-variables': 'hardened-runtime exception for Developer ID builds',
|
|
'com.apple.security.cs.debugger': 'hardened-runtime exception for Developer ID builds (a debugger cannot be sandboxed)',
|
|
'com.apple.security.automation.apple-events': 'needs NSAppleEventsUsageDescription in Info.plist and a reason in the review notes; usually a Developer ID leftover',
|
|
};
|
|
|
|
/** The entitlements of a Mac App Store build against the two lists above (a key set to false is fine). Pure. */
|
|
export function masEntitlementProblems(ent: Record<string, PlistValue> | null | undefined): { errors: string[]; warnings: string[] } {
|
|
const errors: string[] = [], warnings: string[] = [];
|
|
for (const [k, v] of Object.entries(ent ?? {})) {
|
|
if (v === false) continue;
|
|
if (MAS_REFUSED_KEYS[k]) errors.push(`${k} is set: ${MAS_REFUSED_KEYS[k]}`);
|
|
else if (MAS_SUSPECT_KEYS[k]) warnings.push(`${k} is set: ${MAS_SUSPECT_KEYS[k]}`);
|
|
}
|
|
return { errors, warnings };
|
|
}
|
|
|
|
export const entitlementsXml = (e: Record<string, PlistValue>): string => toXmlPlist(e);
|
|
|
|
/** Keys of an entitlements plist (XML or what `codesign -d --entitlements :-` prints). */
|
|
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, '/build-home'],
|
|
];
|
|
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',
|
|
// no CARGO_PROFILE_RELEASE_TRIM_PATHS: still nightly-only in cargo 1.98 ("feature trim-paths is required"); the remaps do the job
|
|
CARGO_PROFILE_RELEASE_STRIP: 'symbols',
|
|
};
|
|
}
|
|
|
|
export interface Leak { file: string; needle: string; count: number }
|
|
|
|
const MAX_SCAN = 400 * 1048576;
|
|
|
|
/** Every file under `dir` that contains one of the needles (home folder, user name as /name/, checkout path). Reads each
|
|
* file whole (an .app is some tens of MB). */
|
|
export function scanPathLeaks(dir: string, needles: string[]): Leak[] {
|
|
const ns = [...new Set(needles.filter(n => n.length >= 4))].map(n => ({ n, b: Buffer.from(n) }));
|
|
const out: Leak[] = [];
|
|
const walk = (d: string): void => {
|
|
let names: string[]; try { names = readdirSync(d); } catch { return; }
|
|
for (const name of names) {
|
|
const p = join(d, name);
|
|
let st; try { st = statSync(p); } catch { continue; }
|
|
if (st.isDirectory()) { walk(p); continue; }
|
|
if (!st.isFile() || st.size > MAX_SCAN) continue;
|
|
const buf = readFileSync(p);
|
|
for (const { n, b } of ns) {
|
|
let count = 0; for (let at = buf.indexOf(b); at >= 0; at = buf.indexOf(b, at + b.length)) count++;
|
|
if (count) out.push({ file: relative(dir, p).split(sep).join('/'), needle: n, count });
|
|
}
|
|
}
|
|
};
|
|
walk(dir);
|
|
return out;
|
|
}
|
|
|
|
/** The needles for a leak scan: the home folder, /Users/<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; allowEntitlements?: boolean }): { errors: string[]; warnings: string[] } {
|
|
const errors: string[] = [], warnings: string[] = [];
|
|
const s = (k: string): string | undefined => (typeof f.info[k] === 'string' ? f.info[k] as string : undefined);
|
|
if (s('CFBundleIdentifier') !== o.bundleId) errors.push(`CFBundleIdentifier is ${s('CFBundleIdentifier') ?? 'missing'}, not ${o.bundleId}`);
|
|
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');
|
|
const ep = masEntitlementProblems(f.entitlements);
|
|
(o.allowEntitlements ? warnings : errors).push(...ep.errors);
|
|
warnings.push(...ep.warnings);
|
|
}
|
|
if (!f.hasProfile) errors.push('Contents/embedded.provisionprofile is missing: a Mac App Store app needs its provisioning profile inside (Tauri bundle.macOS.files)');
|
|
}
|
|
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.dir, 'target', i.target, 'release', 'bundle', 'macos', `${i.tauri.productName}.app`);
|
|
return {
|
|
tauri: [...i.tauriCmd, 'build', '--bundles', 'app', '--target', i.target, ...(i.tauri.overlayPath ? ['--config', i.tauri.overlayPath] : []), '--config', i.configFile],
|
|
codesignVerify: ['codesign', '--verify', '--deep', '--strict', '--verbose=2', appPath],
|
|
productbuild: ['productbuild', '--component', appPath, '/Applications', ...(i.unsigned || !i.installerIdentity ? [] : ['--sign', i.installerIdentity]), i.pkg],
|
|
pkgCheck: ['pkgutil', '--check-signature', i.pkg],
|
|
};
|
|
}
|
|
|
|
/** 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>;
|
|
}
|
|
|
|
/** What bounds and reports the .pkg reading (tests inject a fake spawnSync and short limits). */
|
|
export interface PkgInfoOptions { say?: (msg: string) => void; spawnSync?: typeof rt.spawnSync; checkMs?: number; expandMs?: number }
|
|
/** pkgutil --check-signature evaluates the certificate chain (revocation lookups over the network): never unbounded. */
|
|
export const PKG_CHECK_MS = 90_000;
|
|
/** pkgutil --expand copies the whole archive to a temp folder: minutes for a big app, never unbounded. */
|
|
export const PKG_EXPAND_MS = 5 * 60_000;
|
|
|
|
/** bundle id, version and build of a .pkg: pkgutil --expand on a Mac (PackageInfo), else the <pkg>.json next to it that
|
|
* `apple build --platform macos` wrote. Refuses an unsigned or wrongly signed .pkg when pkgutil is there. Both pkgutil
|
|
* calls have a timeout (no stdin, so no prompt) and say what they do first; one that times out falls back to the
|
|
* .json file, else it fails with the reason (a silent hang over SSH, 10 min, was this read). */
|
|
export function pkgInfo(pkg: string, expectBundleId?: string, o: PkgInfoOptions = {}): { bundleId: string; version: string; build: string } {
|
|
const say = o.say ?? (() => {});
|
|
const spawnSync = o.spawnSync ?? rt.spawnSync;
|
|
const side = `${pkg}.json`;
|
|
let timedOut = '';
|
|
if (rt.which('pkgutil') || o.spawnSync) {
|
|
const checkMs = o.checkMs ?? PKG_CHECK_MS, expandMs = o.expandMs ?? PKG_EXPAND_MS;
|
|
say(`checking the signature of ${basename(pkg)} (pkgutil --check-signature, up to ${Math.round(checkMs / 1000)} s)`);
|
|
const chk = spawnSync(['pkgutil', '--check-signature', pkg], { stdout: 'pipe', stderr: 'pipe', timeout: checkMs });
|
|
if (chk.exitCode === null && chk.signalCode) timedOut = `pkgutil --check-signature gave no answer in ${Math.round(checkMs / 1000)} s (it checks the certificate chain over the network)`;
|
|
else {
|
|
const bad = pkgProblems(parsePkgSignature(chk.stdout.toString()));
|
|
if (bad.length) throw new Error(`${basename(pkg)}: not uploaded:\n${bad.map(x => ` ${x}`).join('\n')}`);
|
|
const tmp = join(process.env.TMPDIR ?? '/tmp', `sb-pkg-${process.pid}-${Date.now()}`);
|
|
say(`reading bundle id, version and build (pkgutil --expand, up to ${Math.round(expandMs / 1000)} s; a big package takes a while)`);
|
|
try {
|
|
const ex = spawnSync(['pkgutil', '--expand', pkg, tmp], { stdout: 'pipe', stderr: 'pipe', timeout: expandMs });
|
|
if (ex.exitCode === null && ex.signalCode) timedOut = `pkgutil --expand gave no answer in ${Math.round(expandMs / 1000)} s`;
|
|
else if (ex.exitCode === 0) {
|
|
for (const d of readdirSync(tmp)) {
|
|
const p = join(tmp, d, 'PackageInfo');
|
|
const info = existsSync(p) ? parsePackageInfo(readFileSync(p, 'utf8')) : null;
|
|
if (info && (!expectBundleId || info.bundleId === expectBundleId)) return info;
|
|
}
|
|
}
|
|
} finally { try { rmSync(tmp, { recursive: true, force: true }); } catch { /* temp */ } }
|
|
}
|
|
}
|
|
if (timedOut) say(`${timedOut}: using ${basename(side)} (written by apple build) instead`);
|
|
if (existsSync(side)) {
|
|
const j = JSON.parse(readFileSync(side, 'utf8')) as { bundleId?: string; version?: string; build?: string; unsigned?: boolean };
|
|
if (j.unsigned) throw new Error(`${basename(pkg)} was built with --unsigned: the Mac App Store refuses it`);
|
|
if (j.bundleId && j.version && j.build) return { bundleId: j.bundleId, version: j.version, build: j.build };
|
|
}
|
|
if (timedOut) throw new Error(`${basename(pkg)}: ${timedOut}, and there is no ${basename(side)} file from apple build to read instead: run it in the Mac's logged-in GUI session, or check the Mac's network`);
|
|
throw new Error(`${basename(pkg)}: cannot read bundle id / version / build (needs pkgutil on a Mac, or the ${basename(side)} file apple build wrote)`);
|
|
}
|