// 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; /** 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 = { 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 = { 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/Info.plist of an IPA. */ /** Every .ipa in the export folder and its per-build subfolders (-/), 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/Info.plist)'); const p = parsePlist(zipRead(ipa, e)) as Record; 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; } /** 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, 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; }