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