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    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        errorMessage += `\nThe app might not be installed, try installing it with: ${chalk.bold(
119          `expo run:ios -d ${this.device.udid}`
120        )}`;
121      }
122      if (error.stderr) {
123        errorMessage += chalk.gray(`\n${error.stderr}`);
124      } else if (error.message) {
125        errorMessage += chalk.gray(`\n${error.message}`);
126      }
127      throw new CommandError(errorMessage);
128    }
129  }
130
131  async installAppAsync(filePath: string) {
132    await SimControl.installAsync(this.device, {
133      filePath,
134    });
135
136    await this.waitForAppInstalledAsync(await this.getApplicationIdFromBundle(filePath));
137  }
138
139  private async getApplicationIdFromBundle(filePath: string): Promise<string> {
140    // TODO: Implement...
141    return EXPO_GO_BUNDLE_IDENTIFIER;
142  }
143
144  private async waitForAppInstalledAsync(applicationId: string): Promise<boolean> {
145    while (true) {
146      if (await this.isAppInstalledAsync(applicationId)) {
147        return true;
148      }
149      await delayAsync(100);
150    }
151  }
152
153  async uninstallAppAsync(appId: string) {
154    await SimControl.uninstallAsync(this.device, {
155      appId,
156    });
157  }
158
159  async isAppInstalledAsync(appId: string) {
160    return !!(await SimControl.getContainerPathAsync(this.device, {
161      appId,
162    }));
163  }
164
165  async openUrlAsync(url: string) {
166    // Non-compliant URLs will be treated as application identifiers.
167    if (!validateUrl(url, { requireProtocol: true })) {
168      return await this.launchApplicationIdAsync(url);
169    }
170
171    try {
172      await SimControl.openUrlAsync(this.device, { url });
173    } catch (error: any) {
174      // 194 means the device does not conform to a given URL, in this case we'll assume that the desired app is not installed.
175      if (error.status === 194) {
176        // An error was encountered processing the command (domain=NSOSStatusErrorDomain, code=-10814):
177        // The operation couldn’t be completed. (OSStatus error -10814.)
178        //
179        // This can be thrown when no app conforms to the URI scheme that we attempted to open.
180        throw new CommandError(
181          'APP_NOT_INSTALLED',
182          `Device ${this.device.name} (${this.device.udid}) has no app to handle the URI: ${url}`
183        );
184      }
185      throw error;
186    }
187  }
188
189  async activateWindowAsync() {
190    await ensureSimulatorAppRunningAsync(this.device);
191    // TODO: Focus the individual window
192    await osascript.execAsync(`tell application "Simulator" to activate`);
193  }
194
195  async ensureExpoGoAsync(sdkVersion?: string): Promise<boolean> {
196    const installer = new ExpoGoInstaller('ios', EXPO_GO_BUNDLE_IDENTIFIER, sdkVersion);
197    return installer.ensureAsync(this);
198  }
199}
200