1import semver from 'semver';
2
3import { getVersionsAsync } from '../../api/getVersions';
4import { APISettings } from '../../api/settings';
5import * as Log from '../../log';
6import { downloadExpoGoAsync } from '../../utils/downloadExpoGoAsync';
7import { CommandError } from '../../utils/errors';
8import { logNewSection } from '../../utils/ora';
9import { confirmAsync } from '../../utils/prompts';
10import type { DeviceManager } from './DeviceManager';
11
12const debug = require('debug')('expo:utils:ExpoGoInstaller') as typeof console.log;
13
14/** Given a platform, appId, and sdkVersion, this module will ensure that Expo Go is up-to-date on the provided device. */
15export class ExpoGoInstaller<IDevice> {
16  // Keep a list of [platform-deviceId] so we can prevent asking multiple times if a user wants to upgrade.
17  // This can prevent annoying interactions when they don't want to upgrade for whatever reason.
18  static cache: Record<string, boolean> = {};
19
20  constructor(
21    private platform: 'ios' | 'android',
22    // Ultimately this should be inlined since we know the platform.
23    private appId: string,
24    private sdkVersion?: string
25  ) {}
26
27  /** Returns true if the installed app matching the previously provided `appId` is outdated. */
28  async isClientOutdatedAsync(device: DeviceManager<IDevice>): Promise<boolean> {
29    const installedVersion = await device.getAppVersionAsync(this.appId);
30    if (!installedVersion) {
31      return true;
32    }
33    const version = await this._getExpectedClientVersionAsync();
34    debug(`Expected Expo Go version: ${version}, installed version: ${installedVersion}`);
35    return version ? !semver.eq(installedVersion, version) : true;
36  }
37
38  /** Returns the expected version of Expo Go given the project SDK Version. Exposed for testing. */
39  async _getExpectedClientVersionAsync(): Promise<string | null> {
40    const versions = await getVersionsAsync();
41    // Like `sdkVersions['44.0.0']['androidClientVersion'] = '1.0.0'`
42    const specificVersion =
43      versions?.sdkVersions?.[this.sdkVersion!]?.[`${this.platform}ClientVersion`];
44    const latestVersion = versions[`${this.platform}Version`];
45    return specificVersion ?? latestVersion ?? null;
46  }
47
48  /** Returns a boolean indicating if Expo Go should be installed. Returns `true` if the app was uninstalled. */
49  async uninstallExpoGoIfOutdatedAsync(deviceManager: DeviceManager<IDevice>): Promise<boolean> {
50    const cacheId = `${this.platform}-${deviceManager.identifier}`;
51
52    if (ExpoGoInstaller.cache[cacheId]) {
53      debug('skipping subsequent upgrade check');
54      return false;
55    }
56    ExpoGoInstaller.cache[cacheId] = true;
57    if (await this.isClientOutdatedAsync(deviceManager)) {
58      // Only prompt once per device, per run.
59      const confirm = await confirmAsync({
60        initial: true,
61        message: `Expo Go on ${deviceManager.name} is outdated, would you like to upgrade?`,
62      });
63      if (confirm) {
64        // Don't need to uninstall to update on iOS.
65        if (this.platform !== 'ios') {
66          Log.log(`Uninstalling Expo Go from ${this.platform} device ${deviceManager.name}.`);
67          await deviceManager.uninstallAppAsync(this.appId);
68        }
69        return true;
70      }
71    }
72    return false;
73  }
74
75  /** Check if a given device has Expo Go installed, if not then download and install it. */
76  async ensureAsync(deviceManager: DeviceManager<IDevice>): Promise<boolean> {
77    let shouldInstall = !(await deviceManager.isAppInstalledAsync(this.appId));
78
79    if (APISettings.isOffline && !shouldInstall) {
80      Log.warn(`Skipping Expo Go version validation in offline mode`);
81      return false;
82    } else if (APISettings.isOffline && shouldInstall) {
83      throw new CommandError(
84        'NO_EXPO_GO',
85        `Expo Go is not installed on device "${deviceManager.name}", while running in offline mode. Manually install Expo Go or run without --offline flag.`
86      );
87    }
88
89    if (!shouldInstall) {
90      shouldInstall = await this.uninstallExpoGoIfOutdatedAsync(deviceManager);
91    }
92
93    if (shouldInstall) {
94      // Download the Expo Go app from the Expo servers.
95      const binaryPath = await downloadExpoGoAsync(this.platform, { sdkVersion: this.sdkVersion });
96      // Install the app on the device.
97      const ora = logNewSection(`Installing Expo Go on ${deviceManager.name}`);
98      try {
99        await deviceManager.installAppAsync(binaryPath);
100      } finally {
101        ora.stop();
102      }
103      return true;
104    }
105    return false;
106  }
107}
108