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