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