xref: /expo/packages/@expo/cli/src/run/ios/XcodeBuild.ts (revision 753557f6)
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