18d307f52SEvan Baconimport spawnAsync, { SpawnOptions, SpawnResult } from '@expo/spawn-async'; 28d307f52SEvan Bacon 3*8a424bebSJames Ideimport { xcrunAsync } from './xcrun'; 48d307f52SEvan Baconimport * as Log from '../../../log'; 58d307f52SEvan Baconimport { CommandError } from '../../../utils/errors'; 68d307f52SEvan Bacon 78d307f52SEvan Bacontype DeviceState = 'Shutdown' | 'Booted'; 88d307f52SEvan Bacon 9c4ef02aeSEvan Baconexport type OSType = 'iOS' | 'tvOS' | 'watchOS' | 'macOS'; 108d307f52SEvan Bacon 118d307f52SEvan Baconexport type Device = { 128d307f52SEvan Bacon availabilityError?: 'runtime profile not found'; 138d307f52SEvan Bacon /** '/Users/name/Library/Developer/CoreSimulator/Devices/00E55DC0-0364-49DF-9EC6-77BE587137D4/data' */ 148d307f52SEvan Bacon dataPath: string; 15c4ef02aeSEvan Bacon /** @example `2811236352` */ 16c4ef02aeSEvan Bacon dataPathSize?: number; 178d307f52SEvan Bacon /** '/Users/name/Library/Logs/CoreSimulator/00E55DC0-0364-49DF-9EC6-77BE587137D4' */ 188d307f52SEvan Bacon logPath: string; 19c4ef02aeSEvan Bacon /** @example `479232` */ 20c4ef02aeSEvan Bacon logPathSize?: number; 218d307f52SEvan Bacon /** '00E55DC0-0364-49DF-9EC6-77BE587137D4' */ 228d307f52SEvan Bacon udid: string; 238d307f52SEvan Bacon /** 'com.apple.CoreSimulator.SimRuntime.iOS-15-1' */ 248d307f52SEvan Bacon runtime: string; 258d307f52SEvan Bacon /** If the device is "available" which generally means that the OS files haven't been deleted (this can happen when Xcode updates). */ 268d307f52SEvan Bacon isAvailable: boolean; 278d307f52SEvan Bacon /** 'com.apple.CoreSimulator.SimDeviceType.iPhone-13-Pro' */ 288d307f52SEvan Bacon deviceTypeIdentifier: string; 298d307f52SEvan Bacon state: DeviceState; 308d307f52SEvan Bacon /** 'iPhone 13 Pro' */ 318d307f52SEvan Bacon name: string; 328d307f52SEvan Bacon /** Type of OS the device uses. */ 338d307f52SEvan Bacon osType: OSType; 348d307f52SEvan Bacon /** '15.1' */ 358d307f52SEvan Bacon osVersion: string; 368d307f52SEvan Bacon /** 'iPhone 13 Pro (15.1)' */ 378d307f52SEvan Bacon windowName: string; 388d307f52SEvan Bacon}; 398d307f52SEvan Bacon 408d307f52SEvan Bacontype SimulatorDeviceList = { 418d307f52SEvan Bacon devices: { 428d307f52SEvan Bacon [runtime: string]: Device[]; 438d307f52SEvan Bacon }; 448d307f52SEvan Bacon}; 458d307f52SEvan Bacon 468d307f52SEvan Bacontype DeviceContext = Pick<Device, 'udid'>; 478d307f52SEvan Bacon 48c4ef02aeSEvan Bacon/** Returns true if the given value is an `OSType`, if we don't recognize the value we continue anyways but warn. */ 49c4ef02aeSEvan Baconexport function isOSType(value: any): value is OSType { 50c4ef02aeSEvan Bacon if (!value || typeof value !== 'string') return false; 51c4ef02aeSEvan Bacon 52c4ef02aeSEvan Bacon const knownTypes = ['iOS', 'tvOS', 'watchOS', 'macOS']; 53c4ef02aeSEvan Bacon if (!knownTypes.includes(value)) { 54c4ef02aeSEvan Bacon Log.warn(`Unknown OS type: ${value}. Expected one of: ${knownTypes.join(', ')}`); 55c4ef02aeSEvan Bacon } 56c4ef02aeSEvan Bacon return true; 57c4ef02aeSEvan Bacon} 58c4ef02aeSEvan Bacon 598d307f52SEvan Bacon/** 608d307f52SEvan Bacon * Returns the local path for the installed tar.app. Returns null when the app isn't installed. 618d307f52SEvan Bacon * 628d307f52SEvan Bacon * @param device context for selecting a device. 638d307f52SEvan Bacon * @param props.appId bundle identifier for app. 648d307f52SEvan Bacon * @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' 658d307f52SEvan Bacon */ 668d307f52SEvan Baconexport async function getContainerPathAsync( 678d307f52SEvan Bacon device: Partial<DeviceContext>, 688d307f52SEvan Bacon { 698d307f52SEvan Bacon appId, 708d307f52SEvan Bacon }: { 718d307f52SEvan Bacon appId: string; 728d307f52SEvan Bacon } 738d307f52SEvan Bacon): Promise<string | null> { 748d307f52SEvan Bacon try { 758d307f52SEvan Bacon const { stdout } = await simctlAsync(['get_app_container', resolveId(device), appId]); 768d307f52SEvan Bacon return stdout.trim(); 778d307f52SEvan Bacon } catch (error: any) { 788d307f52SEvan Bacon if (error.stderr?.match(/No such file or directory/)) { 798d307f52SEvan Bacon return null; 808d307f52SEvan Bacon } 818d307f52SEvan Bacon throw error; 828d307f52SEvan Bacon } 838d307f52SEvan Bacon} 848d307f52SEvan Bacon 858d307f52SEvan Bacon/** Return a value from an installed app's Info.plist. */ 868d307f52SEvan Baconexport async function getInfoPlistValueAsync( 878d307f52SEvan Bacon device: Partial<DeviceContext>, 888d307f52SEvan Bacon { 898d307f52SEvan Bacon appId, 908d307f52SEvan Bacon key, 918d307f52SEvan Bacon }: { 928d307f52SEvan Bacon appId: string; 938d307f52SEvan Bacon key: string; 948d307f52SEvan Bacon } 958d307f52SEvan Bacon): Promise<string | null> { 968d307f52SEvan Bacon const containerPath = await getContainerPathAsync(device, { appId }); 978d307f52SEvan Bacon if (containerPath) { 988d307f52SEvan Bacon try { 998d307f52SEvan Bacon const { output } = await spawnAsync('defaults', ['read', `${containerPath}/Info`, key], { 1008d307f52SEvan Bacon stdio: 'pipe', 1018d307f52SEvan Bacon }); 1028d307f52SEvan Bacon return output.join('\n').trim(); 1038d307f52SEvan Bacon } catch { 1048d307f52SEvan Bacon return null; 1058d307f52SEvan Bacon } 1068d307f52SEvan Bacon } 1078d307f52SEvan Bacon return null; 1088d307f52SEvan Bacon} 1098d307f52SEvan Bacon 1108d307f52SEvan Bacon/** Open a URL on a device. The url can have any protocol. */ 1118d307f52SEvan Baconexport async function openUrlAsync( 1128d307f52SEvan Bacon device: Partial<DeviceContext>, 1138d307f52SEvan Bacon options: { url: string } 1148d307f52SEvan Bacon): Promise<void> { 1158d307f52SEvan Bacon try { 1168d307f52SEvan Bacon // Skip logging since this is likely to fail. 1178d307f52SEvan Bacon await simctlAsync(['openurl', resolveId(device), options.url]); 1188d307f52SEvan Bacon } catch (error: any) { 1198d307f52SEvan Bacon if (!error.stderr?.match(/Unable to lookup in current state: Shut/)) { 1208d307f52SEvan Bacon throw error; 1218d307f52SEvan Bacon } 1228d307f52SEvan Bacon 1238d307f52SEvan Bacon // If the device was in a weird in-between state ("Shutting Down" or "Shutdown"), then attempt to reboot it and try again. 1248d307f52SEvan Bacon // This can happen when quitting the Simulator app, and immediately pressing `i` to reopen the project. 1258d307f52SEvan Bacon 1268d307f52SEvan Bacon // First boot the simulator 1278d307f52SEvan Bacon await bootDeviceAsync({ udid: resolveId(device) }); 1288d307f52SEvan Bacon 1298d307f52SEvan Bacon // Finally, try again... 1308d307f52SEvan Bacon return await openUrlAsync(device, options); 1318d307f52SEvan Bacon } 1328d307f52SEvan Bacon} 1338d307f52SEvan Bacon 1348d307f52SEvan Bacon/** Open a simulator using a bundle identifier. If no app with a matching bundle identifier is installed then an error will be thrown. */ 1358d307f52SEvan Baconexport async function openAppIdAsync( 1368d307f52SEvan Bacon device: Partial<DeviceContext>, 1378d307f52SEvan Bacon options: { 1388d307f52SEvan Bacon appId: string; 1398d307f52SEvan Bacon } 1408d307f52SEvan Bacon): Promise<SpawnResult> { 1418d307f52SEvan Bacon const results = await openAppIdInternalAsync(device, options); 1428d307f52SEvan Bacon // Similar to 194, this is a conformance issue which indicates that the given device has no app that can handle our launch request. 1438d307f52SEvan Bacon if (results.status === 4) { 1448d307f52SEvan Bacon throw new CommandError('APP_NOT_INSTALLED', results.stderr); 1458d307f52SEvan Bacon } 1468d307f52SEvan Bacon return results; 1478d307f52SEvan Bacon} 1488d307f52SEvan Baconasync function openAppIdInternalAsync( 1498d307f52SEvan Bacon device: Partial<DeviceContext>, 1508d307f52SEvan Bacon options: { 1518d307f52SEvan Bacon appId: string; 1528d307f52SEvan Bacon } 1538d307f52SEvan Bacon): Promise<SpawnResult> { 1548d307f52SEvan Bacon try { 1558d307f52SEvan Bacon return await simctlAsync(['launch', resolveId(device), options.appId]); 15629975bfdSEvan Bacon } catch (error: any) { 1578d307f52SEvan Bacon if ('status' in error) { 1588d307f52SEvan Bacon return error; 1598d307f52SEvan Bacon } 1608d307f52SEvan Bacon throw error; 1618d307f52SEvan Bacon } 1628d307f52SEvan Bacon} 1638d307f52SEvan Bacon 1648d307f52SEvan Bacon// This will only boot in headless mode if the Simulator app is not running. 1658d307f52SEvan Baconexport async function bootAsync(device: DeviceContext): Promise<Device | null> { 1668d307f52SEvan Bacon await bootDeviceAsync(device); 1678d307f52SEvan Bacon return isDeviceBootedAsync(device); 1688d307f52SEvan Bacon} 1698d307f52SEvan Bacon 1708d307f52SEvan Bacon/** Returns a list of devices whose current state is 'Booted' as an array. */ 1718d307f52SEvan Baconexport async function getBootedSimulatorsAsync(): Promise<Device[]> { 1728d307f52SEvan Bacon const simulatorDeviceInfo = await getRuntimesAsync('devices'); 1738d307f52SEvan Bacon return Object.values(simulatorDeviceInfo.devices).flatMap((runtime) => 1748d307f52SEvan Bacon runtime.filter((device) => device.state === 'Booted') 1758d307f52SEvan Bacon ); 1768d307f52SEvan Bacon} 1778d307f52SEvan Bacon 1788d307f52SEvan Bacon/** Returns the current device if its state is 'Booted'. */ 1798d307f52SEvan Baconexport async function isDeviceBootedAsync(device: Partial<DeviceContext>): Promise<Device | null> { 1808d307f52SEvan Bacon // Simulators can be booted even if the app isn't running :( 1818d307f52SEvan Bacon const devices = await getBootedSimulatorsAsync(); 1828d307f52SEvan Bacon if (device.udid) { 1838d307f52SEvan Bacon return devices.find((bootedDevice) => bootedDevice.udid === device.udid) ?? null; 1848d307f52SEvan Bacon } 1858d307f52SEvan Bacon 1868d307f52SEvan Bacon return devices[0] ?? null; 1878d307f52SEvan Bacon} 1888d307f52SEvan Bacon 1898d307f52SEvan Bacon/** Boot a device. */ 1908d307f52SEvan Baconexport async function bootDeviceAsync(device: DeviceContext): Promise<void> { 1918d307f52SEvan Bacon try { 1928d307f52SEvan Bacon // Skip logging since this is likely to fail. 1938d307f52SEvan Bacon await simctlAsync(['boot', device.udid]); 1948d307f52SEvan Bacon } catch (error: any) { 1958d307f52SEvan Bacon if (!error.stderr?.match(/Unable to boot device in current state: Booted/)) { 1968d307f52SEvan Bacon throw error; 1978d307f52SEvan Bacon } 1988d307f52SEvan Bacon } 1998d307f52SEvan Bacon} 2008d307f52SEvan Bacon 2018d307f52SEvan Bacon/** Install a binary file on the device. */ 2028d307f52SEvan Baconexport async function installAsync( 2038d307f52SEvan Bacon device: Partial<DeviceContext>, 2048d307f52SEvan Bacon options: { 2058d307f52SEvan Bacon /** Local absolute file path to an app binary that is built and provisioned for iOS simulators. */ 2068d307f52SEvan Bacon filePath: string; 2078d307f52SEvan Bacon } 2088d307f52SEvan Bacon): Promise<any> { 2098d307f52SEvan Bacon return simctlAsync(['install', resolveId(device), options.filePath]); 2108d307f52SEvan Bacon} 2118d307f52SEvan Bacon 2128d307f52SEvan Bacon/** Uninstall an app from the provided device. */ 2138d307f52SEvan Baconexport async function uninstallAsync( 2148d307f52SEvan Bacon device: Partial<DeviceContext>, 2158d307f52SEvan Bacon options: { 2168d307f52SEvan Bacon /** Bundle identifier */ 2178d307f52SEvan Bacon appId: string; 2188d307f52SEvan Bacon } 2198d307f52SEvan Bacon): Promise<any> { 2208d307f52SEvan Bacon return simctlAsync(['uninstall', resolveId(device), options.appId]); 2218d307f52SEvan Bacon} 2228d307f52SEvan Bacon 2238d307f52SEvan Baconfunction parseSimControlJSONResults(input: string): any { 2248d307f52SEvan Bacon try { 2258d307f52SEvan Bacon return JSON.parse(input); 2268d307f52SEvan Bacon } catch (error: any) { 2278d307f52SEvan Bacon // Nov 15, 2020: Observed this can happen when opening the simulator and the simulator prompts the user to update the xcode command line tools. 2288d307f52SEvan Bacon // Unexpected token I in JSON at position 0 2298d307f52SEvan Bacon if (error.message.includes('Unexpected token')) { 2308d307f52SEvan Bacon Log.error(`Apple's simctl returned malformed JSON:\n${input}`); 2318d307f52SEvan Bacon } 2328d307f52SEvan Bacon throw error; 2338d307f52SEvan Bacon } 2348d307f52SEvan Bacon} 2358d307f52SEvan Bacon 2368d307f52SEvan Bacon/** Get all runtime devices given a certain type. */ 2378d307f52SEvan Baconasync function getRuntimesAsync( 2388d307f52SEvan Bacon type: 'devices' | 'devicetypes' | 'runtimes' | 'pairs', 2398d307f52SEvan Bacon query?: string | 'available' 2408d307f52SEvan Bacon): Promise<SimulatorDeviceList> { 2418d307f52SEvan Bacon const result = await simctlAsync(['list', type, '--json', query]); 2428d307f52SEvan Bacon const info = parseSimControlJSONResults(result.stdout) as SimulatorDeviceList; 2438d307f52SEvan Bacon 2448d307f52SEvan Bacon for (const runtime of Object.keys(info.devices)) { 2458d307f52SEvan Bacon // Given a string like 'com.apple.CoreSimulator.SimRuntime.tvOS-13-4' 2468d307f52SEvan Bacon const runtimeSuffix = runtime.split('com.apple.CoreSimulator.SimRuntime.').pop()!; 2478d307f52SEvan Bacon // Create an array [tvOS, 13, 4] 2488d307f52SEvan Bacon const [osType, ...osVersionComponents] = runtimeSuffix.split('-'); 2498d307f52SEvan Bacon // Join the end components [13, 4] -> '13.4' 2508d307f52SEvan Bacon const osVersion = osVersionComponents.join('.'); 2518d307f52SEvan Bacon const sims = info.devices[runtime]; 2528d307f52SEvan Bacon for (const device of sims) { 2538d307f52SEvan Bacon device.runtime = runtime; 2548d307f52SEvan Bacon device.osVersion = osVersion; 2558d307f52SEvan Bacon device.windowName = `${device.name} (${osVersion})`; 2568d307f52SEvan Bacon device.osType = osType as OSType; 2578d307f52SEvan Bacon } 2588d307f52SEvan Bacon } 2598d307f52SEvan Bacon return info; 2608d307f52SEvan Bacon} 2618d307f52SEvan Bacon 2628d307f52SEvan Bacon/** Return a list of iOS simulators. */ 2638d307f52SEvan Baconexport async function getDevicesAsync(): Promise<Device[]> { 2648d307f52SEvan Bacon const simulatorDeviceInfo = await getRuntimesAsync('devices'); 2658d307f52SEvan Bacon return Object.values(simulatorDeviceInfo.devices).flat(); 2668d307f52SEvan Bacon} 2678d307f52SEvan Bacon 2688d307f52SEvan Bacon/** Run a `simctl` command. */ 2698d307f52SEvan Baconexport async function simctlAsync( 2708d307f52SEvan Bacon args: (string | undefined)[], 2718d307f52SEvan Bacon options?: SpawnOptions 2728d307f52SEvan Bacon): Promise<SpawnResult> { 2738d307f52SEvan Bacon return xcrunAsync(['simctl', ...args], options); 2748d307f52SEvan Bacon} 2758d307f52SEvan Bacon 2768d307f52SEvan Baconfunction resolveId(device: Partial<DeviceContext>): string { 2778d307f52SEvan Bacon return device.udid ?? 'booted'; 2788d307f52SEvan Bacon} 279