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