1import * as osascript from '@expo/osascript'; 2import assert from 'assert'; 3import chalk from 'chalk'; 4import fs from 'fs'; 5import path from 'path'; 6 7import { assertSystemRequirementsAsync } from './assertSystemRequirements'; 8import { ensureSimulatorAppRunningAsync } from './ensureSimulatorAppRunning'; 9import { 10 getBestBootedSimulatorAsync, 11 getBestUnbootedSimulatorAsync, 12 getSelectableSimulatorsAsync, 13} from './getBestSimulator'; 14import { promptAppleDeviceAsync } from './promptAppleDevice'; 15import * as SimControl from './simctl'; 16import { delayAsync, waitForActionAsync } from '../../../utils/delay'; 17import { CommandError } from '../../../utils/errors'; 18import { parsePlistAsync } from '../../../utils/plist'; 19import { validateUrl } from '../../../utils/url'; 20import { DeviceManager } from '../DeviceManager'; 21import { ExpoGoInstaller } from '../ExpoGoInstaller'; 22import { BaseResolveDeviceProps } from '../PlatformManager'; 23 24const debug = require('debug')('expo:start:platforms:ios:AppleDeviceManager') as typeof console.log; 25 26const EXPO_GO_BUNDLE_IDENTIFIER = 'host.exp.Exponent'; 27 28/** 29 * Ensure a simulator is booted and the Simulator app is opened. 30 * This is where any timeout related error handling should live. 31 */ 32export async function ensureSimulatorOpenAsync( 33 { udid, osType }: Partial<Pick<SimControl.Device, 'udid' | 'osType'>> = {}, 34 tryAgain: boolean = true 35): Promise<SimControl.Device> { 36 // Use a default simulator if none was specified 37 if (!udid) { 38 // If a simulator is open, side step the entire booting sequence. 39 const simulatorOpenedByApp = await getBestBootedSimulatorAsync({ osType }); 40 if (simulatorOpenedByApp) { 41 return simulatorOpenedByApp; 42 } 43 44 // Otherwise, find the best possible simulator from user defaults and continue 45 const bestUdid = await getBestUnbootedSimulatorAsync({ osType }); 46 if (!bestUdid) { 47 throw new CommandError('No simulators found.'); 48 } 49 udid = bestUdid; 50 } 51 52 const bootedDevice = await waitForActionAsync({ 53 action: () => { 54 // Just for the type check. 55 assert(udid); 56 return SimControl.bootAsync({ udid }); 57 }, 58 }); 59 60 if (!bootedDevice) { 61 // Give it a second chance, this might not be needed but it could potentially lead to a better UX on slower devices. 62 if (tryAgain) { 63 return await ensureSimulatorOpenAsync({ udid, osType }, false); 64 } 65 // 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. 66 throw new CommandError( 67 'SIMULATOR_TIMEOUT', 68 `Simulator didn't boot fast enough. Try opening Simulator first, then running your app.` 69 ); 70 } 71 return bootedDevice; 72} 73export class AppleDeviceManager extends DeviceManager<SimControl.Device> { 74 static assertSystemRequirementsAsync = assertSystemRequirementsAsync; 75 76 static async resolveAsync({ 77 device, 78 shouldPrompt, 79 }: BaseResolveDeviceProps< 80 Partial<Pick<SimControl.Device, 'udid' | 'osType'>> 81 > = {}): Promise<AppleDeviceManager> { 82 if (shouldPrompt) { 83 const devices = await getSelectableSimulatorsAsync(device); 84 device = await promptAppleDeviceAsync(devices, device?.osType); 85 } 86 87 const booted = await ensureSimulatorOpenAsync(device); 88 return new AppleDeviceManager(booted); 89 } 90 91 get name() { 92 return this.device.name; 93 } 94 95 get identifier(): string { 96 return this.device.udid; 97 } 98 99 async getAppVersionAsync(appId: string): Promise<string | null> { 100 return await SimControl.getInfoPlistValueAsync(this.device, { 101 appId, 102 key: 'CFBundleShortVersionString', 103 }); 104 } 105 106 async startAsync(): Promise<SimControl.Device> { 107 return ensureSimulatorOpenAsync({ osType: this.device.osType, udid: this.device.udid }); 108 } 109 110 async launchApplicationIdAsync(appId: string) { 111 try { 112 const result = await SimControl.openAppIdAsync(this.device, { 113 appId, 114 }); 115 if (result.status === 0) { 116 await this.activateWindowAsync(); 117 } else { 118 throw new CommandError(result.stderr); 119 } 120 } catch (error: any) { 121 let errorMessage = `Couldn't open iOS app with ID "${appId}" on device "${this.name}".`; 122 if (error instanceof CommandError && error.code === 'APP_NOT_INSTALLED') { 123 if (appId === EXPO_GO_BUNDLE_IDENTIFIER) { 124 errorMessage = `Couldn't open Expo Go app on device "${this.name}". Please install.`; 125 } else { 126 errorMessage += `\nThe app might not be installed, try installing it with: ${chalk.bold( 127 `npx expo run:ios -d ${this.device.udid}` 128 )}`; 129 } 130 } 131 if (error.stderr) { 132 errorMessage += chalk.gray(`\n${error.stderr}`); 133 } else if (error.message) { 134 errorMessage += chalk.gray(`\n${error.message}`); 135 } 136 throw new CommandError(errorMessage); 137 } 138 } 139 140 async installAppAsync(filePath: string) { 141 await SimControl.installAsync(this.device, { 142 filePath, 143 }); 144 145 await this.waitForAppInstalledAsync(await this.getApplicationIdFromBundle(filePath)); 146 } 147 148 private async getApplicationIdFromBundle(filePath: string): Promise<string> { 149 debug('getApplicationIdFromBundle:', filePath); 150 const builtInfoPlistPath = path.join(filePath, 'Info.plist'); 151 if (fs.existsSync(builtInfoPlistPath)) { 152 const { CFBundleIdentifier } = await parsePlistAsync(builtInfoPlistPath); 153 debug('getApplicationIdFromBundle: using built Info.plist', CFBundleIdentifier); 154 return CFBundleIdentifier; 155 } 156 debug('getApplicationIdFromBundle: no Info.plist found'); 157 return EXPO_GO_BUNDLE_IDENTIFIER; 158 } 159 160 private async waitForAppInstalledAsync(applicationId: string): Promise<boolean> { 161 while (true) { 162 if (await this.isAppInstalledAsync(applicationId)) { 163 return true; 164 } 165 await delayAsync(100); 166 } 167 } 168 169 async uninstallAppAsync(appId: string) { 170 await SimControl.uninstallAsync(this.device, { 171 appId, 172 }); 173 } 174 175 async isAppInstalledAsync(appId: string) { 176 return !!(await SimControl.getContainerPathAsync(this.device, { 177 appId, 178 })); 179 } 180 181 async openUrlAsync(url: string) { 182 // Non-compliant URLs will be treated as application identifiers. 183 if (!validateUrl(url, { requireProtocol: true })) { 184 return await this.launchApplicationIdAsync(url); 185 } 186 187 try { 188 await SimControl.openUrlAsync(this.device, { url }); 189 } catch (error: any) { 190 // 194 means the device does not conform to a given URL, in this case we'll assume that the desired app is not installed. 191 if (error.status === 194) { 192 // An error was encountered processing the command (domain=NSOSStatusErrorDomain, code=-10814): 193 // The operation couldn’t be completed. (OSStatus error -10814.) 194 // 195 // This can be thrown when no app conforms to the URI scheme that we attempted to open. 196 throw new CommandError( 197 'APP_NOT_INSTALLED', 198 `Device ${this.device.name} (${this.device.udid}) has no app to handle the URI: ${url}` 199 ); 200 } 201 throw error; 202 } 203 } 204 205 async activateWindowAsync() { 206 await ensureSimulatorAppRunningAsync(this.device); 207 // TODO: Focus the individual window 208 await osascript.execAsync(`tell application "Simulator" to activate`); 209 } 210 211 async ensureExpoGoAsync(sdkVersion?: string): Promise<boolean> { 212 const installer = new ExpoGoInstaller('ios', EXPO_GO_BUNDLE_IDENTIFIER, sdkVersion); 213 return installer.ensureAsync(this); 214 } 215} 216