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