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