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    return version ? semver.lt(installedVersion, version) : true;
31  }
32
33  /** Returns the expected version of Expo Go given the project SDK Version. Exposed for testing. */
34  async _getExpectedClientVersionAsync(): Promise<string | null> {
35    const versions = await getVersionsAsync();
36    // Like `sdkVersions['44.0.0']['androidClientVersion'] = '1.0.0'`
37    const specificVersion =
38      versions?.sdkVersions?.[this.sdkVersion!]?.[`${this.platform}ClientVersion`];
39    const latestVersion = versions[`${this.platform}Version`];
40    return specificVersion ?? latestVersion ?? null;
41  }
42
43  /** Returns a boolean indicating if Expo Go should be installed. Returns `true` if the app was uninstalled. */
44  async uninstallExpoGoIfOutdatedAsync(deviceManager: DeviceManager<IDevice>): Promise<boolean> {
45    const cacheId = `${this.platform}-${deviceManager.identifier}`;
46
47    if (ExpoGoInstaller.cache[cacheId]) {
48      return false;
49    }
50    if (await this.isClientOutdatedAsync(deviceManager)) {
51      // Only prompt once per device, per run.
52      ExpoGoInstaller.cache[cacheId] = true;
53      const confirm = await confirmAsync({
54        initial: true,
55        message: `Expo Go on ${deviceManager.name} is outdated, would you like to upgrade?`,
56      });
57      if (confirm) {
58        // Don't need to uninstall to update on iOS.
59        if (this.platform !== 'ios') {
60          Log.log(`Uninstalling Expo Go from ${this.platform} device ${deviceManager.name}.`);
61          await deviceManager.uninstallAppAsync(this.appId);
62        }
63        return true;
64      }
65    }
66    return false;
67  }
68
69  /** Check if a given device has Expo Go installed, if not then download and install it. */
70  async ensureAsync(deviceManager: DeviceManager<IDevice>): Promise<boolean> {
71    let shouldInstall = !(await deviceManager.isAppInstalledAsync(this.appId));
72
73    if (!shouldInstall) {
74      shouldInstall = await this.uninstallExpoGoIfOutdatedAsync(deviceManager);
75    }
76
77    if (shouldInstall) {
78      // Download the Expo Go app from the Expo servers.
79      const binaryPath = await downloadExpoGoAsync(this.platform);
80      // Install the app on the device.
81      const ora = logNewSection(`Installing Expo Go on ${deviceManager.name}`);
82      try {
83        await deviceManager.installAppAsync(binaryPath);
84      } finally {
85        ora.stop();
86      }
87      return true;
88    }
89    return false;
90  }
91}
92