1import fs from 'fs-extra'; 2import os from 'os'; 3import path from 'path'; 4 5import { formatXcodeBuildOutput } from '../Formatter'; 6import { spawnAsync } from '../Utils'; 7import { generateXcodeProjectAsync } from './XcodeGen'; 8import { ProjectSpec } from './XcodeGen.types'; 9import { Flavor, Framework, XcodebuildSettings } from './XcodeProject.types'; 10 11/** 12 * Path to the shared derived data directory. 13 */ 14const SHARED_DERIVED_DATA_DIR = path.join(os.tmpdir(), 'Expo/DerivedData'); 15 16/** 17 * Path to the products in derived data directory. We pick `.framework` files from there. 18 */ 19const PRODUCTS_DIR = path.join(SHARED_DERIVED_DATA_DIR, 'Build/Products'); 20 21/** 22 * A class representing single Xcode project and operating on its `.xcodeproj` file. 23 */ 24export default class XcodeProject { 25 /** 26 * Creates `XcodeProject` instance from given path to `.xcodeproj` file. 27 */ 28 static async fromXcodeprojPathAsync(xcodeprojPath: string): Promise<XcodeProject> { 29 if (!(await fs.pathExists(xcodeprojPath))) { 30 throw new Error(`Xcodeproj not found at path: ${xcodeprojPath}`); 31 } 32 return new XcodeProject(xcodeprojPath); 33 } 34 35 /** 36 * Generates `.xcodeproj` file based on given spec and returns it. 37 */ 38 static async generateProjectFromSpec(dir: string, spec: ProjectSpec): Promise<XcodeProject> { 39 const xcodeprojPath = await generateXcodeProjectAsync(dir, spec); 40 return new XcodeProject(xcodeprojPath); 41 } 42 43 /** 44 * Name of the project. It should stay in sync with its filename. 45 */ 46 name: string; 47 48 /** 49 * Root directory of the project and at which the `.xcodeproj` file is placed. 50 */ 51 rootDir: string; 52 53 constructor(xcodeprojPath: string) { 54 this.name = path.basename(xcodeprojPath, '.xcodeproj'); 55 this.rootDir = path.dirname(xcodeprojPath); 56 } 57 58 /** 59 * Returns output path to where the `.xcframework` file will be stored after running `buildXcframeworkAsync`. 60 */ 61 getXcframeworkPath(): string { 62 return path.join(this.rootDir, `${this.name}.xcframework`); 63 } 64 65 /** 66 * Builds `.framework` for given target name and flavor specifying, 67 * configuration, the SDK and a list of architectures to compile against. 68 */ 69 async buildFrameworkAsync( 70 target: string, 71 flavor: Flavor, 72 options?: XcodebuildSettings 73 ): Promise<Framework> { 74 await this.xcodebuildAsync( 75 [ 76 'build', 77 '-project', 78 `${this.name}.xcodeproj`, 79 '-scheme', 80 `${target}_iOS`, 81 '-configuration', 82 flavor.configuration, 83 '-sdk', 84 flavor.sdk, 85 ...spreadArgs('-arch', flavor.archs), 86 '-derivedDataPath', 87 SHARED_DERIVED_DATA_DIR, 88 ], 89 options 90 ); 91 92 const frameworkPath = flavorToFrameworkPath(target, flavor); 93 const stat = await fs.lstat(path.join(frameworkPath, target)); 94 95 // Remove `Headers` as each our module contains headers as part of the provided source code 96 // and CocoaPods exposes them through HEADER_SEARCH_PATHS either way. 97 await fs.remove(path.join(frameworkPath, 'Headers')); 98 99 // `_CodeSignature` is apparently generated only for simulator, afaik we don't need it. 100 await fs.remove(path.join(frameworkPath, '_CodeSignature')); 101 102 return { 103 target, 104 flavor, 105 frameworkPath, 106 binarySize: stat.size, 107 }; 108 } 109 110 /** 111 * Builds universal `.xcframework` from given frameworks. 112 */ 113 async buildXcframeworkAsync( 114 frameworks: Framework[], 115 options?: XcodebuildSettings 116 ): Promise<string> { 117 const frameworkPaths = frameworks.map((framework) => framework.frameworkPath); 118 const outputPath = this.getXcframeworkPath(); 119 120 await fs.remove(outputPath); 121 122 await this.xcodebuildAsync( 123 ['-create-xcframework', ...spreadArgs('-framework', frameworkPaths), '-output', outputPath], 124 options 125 ); 126 return outputPath; 127 } 128 129 /** 130 * Removes `.xcframework` artifact produced by `buildXcframeworkAsync`. 131 */ 132 async cleanXcframeworkAsync(): Promise<void> { 133 await fs.remove(this.getXcframeworkPath()); 134 } 135 136 /** 137 * Generic function spawning `xcodebuild` process. 138 */ 139 async xcodebuildAsync(args: string[], settings?: XcodebuildSettings) { 140 // `xcodebuild` writes error details to stdout but we don't want to pollute our output if nothing wrong happens. 141 // Spawn it quietly, pipe stderr to stdout and pass it to the current process stdout only when it fails. 142 const finalArgs = ['-quiet', ...args, '2>&1']; 143 144 if (settings) { 145 finalArgs.unshift( 146 ...Object.entries(settings).map(([key, value]) => { 147 return `${key}=${parseXcodeSettingsValue(value)}`; 148 }) 149 ); 150 } 151 try { 152 await spawnAsync('xcodebuild', finalArgs, { 153 cwd: this.rootDir, 154 shell: true, 155 stdio: ['ignore', 'pipe', 'inherit'], 156 }); 157 } catch (e) { 158 // Print formatted Xcode logs (merged from stdout and stderr). 159 process.stdout.write(formatXcodeBuildOutput(e.stdout)); 160 throw e; 161 } 162 } 163 164 /** 165 * Cleans shared derived data directory. 166 */ 167 static async cleanBuildFolderAsync(): Promise<void> { 168 await fs.remove(SHARED_DERIVED_DATA_DIR); 169 } 170} 171 172/** 173 * Returns a path to the prebuilt framework for given flavor. 174 */ 175function flavorToFrameworkPath(target: string, flavor: Flavor): string { 176 return path.join(PRODUCTS_DIR, `${flavor.configuration}-${flavor.sdk}`, `${target}.framework`); 177} 178 179/** 180 * Spreads given args under specific flag. 181 * Example: `spreadArgs('-arch', ['arm64', 'x86_64'])` returns `['-arch', 'arm64', '-arch', 'x86_64']` 182 */ 183function spreadArgs(argName: string, args: string[]): string[] { 184 return ([] as string[]).concat(...args.map((arg) => [argName, arg])); 185} 186 187/** 188 * Converts boolean values to its Xcode build settings format. Value of other type just passes through. 189 */ 190function parseXcodeSettingsValue(value: string | boolean): string { 191 if (typeof value === 'boolean') { 192 return value ? 'YES' : 'NO'; 193 } 194 return value; 195} 196