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