1import * as osascript from '@expo/osascript';
2import assert from 'assert';
3import chalk from 'chalk';
4import fs from 'fs';
5import path from 'path';
6
7import { assertSystemRequirementsAsync } from './assertSystemRequirements';
8import { ensureSimulatorAppRunningAsync } from './ensureSimulatorAppRunning';
9import {
10  getBestBootedSimulatorAsync,
11  getBestUnbootedSimulatorAsync,
12  getSelectableSimulatorsAsync,
13} from './getBestSimulator';
14import { promptAppleDeviceAsync } from './promptAppleDevice';
15import * as SimControl from './simctl';
16import { delayAsync, waitForActionAsync } from '../../../utils/delay';
17import { CommandError } from '../../../utils/errors';
18import { parsePlistAsync } from '../../../utils/plist';
19import { validateUrl } from '../../../utils/url';
20import { DeviceManager } from '../DeviceManager';
21import { ExpoGoInstaller } from '../ExpoGoInstaller';
22import { BaseResolveDeviceProps } from '../PlatformManager';
23
24const debug = require('debug')('expo:start:platforms:ios:AppleDeviceManager') as typeof console.log;
25
26const EXPO_GO_BUNDLE_IDENTIFIER = 'host.exp.Exponent';
27
28/**
29 * Ensure a simulator is booted and the Simulator app is opened.
30 * This is where any timeout related error handling should live.
31 */
32export async function ensureSimulatorOpenAsync(
33  { udid, osType }: Partial<Pick<SimControl.Device, 'udid' | 'osType'>> = {},
34  tryAgain: boolean = true
35): Promise<SimControl.Device> {
36  // Use a default simulator if none was specified
37  if (!udid) {
38    // If a simulator is open, side step the entire booting sequence.
39    const simulatorOpenedByApp = await getBestBootedSimulatorAsync({ osType });
40    if (simulatorOpenedByApp) {
41      return simulatorOpenedByApp;
42    }
43
44    // Otherwise, find the best possible simulator from user defaults and continue
45    const bestUdid = await getBestUnbootedSimulatorAsync({ osType });
46    if (!bestUdid) {
47      throw new CommandError('No simulators found.');
48    }
49    udid = bestUdid;
50  }
51
52  const bootedDevice = await waitForActionAsync({
53    action: () => {
54      // Just for the type check.
55      assert(udid);
56      return SimControl.bootAsync({ udid });
57    },
58  });
59
60  if (!bootedDevice) {
61    // Give it a second chance, this might not be needed but it could potentially lead to a better UX on slower devices.
62    if (tryAgain) {
63      return await ensureSimulatorOpenAsync({ udid, osType }, false);
64    }
65    // TODO: We should eliminate all needs for a timeout error, it's bad UX to get an error about the simulator not starting while the user can clearly see it starting on their slow computer.
66    throw new CommandError(
67      'SIMULATOR_TIMEOUT',
68      `Simulator didn't boot fast enough. Try opening Simulator first, then running your app.`
69    );
70  }
71  return bootedDevice;
72}
73export class AppleDeviceManager extends DeviceManager<SimControl.Device> {
74  static assertSystemRequirementsAsync = assertSystemRequirementsAsync;
75
76  static async resolveAsync({
77    device,
78    shouldPrompt,
79  }: BaseResolveDeviceProps<
80    Partial<Pick<SimControl.Device, 'udid' | 'osType'>>
81  > = {}): Promise<AppleDeviceManager> {
82    if (shouldPrompt) {
83      const devices = await getSelectableSimulatorsAsync(device);
84      device = await promptAppleDeviceAsync(devices, device?.osType);
85    }
86
87    const booted = await ensureSimulatorOpenAsync(device);
88    return new AppleDeviceManager(booted);
89  }
90
91  get name() {
92    return this.device.name;
93  }
94
95  get identifier(): string {
96    return this.device.udid;
97  }
98
99  async getAppVersionAsync(appId: string): Promise<string | null> {
100    return await SimControl.getInfoPlistValueAsync(this.device, {
101      appId,
102      key: 'CFBundleShortVersionString',
103    });
104  }
105
106  async startAsync(): Promise<SimControl.Device> {
107    return ensureSimulatorOpenAsync({ osType: this.device.osType, udid: this.device.udid });
108  }
109
110  async launchApplicationIdAsync(appId: string) {
111    try {
112      const result = await SimControl.openAppIdAsync(this.device, {
113        appId,
114      });
115      if (result.status === 0) {
116        await this.activateWindowAsync();
117      } else {
118        throw new CommandError(result.stderr);
119      }
120    } catch (error: any) {
121      let errorMessage = `Couldn't open iOS app with ID "${appId}" on device "${this.name}".`;
122      if (error instanceof CommandError && error.code === 'APP_NOT_INSTALLED') {
123        if (appId === EXPO_GO_BUNDLE_IDENTIFIER) {
124          errorMessage = `Couldn't open Expo Go app on device "${this.name}". Please install.`;
125        } else {
126          errorMessage += `\nThe app might not be installed, try installing it with: ${chalk.bold(
127            `npx expo run:ios -d ${this.device.udid}`
128          )}`;
129        }
130      }
131      if (error.stderr) {
132        errorMessage += chalk.gray(`\n${error.stderr}`);
133      } else if (error.message) {
134        errorMessage += chalk.gray(`\n${error.message}`);
135      }
136      throw new CommandError(errorMessage);
137    }
138  }
139
140  async installAppAsync(filePath: string) {
141    await SimControl.installAsync(this.device, {
142      filePath,
143    });
144
145    await this.waitForAppInstalledAsync(await this.getApplicationIdFromBundle(filePath));
146  }
147
148  private async getApplicationIdFromBundle(filePath: string): Promise<string> {
149    debug('getApplicationIdFromBundle:', filePath);
150    const builtInfoPlistPath = path.join(filePath, 'Info.plist');
151    if (fs.existsSync(builtInfoPlistPath)) {
152      const { CFBundleIdentifier } = await parsePlistAsync(builtInfoPlistPath);
153      debug('getApplicationIdFromBundle: using built Info.plist', CFBundleIdentifier);
154      return CFBundleIdentifier;
155    }
156    debug('getApplicationIdFromBundle: no Info.plist found');
157    return EXPO_GO_BUNDLE_IDENTIFIER;
158  }
159
160  private async waitForAppInstalledAsync(applicationId: string): Promise<boolean> {
161    while (true) {
162      if (await this.isAppInstalledAsync(applicationId)) {
163        return true;
164      }
165      await delayAsync(100);
166    }
167  }
168
169  async uninstallAppAsync(appId: string) {
170    await SimControl.uninstallAsync(this.device, {
171      appId,
172    });
173  }
174
175  async isAppInstalledAsync(appId: string) {
176    return !!(await SimControl.getContainerPathAsync(this.device, {
177      appId,
178    }));
179  }
180
181  async openUrlAsync(url: string) {
182    // Non-compliant URLs will be treated as application identifiers.
183    if (!validateUrl(url, { requireProtocol: true })) {
184      return await this.launchApplicationIdAsync(url);
185    }
186
187    try {
188      await SimControl.openUrlAsync(this.device, { url });
189    } catch (error: any) {
190      // 194 means the device does not conform to a given URL, in this case we'll assume that the desired app is not installed.
191      if (error.status === 194) {
192        // An error was encountered processing the command (domain=NSOSStatusErrorDomain, code=-10814):
193        // The operation couldn’t be completed. (OSStatus error -10814.)
194        //
195        // This can be thrown when no app conforms to the URI scheme that we attempted to open.
196        throw new CommandError(
197          'APP_NOT_INSTALLED',
198          `Device ${this.device.name} (${this.device.udid}) has no app to handle the URI: ${url}`
199        );
200      }
201      throw error;
202    }
203  }
204
205  async activateWindowAsync() {
206    await ensureSimulatorAppRunningAsync(this.device);
207    // TODO: Focus the individual window
208    await osascript.execAsync(`tell application "Simulator" to activate`);
209  }
210
211  async ensureExpoGoAsync(sdkVersion?: string): Promise<boolean> {
212    const installer = new ExpoGoInstaller('ios', EXPO_GO_BUNDLE_IDENTIFIER, sdkVersion);
213    return installer.ensureAsync(this);
214  }
215}
216