1import { getConfig } from '@expo/config';
2import assert from 'assert';
3import chalk from 'chalk';
4
5import { Log } from '../../log';
6import { logEventAsync } from '../../utils/analytics/rudderstackClient';
7import { CommandError, UnimplementedError } from '../../utils/errors';
8import { learnMore } from '../../utils/link';
9import { AppIdResolver } from './AppIdResolver';
10import { DeviceManager } from './DeviceManager';
11
12const debug = require('debug')('expo:start:platforms:platformManager') as typeof console.log;
13
14export interface BaseOpenInCustomProps {
15  scheme?: string;
16  applicationId?: string | null;
17}
18
19export interface BaseResolveDeviceProps<IDevice> {
20  /** Should prompt the user to select a device. */
21  shouldPrompt?: boolean;
22  /** The target device to use. */
23  device?: IDevice;
24}
25
26/** An abstract class for launching a URL on a device. */
27export class PlatformManager<
28  IDevice,
29  IOpenInCustomProps extends BaseOpenInCustomProps = BaseOpenInCustomProps,
30  IResolveDeviceProps extends BaseResolveDeviceProps<IDevice> = BaseResolveDeviceProps<IDevice>
31> {
32  constructor(
33    protected projectRoot: string,
34    protected props: {
35      platform: 'ios' | 'android';
36      /** Get the base URL for the dev server hosting this platform manager. */
37      getDevServerUrl: () => string | null;
38      /** Expo Go URL. */
39      getExpoGoUrl: () => string;
40      /**
41       * Get redirect URL for native disambiguation.
42       * @returns a URL like `http://localhost:19000/_expo/loading`
43       */
44      getRedirectUrl: () => string | null;
45      /** Dev Client */
46      getCustomRuntimeUrl: (props?: { scheme?: string }) => string | null;
47      /** Resolve a device, this function should automatically handle opening the device and asserting any system validations. */
48      resolveDeviceAsync: (
49        resolver?: Partial<IResolveDeviceProps>
50      ) => Promise<DeviceManager<IDevice>>;
51    }
52  ) {}
53
54  /** Returns the project application identifier or asserts that one is not defined. Exposed for testing. */
55  _getAppIdResolver(): AppIdResolver {
56    throw new UnimplementedError();
57  }
58
59  /**
60   * Get the URL for users intending to launch the project in Expo Go.
61   * The CLI will check if the project has a custom dev client and if the redirect page feature is enabled.
62   * If both are true, the CLI will return the redirect page URL.
63   */
64  protected async getExpoGoOrCustomRuntimeUrlAsync(
65    deviceManager: DeviceManager<IDevice>
66  ): Promise<string> {
67    // Determine if the redirect page feature is enabled first since it's the cheapest to check.
68    const redirectUrl = this.props.getRedirectUrl();
69    if (redirectUrl) {
70      // If the redirect page feature is enabled, check if the project has a resolvable native identifier.
71      let applicationId;
72      try {
73        applicationId = await this._getAppIdResolver().getAppIdAsync();
74      } catch {
75        Log.warn(
76          chalk`\u203A Launching in Expo Go. If you want to use a ` +
77            `development build, you need to create and install one first, or, if you already ` +
78            chalk`have a build, add {bold ios.bundleIdentifier} and {bold android.package} to ` +
79            `this project's app config.\n${learnMore('https://docs.expo.dev/development/build/')}`
80        );
81      }
82      if (applicationId) {
83        debug(`Resolving launch URL: (appId: ${applicationId}, redirect URL: ${redirectUrl})`);
84        // NOTE(EvanBacon): This adds considerable amount of time to the command, we should consider removing or memoizing it.
85        // Finally determine if the target device has a custom dev client installed.
86        if (await deviceManager.isAppInstalledAsync(applicationId)) {
87          return redirectUrl;
88        } else {
89          // Log a warning if no development build is available on the device, but the
90          // interstitial page would otherwise be opened.
91          Log.warn(
92            chalk`\u203A The {bold expo-dev-client} package is installed, but a development build is not ` +
93              chalk`installed on {bold ${deviceManager.name}}.\nLaunching in Expo Go. If you want to use a ` +
94              `development build, you need to create and install one first.\n${learnMore(
95                'https://docs.expo.dev/development/build/'
96              )}`
97          );
98        }
99      }
100    }
101
102    return this.props.getExpoGoUrl();
103  }
104
105  protected async openProjectInExpoGoAsync(
106    resolveSettings: Partial<IResolveDeviceProps> = {}
107  ): Promise<{ url: string }> {
108    const deviceManager = await this.props.resolveDeviceAsync(resolveSettings);
109    const url = await this.getExpoGoOrCustomRuntimeUrlAsync(deviceManager);
110
111    deviceManager.logOpeningUrl(url);
112
113    // TODO: Expensive, we should only do this once.
114    const { exp } = getConfig(this.projectRoot);
115    const installedExpo = await deviceManager.ensureExpoGoAsync(exp.sdkVersion);
116
117    deviceManager.activateWindowAsync();
118    await deviceManager.openUrlAsync(url);
119
120    await logEventAsync('Open Url on Device', {
121      platform: this.props.platform,
122      installedExpo,
123    });
124
125    return { url };
126  }
127
128  private async openProjectInCustomRuntimeAsync(
129    resolveSettings: Partial<IResolveDeviceProps> = {},
130    props: Partial<IOpenInCustomProps> = {}
131  ): Promise<{ url: string }> {
132    debug(
133      `open custom (${Object.entries(props)
134        .map(([k, v]) => `${k}: ${v}`)
135        .join(', ')})`
136    );
137
138    let url = this.props.getCustomRuntimeUrl({ scheme: props.scheme });
139    debug(`Opening project in custom runtime: ${url} -- %O`, props);
140    // TODO: It's unclear why we do application id validation when opening with a URL
141    const applicationId = props.applicationId ?? (await this._getAppIdResolver().getAppIdAsync());
142
143    const deviceManager = await this.props.resolveDeviceAsync(resolveSettings);
144
145    if (!(await deviceManager.isAppInstalledAsync(applicationId))) {
146      throw new CommandError(
147        `No development build (${applicationId}) for this project is installed. ` +
148          `Please make and install a development build on the device first.\n${learnMore(
149            'https://docs.expo.dev/development/build/'
150          )}`
151      );
152    }
153
154    // TODO: Rethink analytics
155    await logEventAsync('Open Url on Device', {
156      platform: this.props.platform,
157      installedExpo: false,
158    });
159
160    if (!url) {
161      url = this._resolveAlternativeLaunchUrl(applicationId, props);
162    }
163
164    deviceManager.logOpeningUrl(url);
165    await deviceManager.activateWindowAsync();
166    await deviceManager.openUrlAsync(url);
167
168    return {
169      url,
170    };
171  }
172
173  /** Launch the project on a device given the input runtime. */
174  async openAsync(
175    options:
176      | {
177          runtime: 'expo' | 'web';
178        }
179      | {
180          runtime: 'custom';
181          props?: Partial<IOpenInCustomProps>;
182        },
183    resolveSettings: Partial<IResolveDeviceProps> = {}
184  ): Promise<{ url: string }> {
185    debug(
186      `open (runtime: ${options.runtime}, platform: ${this.props.platform}, device: %O, shouldPrompt: ${resolveSettings.shouldPrompt})`,
187      resolveSettings.device
188    );
189    if (options.runtime === 'expo') {
190      return this.openProjectInExpoGoAsync(resolveSettings);
191    } else if (options.runtime === 'web') {
192      return this.openWebProjectAsync(resolveSettings);
193    } else if (options.runtime === 'custom') {
194      return this.openProjectInCustomRuntimeAsync(resolveSettings, options.props);
195    } else {
196      throw new CommandError(`Invalid runtime target: ${options.runtime}`);
197    }
198  }
199
200  /** Open the current web project (Webpack) in a device . */
201  private async openWebProjectAsync(resolveSettings: Partial<IResolveDeviceProps> = {}): Promise<{
202    url: string;
203  }> {
204    const url = this.props.getDevServerUrl();
205    assert(url, 'Dev server is not running.');
206
207    const deviceManager = await this.props.resolveDeviceAsync(resolveSettings);
208    deviceManager.logOpeningUrl(url);
209    await deviceManager.activateWindowAsync();
210    await deviceManager.openUrlAsync(url);
211
212    return { url };
213  }
214
215  /** 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. */
216  _resolveAlternativeLaunchUrl(
217    applicationId: string,
218    props: Partial<IOpenInCustomProps> = {}
219  ): string {
220    throw new UnimplementedError();
221  }
222}
223