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