18d307f52SEvan Baconimport * as osascript from '@expo/osascript';
229975bfdSEvan Baconimport assert from 'assert';
38d307f52SEvan Baconimport chalk from 'chalk';
447558c3cSAnthony Mittazimport fs from 'fs';
547558c3cSAnthony Mittazimport path from 'path';
68d307f52SEvan Bacon
78d307f52SEvan Baconimport { assertSystemRequirementsAsync } from './assertSystemRequirements';
88d307f52SEvan Baconimport { ensureSimulatorAppRunningAsync } from './ensureSimulatorAppRunning';
98d307f52SEvan Baconimport {
108d307f52SEvan Bacon  getBestBootedSimulatorAsync,
118d307f52SEvan Bacon  getBestUnbootedSimulatorAsync,
128d307f52SEvan Bacon  getSelectableSimulatorsAsync,
138d307f52SEvan Bacon} from './getBestSimulator';
148d307f52SEvan Baconimport { promptAppleDeviceAsync } from './promptAppleDevice';
158d307f52SEvan Baconimport * as SimControl from './simctl';
16*8a424bebSJames Ideimport { delayAsync, waitForActionAsync } from '../../../utils/delay';
17*8a424bebSJames Ideimport { CommandError } from '../../../utils/errors';
18*8a424bebSJames Ideimport { parsePlistAsync } from '../../../utils/plist';
19*8a424bebSJames Ideimport { validateUrl } from '../../../utils/url';
20*8a424bebSJames Ideimport { DeviceManager } from '../DeviceManager';
21*8a424bebSJames Ideimport { ExpoGoInstaller } from '../ExpoGoInstaller';
22*8a424bebSJames Ideimport { BaseResolveDeviceProps } from '../PlatformManager';
238d307f52SEvan Bacon
2447558c3cSAnthony Mittazconst debug = require('debug')('expo:start:platforms:ios:AppleDeviceManager') as typeof console.log;
2547558c3cSAnthony Mittaz
268d307f52SEvan Baconconst EXPO_GO_BUNDLE_IDENTIFIER = 'host.exp.Exponent';
278d307f52SEvan Bacon
288d307f52SEvan Bacon/**
298d307f52SEvan Bacon * Ensure a simulator is booted and the Simulator app is opened.
308d307f52SEvan Bacon * This is where any timeout related error handling should live.
318d307f52SEvan Bacon */
328d307f52SEvan Baconexport async function ensureSimulatorOpenAsync(
338d307f52SEvan Bacon  { udid, osType }: Partial<Pick<SimControl.Device, 'udid' | 'osType'>> = {},
348d307f52SEvan Bacon  tryAgain: boolean = true
358d307f52SEvan Bacon): Promise<SimControl.Device> {
368d307f52SEvan Bacon  // Use a default simulator if none was specified
378d307f52SEvan Bacon  if (!udid) {
388d307f52SEvan Bacon    // If a simulator is open, side step the entire booting sequence.
398d307f52SEvan Bacon    const simulatorOpenedByApp = await getBestBootedSimulatorAsync({ osType });
408d307f52SEvan Bacon    if (simulatorOpenedByApp) {
418d307f52SEvan Bacon      return simulatorOpenedByApp;
428d307f52SEvan Bacon    }
438d307f52SEvan Bacon
448d307f52SEvan Bacon    // Otherwise, find the best possible simulator from user defaults and continue
4529975bfdSEvan Bacon    const bestUdid = await getBestUnbootedSimulatorAsync({ osType });
4629975bfdSEvan Bacon    if (!bestUdid) {
4729975bfdSEvan Bacon      throw new CommandError('No simulators found.');
4829975bfdSEvan Bacon    }
4929975bfdSEvan Bacon    udid = bestUdid;
508d307f52SEvan Bacon  }
518d307f52SEvan Bacon
528d307f52SEvan Bacon  const bootedDevice = await waitForActionAsync({
5329975bfdSEvan Bacon    action: () => {
5429975bfdSEvan Bacon      // Just for the type check.
5529975bfdSEvan Bacon      assert(udid);
5629975bfdSEvan Bacon      return SimControl.bootAsync({ udid });
5729975bfdSEvan Bacon    },
588d307f52SEvan Bacon  });
598d307f52SEvan Bacon
608d307f52SEvan Bacon  if (!bootedDevice) {
618d307f52SEvan Bacon    // Give it a second chance, this might not be needed but it could potentially lead to a better UX on slower devices.
628d307f52SEvan Bacon    if (tryAgain) {
638d307f52SEvan Bacon      return await ensureSimulatorOpenAsync({ udid, osType }, false);
648d307f52SEvan Bacon    }
658d307f52SEvan Bacon    // 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.
668d307f52SEvan Bacon    throw new CommandError(
678d307f52SEvan Bacon      'SIMULATOR_TIMEOUT',
688d307f52SEvan Bacon      `Simulator didn't boot fast enough. Try opening Simulator first, then running your app.`
698d307f52SEvan Bacon    );
708d307f52SEvan Bacon  }
718d307f52SEvan Bacon  return bootedDevice;
728d307f52SEvan Bacon}
738d307f52SEvan Baconexport class AppleDeviceManager extends DeviceManager<SimControl.Device> {
748d307f52SEvan Bacon  static assertSystemRequirementsAsync = assertSystemRequirementsAsync;
758d307f52SEvan Bacon
768d307f52SEvan Bacon  static async resolveAsync({
778d307f52SEvan Bacon    device,
788d307f52SEvan Bacon    shouldPrompt,
798d307f52SEvan Bacon  }: BaseResolveDeviceProps<
80c4ef02aeSEvan Bacon    Partial<Pick<SimControl.Device, 'udid' | 'osType'>>
818d307f52SEvan Bacon  > = {}): Promise<AppleDeviceManager> {
828d307f52SEvan Bacon    if (shouldPrompt) {
838d307f52SEvan Bacon      const devices = await getSelectableSimulatorsAsync(device);
848d307f52SEvan Bacon      device = await promptAppleDeviceAsync(devices, device?.osType);
858d307f52SEvan Bacon    }
868d307f52SEvan Bacon
878d307f52SEvan Bacon    const booted = await ensureSimulatorOpenAsync(device);
888d307f52SEvan Bacon    return new AppleDeviceManager(booted);
898d307f52SEvan Bacon  }
908d307f52SEvan Bacon
918d307f52SEvan Bacon  get name() {
928d307f52SEvan Bacon    return this.device.name;
938d307f52SEvan Bacon  }
948d307f52SEvan Bacon
958d307f52SEvan Bacon  get identifier(): string {
968d307f52SEvan Bacon    return this.device.udid;
978d307f52SEvan Bacon  }
988d307f52SEvan Bacon
998d307f52SEvan Bacon  async getAppVersionAsync(appId: string): Promise<string | null> {
1008d307f52SEvan Bacon    return await SimControl.getInfoPlistValueAsync(this.device, {
1018d307f52SEvan Bacon      appId,
1028d307f52SEvan Bacon      key: 'CFBundleShortVersionString',
1038d307f52SEvan Bacon    });
1048d307f52SEvan Bacon  }
1058d307f52SEvan Bacon
1068d307f52SEvan Bacon  async startAsync(): Promise<SimControl.Device> {
1078d307f52SEvan Bacon    return ensureSimulatorOpenAsync({ osType: this.device.osType, udid: this.device.udid });
1088d307f52SEvan Bacon  }
1098d307f52SEvan Bacon
1108d307f52SEvan Bacon  async launchApplicationIdAsync(appId: string) {
1118d307f52SEvan Bacon    try {
1128d307f52SEvan Bacon      const result = await SimControl.openAppIdAsync(this.device, {
1138d307f52SEvan Bacon        appId,
1148d307f52SEvan Bacon      });
1158d307f52SEvan Bacon      if (result.status === 0) {
1168d307f52SEvan Bacon        await this.activateWindowAsync();
1178d307f52SEvan Bacon      } else {
1188d307f52SEvan Bacon        throw new CommandError(result.stderr);
1198d307f52SEvan Bacon      }
12029975bfdSEvan Bacon    } catch (error: any) {
1218d307f52SEvan Bacon      let errorMessage = `Couldn't open iOS app with ID "${appId}" on device "${this.name}".`;
1228d307f52SEvan Bacon      if (error instanceof CommandError && error.code === 'APP_NOT_INSTALLED') {
123c4ef02aeSEvan Bacon        if (appId === EXPO_GO_BUNDLE_IDENTIFIER) {
124c4ef02aeSEvan Bacon          errorMessage = `Couldn't open Expo Go app on device "${this.name}". Please install.`;
125c4ef02aeSEvan Bacon        } else {
1268d307f52SEvan Bacon          errorMessage += `\nThe app might not be installed, try installing it with: ${chalk.bold(
127384598e2SBrent Vatne            `npx expo run:ios -d ${this.device.udid}`
1288d307f52SEvan Bacon          )}`;
1298d307f52SEvan Bacon        }
130c4ef02aeSEvan Bacon      }
1318d307f52SEvan Bacon      if (error.stderr) {
1328d307f52SEvan Bacon        errorMessage += chalk.gray(`\n${error.stderr}`);
1338d307f52SEvan Bacon      } else if (error.message) {
1348d307f52SEvan Bacon        errorMessage += chalk.gray(`\n${error.message}`);
1358d307f52SEvan Bacon      }
1368d307f52SEvan Bacon      throw new CommandError(errorMessage);
1378d307f52SEvan Bacon    }
1388d307f52SEvan Bacon  }
1398d307f52SEvan Bacon
1408d307f52SEvan Bacon  async installAppAsync(filePath: string) {
1418d307f52SEvan Bacon    await SimControl.installAsync(this.device, {
1428d307f52SEvan Bacon      filePath,
1438d307f52SEvan Bacon    });
1448d307f52SEvan Bacon
1458d307f52SEvan Bacon    await this.waitForAppInstalledAsync(await this.getApplicationIdFromBundle(filePath));
1468d307f52SEvan Bacon  }
1478d307f52SEvan Bacon
1488d307f52SEvan Bacon  private async getApplicationIdFromBundle(filePath: string): Promise<string> {
14947558c3cSAnthony Mittaz    debug('getApplicationIdFromBundle:', filePath);
15047558c3cSAnthony Mittaz    const builtInfoPlistPath = path.join(filePath, 'Info.plist');
15147558c3cSAnthony Mittaz    if (fs.existsSync(builtInfoPlistPath)) {
15247558c3cSAnthony Mittaz      const { CFBundleIdentifier } = await parsePlistAsync(builtInfoPlistPath);
15347558c3cSAnthony Mittaz      debug('getApplicationIdFromBundle: using built Info.plist', CFBundleIdentifier);
15447558c3cSAnthony Mittaz      return CFBundleIdentifier;
15547558c3cSAnthony Mittaz    }
15647558c3cSAnthony Mittaz    debug('getApplicationIdFromBundle: no Info.plist found');
1578d307f52SEvan Bacon    return EXPO_GO_BUNDLE_IDENTIFIER;
1588d307f52SEvan Bacon  }
1598d307f52SEvan Bacon
1608d307f52SEvan Bacon  private async waitForAppInstalledAsync(applicationId: string): Promise<boolean> {
1618d307f52SEvan Bacon    while (true) {
1628d307f52SEvan Bacon      if (await this.isAppInstalledAsync(applicationId)) {
1638d307f52SEvan Bacon        return true;
1648d307f52SEvan Bacon      }
1658d307f52SEvan Bacon      await delayAsync(100);
1668d307f52SEvan Bacon    }
1678d307f52SEvan Bacon  }
1688d307f52SEvan Bacon
1698d307f52SEvan Bacon  async uninstallAppAsync(appId: string) {
1708d307f52SEvan Bacon    await SimControl.uninstallAsync(this.device, {
1718d307f52SEvan Bacon      appId,
1728d307f52SEvan Bacon    });
1738d307f52SEvan Bacon  }
1748d307f52SEvan Bacon
1758d307f52SEvan Bacon  async isAppInstalledAsync(appId: string) {
1768d307f52SEvan Bacon    return !!(await SimControl.getContainerPathAsync(this.device, {
1778d307f52SEvan Bacon      appId,
1788d307f52SEvan Bacon    }));
1798d307f52SEvan Bacon  }
1808d307f52SEvan Bacon
1818d307f52SEvan Bacon  async openUrlAsync(url: string) {
1828d307f52SEvan Bacon    // Non-compliant URLs will be treated as application identifiers.
1838d307f52SEvan Bacon    if (!validateUrl(url, { requireProtocol: true })) {
1848d307f52SEvan Bacon      return await this.launchApplicationIdAsync(url);
1858d307f52SEvan Bacon    }
1868d307f52SEvan Bacon
1878d307f52SEvan Bacon    try {
1888d307f52SEvan Bacon      await SimControl.openUrlAsync(this.device, { url });
18929975bfdSEvan Bacon    } catch (error: any) {
1908d307f52SEvan Bacon      // 194 means the device does not conform to a given URL, in this case we'll assume that the desired app is not installed.
1918d307f52SEvan Bacon      if (error.status === 194) {
1928d307f52SEvan Bacon        // An error was encountered processing the command (domain=NSOSStatusErrorDomain, code=-10814):
1938d307f52SEvan Bacon        // The operation couldn’t be completed. (OSStatus error -10814.)
1948d307f52SEvan Bacon        //
1958d307f52SEvan Bacon        // This can be thrown when no app conforms to the URI scheme that we attempted to open.
1968d307f52SEvan Bacon        throw new CommandError(
1978d307f52SEvan Bacon          'APP_NOT_INSTALLED',
1988d307f52SEvan Bacon          `Device ${this.device.name} (${this.device.udid}) has no app to handle the URI: ${url}`
1998d307f52SEvan Bacon        );
2008d307f52SEvan Bacon      }
2018d307f52SEvan Bacon      throw error;
2028d307f52SEvan Bacon    }
2038d307f52SEvan Bacon  }
2048d307f52SEvan Bacon
2058d307f52SEvan Bacon  async activateWindowAsync() {
2068d307f52SEvan Bacon    await ensureSimulatorAppRunningAsync(this.device);
2078d307f52SEvan Bacon    // TODO: Focus the individual window
2088d307f52SEvan Bacon    await osascript.execAsync(`tell application "Simulator" to activate`);
2098d307f52SEvan Bacon  }
2108d307f52SEvan Bacon
2118d307f52SEvan Bacon  async ensureExpoGoAsync(sdkVersion?: string): Promise<boolean> {
2128d307f52SEvan Bacon    const installer = new ExpoGoInstaller('ios', EXPO_GO_BUNDLE_IDENTIFIER, sdkVersion);
2138d307f52SEvan Bacon    return installer.ensureAsync(this);
2148d307f52SEvan Bacon  }
2158d307f52SEvan Bacon}
216