1import { ExpoRunFormatter } from '@expo/xcpretty'; 2import chalk from 'chalk'; 3import { spawn, SpawnOptionsWithoutStdio } from 'child_process'; 4import fs from 'fs'; 5import os from 'os'; 6import path from 'path'; 7 8import * as Log from '../../log'; 9import { ensureDirectory } from '../../utils/dir'; 10import { env } from '../../utils/env'; 11import { AbortCommandError, CommandError } from '../../utils/errors'; 12import { getUserTerminal } from '../../utils/terminal'; 13import { BuildProps, ProjectInfo } from './XcodeBuild.types'; 14import { ensureDeviceIsCodeSignedForDeploymentAsync } from './codeSigning/configureCodeSigning'; 15import { simulatorBuildRequiresCodeSigning } from './codeSigning/simulatorCodeSigning'; 16export function logPrettyItem(message: string) { 17 Log.log(chalk`{whiteBright \u203A} ${message}`); 18} 19 20/** 21 * 22 * @returns '/Users/evanbacon/Library/Developer/Xcode/DerivedData/myapp-gpgjqjodrxtervaufttwnsgimhrx/Build/Products/Debug-iphonesimulator/myapp.app' 23 */ 24export function getAppBinaryPath(buildOutput: string) { 25 // Matches what's used in "Bundle React Native code and images" script. 26 // Requires that `-hideShellScriptEnvironment` is not included in the build command (extra logs). 27 28 // Like `\=/Users/evanbacon/Library/Developer/Xcode/DerivedData/Exponent-anpuosnglkxokahjhfszejloqfvo/Build/Products/Debug-iphonesimulator` 29 const CONFIGURATION_BUILD_DIR = extractEnvVariableFromBuild( 30 buildOutput, 31 'CONFIGURATION_BUILD_DIR' 32 ).sort( 33 // Longer name means more suffixes, we want the shortest possible one to be first. 34 // Massive projects (like Expo Go) can sometimes print multiple different sets of environment variables. 35 // This can become an issue with some 36 (a, b) => a.length - b.length 37 ); 38 // Like `Exponent.app` 39 const UNLOCALIZED_RESOURCES_FOLDER_PATH = extractEnvVariableFromBuild( 40 buildOutput, 41 'UNLOCALIZED_RESOURCES_FOLDER_PATH' 42 ); 43 44 const binaryPath = path.join( 45 // Use the shortest defined env variable (usually there's just one). 46 CONFIGURATION_BUILD_DIR[0], 47 // Use the last defined env variable. 48 UNLOCALIZED_RESOURCES_FOLDER_PATH[UNLOCALIZED_RESOURCES_FOLDER_PATH.length - 1] 49 ); 50 51 // If the app has a space in the name it'll fail because it isn't escaped properly by Xcode. 52 return getEscapedPath(binaryPath); 53} 54 55export function getEscapedPath(filePath: string): string { 56 if (fs.existsSync(filePath)) { 57 return filePath; 58 } 59 const unescapedPath = filePath.split(/\\ /).join(' '); 60 if (fs.existsSync(unescapedPath)) { 61 return unescapedPath; 62 } 63 throw new CommandError( 64 'XCODE_BUILD', 65 `Unexpected: Generated app at path "${filePath}" cannot be read, the app cannot be installed. Please report this and build onto a simulator.` 66 ); 67} 68 69export function extractEnvVariableFromBuild(buildOutput: string, variableName: string) { 70 // Xcode can sometimes escape `=` with a backslash or put the value in quotes 71 const reg = new RegExp(`export ${variableName}\\\\?=(.*)$`, 'mg'); 72 const matched = [...buildOutput.matchAll(reg)]; 73 74 if (!matched || !matched.length) { 75 throw new CommandError( 76 'XCODE_BUILD', 77 `Malformed xcodebuild results: "${variableName}" variable was not generated in build output. Please report this issue and run your project with Xcode instead.` 78 ); 79 } 80 return matched.map((value) => value[1]).filter(Boolean) as string[]; 81} 82 83export function getProcessOptions({ 84 packager, 85 shouldSkipInitialBundling, 86 terminal, 87 port, 88}: { 89 packager: boolean; 90 shouldSkipInitialBundling?: boolean; 91 terminal: string | undefined; 92 port: number; 93}): SpawnOptionsWithoutStdio { 94 const SKIP_BUNDLING = shouldSkipInitialBundling ? '1' : undefined; 95 if (packager) { 96 return { 97 env: { 98 ...process.env, 99 RCT_TERMINAL: terminal, 100 SKIP_BUNDLING, 101 RCT_METRO_PORT: port.toString(), 102 }, 103 }; 104 } 105 106 return { 107 env: { 108 ...process.env, 109 RCT_TERMINAL: terminal, 110 SKIP_BUNDLING, 111 // Always skip launching the packager from a build script. 112 // The script is used for people building their project directly from Xcode. 113 // This essentially means "› Running script 'Start Packager'" does nothing. 114 RCT_NO_LAUNCH_PACKAGER: 'true', 115 // FORCE_BUNDLING: '0' 116 }, 117 }; 118} 119 120export async function getXcodeBuildArgsAsync( 121 props: Pick< 122 BuildProps, 123 | 'buildCache' 124 | 'projectRoot' 125 | 'xcodeProject' 126 | 'configuration' 127 | 'scheme' 128 | 'device' 129 | 'isSimulator' 130 > 131): Promise<string[]> { 132 const args = [ 133 props.xcodeProject.isWorkspace ? '-workspace' : '-project', 134 props.xcodeProject.name, 135 '-configuration', 136 props.configuration, 137 '-scheme', 138 props.scheme, 139 '-destination', 140 `id=${props.device.udid}`, 141 ]; 142 143 if (!props.isSimulator || simulatorBuildRequiresCodeSigning(props.projectRoot)) { 144 const developmentTeamId = await ensureDeviceIsCodeSignedForDeploymentAsync(props.projectRoot); 145 if (developmentTeamId) { 146 args.push( 147 `DEVELOPMENT_TEAM=${developmentTeamId}`, 148 '-allowProvisioningUpdates', 149 '-allowProvisioningDeviceRegistration' 150 ); 151 } 152 } 153 154 // Add last 155 if (props.buildCache === false) { 156 args.push( 157 // Will first clean the derived data folder. 158 'clean', 159 // Then build step must be added otherwise the process will simply clean and exit. 160 'build' 161 ); 162 } 163 return args; 164} 165 166function spawnXcodeBuild( 167 args: string[], 168 options: SpawnOptionsWithoutStdio, 169 { onData }: { onData: (data: string) => void } 170): Promise<{ code: number | null; results: string; error: string }> { 171 const buildProcess = spawn('xcodebuild', args, options); 172 173 let results = ''; 174 let error = ''; 175 176 buildProcess.stdout.on('data', (data: Buffer) => { 177 const stringData = data.toString(); 178 results += stringData; 179 onData(stringData); 180 }); 181 182 buildProcess.stderr.on('data', (data: Buffer) => { 183 const stringData = data instanceof Buffer ? data.toString() : data; 184 error += stringData; 185 }); 186 187 return new Promise(async (resolve, reject) => { 188 buildProcess.on('close', (code: number) => { 189 resolve({ code, results, error }); 190 }); 191 }); 192} 193 194async function spawnXcodeBuildWithFlush( 195 args: string[], 196 options: SpawnOptionsWithoutStdio, 197 { onFlush }: { onFlush: (data: string) => void } 198): Promise<{ code: number | null; results: string; error: string }> { 199 let currentBuffer = ''; 200 201 // Data can be sent in chunks that would have no relevance to our regex 202 // this can cause massive slowdowns, so we need to ensure the data is complete before attempting to parse it. 203 function flushBuffer() { 204 if (!currentBuffer) { 205 return; 206 } 207 208 const data = currentBuffer; 209 // Reset buffer. 210 currentBuffer = ''; 211 // Process data. 212 onFlush(data); 213 } 214 215 const data = await spawnXcodeBuild(args, options, { 216 onData(stringData) { 217 currentBuffer += stringData; 218 // Only flush the data if we have a full line. 219 if (currentBuffer.endsWith(os.EOL)) { 220 flushBuffer(); 221 } 222 }, 223 }); 224 225 // Flush log data at the end just in case we missed something. 226 flushBuffer(); 227 return data; 228} 229 230async function spawnXcodeBuildWithFormat( 231 args: string[], 232 options: SpawnOptionsWithoutStdio, 233 { projectRoot, xcodeProject }: { projectRoot: string; xcodeProject: ProjectInfo } 234): Promise<{ code: number | null; results: string; error: string; formatter: ExpoRunFormatter }> { 235 Log.debug(` xcodebuild ${args.join(' ')}`); 236 237 logPrettyItem(chalk.bold`Planning build`); 238 239 const formatter = ExpoRunFormatter.create(projectRoot, { 240 xcodeProject, 241 isDebug: env.EXPO_DEBUG, 242 }); 243 244 const results = await spawnXcodeBuildWithFlush(args, options, { 245 onFlush(data) { 246 // Process data. 247 for (const line of formatter.pipe(data)) { 248 // Log parsed results. 249 Log.log(line); 250 } 251 }, 252 }); 253 254 Log.debug(`Exited with code: ${results.code}`); 255 256 if ( 257 // User cancelled with ctrl-c 258 results.code === null || 259 // Build interrupted 260 results.code === 75 261 ) { 262 throw new AbortCommandError(); 263 } 264 265 Log.log(formatter.getBuildSummary()); 266 267 return { ...results, formatter }; 268} 269 270export async function buildAsync(props: BuildProps): Promise<string> { 271 const args = await getXcodeBuildArgsAsync(props); 272 273 const { projectRoot, xcodeProject, shouldSkipInitialBundling, port } = props; 274 275 const { code, results, formatter, error } = await spawnXcodeBuildWithFormat( 276 args, 277 getProcessOptions({ 278 packager: false, 279 terminal: getUserTerminal(), 280 shouldSkipInitialBundling, 281 port, 282 }), 283 { 284 projectRoot, 285 xcodeProject, 286 } 287 ); 288 289 const logFilePath = writeBuildLogs(projectRoot, results, error); 290 291 if (code !== 0) { 292 // Determine if the logger found any errors; 293 const wasErrorPresented = !!formatter.errors.length; 294 295 if (wasErrorPresented) { 296 // This has a flaw, if the user is missing a file, and there is a script error, only the missing file error will be shown. 297 // They will only see the script error if they fix the missing file and rerun. 298 // The flaw can be fixed by catching script errors in the custom logger. 299 throw new CommandError( 300 `Failed to build iOS project. "xcodebuild" exited with error code ${code}.` 301 ); 302 } 303 304 _assertXcodeBuildResults(code, results, error, xcodeProject, logFilePath); 305 } 306 return results; 307} 308 309// Exposed for testing. 310export function _assertXcodeBuildResults( 311 code: number | null, 312 results: string, 313 error: string, 314 xcodeProject: { name: string }, 315 logFilePath: string 316): void { 317 const errorTitle = `Failed to build iOS project. "xcodebuild" exited with error code ${code}.`; 318 319 const throwWithMessage = (message: string): never => { 320 throw new CommandError( 321 `${errorTitle}\nTo view more error logs, try building the app with Xcode directly, by opening ${xcodeProject.name}.\n\n` + 322 message + 323 `Build logs written to ${chalk.underline(logFilePath)}` 324 ); 325 }; 326 327 const localizedError = error.match(/NSLocalizedFailure = "(.*)"/)?.[1]; 328 329 if (localizedError) { 330 throwWithMessage(chalk.bold(localizedError) + '\n\n'); 331 } 332 // Show all the log info because often times the error is coming from a shell script, 333 // that invoked a node script, that started metro, which threw an error. 334 335 throwWithMessage(results + '\n\n' + error); 336} 337 338function writeBuildLogs(projectRoot: string, buildOutput: string, errorOutput: string) { 339 const [logFilePath, errorFilePath] = getErrorLogFilePath(projectRoot); 340 341 fs.writeFileSync(logFilePath, buildOutput); 342 fs.writeFileSync(errorFilePath, errorOutput); 343 return logFilePath; 344} 345 346function getErrorLogFilePath(projectRoot: string): [string, string] { 347 const folder = path.join(projectRoot, '.expo'); 348 ensureDirectory(folder); 349 return [path.join(folder, 'xcodebuild.log'), path.join(folder, 'xcodebuild-error.log')]; 350} 351