1import { getConfig } from '@expo/config';
2import assert from 'assert';
3
4import { logEvent } from '../../utils/analytics/rudderstackClient';
5import { CommandError, UnimplementedError } from '../../utils/errors';
6import { learnMore } from '../../utils/link';
7import { AppIdResolver } from './AppIdResolver';
8import { DeviceManager } from './DeviceManager';
9
10const debug = require('debug')('expo:start:platforms:platformManager') as typeof console.log;
11
12export interface BaseOpenInCustomProps {
13  scheme?: string;
14  applicationId?: string | null;
15}
16
17export interface BaseResolveDeviceProps<IDevice> {
18  /** Should prompt the user to select a device. */
19  shouldPrompt?: boolean;
20  /** The target device to use. */
21  device?: IDevice;
22}
23
24/** An abstract class for launching a URL on a device. */
25export class PlatformManager<
26  IDevice,
27  IOpenInCustomProps extends BaseOpenInCustomProps = BaseOpenInCustomProps,
28  IResolveDeviceProps extends BaseResolveDeviceProps<IDevice> = BaseResolveDeviceProps<IDevice>
29> {
30  constructor(
31    protected projectRoot: string,
32    protected props: {
33      platform: 'ios' | 'android';
34      /** Get the base URL for the dev server hosting this platform manager. */
35      getDevServerUrl: () => string | null;
36      /** Expo Go URL */
37      getExpoGoUrl: () => string | null;
38      /** Dev Client */
39      getCustomRuntimeUrl: (props?: { scheme?: string }) => string | null;
40      /** Resolve a device, this function should automatically handle opening the device and asserting any system validations. */
41      resolveDeviceAsync: (
42        resolver?: Partial<IResolveDeviceProps>
43      ) => Promise<DeviceManager<IDevice>>;
44    }
45  ) {}
46
47  /** Returns the project application identifier or asserts that one is not defined. Exposed for testing. */
48  _getAppIdResolver(): AppIdResolver {
49    throw new UnimplementedError();
50  }
51
52  protected async openProjectInExpoGoAsync(
53    resolveSettings: Partial<IResolveDeviceProps> = {}
54  ): Promise<{ url: string }> {
55    const url = this.props.getExpoGoUrl();
56    // This should never happen, but just in case...
57    assert(url, 'Could not get dev server URL');
58
59    const deviceManager = await this.props.resolveDeviceAsync(resolveSettings);
60    deviceManager.logOpeningUrl(url);
61
62    // TODO: Expensive, we should only do this once.
63    const { exp } = getConfig(this.projectRoot);
64    const installedExpo = await deviceManager.ensureExpoGoAsync(exp.sdkVersion);
65
66    await deviceManager.activateWindowAsync();
67    await deviceManager.openUrlAsync(url);
68
69    logEvent('Open Url on Device', {
70      platform: this.props.platform,
71      installedExpo,
72    });
73
74    return { url };
75  }
76
77  private async openProjectInCustomRuntimeAsync(
78    resolveSettings: Partial<IResolveDeviceProps> = {},
79    props: Partial<IOpenInCustomProps> = {}
80  ): Promise<{ url: string }> {
81    debug(
82      `open custom (${Object.entries(props)
83        .map(([k, v]) => `${k}: ${v}`)
84        .join(', ')})`
85    );
86
87    let url = this.props.getCustomRuntimeUrl({ scheme: props.scheme });
88    debug(`Opening project in custom runtime: ${url} -- %O`, props);
89    // TODO: It's unclear why we do application id validation when opening with a URL
90    const applicationId = props.applicationId ?? (await this._getAppIdResolver().getAppIdAsync());
91
92    const deviceManager = await this.props.resolveDeviceAsync(resolveSettings);
93
94    if (!(await deviceManager.isAppInstalledAsync(applicationId))) {
95      throw new CommandError(
96        `The development client (${applicationId}) for this project is not installed. ` +
97          `Please build and install the client on the device first.\n${learnMore(
98            'https://docs.expo.dev/development/build/'
99          )}`
100      );
101    }
102
103    // TODO: Rethink analytics
104    logEvent('Open Url on Device', {
105      platform: this.props.platform,
106      installedExpo: false,
107    });
108
109    if (!url) {
110      url = this._resolveAlternativeLaunchUrl(applicationId, props);
111    }
112
113    deviceManager.logOpeningUrl(url);
114    await deviceManager.activateWindowAsync();
115    await deviceManager.openUrlAsync(url);
116
117    return {
118      url,
119    };
120  }
121
122  /** Launch the project on a device given the input runtime. */
123  async openAsync(
124    options:
125      | {
126          runtime: 'expo' | 'web';
127        }
128      | {
129          runtime: 'custom';
130          props?: Partial<IOpenInCustomProps>;
131        },
132    resolveSettings: Partial<IResolveDeviceProps> = {}
133  ): Promise<{ url: string }> {
134    debug(
135      `open (runtime: ${options.runtime}, platform: ${this.props.platform}, device: %O, shouldPrompt: ${resolveSettings.shouldPrompt})`,
136      resolveSettings.device
137    );
138    if (options.runtime === 'expo') {
139      return this.openProjectInExpoGoAsync(resolveSettings);
140    } else if (options.runtime === 'web') {
141      return this.openWebProjectAsync(resolveSettings);
142    } else if (options.runtime === 'custom') {
143      return this.openProjectInCustomRuntimeAsync(resolveSettings, options.props);
144    } else {
145      throw new CommandError(`Invalid runtime target: ${options.runtime}`);
146    }
147  }
148
149  /** Open the current web project (Webpack) in a device . */
150  private async openWebProjectAsync(resolveSettings: Partial<IResolveDeviceProps> = {}): Promise<{
151    url: string;
152  }> {
153    const url = this.props.getDevServerUrl();
154    assert(url, 'Dev server is not running.');
155
156    const deviceManager = await this.props.resolveDeviceAsync(resolveSettings);
157    deviceManager.logOpeningUrl(url);
158    await deviceManager.activateWindowAsync();
159    await deviceManager.openUrlAsync(url);
160
161    return { url };
162  }
163
164  /** 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. */
165  _resolveAlternativeLaunchUrl(
166    applicationId: string,
167    props: Partial<IOpenInCustomProps> = {}
168  ): string {
169    throw new UnimplementedError();
170  }
171}
172