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