18d307f52SEvan Baconimport semver from 'semver';
28d307f52SEvan Bacon
3*8a424bebSJames Ideimport type { DeviceManager } from './DeviceManager';
48d307f52SEvan Baconimport { getVersionsAsync } from '../../api/getVersions';
58d307f52SEvan Baconimport * as Log from '../../log';
68d307f52SEvan Baconimport { downloadExpoGoAsync } from '../../utils/downloadExpoGoAsync';
7e32ccf9fSEvan Baconimport { env } from '../../utils/env';
874e3651eSCedric van Puttenimport { CommandError } from '../../utils/errors';
98d307f52SEvan Baconimport { logNewSection } from '../../utils/ora';
108d307f52SEvan Baconimport { confirmAsync } from '../../utils/prompts';
118d307f52SEvan Bacon
12474a7a4bSEvan Baconconst debug = require('debug')('expo:utils:ExpoGoInstaller') as typeof console.log;
13474a7a4bSEvan Bacon
148d307f52SEvan Bacon/** Given a platform, appId, and sdkVersion, this module will ensure that Expo Go is up-to-date on the provided device. */
158d307f52SEvan Baconexport class ExpoGoInstaller<IDevice> {
168d307f52SEvan Bacon  // Keep a list of [platform-deviceId] so we can prevent asking multiple times if a user wants to upgrade.
178d307f52SEvan Bacon  // This can prevent annoying interactions when they don't want to upgrade for whatever reason.
188d307f52SEvan Bacon  static cache: Record<string, boolean> = {};
198d307f52SEvan Bacon
208d307f52SEvan Bacon  constructor(
218d307f52SEvan Bacon    private platform: 'ios' | 'android',
228d307f52SEvan Bacon    // Ultimately this should be inlined since we know the platform.
238d307f52SEvan Bacon    private appId: string,
248d307f52SEvan Bacon    private sdkVersion?: string
258d307f52SEvan Bacon  ) {}
268d307f52SEvan Bacon
278d307f52SEvan Bacon  /** Returns true if the installed app matching the previously provided `appId` is outdated. */
288d307f52SEvan Bacon  async isClientOutdatedAsync(device: DeviceManager<IDevice>): Promise<boolean> {
298d307f52SEvan Bacon    const installedVersion = await device.getAppVersionAsync(this.appId);
308d307f52SEvan Bacon    if (!installedVersion) {
318d307f52SEvan Bacon      return true;
328d307f52SEvan Bacon    }
338d307f52SEvan Bacon    const version = await this._getExpectedClientVersionAsync();
34474a7a4bSEvan Bacon    debug(`Expected Expo Go version: ${version}, installed version: ${installedVersion}`);
356079da0aSEvan Bacon    return version ? !semver.eq(installedVersion, version) : true;
368d307f52SEvan Bacon  }
378d307f52SEvan Bacon
388d307f52SEvan Bacon  /** Returns the expected version of Expo Go given the project SDK Version. Exposed for testing. */
398d307f52SEvan Bacon  async _getExpectedClientVersionAsync(): Promise<string | null> {
408d307f52SEvan Bacon    const versions = await getVersionsAsync();
418d307f52SEvan Bacon    // Like `sdkVersions['44.0.0']['androidClientVersion'] = '1.0.0'`
428d307f52SEvan Bacon    const specificVersion =
4329975bfdSEvan Bacon      versions?.sdkVersions?.[this.sdkVersion!]?.[`${this.platform}ClientVersion`];
448d307f52SEvan Bacon    const latestVersion = versions[`${this.platform}Version`];
458d307f52SEvan Bacon    return specificVersion ?? latestVersion ?? null;
468d307f52SEvan Bacon  }
478d307f52SEvan Bacon
488d307f52SEvan Bacon  /** Returns a boolean indicating if Expo Go should be installed. Returns `true` if the app was uninstalled. */
498d307f52SEvan Bacon  async uninstallExpoGoIfOutdatedAsync(deviceManager: DeviceManager<IDevice>): Promise<boolean> {
508d307f52SEvan Bacon    const cacheId = `${this.platform}-${deviceManager.identifier}`;
518d307f52SEvan Bacon
528d307f52SEvan Bacon    if (ExpoGoInstaller.cache[cacheId]) {
5388643930SEvan Bacon      debug('skipping subsequent upgrade check');
548d307f52SEvan Bacon      return false;
558d307f52SEvan Bacon    }
5688643930SEvan Bacon    ExpoGoInstaller.cache[cacheId] = true;
576a116c5fSEvan Bacon
588d307f52SEvan Bacon    if (await this.isClientOutdatedAsync(deviceManager)) {
596a116c5fSEvan Bacon      if (this.sdkVersion === 'UNVERSIONED') {
606a116c5fSEvan Bacon        // This should only happen in the expo/expo repo, e.g. `apps/test-suite`
616a116c5fSEvan Bacon        Log.log(
626a116c5fSEvan Bacon          `Skipping Expo Go upgrade check for UNVERSIONED project. Manually ensure the Expo Go app is built from source.`
636a116c5fSEvan Bacon        );
646a116c5fSEvan Bacon        return false;
656a116c5fSEvan Bacon      }
666a116c5fSEvan Bacon
678d307f52SEvan Bacon      // Only prompt once per device, per run.
688d307f52SEvan Bacon      const confirm = await confirmAsync({
698d307f52SEvan Bacon        initial: true,
708d307f52SEvan Bacon        message: `Expo Go on ${deviceManager.name} is outdated, would you like to upgrade?`,
718d307f52SEvan Bacon      });
728d307f52SEvan Bacon      if (confirm) {
738d307f52SEvan Bacon        // Don't need to uninstall to update on iOS.
748d307f52SEvan Bacon        if (this.platform !== 'ios') {
758d307f52SEvan Bacon          Log.log(`Uninstalling Expo Go from ${this.platform} device ${deviceManager.name}.`);
768d307f52SEvan Bacon          await deviceManager.uninstallAppAsync(this.appId);
778d307f52SEvan Bacon        }
788d307f52SEvan Bacon        return true;
798d307f52SEvan Bacon      }
808d307f52SEvan Bacon    }
818d307f52SEvan Bacon    return false;
828d307f52SEvan Bacon  }
838d307f52SEvan Bacon
848d307f52SEvan Bacon  /** Check if a given device has Expo Go installed, if not then download and install it. */
858d307f52SEvan Bacon  async ensureAsync(deviceManager: DeviceManager<IDevice>): Promise<boolean> {
868d307f52SEvan Bacon    let shouldInstall = !(await deviceManager.isAppInstalledAsync(this.appId));
878d307f52SEvan Bacon
88e32ccf9fSEvan Bacon    if (env.EXPO_OFFLINE) {
89e32ccf9fSEvan Bacon      if (!shouldInstall) {
9074e3651eSCedric van Putten        Log.warn(`Skipping Expo Go version validation in offline mode`);
9174e3651eSCedric van Putten        return false;
92e32ccf9fSEvan Bacon      }
9374e3651eSCedric van Putten      throw new CommandError(
9474e3651eSCedric van Putten        'NO_EXPO_GO',
95e32ccf9fSEvan Bacon        `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).`
9674e3651eSCedric van Putten      );
9774e3651eSCedric van Putten    }
9874e3651eSCedric van Putten
998d307f52SEvan Bacon    if (!shouldInstall) {
1008d307f52SEvan Bacon      shouldInstall = await this.uninstallExpoGoIfOutdatedAsync(deviceManager);
1018d307f52SEvan Bacon    }
1028d307f52SEvan Bacon
1038d307f52SEvan Bacon    if (shouldInstall) {
1048d307f52SEvan Bacon      // Download the Expo Go app from the Expo servers.
1058a782c0fSEvan Bacon      const binaryPath = await downloadExpoGoAsync(this.platform, { sdkVersion: this.sdkVersion });
1068d307f52SEvan Bacon      // Install the app on the device.
1078d307f52SEvan Bacon      const ora = logNewSection(`Installing Expo Go on ${deviceManager.name}`);
1088d307f52SEvan Bacon      try {
1098d307f52SEvan Bacon        await deviceManager.installAppAsync(binaryPath);
1108d307f52SEvan Bacon      } finally {
1118d307f52SEvan Bacon        ora.stop();
1128d307f52SEvan Bacon      }
1138d307f52SEvan Bacon      return true;
1148d307f52SEvan Bacon    }
1158d307f52SEvan Bacon    return false;
1168d307f52SEvan Bacon  }
1178d307f52SEvan Bacon}
118