storebound/cli/lib/xcode.ts

244 lines
16 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;
}
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');
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 }
/** 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,
...authArgs(i.auth), 'archive', `CURRENT_PROJECT_VERSION=${i.build}`, ...(i.version ? [`MARKETING_VERSION=${i.version}`] : []),
...(i.teamId ? [`DEVELOPMENT_TEAM=${i.teamId}`] : [])];
}
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') };
}
/** 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) {
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' },
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' },
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, '--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;
}