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