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 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 errorMessage += `\nThe app might not be installed, try installing it with: ${chalk.bold( 119 `expo run:ios -d ${this.device.udid}` 120 )}`; 121 } 122 if (error.stderr) { 123 errorMessage += chalk.gray(`\n${error.stderr}`); 124 } else if (error.message) { 125 errorMessage += chalk.gray(`\n${error.message}`); 126 } 127 throw new CommandError(errorMessage); 128 } 129 } 130 131 async installAppAsync(filePath: string) { 132 await SimControl.installAsync(this.device, { 133 filePath, 134 }); 135 136 await this.waitForAppInstalledAsync(await this.getApplicationIdFromBundle(filePath)); 137 } 138 139 private async getApplicationIdFromBundle(filePath: string): Promise<string> { 140 // TODO: Implement... 141 return EXPO_GO_BUNDLE_IDENTIFIER; 142 } 143 144 private async waitForAppInstalledAsync(applicationId: string): Promise<boolean> { 145 while (true) { 146 if (await this.isAppInstalledAsync(applicationId)) { 147 return true; 148 } 149 await delayAsync(100); 150 } 151 } 152 153 async uninstallAppAsync(appId: string) { 154 await SimControl.uninstallAsync(this.device, { 155 appId, 156 }); 157 } 158 159 async isAppInstalledAsync(appId: string) { 160 return !!(await SimControl.getContainerPathAsync(this.device, { 161 appId, 162 })); 163 } 164 165 async openUrlAsync(url: string) { 166 // Non-compliant URLs will be treated as application identifiers. 167 if (!validateUrl(url, { requireProtocol: true })) { 168 return await this.launchApplicationIdAsync(url); 169 } 170 171 try { 172 await SimControl.openUrlAsync(this.device, { url }); 173 } catch (error: any) { 174 // 194 means the device does not conform to a given URL, in this case we'll assume that the desired app is not installed. 175 if (error.status === 194) { 176 // An error was encountered processing the command (domain=NSOSStatusErrorDomain, code=-10814): 177 // The operation couldn’t be completed. (OSStatus error -10814.) 178 // 179 // This can be thrown when no app conforms to the URI scheme that we attempted to open. 180 throw new CommandError( 181 'APP_NOT_INSTALLED', 182 `Device ${this.device.name} (${this.device.udid}) has no app to handle the URI: ${url}` 183 ); 184 } 185 throw error; 186 } 187 } 188 189 async activateWindowAsync() { 190 await ensureSimulatorAppRunningAsync(this.device); 191 // TODO: Focus the individual window 192 await osascript.execAsync(`tell application "Simulator" to activate`); 193 } 194 195 async ensureExpoGoAsync(sdkVersion?: string): Promise<boolean> { 196 const installer = new ExpoGoInstaller('ios', EXPO_GO_BUNDLE_IDENTIFIER, sdkVersion); 197 return installer.ensureAsync(this); 198 } 199} 200