1import chalk from 'chalk';
2import { execSync } from 'child_process';
3import semver from 'semver';
4
5import * as Log from '../../../log';
6import { AbortCommandError } from '../../../utils/errors';
7import { profile } from '../../../utils/profile';
8import { confirmAsync } from '../../../utils/prompts';
9import { Prerequisite } from '../Prerequisite';
10
11const debug = require('debug')('expo:doctor:apple:xcode') as typeof console.log;
12
13// Based on the Apple announcement (last updated: Aug 2023).
14// https://developer.apple.com/news/upcoming-requirements/?id=04252023a
15const MIN_XCODE_VERSION = '14.1';
16const APP_STORE_ID = '497799835';
17
18const SUGGESTED_XCODE_VERSION = `${MIN_XCODE_VERSION}.0`;
19
20const promptToOpenAppStoreAsync = async (message: string) => {
21  // This prompt serves no purpose accept informing the user what to do next, we could just open the App Store but it could be confusing if they don't know what's going on.
22  const confirm = await confirmAsync({ initial: true, message });
23  if (confirm) {
24    Log.log(`Going to the App Store, re-run Expo CLI when Xcode has finished installing.`);
25    openAppStore(APP_STORE_ID);
26  }
27};
28
29/** Exposed for testing, use `getXcodeVersion` */
30export const getXcodeVersionAsync = (): string | null | false => {
31  try {
32    const last = execSync('xcodebuild -version', { stdio: 'pipe' })
33      .toString()
34      .match(/^Xcode (\d+\.\d+)/)?.[1];
35    // Convert to a semver string
36    if (last) {
37      const version = `${last}.0`;
38
39      if (!semver.valid(version)) {
40        // Not sure why this would happen, if it does we should add a more confident error message.
41        Log.error(`Xcode version is in an unknown format: ${version}`);
42        return false;
43      }
44
45      return version;
46    }
47    // not sure what's going on
48    Log.error(
49      'Unable to check Xcode version. Command ran successfully but no version number was found.'
50    );
51  } catch {
52    // not installed
53  }
54  return null;
55};
56
57/**
58 * Open a link to the App Store. Just link in mobile apps, **never** redirect without prompting first.
59 *
60 * @param appId
61 */
62function openAppStore(appId: string) {
63  const link = getAppStoreLink(appId);
64  execSync(`open ${link}`, { stdio: 'ignore' });
65}
66
67function getAppStoreLink(appId: string): string {
68  if (process.platform === 'darwin') {
69    // TODO: Is there ever a case where the macappstore isn't available on mac?
70    return `macappstore://itunes.apple.com/app/id${appId}`;
71  }
72  return `https://apps.apple.com/us/app/id${appId}`;
73}
74
75function spawnForString(cmd: string): string | null {
76  try {
77    return execSync(cmd, { stdio: 'pipe' }).toString().trim();
78  } catch {}
79  return null;
80}
81
82/** @returns a string like `/Applications/Xcode.app/Contents/Developer` when Xcode has a correctly selected path. */
83function getXcodeSelectPathAsync() {
84  return spawnForString('/usr/bin/xcode-select --print-path');
85}
86function getXcodeInstalled() {
87  return spawnForString('ls /Applications/Xcode.app/Contents/Developer');
88}
89
90export class XcodePrerequisite extends Prerequisite {
91  static instance = new XcodePrerequisite();
92
93  /**
94   * Ensure Xcode is installed and recent enough to be used with Expo.
95   */
96  async assertImplementation(): Promise<void> {
97    const version = profile(getXcodeVersionAsync)();
98    debug(`Xcode version: ${version}`);
99    if (!version) {
100      // A couple different issues could have occurred, let's check them after we're past the point of no return
101      // since we no longer need to be fast about validation.
102
103      // Ensure Xcode.app can be found before we prompt to sudo select it.
104      if (getXcodeInstalled()) {
105        const selectPath = profile(getXcodeSelectPathAsync)();
106        debug(`Xcode select path: ${selectPath}`);
107        if (!selectPath) {
108          Log.error(
109            [
110              '',
111              chalk.bold('Xcode has not been fully setup for Apple development yet.'),
112              'Download at: https://developer.apple.com/xcode/',
113              'or in the App Store.',
114              '',
115              'After downloading Xcode, run the following two commands in your terminal:',
116              chalk.cyan('  sudo xcode-select --switch /Applications/Xcode.app/Contents/Developer'),
117              chalk.cyan('  sudo xcodebuild -runFirstLaunch'),
118              '',
119              'Then you can re-run Expo CLI. Alternatively, you can build apps in the cloud with EAS CLI, or preview using the Expo Go app on a physical device.',
120              '',
121            ].join('\n')
122          );
123          throw new AbortCommandError();
124        } else {
125          debug(`Unexpected Xcode setup (version: ${version}, select: ${selectPath})`);
126        }
127      }
128
129      // Almost certainly Xcode isn't installed.
130      await promptToOpenAppStoreAsync(
131        `Xcode must be fully installed before you can continue. Continue to the App Store?`
132      );
133      throw new AbortCommandError();
134    }
135
136    if (semver.lt(version, SUGGESTED_XCODE_VERSION)) {
137      // Xcode version is too old.
138      await promptToOpenAppStoreAsync(
139        `Xcode (${version}) needs to be updated to at least version ${MIN_XCODE_VERSION}. Continue to the App Store?`
140      );
141      throw new AbortCommandError();
142    }
143  }
144}
145