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