1import semver from 'semver';
2
3import type { DeviceManager } from './DeviceManager';
4import { getVersionsAsync } from '../../api/getVersions';
5import * as Log from '../../log';
6import { downloadExpoGoAsync } from '../../utils/downloadExpoGoAsync';
7import { env } from '../../utils/env';
8import { CommandError } from '../../utils/errors';
9import { logNewSection } from '../../utils/ora';
10import { confirmAsync } from '../../utils/prompts';
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
58    if (await this.isClientOutdatedAsync(deviceManager)) {
59      if (this.sdkVersion === 'UNVERSIONED') {
60        // This should only happen in the expo/expo repo, e.g. `apps/test-suite`
61        Log.log(
62          `Skipping Expo Go upgrade check for UNVERSIONED project. Manually ensure the Expo Go app is built from source.`
63        );
64        return false;
65      }
66
67      // Only prompt once per device, per run.
68      const confirm = await confirmAsync({
69        initial: true,
70        message: `Expo Go on ${deviceManager.name} is outdated, would you like to upgrade?`,
71      });
72      if (confirm) {
73        // Don't need to uninstall to update on iOS.
74        if (this.platform !== 'ios') {
75          Log.log(`Uninstalling Expo Go from ${this.platform} device ${deviceManager.name}.`);
76          await deviceManager.uninstallAppAsync(this.appId);
77        }
78        return true;
79      }
80    }
81    return false;
82  }
83
84  /** Check if a given device has Expo Go installed, if not then download and install it. */
85  async ensureAsync(deviceManager: DeviceManager<IDevice>): Promise<boolean> {
86    let shouldInstall = !(await deviceManager.isAppInstalledAsync(this.appId));
87
88    if (env.EXPO_OFFLINE) {
89      if (!shouldInstall) {
90        Log.warn(`Skipping Expo Go version validation in offline mode`);
91        return false;
92      }
93      throw new CommandError(
94        'NO_EXPO_GO',
95        `Expo Go is not installed on device "${deviceManager.name}", while running in offline mode. Manually install Expo Go or run without --offline flag (or EXPO_OFFLINE environment variable).`
96      );
97    }
98
99    if (!shouldInstall) {
100      shouldInstall = await this.uninstallExpoGoIfOutdatedAsync(deviceManager);
101    }
102
103    if (shouldInstall) {
104      // Download the Expo Go app from the Expo servers.
105      const binaryPath = await downloadExpoGoAsync(this.platform, { sdkVersion: this.sdkVersion });
106      // Install the app on the device.
107      const ora = logNewSection(`Installing Expo Go on ${deviceManager.name}`);
108      try {
109        await deviceManager.installAppAsync(binaryPath);
110      } finally {
111        ora.stop();
112      }
113      return true;
114    }
115    return false;
116  }
117}
118