18d307f52SEvan Baconimport { getConfig } from '@expo/config';
28d307f52SEvan Baconimport assert from 'assert';
3212e3a1aSEric Samelsonimport chalk from 'chalk';
48d307f52SEvan Bacon
5*8a424bebSJames Ideimport { AppIdResolver } from './AppIdResolver';
6*8a424bebSJames Ideimport { DeviceManager } from './DeviceManager';
7212e3a1aSEric Samelsonimport { Log } from '../../log';
8ea99eec9SEvan Baconimport { logEventAsync } from '../../utils/analytics/rudderstackClient';
98d307f52SEvan Baconimport { CommandError, UnimplementedError } from '../../utils/errors';
108d307f52SEvan Baconimport { learnMore } from '../../utils/link';
118d307f52SEvan Bacon
12474a7a4bSEvan Baconconst debug = require('debug')('expo:start:platforms:platformManager') as typeof console.log;
13474a7a4bSEvan Bacon
148d307f52SEvan Baconexport interface BaseOpenInCustomProps {
158d307f52SEvan Bacon  scheme?: string;
168d307f52SEvan Bacon  applicationId?: string | null;
178d307f52SEvan Bacon}
188d307f52SEvan Bacon
198d307f52SEvan Baconexport interface BaseResolveDeviceProps<IDevice> {
208d307f52SEvan Bacon  /** Should prompt the user to select a device. */
218d307f52SEvan Bacon  shouldPrompt?: boolean;
228d307f52SEvan Bacon  /** The target device to use. */
238d307f52SEvan Bacon  device?: IDevice;
248d307f52SEvan Bacon}
258d307f52SEvan Bacon
268d307f52SEvan Bacon/** An abstract class for launching a URL on a device. */
278d307f52SEvan Baconexport class PlatformManager<
288d307f52SEvan Bacon  IDevice,
298d307f52SEvan Bacon  IOpenInCustomProps extends BaseOpenInCustomProps = BaseOpenInCustomProps,
30*8a424bebSJames Ide  IResolveDeviceProps extends BaseResolveDeviceProps<IDevice> = BaseResolveDeviceProps<IDevice>,
318d307f52SEvan Bacon> {
328d307f52SEvan Bacon  constructor(
338d307f52SEvan Bacon    protected projectRoot: string,
348d307f52SEvan Bacon    protected props: {
358d307f52SEvan Bacon      platform: 'ios' | 'android';
368d307f52SEvan Bacon      /** Get the base URL for the dev server hosting this platform manager. */
378d307f52SEvan Bacon      getDevServerUrl: () => string | null;
38212e3a1aSEric Samelson      /** Expo Go URL. */
39212e3a1aSEric Samelson      getExpoGoUrl: () => string;
40212e3a1aSEric Samelson      /**
41212e3a1aSEric Samelson       * Get redirect URL for native disambiguation.
4247d62600SKudo Chien       * @returns a URL like `http://localhost:8081/_expo/loading`
43212e3a1aSEric Samelson       */
44212e3a1aSEric Samelson      getRedirectUrl: () => string | null;
458d307f52SEvan Bacon      /** Dev Client */
468d307f52SEvan Bacon      getCustomRuntimeUrl: (props?: { scheme?: string }) => string | null;
478d307f52SEvan Bacon      /** Resolve a device, this function should automatically handle opening the device and asserting any system validations. */
488d307f52SEvan Bacon      resolveDeviceAsync: (
498d307f52SEvan Bacon        resolver?: Partial<IResolveDeviceProps>
508d307f52SEvan Bacon      ) => Promise<DeviceManager<IDevice>>;
518d307f52SEvan Bacon    }
528d307f52SEvan Bacon  ) {}
538d307f52SEvan Bacon
548d307f52SEvan Bacon  /** Returns the project application identifier or asserts that one is not defined. Exposed for testing. */
558d307f52SEvan Bacon  _getAppIdResolver(): AppIdResolver {
568d307f52SEvan Bacon    throw new UnimplementedError();
578d307f52SEvan Bacon  }
588d307f52SEvan Bacon
59212e3a1aSEric Samelson  /**
60212e3a1aSEric Samelson   * Get the URL for users intending to launch the project in Expo Go.
61212e3a1aSEric Samelson   * The CLI will check if the project has a custom dev client and if the redirect page feature is enabled.
62212e3a1aSEric Samelson   * If both are true, the CLI will return the redirect page URL.
63212e3a1aSEric Samelson   */
64212e3a1aSEric Samelson  protected async getExpoGoOrCustomRuntimeUrlAsync(
65212e3a1aSEric Samelson    deviceManager: DeviceManager<IDevice>
66212e3a1aSEric Samelson  ): Promise<string> {
67212e3a1aSEric Samelson    // Determine if the redirect page feature is enabled first since it's the cheapest to check.
68212e3a1aSEric Samelson    const redirectUrl = this.props.getRedirectUrl();
69212e3a1aSEric Samelson    if (redirectUrl) {
70212e3a1aSEric Samelson      // If the redirect page feature is enabled, check if the project has a resolvable native identifier.
71cf262328SEric Samelson      let applicationId;
72cf262328SEric Samelson      try {
73cf262328SEric Samelson        applicationId = await this._getAppIdResolver().getAppIdAsync();
74cf262328SEric Samelson      } catch {
75cf262328SEric Samelson        Log.warn(
76cf262328SEric Samelson          chalk`\u203A Launching in Expo Go. If you want to use a ` +
77cf262328SEric Samelson            `development build, you need to create and install one first, or, if you already ` +
78cf262328SEric Samelson            chalk`have a build, add {bold ios.bundleIdentifier} and {bold android.package} to ` +
79cf262328SEric Samelson            `this project's app config.\n${learnMore('https://docs.expo.dev/development/build/')}`
80cf262328SEric Samelson        );
81cf262328SEric Samelson      }
82212e3a1aSEric Samelson      if (applicationId) {
83212e3a1aSEric Samelson        debug(`Resolving launch URL: (appId: ${applicationId}, redirect URL: ${redirectUrl})`);
84212e3a1aSEric Samelson        // NOTE(EvanBacon): This adds considerable amount of time to the command, we should consider removing or memoizing it.
85212e3a1aSEric Samelson        // Finally determine if the target device has a custom dev client installed.
86212e3a1aSEric Samelson        if (await deviceManager.isAppInstalledAsync(applicationId)) {
87212e3a1aSEric Samelson          return redirectUrl;
88212e3a1aSEric Samelson        } else {
89212e3a1aSEric Samelson          // Log a warning if no development build is available on the device, but the
90212e3a1aSEric Samelson          // interstitial page would otherwise be opened.
91212e3a1aSEric Samelson          Log.warn(
92212e3a1aSEric Samelson            chalk`\u203A The {bold expo-dev-client} package is installed, but a development build is not ` +
93212e3a1aSEric Samelson              chalk`installed on {bold ${deviceManager.name}}.\nLaunching in Expo Go. If you want to use a ` +
94212e3a1aSEric Samelson              `development build, you need to create and install one first.\n${learnMore(
95212e3a1aSEric Samelson                'https://docs.expo.dev/development/build/'
96212e3a1aSEric Samelson              )}`
97212e3a1aSEric Samelson          );
98212e3a1aSEric Samelson        }
99212e3a1aSEric Samelson      }
100212e3a1aSEric Samelson    }
101212e3a1aSEric Samelson
102212e3a1aSEric Samelson    return this.props.getExpoGoUrl();
103212e3a1aSEric Samelson  }
104212e3a1aSEric Samelson
1058d307f52SEvan Bacon  protected async openProjectInExpoGoAsync(
1068d307f52SEvan Bacon    resolveSettings: Partial<IResolveDeviceProps> = {}
1078d307f52SEvan Bacon  ): Promise<{ url: string }> {
1088d307f52SEvan Bacon    const deviceManager = await this.props.resolveDeviceAsync(resolveSettings);
109212e3a1aSEric Samelson    const url = await this.getExpoGoOrCustomRuntimeUrlAsync(deviceManager);
110212e3a1aSEric Samelson
1118d307f52SEvan Bacon    deviceManager.logOpeningUrl(url);
1128d307f52SEvan Bacon
1138d307f52SEvan Bacon    // TODO: Expensive, we should only do this once.
1148d307f52SEvan Bacon    const { exp } = getConfig(this.projectRoot);
1158d307f52SEvan Bacon    const installedExpo = await deviceManager.ensureExpoGoAsync(exp.sdkVersion);
1168d307f52SEvan Bacon
11788643930SEvan Bacon    deviceManager.activateWindowAsync();
1188d307f52SEvan Bacon    await deviceManager.openUrlAsync(url);
1198d307f52SEvan Bacon
120ea99eec9SEvan Bacon    await logEventAsync('Open Url on Device', {
1218d307f52SEvan Bacon      platform: this.props.platform,
1228d307f52SEvan Bacon      installedExpo,
1238d307f52SEvan Bacon    });
1248d307f52SEvan Bacon
1258d307f52SEvan Bacon    return { url };
1268d307f52SEvan Bacon  }
1278d307f52SEvan Bacon
1288d307f52SEvan Bacon  private async openProjectInCustomRuntimeAsync(
1298d307f52SEvan Bacon    resolveSettings: Partial<IResolveDeviceProps> = {},
1308d307f52SEvan Bacon    props: Partial<IOpenInCustomProps> = {}
1318d307f52SEvan Bacon  ): Promise<{ url: string }> {
132474a7a4bSEvan Bacon    debug(
1333d6e487dSEvan Bacon      `open custom (${Object.entries(props)
1343d6e487dSEvan Bacon        .map(([k, v]) => `${k}: ${v}`)
1353d6e487dSEvan Bacon        .join(', ')})`
1363d6e487dSEvan Bacon    );
1373d6e487dSEvan Bacon
1388d307f52SEvan Bacon    let url = this.props.getCustomRuntimeUrl({ scheme: props.scheme });
139474a7a4bSEvan Bacon    debug(`Opening project in custom runtime: ${url} -- %O`, props);
1408d307f52SEvan Bacon    // TODO: It's unclear why we do application id validation when opening with a URL
1418d307f52SEvan Bacon    const applicationId = props.applicationId ?? (await this._getAppIdResolver().getAppIdAsync());
1428d307f52SEvan Bacon
1438d307f52SEvan Bacon    const deviceManager = await this.props.resolveDeviceAsync(resolveSettings);
1448d307f52SEvan Bacon
1458d307f52SEvan Bacon    if (!(await deviceManager.isAppInstalledAsync(applicationId))) {
1468d307f52SEvan Bacon      throw new CommandError(
147212e3a1aSEric Samelson        `No development build (${applicationId}) for this project is installed. ` +
148212e3a1aSEric Samelson          `Please make and install a development build on the device first.\n${learnMore(
1498d307f52SEvan Bacon            'https://docs.expo.dev/development/build/'
1508d307f52SEvan Bacon          )}`
1518d307f52SEvan Bacon      );
1528d307f52SEvan Bacon    }
1538d307f52SEvan Bacon
1548d307f52SEvan Bacon    // TODO: Rethink analytics
155ea99eec9SEvan Bacon    await logEventAsync('Open Url on Device', {
1568d307f52SEvan Bacon      platform: this.props.platform,
1578d307f52SEvan Bacon      installedExpo: false,
1588d307f52SEvan Bacon    });
1598d307f52SEvan Bacon
1608d307f52SEvan Bacon    if (!url) {
1618d307f52SEvan Bacon      url = this._resolveAlternativeLaunchUrl(applicationId, props);
1628d307f52SEvan Bacon    }
1638d307f52SEvan Bacon
1648d307f52SEvan Bacon    deviceManager.logOpeningUrl(url);
1658d307f52SEvan Bacon    await deviceManager.activateWindowAsync();
1668d307f52SEvan Bacon    await deviceManager.openUrlAsync(url);
1678d307f52SEvan Bacon
1688d307f52SEvan Bacon    return {
1698d307f52SEvan Bacon      url,
1708d307f52SEvan Bacon    };
1718d307f52SEvan Bacon  }
1728d307f52SEvan Bacon
1738d307f52SEvan Bacon  /** Launch the project on a device given the input runtime. */
1748d307f52SEvan Bacon  async openAsync(
1758d307f52SEvan Bacon    options:
1768d307f52SEvan Bacon      | {
1778d307f52SEvan Bacon          runtime: 'expo' | 'web';
1788d307f52SEvan Bacon        }
1798d307f52SEvan Bacon      | {
1808d307f52SEvan Bacon          runtime: 'custom';
1818d307f52SEvan Bacon          props?: Partial<IOpenInCustomProps>;
1828d307f52SEvan Bacon        },
1838d307f52SEvan Bacon    resolveSettings: Partial<IResolveDeviceProps> = {}
1848d307f52SEvan Bacon  ): Promise<{ url: string }> {
185474a7a4bSEvan Bacon    debug(
1863d6e487dSEvan Bacon      `open (runtime: ${options.runtime}, platform: ${this.props.platform}, device: %O, shouldPrompt: ${resolveSettings.shouldPrompt})`,
1873d6e487dSEvan Bacon      resolveSettings.device
1883d6e487dSEvan Bacon    );
1898d307f52SEvan Bacon    if (options.runtime === 'expo') {
1908d307f52SEvan Bacon      return this.openProjectInExpoGoAsync(resolveSettings);
1918d307f52SEvan Bacon    } else if (options.runtime === 'web') {
1928d307f52SEvan Bacon      return this.openWebProjectAsync(resolveSettings);
1938d307f52SEvan Bacon    } else if (options.runtime === 'custom') {
1948d307f52SEvan Bacon      return this.openProjectInCustomRuntimeAsync(resolveSettings, options.props);
1958d307f52SEvan Bacon    } else {
1968d307f52SEvan Bacon      throw new CommandError(`Invalid runtime target: ${options.runtime}`);
1978d307f52SEvan Bacon    }
1988d307f52SEvan Bacon  }
1998d307f52SEvan Bacon
2008d307f52SEvan Bacon  /** Open the current web project (Webpack) in a device . */
2018d307f52SEvan Bacon  private async openWebProjectAsync(resolveSettings: Partial<IResolveDeviceProps> = {}): Promise<{
2028d307f52SEvan Bacon    url: string;
2038d307f52SEvan Bacon  }> {
2048d307f52SEvan Bacon    const url = this.props.getDevServerUrl();
2058d307f52SEvan Bacon    assert(url, 'Dev server is not running.');
2068d307f52SEvan Bacon
2078d307f52SEvan Bacon    const deviceManager = await this.props.resolveDeviceAsync(resolveSettings);
2088d307f52SEvan Bacon    deviceManager.logOpeningUrl(url);
2098d307f52SEvan Bacon    await deviceManager.activateWindowAsync();
2108d307f52SEvan Bacon    await deviceManager.openUrlAsync(url);
2118d307f52SEvan Bacon
2128d307f52SEvan Bacon    return { url };
2138d307f52SEvan Bacon  }
2148d307f52SEvan Bacon
2158d307f52SEvan Bacon  /** If the launch URL cannot be determined (`custom` runtimes) then an alternative string can be provided to open the device. Often a device ID or activity to launch. Exposed for testing. */
2168d307f52SEvan Bacon  _resolveAlternativeLaunchUrl(
2178d307f52SEvan Bacon    applicationId: string,
2188d307f52SEvan Bacon    props: Partial<IOpenInCustomProps> = {}
2198d307f52SEvan Bacon  ): string {
2208d307f52SEvan Bacon    throw new UnimplementedError();
2218d307f52SEvan Bacon  }
2228d307f52SEvan Bacon}
223