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 const applicationId = await this._getAppIdResolver().getAppIdAsync(); 72 if (applicationId) { 73 debug(`Resolving launch URL: (appId: ${applicationId}, redirect URL: ${redirectUrl})`); 74 // NOTE(EvanBacon): This adds considerable amount of time to the command, we should consider removing or memoizing it. 75 // Finally determine if the target device has a custom dev client installed. 76 if (await deviceManager.isAppInstalledAsync(applicationId)) { 77 return redirectUrl; 78 } else { 79 // Log a warning if no development build is available on the device, but the 80 // interstitial page would otherwise be opened. 81 Log.warn( 82 chalk`\u203A The {bold expo-dev-client} package is installed, but a development build is not ` + 83 chalk`installed on {bold ${deviceManager.name}}.\nLaunching in Expo Go. If you want to use a ` + 84 `development build, you need to create and install one first.\n${learnMore( 85 'https://docs.expo.dev/development/build/' 86 )}` 87 ); 88 } 89 } 90 } 91 92 return this.props.getExpoGoUrl(); 93 } 94 95 protected async openProjectInExpoGoAsync( 96 resolveSettings: Partial<IResolveDeviceProps> = {} 97 ): Promise<{ url: string }> { 98 const deviceManager = await this.props.resolveDeviceAsync(resolveSettings); 99 const url = await this.getExpoGoOrCustomRuntimeUrlAsync(deviceManager); 100 101 deviceManager.logOpeningUrl(url); 102 103 // TODO: Expensive, we should only do this once. 104 const { exp } = getConfig(this.projectRoot); 105 const installedExpo = await deviceManager.ensureExpoGoAsync(exp.sdkVersion); 106 107 deviceManager.activateWindowAsync(); 108 await deviceManager.openUrlAsync(url); 109 110 await logEventAsync('Open Url on Device', { 111 platform: this.props.platform, 112 installedExpo, 113 }); 114 115 return { url }; 116 } 117 118 private async openProjectInCustomRuntimeAsync( 119 resolveSettings: Partial<IResolveDeviceProps> = {}, 120 props: Partial<IOpenInCustomProps> = {} 121 ): Promise<{ url: string }> { 122 debug( 123 `open custom (${Object.entries(props) 124 .map(([k, v]) => `${k}: ${v}`) 125 .join(', ')})` 126 ); 127 128 let url = this.props.getCustomRuntimeUrl({ scheme: props.scheme }); 129 debug(`Opening project in custom runtime: ${url} -- %O`, props); 130 // TODO: It's unclear why we do application id validation when opening with a URL 131 const applicationId = props.applicationId ?? (await this._getAppIdResolver().getAppIdAsync()); 132 133 const deviceManager = await this.props.resolveDeviceAsync(resolveSettings); 134 135 if (!(await deviceManager.isAppInstalledAsync(applicationId))) { 136 throw new CommandError( 137 `No development build (${applicationId}) for this project is installed. ` + 138 `Please make and install a development build on the device first.\n${learnMore( 139 'https://docs.expo.dev/development/build/' 140 )}` 141 ); 142 } 143 144 // TODO: Rethink analytics 145 await logEventAsync('Open Url on Device', { 146 platform: this.props.platform, 147 installedExpo: false, 148 }); 149 150 if (!url) { 151 url = this._resolveAlternativeLaunchUrl(applicationId, props); 152 } 153 154 deviceManager.logOpeningUrl(url); 155 await deviceManager.activateWindowAsync(); 156 await deviceManager.openUrlAsync(url); 157 158 return { 159 url, 160 }; 161 } 162 163 /** Launch the project on a device given the input runtime. */ 164 async openAsync( 165 options: 166 | { 167 runtime: 'expo' | 'web'; 168 } 169 | { 170 runtime: 'custom'; 171 props?: Partial<IOpenInCustomProps>; 172 }, 173 resolveSettings: Partial<IResolveDeviceProps> = {} 174 ): Promise<{ url: string }> { 175 debug( 176 `open (runtime: ${options.runtime}, platform: ${this.props.platform}, device: %O, shouldPrompt: ${resolveSettings.shouldPrompt})`, 177 resolveSettings.device 178 ); 179 if (options.runtime === 'expo') { 180 return this.openProjectInExpoGoAsync(resolveSettings); 181 } else if (options.runtime === 'web') { 182 return this.openWebProjectAsync(resolveSettings); 183 } else if (options.runtime === 'custom') { 184 return this.openProjectInCustomRuntimeAsync(resolveSettings, options.props); 185 } else { 186 throw new CommandError(`Invalid runtime target: ${options.runtime}`); 187 } 188 } 189 190 /** Open the current web project (Webpack) in a device . */ 191 private async openWebProjectAsync(resolveSettings: Partial<IResolveDeviceProps> = {}): Promise<{ 192 url: string; 193 }> { 194 const url = this.props.getDevServerUrl(); 195 assert(url, 'Dev server is not running.'); 196 197 const deviceManager = await this.props.resolveDeviceAsync(resolveSettings); 198 deviceManager.logOpeningUrl(url); 199 await deviceManager.activateWindowAsync(); 200 await deviceManager.openUrlAsync(url); 201 202 return { url }; 203 } 204 205 /** 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. */ 206 _resolveAlternativeLaunchUrl( 207 applicationId: string, 208 props: Partial<IOpenInCustomProps> = {} 209 ): string { 210 throw new UnimplementedError(); 211 } 212} 213