268 lines
18 KiB
TypeScript
268 lines
18 KiB
TypeScript
// Xcode builds without Xcode's UI: find the workspace / project and scheme (native, Capacitor, React Native, Flutter,
|
|
// Unity-exported projects), build the xcodebuild archive and export command lines, read an IPA's Info.plist.
|
|
// Pure except scanXcode (a bounded directory walk) and ipaInfo (reads the IPA).
|
|
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
import { basename, dirname, join, relative } from 'node:path';
|
|
import { parsePlist, type PlistValue } from './plist';
|
|
import { zipEntries, zipRead } from './zip';
|
|
|
|
export type Framework = 'flutter' | 'capacitor' | 'react-native' | 'unity' | 'native';
|
|
|
|
export interface AppleBuildConfig {
|
|
/** .xcworkspace (CocoaPods / Flutter / React Native) relative to the config; wins over `project`. */
|
|
workspace?: string;
|
|
/** .xcodeproj when there is no workspace. */
|
|
project?: string;
|
|
scheme?: string;
|
|
/** Default Release. */
|
|
configuration?: string;
|
|
/** Output folder for the archive, ExportOptions.plist, the IPA and logs. Default build/ios. */
|
|
out?: string;
|
|
/** xcodebuild -derivedDataPath. Default <out>/DerivedData when the output folder is chosen (--out / out), else Xcode's own folder under ~/Library. */
|
|
derivedData?: string;
|
|
/** Remap the home folder, checkout, output folder and DerivedData out of the binary and the debug symbols, then scan the archive (same as --path-free). */
|
|
pathFree?: boolean;
|
|
}
|
|
|
|
export function validateAppleBuild(b: AppleBuildConfig | undefined): string[] {
|
|
if (!b) return [];
|
|
const e: string[] = [];
|
|
if (b.workspace && !b.workspace.endsWith('.xcworkspace')) e.push('apple.build.workspace: a path ending in .xcworkspace');
|
|
if (b.project && !b.project.endsWith('.xcodeproj')) e.push('apple.build.project: a path ending in .xcodeproj');
|
|
if (b.scheme !== undefined && !b.scheme.trim()) e.push('apple.build.scheme: empty');
|
|
if (b.derivedData !== undefined && (typeof b.derivedData !== 'string' || !b.derivedData.trim())) e.push('apple.build.derivedData: a folder path');
|
|
if (b.pathFree !== undefined && typeof b.pathFree !== 'boolean') e.push('apple.build.pathFree: true or false');
|
|
return e;
|
|
}
|
|
|
|
/** What scanXcode found, relative to the project root. */
|
|
export interface XcodeScan {
|
|
/** Files that tell the framework: pubspec.yaml, capacitor.config.*, package.json with react-native, Unity-iPhone.xcodeproj. */
|
|
markers: string[];
|
|
workspaces: string[];
|
|
projects: string[];
|
|
/** Shared scheme names per container (xcshareddata/xcschemes). */
|
|
schemes: Record<string, string[]>;
|
|
/** Folders with a Podfile and whether Pods/ exists next to it. */
|
|
pods: { dir: string; installed: boolean }[];
|
|
}
|
|
|
|
const SKIP_DIRS = new Set(['node_modules', 'Pods', 'build', 'DerivedData', '.git', '.storebound', '.dart_tool', 'Library', 'Temp', '.gradle', 'dist', 'www']);
|
|
|
|
/** Walks at most `depth` folders deep (default 4) for Xcode containers. */
|
|
export function scanXcode(root: string, depth = 4): XcodeScan {
|
|
const s: XcodeScan = { markers: [], workspaces: [], projects: [], schemes: {}, pods: [] };
|
|
for (const m of ['pubspec.yaml', 'capacitor.config.ts', 'capacitor.config.json', 'capacitor.config.js']) if (existsSync(join(root, m))) s.markers.push(m);
|
|
try {
|
|
const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'));
|
|
if (pkg.dependencies?.['react-native'] || pkg.devDependencies?.['react-native']) s.markers.push('package.json:react-native');
|
|
} catch { /* no package.json */ }
|
|
const walk = (dir: string, d: number): void => {
|
|
let names: string[];
|
|
try { names = readdirSync(dir); } catch { return; }
|
|
if (names.includes('Podfile')) s.pods.push({ dir: relative(root, dir) || '.', installed: names.includes('Pods') });
|
|
for (const n of names) {
|
|
const p = join(dir, n);
|
|
if (n.endsWith('.xcworkspace')) { s.workspaces.push(relative(root, p)); continue; }
|
|
if (n.endsWith('.xcodeproj')) {
|
|
s.projects.push(relative(root, p));
|
|
if (n === 'Unity-iPhone.xcodeproj') s.markers.push('Unity-iPhone.xcodeproj');
|
|
const sd = join(p, 'xcshareddata', 'xcschemes');
|
|
try { s.schemes[relative(root, p)] = readdirSync(sd).filter(f => f.endsWith('.xcscheme')).map(f => f.slice(0, -9)); } catch { /* none shared */ }
|
|
continue;
|
|
}
|
|
if (d <= 0 || n.startsWith('.') || SKIP_DIRS.has(n)) continue;
|
|
try { if (statSync(p).isDirectory()) walk(p, d - 1); } catch { /* unreadable */ }
|
|
}
|
|
};
|
|
walk(root, depth);
|
|
return s;
|
|
}
|
|
|
|
export interface XcodeTarget { framework: Framework; workspace?: string; project?: string; scheme: string; notes: string[] }
|
|
|
|
const stem = (p: string): string => basename(p).replace(/\.(xcworkspace|xcodeproj)$/, '');
|
|
|
|
/** Picks the container and scheme. Config values win; else the framework's usual layout; else the only one found. */
|
|
export function pickXcode(scan: XcodeScan, cfg: AppleBuildConfig = {}): XcodeTarget {
|
|
const notes: string[] = [];
|
|
const has = (m: string) => scan.markers.includes(m);
|
|
const framework: Framework = has('pubspec.yaml') ? 'flutter' : scan.markers.some(m => m.startsWith('capacitor.config')) ? 'capacitor'
|
|
: has('package.json:react-native') ? 'react-native' : has('Unity-iPhone.xcodeproj') ? 'unity' : 'native';
|
|
let workspace = cfg.workspace, project = cfg.project;
|
|
if (!workspace && !project) {
|
|
const pref: Record<Framework, (string | RegExp)[]> = {
|
|
flutter: ['ios/Runner.xcworkspace', 'ios/Runner.xcodeproj'],
|
|
capacitor: ['ios/App/App.xcworkspace', 'ios/App/App.xcodeproj'],
|
|
'react-native': [/^ios\/[^/]+\.xcworkspace$/, /^ios\/[^/]+\.xcodeproj$/],
|
|
unity: [/(^|\/)Unity-iPhone\.xcworkspace$/, /(^|\/)Unity-iPhone\.xcodeproj$/],
|
|
native: [],
|
|
};
|
|
const all = [...scan.workspaces, ...scan.projects];
|
|
let hit = pref[framework].map(p => all.find(x => (typeof p === 'string' ? x === p : p.test(x)))).find(Boolean);
|
|
if (!hit) {
|
|
// a workspace next to a project (CocoaPods) beats the project; a lone container is taken as is
|
|
const ws = scan.workspaces.filter(w => !w.includes('.xcodeproj/'));
|
|
if (ws.length === 1) hit = ws[0];
|
|
else if (!ws.length && scan.projects.length === 1) hit = scan.projects[0];
|
|
else if (ws.length + scan.projects.length === 0) throw new Error('no .xcworkspace or .xcodeproj found (set apple.build.workspace or apple.build.project; Flutter: flutter create, Capacitor: npx cap add ios, Unity: Build Settings > iOS > Build)');
|
|
else throw new Error(`several Xcode containers (${[...ws, ...scan.projects].slice(0, 6).join(', ')}): set apple.build.workspace or apple.build.project`);
|
|
}
|
|
if (hit!.endsWith('.xcworkspace')) workspace = hit; else project = hit;
|
|
// CocoaPods: the workspace next to the project
|
|
if (project) { const w = project.replace(/\.xcodeproj$/, '.xcworkspace'); if (scan.workspaces.includes(w)) { workspace = w; project = undefined; } }
|
|
}
|
|
const container = (workspace ?? project)!;
|
|
const projOf = workspace ? scan.projects.find(p => dirname(p) === dirname(workspace!) && !/^Pods\//.test(basename(p))) : project;
|
|
const shared = projOf ? scan.schemes[projOf] ?? [] : [];
|
|
const usual: Record<Framework, string | null> = { flutter: 'Runner', capacitor: 'App', 'react-native': null, unity: 'Unity-iPhone', native: null };
|
|
const scheme = cfg.scheme ?? usual[framework] ?? (shared.includes(stem(projOf ?? container)) ? stem(projOf ?? container) : shared.length === 1 ? shared[0]! : stem(projOf ?? container));
|
|
if (!cfg.scheme && shared.length > 1 && !usual[framework]) notes.push(`several shared schemes (${shared.join(', ')}); using "${scheme}" (set apple.build.scheme to pick another)`);
|
|
const podDir = dirname(container);
|
|
const pod = scan.pods.find(p => p.dir === podDir || (p.dir === '.' && podDir === '.'));
|
|
if (pod && !pod.installed) notes.push(`Podfile without Pods/ in ${pod.dir}: run pod install there first${framework === 'capacitor' ? ' (or npx cap sync ios)' : ''}`);
|
|
if (framework === 'capacitor') notes.push('Capacitor: run npx cap sync ios after the web build so the archive has the current web assets');
|
|
if (framework === 'flutter') notes.push('Flutter: storebound runs flutter build ios --config-only first (build number and name, Generated.xcconfig)');
|
|
if (framework === 'unity') notes.push('Unity: build the Xcode project from Unity (Build Settings > iOS) before each archive; signing team can stay empty in Unity');
|
|
return { framework, ...(workspace ? { workspace } : { project }), scheme, notes };
|
|
}
|
|
|
|
export interface AscAuth { keyPath: string; keyId: string; issuerId: string }
|
|
|
|
const authArgs = (a: AscAuth | null): string[] => (a ? ['-allowProvisioningUpdates', '-authenticationKeyPath', a.keyPath, '-authenticationKeyID', a.keyId, '-authenticationKeyIssuerID', a.issuerId] : []);
|
|
|
|
export interface ArchiveInput { target: XcodeTarget; root: string; configuration: string; archivePath: string; version?: string; build: string; teamId?: string; auth: AscAuth | null; /** extra build settings, e.g. OTHER_CODE_SIGN_FLAGS=--keychain <path> */ settings?: string[]; /** xcodebuild -derivedDataPath */ derivedData?: string | null }
|
|
|
|
/** xcodebuild archive. Version / build go in as build settings (they beat the project's values). */
|
|
export function archiveArgs(i: ArchiveInput): string[] {
|
|
return ['xcodebuild', ...(i.target.workspace ? ['-workspace', join(i.root, i.target.workspace)] : ['-project', join(i.root, i.target.project!)]),
|
|
'-scheme', i.target.scheme, '-configuration', i.configuration, '-destination', 'generic/platform=iOS', '-archivePath', i.archivePath,
|
|
...(i.derivedData ? ['-derivedDataPath', i.derivedData] : []), ...authArgs(i.auth), 'archive', `CURRENT_PROJECT_VERSION=${i.build}`, ...(i.version ? [`MARKETING_VERSION=${i.version}`] : []),
|
|
...(i.teamId ? [`DEVELOPMENT_TEAM=${i.teamId}`] : []), ...(i.settings ?? [])];
|
|
}
|
|
|
|
export function exportArgs(archivePath: string, plist: string, exportPath: string, auth: AscAuth | null): string[] {
|
|
return ['xcodebuild', '-exportArchive', '-archivePath', archivePath, '-exportOptionsPlist', plist, '-exportPath', exportPath, ...authArgs(auth)];
|
|
}
|
|
|
|
/** flutter build ios --config-only: writes Generated.xcconfig with the build name / number, no build, no signing. */
|
|
export const flutterConfigArgs = (build: string, version?: string): string[] =>
|
|
['flutter', 'build', 'ios', '--release', '--config-only', '--no-codesign', '--build-number', build, ...(version ? ['--build-name', version] : [])];
|
|
|
|
/** A command line for logs: the API key path is shown as $ASC_P8. */
|
|
export const showCmd = (argv: string[], auth: AscAuth | null): string =>
|
|
argv.map(a => (auth && a === auth.keyPath ? '$ASC_P8' : /[\s"'$]/.test(a) ? JSON.stringify(a) : a)).join(' ');
|
|
|
|
export interface IpaInfo { bundleId: string; version: string; build: string; minOs?: string; name?: string; platform?: string; xcode?: string; sdkName?: string; platformVersion?: string }
|
|
|
|
/** Bundle id, CFBundleShortVersionString and CFBundleVersion from Payload/<App>.app/Info.plist of an IPA. */
|
|
/** Every .ipa in the export folder and its per-build subfolders (<version>-<build>/), newest first. */
|
|
export function exportedIpas(dir: string): string[] {
|
|
if (!existsSync(dir)) return [];
|
|
const out: string[] = [];
|
|
for (const f of readdirSync(dir)) {
|
|
const p = join(dir, f);
|
|
if (f.endsWith('.ipa')) out.push(p);
|
|
else if (statSync(p).isDirectory()) for (const g of readdirSync(p)) if (g.endsWith('.ipa')) out.push(join(p, g));
|
|
}
|
|
return out.sort((x, y) => statSync(y).mtimeMs - statSync(x).mtimeMs);
|
|
}
|
|
|
|
export function ipaInfo(ipa: string | Buffer): IpaInfo {
|
|
const e = zipEntries(ipa).find(x => /^Payload\/[^/]+\.app\/Info\.plist$/.test(x.name));
|
|
if (!e) throw new Error('not an iOS app archive (no Payload/<App>.app/Info.plist)');
|
|
const p = parsePlist(zipRead(ipa, e)) as Record<string, PlistValue>;
|
|
const str = (k: string): string | undefined => (typeof p[k] === 'string' ? p[k] as string : undefined);
|
|
const bundleId = str('CFBundleIdentifier'), version = str('CFBundleShortVersionString'), build = str('CFBundleVersion');
|
|
if (!bundleId || !version || !build) throw new Error('Info.plist without CFBundleIdentifier / CFBundleShortVersionString / CFBundleVersion');
|
|
return { bundleId, version, build, minOs: str('MinimumOSVersion'), name: str('CFBundleDisplayName') ?? str('CFBundleName'), platform: (p.CFBundleSupportedPlatforms as string[] | undefined)?.[0],
|
|
xcode: str('DTXcode'), sdkName: str('DTSDKName'), platformVersion: str('DTPlatformVersion') };
|
|
}
|
|
|
|
/** The refusal text when the IPA's CFBundleShortVersionString is not the version being prepared (an Xcode project
|
|
* that ignores the version storebound or the release script intends exports MARKETING_VERSION 1.0), else null.
|
|
* `wanted` is the --version of apple build or the editable App Store version; `source` names where it comes from. Pure. */
|
|
export function ipaVersionProblem(ipaVersion: string, wanted: string | null | undefined, source: string): string | null {
|
|
// Apple compares version strings numerically with missing parts as 0: 1.0 and 1.0.0 are the same train
|
|
const norm = (v: string) => v.trim().split('.').map(n => String(Number(n))).join('.').replace(/(\.0)+$/, '');
|
|
if (!wanted || norm(ipaVersion) === norm(wanted)) return null;
|
|
return `the IPA has version ${ipaVersion} (CFBundleShortVersionString), but ${source} is ${wanted}: the Xcode project sets MARKETING_VERSION itself, so the build lands in the wrong version train. Make the project read the intended version (xcodebuild MARKETING_VERSION=${wanted}, or set it in the project / Info.plist), build again, or pass --allow-version-mismatch to go on anyway.`;
|
|
}
|
|
|
|
/** End states of an App Store Connect build upload (BuildUploadState); processing is polled by apple testflight wait. */
|
|
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, 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 },
|
|
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: 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' } } } } } }),
|
|
};
|
|
}
|
|
|
|
/** altool upload (Xcode's command line uploader) with the API key. */
|
|
export const altoolArgs = (ipa: string, auth: AscAuth): string[] =>
|
|
['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[] } {
|
|
const f = (x: { code?: string; message?: string; description?: string }) => [x.code, x.message ?? x.description].filter(Boolean).join(': ');
|
|
return { errors: (state?.errors ?? []).map(f), warnings: (state?.warnings ?? []).map(f) };
|
|
}
|
|
|
|
/** Apple's own tool folders, first on PATH for xcodebuild. */
|
|
export const APPLE_PATH = ['/usr/bin', '/bin', '/usr/sbin', '/sbin'];
|
|
|
|
/**
|
|
* PATH for xcodebuild: Xcode's developer usr/bin (when known) and Apple's folders first. Xcode's IPA packaging
|
|
* (IDEDistributionCreateIPAStep) runs `rsync` from PATH and fails with "rsync error: syntax or usage error (code 1)",
|
|
* shown as "exportArchive Copy failed", when Homebrew's rsync 3.x comes first (pitfall 54).
|
|
*/
|
|
export function xcodePath(path: string | undefined, developerDir?: string): string {
|
|
const first = [...(developerDir ? [join(developerDir, 'usr', 'bin')] : []), ...APPLE_PATH];
|
|
const rest = (path ?? '').split(':').filter(p => p && !first.includes(p));
|
|
return [...first, ...rest].join(':');
|
|
}
|
|
|
|
/** The first rsync on PATH when it is not Apple's (/usr/bin, /bin): Homebrew, MacPorts, ...; else null. */
|
|
export function foreignRsync(path: string | undefined, exists: (p: string) => boolean = existsSync): string | null {
|
|
for (const d of (path ?? '').split(':').filter(Boolean)) {
|
|
const p = join(d, 'rsync');
|
|
if (exists(p)) return d === '/usr/bin' || d === '/bin' ? null : p;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* The real reason behind "exportArchive Copy failed": the error lines of the newest IDEDistribution*.log in the
|
|
* .xcdistributionlogs folder xcodebuild names in its output, else the newest such folder in the temp folder.
|
|
*/
|
|
export function distributionErrors(output: string, tmp: string = process.env.TMPDIR ?? '/tmp'): { log: string; lines: string[] } | null {
|
|
let dir = (output.match(/\/[^\s"']+\.xcdistributionlogs/g) ?? []).filter(p => existsSync(p)).at(-1);
|
|
if (!dir && existsSync(tmp)) {
|
|
dir = readdirSync(tmp).filter(f => f.endsWith('.xcdistributionlogs')).map(f => join(tmp, f))
|
|
.sort((a, b) => statSync(b).mtimeMs - statSync(a).mtimeMs)[0];
|
|
}
|
|
if (!dir) return null;
|
|
const d = dir;
|
|
const logs = readdirSync(d).filter(f => /^IDEDistribution.*\.log$/.test(f)).map(f => join(d, f)).sort((a, b) => statSync(b).mtimeMs - statSync(a).mtimeMs);
|
|
for (const l of logs) {
|
|
const lines = readFileSync(l, 'utf8').split('\n').filter(x => /rsync error|\berror:/i.test(x)).map(x => x.trim().slice(0, 300));
|
|
if (lines.length) return { log: l, lines: [...new Set(lines)].slice(-10) };
|
|
}
|
|
return logs[0] ? { log: logs[0], lines: [] } : null;
|
|
}
|
|
|
|
/** Why this is not the Mac's logged-in GUI session (SSH, or a launchd manager other than Aqua), else null: codesign
|
|
* cannot reach the login keychain there (errSecInternalComponent). */
|
|
export function remoteBuildSession(env: Record<string, string | undefined>, managerName: string): string | null {
|
|
if (env.SSH_CONNECTION || env.SSH_CLIENT || env.SSH_TTY) return 'this is an SSH session';
|
|
if (managerName && managerName !== 'Aqua') return `this is not the logged-in GUI session (launchctl manager: ${managerName})`;
|
|
return null;
|
|
}
|