1import { execSync } from 'child_process'; 2import semver from 'semver'; 3 4import * as Log from '../../../log'; 5import { AbortCommandError } from '../../../utils/errors'; 6import { profile } from '../../../utils/profile'; 7import { confirmAsync } from '../../../utils/prompts'; 8import { Prerequisite } from '../Prerequisite'; 9 10const debug = require('debug')('expo:doctor:apple:xcode') as typeof console.log; 11 12// Based on the RN docs (Aug 2020). 13const MIN_XCODE_VERSION = 9.4; 14const APP_STORE_ID = '497799835'; 15 16const SUGGESTED_XCODE_VERSION = `${MIN_XCODE_VERSION}.0`; 17 18const promptToOpenAppStoreAsync = async (message: string) => { 19 // 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. 20 const confirm = await confirmAsync({ initial: true, message }); 21 if (confirm) { 22 Log.log(`Going to the App Store, re-run Expo CLI when Xcode has finished installing.`); 23 openAppStore(APP_STORE_ID); 24 } 25}; 26 27/** Exposed for testing, use `getXcodeVersion` */ 28export const getXcodeVersionAsync = (): string | null | false => { 29 try { 30 const last = execSync('xcodebuild -version', { stdio: 'pipe' }) 31 .toString() 32 .match(/^Xcode (\d+\.\d+)/)?.[1]; 33 // Convert to a semver string 34 if (last) { 35 const version = `${last}.0`; 36 37 if (!semver.valid(version)) { 38 // Not sure why this would happen, if it does we should add a more confident error message. 39 Log.error(`Xcode version is in an unknown format: ${version}`); 40 return false; 41 } 42 43 return version; 44 } 45 // not sure what's going on 46 Log.error( 47 'Unable to check Xcode version. Command ran successfully but no version number was found.' 48 ); 49 } catch { 50 // not installed 51 } 52 return null; 53}; 54 55/** 56 * Open a link to the App Store. Just link in mobile apps, **never** redirect without prompting first. 57 * 58 * @param appId 59 */ 60function openAppStore(appId: string) { 61 const link = getAppStoreLink(appId); 62 execSync(`open ${link}`, { stdio: 'ignore' }); 63} 64 65function getAppStoreLink(appId: string): string { 66 if (process.platform === 'darwin') { 67 // TODO: Is there ever a case where the macappstore isn't available on mac? 68 return `macappstore://itunes.apple.com/app/id${appId}`; 69 } 70 return `https://apps.apple.com/us/app/id${appId}`; 71} 72 73export class XcodePrerequisite extends Prerequisite { 74 static instance = new XcodePrerequisite(); 75 76 /** 77 * Ensure Xcode is installed and recent enough to be used with Expo. 78 */ 79 async assertImplementation(): Promise<void> { 80 const version = profile(getXcodeVersionAsync)(); 81 debug(`Xcode version: ${version}`); 82 if (!version) { 83 // Almost certainly Xcode isn't installed. 84 await promptToOpenAppStoreAsync( 85 `Xcode must be fully installed before you can continue. Continue to the App Store?` 86 ); 87 throw new AbortCommandError(); 88 } 89 90 if (semver.lt(version, SUGGESTED_XCODE_VERSION)) { 91 // Xcode version is too old. 92 await promptToOpenAppStoreAsync( 93 `Xcode (${version}) needs to be updated to at least version ${MIN_XCODE_VERSION}. Continue to the App Store?` 94 ); 95 throw new AbortCommandError(); 96 } 97 } 98} 99