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