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      const applicationId = await this._getAppIdResolver().getAppIdAsync();
72      if (applicationId) {
73        debug(`Resolving launch URL: (appId: ${applicationId}, redirect URL: ${redirectUrl})`);
74        // NOTE(EvanBacon): This adds considerable amount of time to the command, we should consider removing or memoizing it.
75        // Finally determine if the target device has a custom dev client installed.
76        if (await deviceManager.isAppInstalledAsync(applicationId)) {
77          return redirectUrl;
78        } else {
79          // Log a warning if no development build is available on the device, but the
80          // interstitial page would otherwise be opened.
81          Log.warn(
82            chalk`\u203A The {bold expo-dev-client} package is installed, but a development build is not ` +
83              chalk`installed on {bold ${deviceManager.name}}.\nLaunching in Expo Go. If you want to use a ` +
84              `development build, you need to create and install one first.\n${learnMore(
85                'https://docs.expo.dev/development/build/'
86              )}`
87          );
88        }
89      }
90    }
91
92    return this.props.getExpoGoUrl();
93  }
94
95  protected async openProjectInExpoGoAsync(
96    resolveSettings: Partial<IResolveDeviceProps> = {}
97  ): Promise<{ url: string }> {
98    const deviceManager = await this.props.resolveDeviceAsync(resolveSettings);
99    const url = await this.getExpoGoOrCustomRuntimeUrlAsync(deviceManager);
100
101    deviceManager.logOpeningUrl(url);
102
103    // TODO: Expensive, we should only do this once.
104    const { exp } = getConfig(this.projectRoot);
105    const installedExpo = await deviceManager.ensureExpoGoAsync(exp.sdkVersion);
106
107    deviceManager.activateWindowAsync();
108    await deviceManager.openUrlAsync(url);
109
110    await logEventAsync('Open Url on Device', {
111      platform: this.props.platform,
112      installedExpo,
113    });
114
115    return { url };
116  }
117
118  private async openProjectInCustomRuntimeAsync(
119    resolveSettings: Partial<IResolveDeviceProps> = {},
120    props: Partial<IOpenInCustomProps> = {}
121  ): Promise<{ url: string }> {
122    debug(
123      `open custom (${Object.entries(props)
124        .map(([k, v]) => `${k}: ${v}`)
125        .join(', ')})`
126    );
127
128    let url = this.props.getCustomRuntimeUrl({ scheme: props.scheme });
129    debug(`Opening project in custom runtime: ${url} -- %O`, props);
130    // TODO: It's unclear why we do application id validation when opening with a URL
131    const applicationId = props.applicationId ?? (await this._getAppIdResolver().getAppIdAsync());
132
133    const deviceManager = await this.props.resolveDeviceAsync(resolveSettings);
134
135    if (!(await deviceManager.isAppInstalledAsync(applicationId))) {
136      throw new CommandError(
137        `No development build (${applicationId}) for this project is installed. ` +
138          `Please make and install a development build on the device first.\n${learnMore(
139            'https://docs.expo.dev/development/build/'
140          )}`
141      );
142    }
143
144    // TODO: Rethink analytics
145    await logEventAsync('Open Url on Device', {
146      platform: this.props.platform,
147      installedExpo: false,
148    });
149
150    if (!url) {
151      url = this._resolveAlternativeLaunchUrl(applicationId, props);
152    }
153
154    deviceManager.logOpeningUrl(url);
155    await deviceManager.activateWindowAsync();
156    await deviceManager.openUrlAsync(url);
157
158    return {
159      url,
160    };
161  }
162
163  /** Launch the project on a device given the input runtime. */
164  async openAsync(
165    options:
166      | {
167          runtime: 'expo' | 'web';
168        }
169      | {
170          runtime: 'custom';
171          props?: Partial<IOpenInCustomProps>;
172        },
173    resolveSettings: Partial<IResolveDeviceProps> = {}
174  ): Promise<{ url: string }> {
175    debug(
176      `open (runtime: ${options.runtime}, platform: ${this.props.platform}, device: %O, shouldPrompt: ${resolveSettings.shouldPrompt})`,
177      resolveSettings.device
178    );
179    if (options.runtime === 'expo') {
180      return this.openProjectInExpoGoAsync(resolveSettings);
181    } else if (options.runtime === 'web') {
182      return this.openWebProjectAsync(resolveSettings);
183    } else if (options.runtime === 'custom') {
184      return this.openProjectInCustomRuntimeAsync(resolveSettings, options.props);
185    } else {
186      throw new CommandError(`Invalid runtime target: ${options.runtime}`);
187    }
188  }
189
190  /** Open the current web project (Webpack) in a device . */
191  private async openWebProjectAsync(resolveSettings: Partial<IResolveDeviceProps> = {}): Promise<{
192    url: string;
193  }> {
194    const url = this.props.getDevServerUrl();
195    assert(url, 'Dev server is not running.');
196
197    const deviceManager = await this.props.resolveDeviceAsync(resolveSettings);
198    deviceManager.logOpeningUrl(url);
199    await deviceManager.activateWindowAsync();
200    await deviceManager.openUrlAsync(url);
201
202    return { url };
203  }
204
205  /** 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. */
206  _resolveAlternativeLaunchUrl(
207    applicationId: string,
208    props: Partial<IOpenInCustomProps> = {}
209  ): string {
210    throw new UnimplementedError();
211  }
212}
213