1import { getConfig } from '@expo/config'; 2import assert from 'assert'; 3import chalk from 'chalk'; 4 5import { Log } from '../../log'; 6import { logEventAsync } from '../../utils/analytics/rudderstackClient'; 7import { CommandError, UnimplementedError } from '../../utils/errors'; 8import { learnMore } from '../../utils/link'; 9import { AppIdResolver } from './AppIdResolver'; 10import { DeviceManager } from './DeviceManager'; 11 12const debug = require('debug')('expo:start:platforms:platformManager') as typeof console.log; 13 14export interface BaseOpenInCustomProps { 15 scheme?: string; 16 applicationId?: string | null; 17} 18 19export interface BaseResolveDeviceProps<IDevice> { 20 /** Should prompt the user to select a device. */ 21 shouldPrompt?: boolean; 22 /** The target device to use. */ 23 device?: IDevice; 24} 25 26/** An abstract class for launching a URL on a device. */ 27export class PlatformManager< 28 IDevice, 29 IOpenInCustomProps extends BaseOpenInCustomProps = BaseOpenInCustomProps, 30 IResolveDeviceProps extends BaseResolveDeviceProps<IDevice> = BaseResolveDeviceProps<IDevice> 31> { 32 constructor( 33 protected projectRoot: string, 34 protected props: { 35 platform: 'ios' | 'android'; 36 /** Get the base URL for the dev server hosting this platform manager. */ 37 getDevServerUrl: () => string | null; 38 /** Expo Go URL. */ 39 getExpoGoUrl: () => string; 40 /** 41 * Get redirect URL for native disambiguation. 42 * @returns a URL like `http://localhost:19000/_expo/loading` 43 */ 44 getRedirectUrl: () => string | null; 45 /** Dev Client */ 46 getCustomRuntimeUrl: (props?: { scheme?: string }) => string | null; 47 /** Resolve a device, this function should automatically handle opening the device and asserting any system validations. */ 48 resolveDeviceAsync: ( 49 resolver?: Partial<IResolveDeviceProps> 50 ) => Promise<DeviceManager<IDevice>>; 51 } 52 ) {} 53 54 /** Returns the project application identifier or asserts that one is not defined. Exposed for testing. */ 55 _getAppIdResolver(): AppIdResolver { 56 throw new UnimplementedError(); 57 } 58 59 /** 60 * Get the URL for users intending to launch the project in Expo Go. 61 * The CLI will check if the project has a custom dev client and if the redirect page feature is enabled. 62 * If both are true, the CLI will return the redirect page URL. 63 */ 64 protected async getExpoGoOrCustomRuntimeUrlAsync( 65 deviceManager: DeviceManager<IDevice> 66 ): Promise<string> { 67 // Determine if the redirect page feature is enabled first since it's the cheapest to check. 68 const redirectUrl = this.props.getRedirectUrl(); 69 if (redirectUrl) { 70 // If the redirect page feature is enabled, check if the project has a resolvable native identifier. 71 let applicationId; 72 try { 73 applicationId = await this._getAppIdResolver().getAppIdAsync(); 74 } catch { 75 Log.warn( 76 chalk`\u203A Launching in Expo Go. If you want to use a ` + 77 `development build, you need to create and install one first, or, if you already ` + 78 chalk`have a build, add {bold ios.bundleIdentifier} and {bold android.package} to ` + 79 `this project's app config.\n${learnMore('https://docs.expo.dev/development/build/')}` 80 ); 81 } 82 if (applicationId) { 83 debug(`Resolving launch URL: (appId: ${applicationId}, redirect URL: ${redirectUrl})`); 84 // NOTE(EvanBacon): This adds considerable amount of time to the command, we should consider removing or memoizing it. 85 // Finally determine if the target device has a custom dev client installed. 86 if (await deviceManager.isAppInstalledAsync(applicationId)) { 87 return redirectUrl; 88 } else { 89 // Log a warning if no development build is available on the device, but the 90 // interstitial page would otherwise be opened. 91 Log.warn( 92 chalk`\u203A The {bold expo-dev-client} package is installed, but a development build is not ` + 93 chalk`installed on {bold ${deviceManager.name}}.\nLaunching in Expo Go. If you want to use a ` + 94 `development build, you need to create and install one first.\n${learnMore( 95 'https://docs.expo.dev/development/build/' 96 )}` 97 ); 98 } 99 } 100 } 101 102 return this.props.getExpoGoUrl(); 103 } 104 105 protected async openProjectInExpoGoAsync( 106 resolveSettings: Partial<IResolveDeviceProps> = {} 107 ): Promise<{ url: string }> { 108 const deviceManager = await this.props.resolveDeviceAsync(resolveSettings); 109 const url = await this.getExpoGoOrCustomRuntimeUrlAsync(deviceManager); 110 111 deviceManager.logOpeningUrl(url); 112 113 // TODO: Expensive, we should only do this once. 114 const { exp } = getConfig(this.projectRoot); 115 const installedExpo = await deviceManager.ensureExpoGoAsync(exp.sdkVersion); 116 117 deviceManager.activateWindowAsync(); 118 await deviceManager.openUrlAsync(url); 119 120 await logEventAsync('Open Url on Device', { 121 platform: this.props.platform, 122 installedExpo, 123 }); 124 125 return { url }; 126 } 127 128 private async openProjectInCustomRuntimeAsync( 129 resolveSettings: Partial<IResolveDeviceProps> = {}, 130 props: Partial<IOpenInCustomProps> = {} 131 ): Promise<{ url: string }> { 132 debug( 133 `open custom (${Object.entries(props) 134 .map(([k, v]) => `${k}: ${v}`) 135 .join(', ')})` 136 ); 137 138 let url = this.props.getCustomRuntimeUrl({ scheme: props.scheme }); 139 debug(`Opening project in custom runtime: ${url} -- %O`, props); 140 // TODO: It's unclear why we do application id validation when opening with a URL 141 const applicationId = props.applicationId ?? (await this._getAppIdResolver().getAppIdAsync()); 142 143 const deviceManager = await this.props.resolveDeviceAsync(resolveSettings); 144 145 if (!(await deviceManager.isAppInstalledAsync(applicationId))) { 146 throw new CommandError( 147 `No development build (${applicationId}) for this project is installed. ` + 148 `Please make and install a development build on the device first.\n${learnMore( 149 'https://docs.expo.dev/development/build/' 150 )}` 151 ); 152 } 153 154 // TODO: Rethink analytics 155 await logEventAsync('Open Url on Device', { 156 platform: this.props.platform, 157 installedExpo: false, 158 }); 159 160 if (!url) { 161 url = this._resolveAlternativeLaunchUrl(applicationId, props); 162 } 163 164 deviceManager.logOpeningUrl(url); 165 await deviceManager.activateWindowAsync(); 166 await deviceManager.openUrlAsync(url); 167 168 return { 169 url, 170 }; 171 } 172 173 /** Launch the project on a device given the input runtime. */ 174 async openAsync( 175 options: 176 | { 177 runtime: 'expo' | 'web'; 178 } 179 | { 180 runtime: 'custom'; 181 props?: Partial<IOpenInCustomProps>; 182 }, 183 resolveSettings: Partial<IResolveDeviceProps> = {} 184 ): Promise<{ url: string }> { 185 debug( 186 `open (runtime: ${options.runtime}, platform: ${this.props.platform}, device: %O, shouldPrompt: ${resolveSettings.shouldPrompt})`, 187 resolveSettings.device 188 ); 189 if (options.runtime === 'expo') { 190 return this.openProjectInExpoGoAsync(resolveSettings); 191 } else if (options.runtime === 'web') { 192 return this.openWebProjectAsync(resolveSettings); 193 } else if (options.runtime === 'custom') { 194 return this.openProjectInCustomRuntimeAsync(resolveSettings, options.props); 195 } else { 196 throw new CommandError(`Invalid runtime target: ${options.runtime}`); 197 } 198 } 199 200 /** Open the current web project (Webpack) in a device . */ 201 private async openWebProjectAsync(resolveSettings: Partial<IResolveDeviceProps> = {}): Promise<{ 202 url: string; 203 }> { 204 const url = this.props.getDevServerUrl(); 205 assert(url, 'Dev server is not running.'); 206 207 const deviceManager = await this.props.resolveDeviceAsync(resolveSettings); 208 deviceManager.logOpeningUrl(url); 209 await deviceManager.activateWindowAsync(); 210 await deviceManager.openUrlAsync(url); 211 212 return { url }; 213 } 214 215 /** If the launch URL cannot be determined (`custom` runtimes) then an alternative string can be provided to open the device. Often a device ID or activity to launch. Exposed for testing. */ 216 _resolveAlternativeLaunchUrl( 217 applicationId: string, 218 props: Partial<IOpenInCustomProps> = {} 219 ): string { 220 throw new UnimplementedError(); 221 } 222} 223