1import spawnAsync, { SpawnOptions, SpawnResult } from '@expo/spawn-async'; 2 3import * as Log from '../../../log'; 4import { CommandError } from '../../../utils/errors'; 5import { xcrunAsync } from './xcrun'; 6 7type DeviceState = 'Shutdown' | 'Booted'; 8 9type OSType = 'iOS' | 'tvOS' | 'watchOS' | 'macOS'; 10 11export type Device = { 12 availabilityError?: 'runtime profile not found'; 13 /** '/Users/name/Library/Developer/CoreSimulator/Devices/00E55DC0-0364-49DF-9EC6-77BE587137D4/data' */ 14 dataPath: string; 15 /** '/Users/name/Library/Logs/CoreSimulator/00E55DC0-0364-49DF-9EC6-77BE587137D4' */ 16 logPath: string; 17 /** '00E55DC0-0364-49DF-9EC6-77BE587137D4' */ 18 udid: string; 19 /** 'com.apple.CoreSimulator.SimRuntime.iOS-15-1' */ 20 runtime: string; 21 /** If the device is "available" which generally means that the OS files haven't been deleted (this can happen when Xcode updates). */ 22 isAvailable: boolean; 23 /** 'com.apple.CoreSimulator.SimDeviceType.iPhone-13-Pro' */ 24 deviceTypeIdentifier: string; 25 state: DeviceState; 26 /** 'iPhone 13 Pro' */ 27 name: string; 28 /** Type of OS the device uses. */ 29 osType: OSType; 30 /** '15.1' */ 31 osVersion: string; 32 /** 'iPhone 13 Pro (15.1)' */ 33 windowName: string; 34}; 35 36type SimulatorDeviceList = { 37 devices: { 38 [runtime: string]: Device[]; 39 }; 40}; 41 42type DeviceContext = Pick<Device, 'udid'>; 43 44/** 45 * Returns the local path for the installed tar.app. Returns null when the app isn't installed. 46 * 47 * @param device context for selecting a device. 48 * @param props.appId bundle identifier for app. 49 * @returns local file path to installed app binary, e.g. '/Users/evanbacon/Library/Developer/CoreSimulator/Devices/EFEEA6EF-E3F5-4EDE-9B72-29EAFA7514AE/data/Containers/Bundle/Application/FA43A0C6-C2AD-442D-B8B1-EAF3E88CF3BF/Exponent-2.21.3.tar.app' 50 */ 51export async function getContainerPathAsync( 52 device: Partial<DeviceContext>, 53 { 54 appId, 55 }: { 56 appId: string; 57 } 58): Promise<string | null> { 59 try { 60 const { stdout } = await simctlAsync(['get_app_container', resolveId(device), appId]); 61 return stdout.trim(); 62 } catch (error: any) { 63 if (error.stderr?.match(/No such file or directory/)) { 64 return null; 65 } 66 throw error; 67 } 68} 69 70/** Return a value from an installed app's Info.plist. */ 71export async function getInfoPlistValueAsync( 72 device: Partial<DeviceContext>, 73 { 74 appId, 75 key, 76 }: { 77 appId: string; 78 key: string; 79 } 80): Promise<string | null> { 81 const containerPath = await getContainerPathAsync(device, { appId }); 82 if (containerPath) { 83 try { 84 const { output } = await spawnAsync('defaults', ['read', `${containerPath}/Info`, key], { 85 stdio: 'pipe', 86 }); 87 return output.join('\n').trim(); 88 } catch { 89 return null; 90 } 91 } 92 return null; 93} 94 95/** Open a URL on a device. The url can have any protocol. */ 96export async function openUrlAsync( 97 device: Partial<DeviceContext>, 98 options: { url: string } 99): Promise<void> { 100 try { 101 // Skip logging since this is likely to fail. 102 await simctlAsync(['openurl', resolveId(device), options.url]); 103 } catch (error: any) { 104 if (!error.stderr?.match(/Unable to lookup in current state: Shut/)) { 105 throw error; 106 } 107 108 // If the device was in a weird in-between state ("Shutting Down" or "Shutdown"), then attempt to reboot it and try again. 109 // This can happen when quitting the Simulator app, and immediately pressing `i` to reopen the project. 110 111 // First boot the simulator 112 await bootDeviceAsync({ udid: resolveId(device) }); 113 114 // Finally, try again... 115 return await openUrlAsync(device, options); 116 } 117} 118 119/** Open a simulator using a bundle identifier. If no app with a matching bundle identifier is installed then an error will be thrown. */ 120export async function openAppIdAsync( 121 device: Partial<DeviceContext>, 122 options: { 123 appId: string; 124 } 125): Promise<SpawnResult> { 126 const results = await openAppIdInternalAsync(device, options); 127 // Similar to 194, this is a conformance issue which indicates that the given device has no app that can handle our launch request. 128 if (results.status === 4) { 129 throw new CommandError('APP_NOT_INSTALLED', results.stderr); 130 } 131 return results; 132} 133async function openAppIdInternalAsync( 134 device: Partial<DeviceContext>, 135 options: { 136 appId: string; 137 } 138): Promise<SpawnResult> { 139 try { 140 return await simctlAsync(['launch', resolveId(device), options.appId]); 141 } catch (error: any) { 142 if ('status' in error) { 143 return error; 144 } 145 throw error; 146 } 147} 148 149// This will only boot in headless mode if the Simulator app is not running. 150export async function bootAsync(device: DeviceContext): Promise<Device | null> { 151 await bootDeviceAsync(device); 152 return isDeviceBootedAsync(device); 153} 154 155/** Returns a list of devices whose current state is 'Booted' as an array. */ 156export async function getBootedSimulatorsAsync(): Promise<Device[]> { 157 const simulatorDeviceInfo = await getRuntimesAsync('devices'); 158 return Object.values(simulatorDeviceInfo.devices).flatMap((runtime) => 159 runtime.filter((device) => device.state === 'Booted') 160 ); 161} 162 163/** Returns the current device if its state is 'Booted'. */ 164export async function isDeviceBootedAsync(device: Partial<DeviceContext>): Promise<Device | null> { 165 // Simulators can be booted even if the app isn't running :( 166 const devices = await getBootedSimulatorsAsync(); 167 if (device.udid) { 168 return devices.find((bootedDevice) => bootedDevice.udid === device.udid) ?? null; 169 } 170 171 return devices[0] ?? null; 172} 173 174/** Boot a device. */ 175export async function bootDeviceAsync(device: DeviceContext): Promise<void> { 176 try { 177 // Skip logging since this is likely to fail. 178 await simctlAsync(['boot', device.udid]); 179 } catch (error: any) { 180 if (!error.stderr?.match(/Unable to boot device in current state: Booted/)) { 181 throw error; 182 } 183 } 184} 185 186/** Install a binary file on the device. */ 187export async function installAsync( 188 device: Partial<DeviceContext>, 189 options: { 190 /** Local absolute file path to an app binary that is built and provisioned for iOS simulators. */ 191 filePath: string; 192 } 193): Promise<any> { 194 return simctlAsync(['install', resolveId(device), options.filePath]); 195} 196 197/** Uninstall an app from the provided device. */ 198export async function uninstallAsync( 199 device: Partial<DeviceContext>, 200 options: { 201 /** Bundle identifier */ 202 appId: string; 203 } 204): Promise<any> { 205 return simctlAsync(['uninstall', resolveId(device), options.appId]); 206} 207 208function parseSimControlJSONResults(input: string): any { 209 try { 210 return JSON.parse(input); 211 } catch (error: any) { 212 // Nov 15, 2020: Observed this can happen when opening the simulator and the simulator prompts the user to update the xcode command line tools. 213 // Unexpected token I in JSON at position 0 214 if (error.message.includes('Unexpected token')) { 215 Log.error(`Apple's simctl returned malformed JSON:\n${input}`); 216 } 217 throw error; 218 } 219} 220 221/** Get all runtime devices given a certain type. */ 222async function getRuntimesAsync( 223 type: 'devices' | 'devicetypes' | 'runtimes' | 'pairs', 224 query?: string | 'available' 225): Promise<SimulatorDeviceList> { 226 const result = await simctlAsync(['list', type, '--json', query]); 227 const info = parseSimControlJSONResults(result.stdout) as SimulatorDeviceList; 228 229 for (const runtime of Object.keys(info.devices)) { 230 // Given a string like 'com.apple.CoreSimulator.SimRuntime.tvOS-13-4' 231 const runtimeSuffix = runtime.split('com.apple.CoreSimulator.SimRuntime.').pop()!; 232 // Create an array [tvOS, 13, 4] 233 const [osType, ...osVersionComponents] = runtimeSuffix.split('-'); 234 // Join the end components [13, 4] -> '13.4' 235 const osVersion = osVersionComponents.join('.'); 236 const sims = info.devices[runtime]; 237 for (const device of sims) { 238 device.runtime = runtime; 239 device.osVersion = osVersion; 240 device.windowName = `${device.name} (${osVersion})`; 241 device.osType = osType as OSType; 242 } 243 } 244 return info; 245} 246 247/** Return a list of iOS simulators. */ 248export async function getDevicesAsync(): Promise<Device[]> { 249 const simulatorDeviceInfo = await getRuntimesAsync('devices'); 250 return Object.values(simulatorDeviceInfo.devices).flat(); 251} 252 253/** Run a `simctl` command. */ 254export async function simctlAsync( 255 args: (string | undefined)[], 256 options?: SpawnOptions 257): Promise<SpawnResult> { 258 return xcrunAsync(['simctl', ...args], options); 259} 260 261function resolveId(device: Partial<DeviceContext>): string { 262 return device.udid ?? 'booted'; 263} 264