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
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