1import { getConfig } from '@expo/config'; 2import assert from 'assert'; 3 4import { logEvent } from '../../utils/analytics/rudderstackClient'; 5import { CommandError, UnimplementedError } from '../../utils/errors'; 6import { learnMore } from '../../utils/link'; 7import { AppIdResolver } from './AppIdResolver'; 8import { DeviceManager } from './DeviceManager'; 9 10const debug = require('debug')('expo:start:platforms:platformManager') as typeof console.log; 11 12export interface BaseOpenInCustomProps { 13 scheme?: string; 14 applicationId?: string | null; 15} 16 17export interface BaseResolveDeviceProps<IDevice> { 18 /** Should prompt the user to select a device. */ 19 shouldPrompt?: boolean; 20 /** The target device to use. */ 21 device?: IDevice; 22} 23 24/** An abstract class for launching a URL on a device. */ 25export class PlatformManager< 26 IDevice, 27 IOpenInCustomProps extends BaseOpenInCustomProps = BaseOpenInCustomProps, 28 IResolveDeviceProps extends BaseResolveDeviceProps<IDevice> = BaseResolveDeviceProps<IDevice> 29> { 30 constructor( 31 protected projectRoot: string, 32 protected props: { 33 platform: 'ios' | 'android'; 34 /** Get the base URL for the dev server hosting this platform manager. */ 35 getDevServerUrl: () => string | null; 36 /** Expo Go URL */ 37 getExpoGoUrl: () => string | null; 38 /** Dev Client */ 39 getCustomRuntimeUrl: (props?: { scheme?: string }) => string | null; 40 /** Resolve a device, this function should automatically handle opening the device and asserting any system validations. */ 41 resolveDeviceAsync: ( 42 resolver?: Partial<IResolveDeviceProps> 43 ) => Promise<DeviceManager<IDevice>>; 44 } 45 ) {} 46 47 /** Returns the project application identifier or asserts that one is not defined. Exposed for testing. */ 48 _getAppIdResolver(): AppIdResolver { 49 throw new UnimplementedError(); 50 } 51 52 protected async openProjectInExpoGoAsync( 53 resolveSettings: Partial<IResolveDeviceProps> = {} 54 ): Promise<{ url: string }> { 55 const url = this.props.getExpoGoUrl(); 56 // This should never happen, but just in case... 57 assert(url, 'Could not get dev server URL'); 58 59 const deviceManager = await this.props.resolveDeviceAsync(resolveSettings); 60 deviceManager.logOpeningUrl(url); 61 62 // TODO: Expensive, we should only do this once. 63 const { exp } = getConfig(this.projectRoot); 64 const installedExpo = await deviceManager.ensureExpoGoAsync(exp.sdkVersion); 65 66 await deviceManager.activateWindowAsync(); 67 await deviceManager.openUrlAsync(url); 68 69 logEvent('Open Url on Device', { 70 platform: this.props.platform, 71 installedExpo, 72 }); 73 74 return { url }; 75 } 76 77 private async openProjectInCustomRuntimeAsync( 78 resolveSettings: Partial<IResolveDeviceProps> = {}, 79 props: Partial<IOpenInCustomProps> = {} 80 ): Promise<{ url: string }> { 81 debug( 82 `open custom (${Object.entries(props) 83 .map(([k, v]) => `${k}: ${v}`) 84 .join(', ')})` 85 ); 86 87 let url = this.props.getCustomRuntimeUrl({ scheme: props.scheme }); 88 debug(`Opening project in custom runtime: ${url} -- %O`, props); 89 // TODO: It's unclear why we do application id validation when opening with a URL 90 const applicationId = props.applicationId ?? (await this._getAppIdResolver().getAppIdAsync()); 91 92 const deviceManager = await this.props.resolveDeviceAsync(resolveSettings); 93 94 if (!(await deviceManager.isAppInstalledAsync(applicationId))) { 95 throw new CommandError( 96 `The development client (${applicationId}) for this project is not installed. ` + 97 `Please build and install the client on the device first.\n${learnMore( 98 'https://docs.expo.dev/development/build/' 99 )}` 100 ); 101 } 102 103 // TODO: Rethink analytics 104 logEvent('Open Url on Device', { 105 platform: this.props.platform, 106 installedExpo: false, 107 }); 108 109 if (!url) { 110 url = this._resolveAlternativeLaunchUrl(applicationId, props); 111 } 112 113 deviceManager.logOpeningUrl(url); 114 await deviceManager.activateWindowAsync(); 115 await deviceManager.openUrlAsync(url); 116 117 return { 118 url, 119 }; 120 } 121 122 /** Launch the project on a device given the input runtime. */ 123 async openAsync( 124 options: 125 | { 126 runtime: 'expo' | 'web'; 127 } 128 | { 129 runtime: 'custom'; 130 props?: Partial<IOpenInCustomProps>; 131 }, 132 resolveSettings: Partial<IResolveDeviceProps> = {} 133 ): Promise<{ url: string }> { 134 debug( 135 `open (runtime: ${options.runtime}, platform: ${this.props.platform}, device: %O, shouldPrompt: ${resolveSettings.shouldPrompt})`, 136 resolveSettings.device 137 ); 138 if (options.runtime === 'expo') { 139 return this.openProjectInExpoGoAsync(resolveSettings); 140 } else if (options.runtime === 'web') { 141 return this.openWebProjectAsync(resolveSettings); 142 } else if (options.runtime === 'custom') { 143 return this.openProjectInCustomRuntimeAsync(resolveSettings, options.props); 144 } else { 145 throw new CommandError(`Invalid runtime target: ${options.runtime}`); 146 } 147 } 148 149 /** Open the current web project (Webpack) in a device . */ 150 private async openWebProjectAsync(resolveSettings: Partial<IResolveDeviceProps> = {}): Promise<{ 151 url: string; 152 }> { 153 const url = this.props.getDevServerUrl(); 154 assert(url, 'Dev server is not running.'); 155 156 const deviceManager = await this.props.resolveDeviceAsync(resolveSettings); 157 deviceManager.logOpeningUrl(url); 158 await deviceManager.activateWindowAsync(); 159 await deviceManager.openUrlAsync(url); 160 161 return { url }; 162 } 163 164 /** 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. */ 165 _resolveAlternativeLaunchUrl( 166 applicationId: string, 167 props: Partial<IOpenInCustomProps> = {} 168 ): string { 169 throw new UnimplementedError(); 170 } 171} 172