18d307f52SEvan Baconimport * as osascript from '@expo/osascript'; 229975bfdSEvan Baconimport assert from 'assert'; 38d307f52SEvan Baconimport chalk from 'chalk'; 447558c3cSAnthony Mittazimport fs from 'fs'; 547558c3cSAnthony Mittazimport path from 'path'; 68d307f52SEvan Bacon 78d307f52SEvan Baconimport { assertSystemRequirementsAsync } from './assertSystemRequirements'; 88d307f52SEvan Baconimport { ensureSimulatorAppRunningAsync } from './ensureSimulatorAppRunning'; 98d307f52SEvan Baconimport { 108d307f52SEvan Bacon getBestBootedSimulatorAsync, 118d307f52SEvan Bacon getBestUnbootedSimulatorAsync, 128d307f52SEvan Bacon getSelectableSimulatorsAsync, 138d307f52SEvan Bacon} from './getBestSimulator'; 148d307f52SEvan Baconimport { promptAppleDeviceAsync } from './promptAppleDevice'; 158d307f52SEvan Baconimport * as SimControl from './simctl'; 16*8a424bebSJames Ideimport { delayAsync, waitForActionAsync } from '../../../utils/delay'; 17*8a424bebSJames Ideimport { CommandError } from '../../../utils/errors'; 18*8a424bebSJames Ideimport { parsePlistAsync } from '../../../utils/plist'; 19*8a424bebSJames Ideimport { validateUrl } from '../../../utils/url'; 20*8a424bebSJames Ideimport { DeviceManager } from '../DeviceManager'; 21*8a424bebSJames Ideimport { ExpoGoInstaller } from '../ExpoGoInstaller'; 22*8a424bebSJames Ideimport { BaseResolveDeviceProps } from '../PlatformManager'; 238d307f52SEvan Bacon 2447558c3cSAnthony Mittazconst debug = require('debug')('expo:start:platforms:ios:AppleDeviceManager') as typeof console.log; 2547558c3cSAnthony Mittaz 268d307f52SEvan Baconconst EXPO_GO_BUNDLE_IDENTIFIER = 'host.exp.Exponent'; 278d307f52SEvan Bacon 288d307f52SEvan Bacon/** 298d307f52SEvan Bacon * Ensure a simulator is booted and the Simulator app is opened. 308d307f52SEvan Bacon * This is where any timeout related error handling should live. 318d307f52SEvan Bacon */ 328d307f52SEvan Baconexport async function ensureSimulatorOpenAsync( 338d307f52SEvan Bacon { udid, osType }: Partial<Pick<SimControl.Device, 'udid' | 'osType'>> = {}, 348d307f52SEvan Bacon tryAgain: boolean = true 358d307f52SEvan Bacon): Promise<SimControl.Device> { 368d307f52SEvan Bacon // Use a default simulator if none was specified 378d307f52SEvan Bacon if (!udid) { 388d307f52SEvan Bacon // If a simulator is open, side step the entire booting sequence. 398d307f52SEvan Bacon const simulatorOpenedByApp = await getBestBootedSimulatorAsync({ osType }); 408d307f52SEvan Bacon if (simulatorOpenedByApp) { 418d307f52SEvan Bacon return simulatorOpenedByApp; 428d307f52SEvan Bacon } 438d307f52SEvan Bacon 448d307f52SEvan Bacon // Otherwise, find the best possible simulator from user defaults and continue 4529975bfdSEvan Bacon const bestUdid = await getBestUnbootedSimulatorAsync({ osType }); 4629975bfdSEvan Bacon if (!bestUdid) { 4729975bfdSEvan Bacon throw new CommandError('No simulators found.'); 4829975bfdSEvan Bacon } 4929975bfdSEvan Bacon udid = bestUdid; 508d307f52SEvan Bacon } 518d307f52SEvan Bacon 528d307f52SEvan Bacon const bootedDevice = await waitForActionAsync({ 5329975bfdSEvan Bacon action: () => { 5429975bfdSEvan Bacon // Just for the type check. 5529975bfdSEvan Bacon assert(udid); 5629975bfdSEvan Bacon return SimControl.bootAsync({ udid }); 5729975bfdSEvan Bacon }, 588d307f52SEvan Bacon }); 598d307f52SEvan Bacon 608d307f52SEvan Bacon if (!bootedDevice) { 618d307f52SEvan Bacon // Give it a second chance, this might not be needed but it could potentially lead to a better UX on slower devices. 628d307f52SEvan Bacon if (tryAgain) { 638d307f52SEvan Bacon return await ensureSimulatorOpenAsync({ udid, osType }, false); 648d307f52SEvan Bacon } 658d307f52SEvan Bacon // TODO: We should eliminate all needs for a timeout error, it's bad UX to get an error about the simulator not starting while the user can clearly see it starting on their slow computer. 668d307f52SEvan Bacon throw new CommandError( 678d307f52SEvan Bacon 'SIMULATOR_TIMEOUT', 688d307f52SEvan Bacon `Simulator didn't boot fast enough. Try opening Simulator first, then running your app.` 698d307f52SEvan Bacon ); 708d307f52SEvan Bacon } 718d307f52SEvan Bacon return bootedDevice; 728d307f52SEvan Bacon} 738d307f52SEvan Baconexport class AppleDeviceManager extends DeviceManager<SimControl.Device> { 748d307f52SEvan Bacon static assertSystemRequirementsAsync = assertSystemRequirementsAsync; 758d307f52SEvan Bacon 768d307f52SEvan Bacon static async resolveAsync({ 778d307f52SEvan Bacon device, 788d307f52SEvan Bacon shouldPrompt, 798d307f52SEvan Bacon }: BaseResolveDeviceProps< 80c4ef02aeSEvan Bacon Partial<Pick<SimControl.Device, 'udid' | 'osType'>> 818d307f52SEvan Bacon > = {}): Promise<AppleDeviceManager> { 828d307f52SEvan Bacon if (shouldPrompt) { 838d307f52SEvan Bacon const devices = await getSelectableSimulatorsAsync(device); 848d307f52SEvan Bacon device = await promptAppleDeviceAsync(devices, device?.osType); 858d307f52SEvan Bacon } 868d307f52SEvan Bacon 878d307f52SEvan Bacon const booted = await ensureSimulatorOpenAsync(device); 888d307f52SEvan Bacon return new AppleDeviceManager(booted); 898d307f52SEvan Bacon } 908d307f52SEvan Bacon 918d307f52SEvan Bacon get name() { 928d307f52SEvan Bacon return this.device.name; 938d307f52SEvan Bacon } 948d307f52SEvan Bacon 958d307f52SEvan Bacon get identifier(): string { 968d307f52SEvan Bacon return this.device.udid; 978d307f52SEvan Bacon } 988d307f52SEvan Bacon 998d307f52SEvan Bacon async getAppVersionAsync(appId: string): Promise<string | null> { 1008d307f52SEvan Bacon return await SimControl.getInfoPlistValueAsync(this.device, { 1018d307f52SEvan Bacon appId, 1028d307f52SEvan Bacon key: 'CFBundleShortVersionString', 1038d307f52SEvan Bacon }); 1048d307f52SEvan Bacon } 1058d307f52SEvan Bacon 1068d307f52SEvan Bacon async startAsync(): Promise<SimControl.Device> { 1078d307f52SEvan Bacon return ensureSimulatorOpenAsync({ osType: this.device.osType, udid: this.device.udid }); 1088d307f52SEvan Bacon } 1098d307f52SEvan Bacon 1108d307f52SEvan Bacon async launchApplicationIdAsync(appId: string) { 1118d307f52SEvan Bacon try { 1128d307f52SEvan Bacon const result = await SimControl.openAppIdAsync(this.device, { 1138d307f52SEvan Bacon appId, 1148d307f52SEvan Bacon }); 1158d307f52SEvan Bacon if (result.status === 0) { 1168d307f52SEvan Bacon await this.activateWindowAsync(); 1178d307f52SEvan Bacon } else { 1188d307f52SEvan Bacon throw new CommandError(result.stderr); 1198d307f52SEvan Bacon } 12029975bfdSEvan Bacon } catch (error: any) { 1218d307f52SEvan Bacon let errorMessage = `Couldn't open iOS app with ID "${appId}" on device "${this.name}".`; 1228d307f52SEvan Bacon if (error instanceof CommandError && error.code === 'APP_NOT_INSTALLED') { 123c4ef02aeSEvan Bacon if (appId === EXPO_GO_BUNDLE_IDENTIFIER) { 124c4ef02aeSEvan Bacon errorMessage = `Couldn't open Expo Go app on device "${this.name}". Please install.`; 125c4ef02aeSEvan Bacon } else { 1268d307f52SEvan Bacon errorMessage += `\nThe app might not be installed, try installing it with: ${chalk.bold( 127384598e2SBrent Vatne `npx expo run:ios -d ${this.device.udid}` 1288d307f52SEvan Bacon )}`; 1298d307f52SEvan Bacon } 130c4ef02aeSEvan Bacon } 1318d307f52SEvan Bacon if (error.stderr) { 1328d307f52SEvan Bacon errorMessage += chalk.gray(`\n${error.stderr}`); 1338d307f52SEvan Bacon } else if (error.message) { 1348d307f52SEvan Bacon errorMessage += chalk.gray(`\n${error.message}`); 1358d307f52SEvan Bacon } 1368d307f52SEvan Bacon throw new CommandError(errorMessage); 1378d307f52SEvan Bacon } 1388d307f52SEvan Bacon } 1398d307f52SEvan Bacon 1408d307f52SEvan Bacon async installAppAsync(filePath: string) { 1418d307f52SEvan Bacon await SimControl.installAsync(this.device, { 1428d307f52SEvan Bacon filePath, 1438d307f52SEvan Bacon }); 1448d307f52SEvan Bacon 1458d307f52SEvan Bacon await this.waitForAppInstalledAsync(await this.getApplicationIdFromBundle(filePath)); 1468d307f52SEvan Bacon } 1478d307f52SEvan Bacon 1488d307f52SEvan Bacon private async getApplicationIdFromBundle(filePath: string): Promise<string> { 14947558c3cSAnthony Mittaz debug('getApplicationIdFromBundle:', filePath); 15047558c3cSAnthony Mittaz const builtInfoPlistPath = path.join(filePath, 'Info.plist'); 15147558c3cSAnthony Mittaz if (fs.existsSync(builtInfoPlistPath)) { 15247558c3cSAnthony Mittaz const { CFBundleIdentifier } = await parsePlistAsync(builtInfoPlistPath); 15347558c3cSAnthony Mittaz debug('getApplicationIdFromBundle: using built Info.plist', CFBundleIdentifier); 15447558c3cSAnthony Mittaz return CFBundleIdentifier; 15547558c3cSAnthony Mittaz } 15647558c3cSAnthony Mittaz debug('getApplicationIdFromBundle: no Info.plist found'); 1578d307f52SEvan Bacon return EXPO_GO_BUNDLE_IDENTIFIER; 1588d307f52SEvan Bacon } 1598d307f52SEvan Bacon 1608d307f52SEvan Bacon private async waitForAppInstalledAsync(applicationId: string): Promise<boolean> { 1618d307f52SEvan Bacon while (true) { 1628d307f52SEvan Bacon if (await this.isAppInstalledAsync(applicationId)) { 1638d307f52SEvan Bacon return true; 1648d307f52SEvan Bacon } 1658d307f52SEvan Bacon await delayAsync(100); 1668d307f52SEvan Bacon } 1678d307f52SEvan Bacon } 1688d307f52SEvan Bacon 1698d307f52SEvan Bacon async uninstallAppAsync(appId: string) { 1708d307f52SEvan Bacon await SimControl.uninstallAsync(this.device, { 1718d307f52SEvan Bacon appId, 1728d307f52SEvan Bacon }); 1738d307f52SEvan Bacon } 1748d307f52SEvan Bacon 1758d307f52SEvan Bacon async isAppInstalledAsync(appId: string) { 1768d307f52SEvan Bacon return !!(await SimControl.getContainerPathAsync(this.device, { 1778d307f52SEvan Bacon appId, 1788d307f52SEvan Bacon })); 1798d307f52SEvan Bacon } 1808d307f52SEvan Bacon 1818d307f52SEvan Bacon async openUrlAsync(url: string) { 1828d307f52SEvan Bacon // Non-compliant URLs will be treated as application identifiers. 1838d307f52SEvan Bacon if (!validateUrl(url, { requireProtocol: true })) { 1848d307f52SEvan Bacon return await this.launchApplicationIdAsync(url); 1858d307f52SEvan Bacon } 1868d307f52SEvan Bacon 1878d307f52SEvan Bacon try { 1888d307f52SEvan Bacon await SimControl.openUrlAsync(this.device, { url }); 18929975bfdSEvan Bacon } catch (error: any) { 1908d307f52SEvan Bacon // 194 means the device does not conform to a given URL, in this case we'll assume that the desired app is not installed. 1918d307f52SEvan Bacon if (error.status === 194) { 1928d307f52SEvan Bacon // An error was encountered processing the command (domain=NSOSStatusErrorDomain, code=-10814): 1938d307f52SEvan Bacon // The operation couldn’t be completed. (OSStatus error -10814.) 1948d307f52SEvan Bacon // 1958d307f52SEvan Bacon // This can be thrown when no app conforms to the URI scheme that we attempted to open. 1968d307f52SEvan Bacon throw new CommandError( 1978d307f52SEvan Bacon 'APP_NOT_INSTALLED', 1988d307f52SEvan Bacon `Device ${this.device.name} (${this.device.udid}) has no app to handle the URI: ${url}` 1998d307f52SEvan Bacon ); 2008d307f52SEvan Bacon } 2018d307f52SEvan Bacon throw error; 2028d307f52SEvan Bacon } 2038d307f52SEvan Bacon } 2048d307f52SEvan Bacon 2058d307f52SEvan Bacon async activateWindowAsync() { 2068d307f52SEvan Bacon await ensureSimulatorAppRunningAsync(this.device); 2078d307f52SEvan Bacon // TODO: Focus the individual window 2088d307f52SEvan Bacon await osascript.execAsync(`tell application "Simulator" to activate`); 2098d307f52SEvan Bacon } 2108d307f52SEvan Bacon 2118d307f52SEvan Bacon async ensureExpoGoAsync(sdkVersion?: string): Promise<boolean> { 2128d307f52SEvan Bacon const installer = new ExpoGoInstaller('ios', EXPO_GO_BUNDLE_IDENTIFIER, sdkVersion); 2138d307f52SEvan Bacon return installer.ensureAsync(this); 2148d307f52SEvan Bacon } 2158d307f52SEvan Bacon} 216