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