18d307f52SEvan Baconimport { getConfig } from '@expo/config'; 28d307f52SEvan Baconimport assert from 'assert'; 3212e3a1aSEric Samelsonimport chalk from 'chalk'; 48d307f52SEvan Bacon 5*8a424bebSJames Ideimport { AppIdResolver } from './AppIdResolver'; 6*8a424bebSJames Ideimport { DeviceManager } from './DeviceManager'; 7212e3a1aSEric Samelsonimport { Log } from '../../log'; 8ea99eec9SEvan Baconimport { logEventAsync } from '../../utils/analytics/rudderstackClient'; 98d307f52SEvan Baconimport { CommandError, UnimplementedError } from '../../utils/errors'; 108d307f52SEvan Baconimport { learnMore } from '../../utils/link'; 118d307f52SEvan Bacon 12474a7a4bSEvan Baconconst debug = require('debug')('expo:start:platforms:platformManager') as typeof console.log; 13474a7a4bSEvan Bacon 148d307f52SEvan Baconexport interface BaseOpenInCustomProps { 158d307f52SEvan Bacon scheme?: string; 168d307f52SEvan Bacon applicationId?: string | null; 178d307f52SEvan Bacon} 188d307f52SEvan Bacon 198d307f52SEvan Baconexport interface BaseResolveDeviceProps<IDevice> { 208d307f52SEvan Bacon /** Should prompt the user to select a device. */ 218d307f52SEvan Bacon shouldPrompt?: boolean; 228d307f52SEvan Bacon /** The target device to use. */ 238d307f52SEvan Bacon device?: IDevice; 248d307f52SEvan Bacon} 258d307f52SEvan Bacon 268d307f52SEvan Bacon/** An abstract class for launching a URL on a device. */ 278d307f52SEvan Baconexport class PlatformManager< 288d307f52SEvan Bacon IDevice, 298d307f52SEvan Bacon IOpenInCustomProps extends BaseOpenInCustomProps = BaseOpenInCustomProps, 30*8a424bebSJames Ide IResolveDeviceProps extends BaseResolveDeviceProps<IDevice> = BaseResolveDeviceProps<IDevice>, 318d307f52SEvan Bacon> { 328d307f52SEvan Bacon constructor( 338d307f52SEvan Bacon protected projectRoot: string, 348d307f52SEvan Bacon protected props: { 358d307f52SEvan Bacon platform: 'ios' | 'android'; 368d307f52SEvan Bacon /** Get the base URL for the dev server hosting this platform manager. */ 378d307f52SEvan Bacon getDevServerUrl: () => string | null; 38212e3a1aSEric Samelson /** Expo Go URL. */ 39212e3a1aSEric Samelson getExpoGoUrl: () => string; 40212e3a1aSEric Samelson /** 41212e3a1aSEric Samelson * Get redirect URL for native disambiguation. 4247d62600SKudo Chien * @returns a URL like `http://localhost:8081/_expo/loading` 43212e3a1aSEric Samelson */ 44212e3a1aSEric Samelson getRedirectUrl: () => string | null; 458d307f52SEvan Bacon /** Dev Client */ 468d307f52SEvan Bacon getCustomRuntimeUrl: (props?: { scheme?: string }) => string | null; 478d307f52SEvan Bacon /** Resolve a device, this function should automatically handle opening the device and asserting any system validations. */ 488d307f52SEvan Bacon resolveDeviceAsync: ( 498d307f52SEvan Bacon resolver?: Partial<IResolveDeviceProps> 508d307f52SEvan Bacon ) => Promise<DeviceManager<IDevice>>; 518d307f52SEvan Bacon } 528d307f52SEvan Bacon ) {} 538d307f52SEvan Bacon 548d307f52SEvan Bacon /** Returns the project application identifier or asserts that one is not defined. Exposed for testing. */ 558d307f52SEvan Bacon _getAppIdResolver(): AppIdResolver { 568d307f52SEvan Bacon throw new UnimplementedError(); 578d307f52SEvan Bacon } 588d307f52SEvan Bacon 59212e3a1aSEric Samelson /** 60212e3a1aSEric Samelson * Get the URL for users intending to launch the project in Expo Go. 61212e3a1aSEric Samelson * The CLI will check if the project has a custom dev client and if the redirect page feature is enabled. 62212e3a1aSEric Samelson * If both are true, the CLI will return the redirect page URL. 63212e3a1aSEric Samelson */ 64212e3a1aSEric Samelson protected async getExpoGoOrCustomRuntimeUrlAsync( 65212e3a1aSEric Samelson deviceManager: DeviceManager<IDevice> 66212e3a1aSEric Samelson ): Promise<string> { 67212e3a1aSEric Samelson // Determine if the redirect page feature is enabled first since it's the cheapest to check. 68212e3a1aSEric Samelson const redirectUrl = this.props.getRedirectUrl(); 69212e3a1aSEric Samelson if (redirectUrl) { 70212e3a1aSEric Samelson // If the redirect page feature is enabled, check if the project has a resolvable native identifier. 71cf262328SEric Samelson let applicationId; 72cf262328SEric Samelson try { 73cf262328SEric Samelson applicationId = await this._getAppIdResolver().getAppIdAsync(); 74cf262328SEric Samelson } catch { 75cf262328SEric Samelson Log.warn( 76cf262328SEric Samelson chalk`\u203A Launching in Expo Go. If you want to use a ` + 77cf262328SEric Samelson `development build, you need to create and install one first, or, if you already ` + 78cf262328SEric Samelson chalk`have a build, add {bold ios.bundleIdentifier} and {bold android.package} to ` + 79cf262328SEric Samelson `this project's app config.\n${learnMore('https://docs.expo.dev/development/build/')}` 80cf262328SEric Samelson ); 81cf262328SEric Samelson } 82212e3a1aSEric Samelson if (applicationId) { 83212e3a1aSEric Samelson debug(`Resolving launch URL: (appId: ${applicationId}, redirect URL: ${redirectUrl})`); 84212e3a1aSEric Samelson // NOTE(EvanBacon): This adds considerable amount of time to the command, we should consider removing or memoizing it. 85212e3a1aSEric Samelson // Finally determine if the target device has a custom dev client installed. 86212e3a1aSEric Samelson if (await deviceManager.isAppInstalledAsync(applicationId)) { 87212e3a1aSEric Samelson return redirectUrl; 88212e3a1aSEric Samelson } else { 89212e3a1aSEric Samelson // Log a warning if no development build is available on the device, but the 90212e3a1aSEric Samelson // interstitial page would otherwise be opened. 91212e3a1aSEric Samelson Log.warn( 92212e3a1aSEric Samelson chalk`\u203A The {bold expo-dev-client} package is installed, but a development build is not ` + 93212e3a1aSEric Samelson chalk`installed on {bold ${deviceManager.name}}.\nLaunching in Expo Go. If you want to use a ` + 94212e3a1aSEric Samelson `development build, you need to create and install one first.\n${learnMore( 95212e3a1aSEric Samelson 'https://docs.expo.dev/development/build/' 96212e3a1aSEric Samelson )}` 97212e3a1aSEric Samelson ); 98212e3a1aSEric Samelson } 99212e3a1aSEric Samelson } 100212e3a1aSEric Samelson } 101212e3a1aSEric Samelson 102212e3a1aSEric Samelson return this.props.getExpoGoUrl(); 103212e3a1aSEric Samelson } 104212e3a1aSEric Samelson 1058d307f52SEvan Bacon protected async openProjectInExpoGoAsync( 1068d307f52SEvan Bacon resolveSettings: Partial<IResolveDeviceProps> = {} 1078d307f52SEvan Bacon ): Promise<{ url: string }> { 1088d307f52SEvan Bacon const deviceManager = await this.props.resolveDeviceAsync(resolveSettings); 109212e3a1aSEric Samelson const url = await this.getExpoGoOrCustomRuntimeUrlAsync(deviceManager); 110212e3a1aSEric Samelson 1118d307f52SEvan Bacon deviceManager.logOpeningUrl(url); 1128d307f52SEvan Bacon 1138d307f52SEvan Bacon // TODO: Expensive, we should only do this once. 1148d307f52SEvan Bacon const { exp } = getConfig(this.projectRoot); 1158d307f52SEvan Bacon const installedExpo = await deviceManager.ensureExpoGoAsync(exp.sdkVersion); 1168d307f52SEvan Bacon 11788643930SEvan Bacon deviceManager.activateWindowAsync(); 1188d307f52SEvan Bacon await deviceManager.openUrlAsync(url); 1198d307f52SEvan Bacon 120ea99eec9SEvan Bacon await logEventAsync('Open Url on Device', { 1218d307f52SEvan Bacon platform: this.props.platform, 1228d307f52SEvan Bacon installedExpo, 1238d307f52SEvan Bacon }); 1248d307f52SEvan Bacon 1258d307f52SEvan Bacon return { url }; 1268d307f52SEvan Bacon } 1278d307f52SEvan Bacon 1288d307f52SEvan Bacon private async openProjectInCustomRuntimeAsync( 1298d307f52SEvan Bacon resolveSettings: Partial<IResolveDeviceProps> = {}, 1308d307f52SEvan Bacon props: Partial<IOpenInCustomProps> = {} 1318d307f52SEvan Bacon ): Promise<{ url: string }> { 132474a7a4bSEvan Bacon debug( 1333d6e487dSEvan Bacon `open custom (${Object.entries(props) 1343d6e487dSEvan Bacon .map(([k, v]) => `${k}: ${v}`) 1353d6e487dSEvan Bacon .join(', ')})` 1363d6e487dSEvan Bacon ); 1373d6e487dSEvan Bacon 1388d307f52SEvan Bacon let url = this.props.getCustomRuntimeUrl({ scheme: props.scheme }); 139474a7a4bSEvan Bacon debug(`Opening project in custom runtime: ${url} -- %O`, props); 1408d307f52SEvan Bacon // TODO: It's unclear why we do application id validation when opening with a URL 1418d307f52SEvan Bacon const applicationId = props.applicationId ?? (await this._getAppIdResolver().getAppIdAsync()); 1428d307f52SEvan Bacon 1438d307f52SEvan Bacon const deviceManager = await this.props.resolveDeviceAsync(resolveSettings); 1448d307f52SEvan Bacon 1458d307f52SEvan Bacon if (!(await deviceManager.isAppInstalledAsync(applicationId))) { 1468d307f52SEvan Bacon throw new CommandError( 147212e3a1aSEric Samelson `No development build (${applicationId}) for this project is installed. ` + 148212e3a1aSEric Samelson `Please make and install a development build on the device first.\n${learnMore( 1498d307f52SEvan Bacon 'https://docs.expo.dev/development/build/' 1508d307f52SEvan Bacon )}` 1518d307f52SEvan Bacon ); 1528d307f52SEvan Bacon } 1538d307f52SEvan Bacon 1548d307f52SEvan Bacon // TODO: Rethink analytics 155ea99eec9SEvan Bacon await logEventAsync('Open Url on Device', { 1568d307f52SEvan Bacon platform: this.props.platform, 1578d307f52SEvan Bacon installedExpo: false, 1588d307f52SEvan Bacon }); 1598d307f52SEvan Bacon 1608d307f52SEvan Bacon if (!url) { 1618d307f52SEvan Bacon url = this._resolveAlternativeLaunchUrl(applicationId, props); 1628d307f52SEvan Bacon } 1638d307f52SEvan Bacon 1648d307f52SEvan Bacon deviceManager.logOpeningUrl(url); 1658d307f52SEvan Bacon await deviceManager.activateWindowAsync(); 1668d307f52SEvan Bacon await deviceManager.openUrlAsync(url); 1678d307f52SEvan Bacon 1688d307f52SEvan Bacon return { 1698d307f52SEvan Bacon url, 1708d307f52SEvan Bacon }; 1718d307f52SEvan Bacon } 1728d307f52SEvan Bacon 1738d307f52SEvan Bacon /** Launch the project on a device given the input runtime. */ 1748d307f52SEvan Bacon async openAsync( 1758d307f52SEvan Bacon options: 1768d307f52SEvan Bacon | { 1778d307f52SEvan Bacon runtime: 'expo' | 'web'; 1788d307f52SEvan Bacon } 1798d307f52SEvan Bacon | { 1808d307f52SEvan Bacon runtime: 'custom'; 1818d307f52SEvan Bacon props?: Partial<IOpenInCustomProps>; 1828d307f52SEvan Bacon }, 1838d307f52SEvan Bacon resolveSettings: Partial<IResolveDeviceProps> = {} 1848d307f52SEvan Bacon ): Promise<{ url: string }> { 185474a7a4bSEvan Bacon debug( 1863d6e487dSEvan Bacon `open (runtime: ${options.runtime}, platform: ${this.props.platform}, device: %O, shouldPrompt: ${resolveSettings.shouldPrompt})`, 1873d6e487dSEvan Bacon resolveSettings.device 1883d6e487dSEvan Bacon ); 1898d307f52SEvan Bacon if (options.runtime === 'expo') { 1908d307f52SEvan Bacon return this.openProjectInExpoGoAsync(resolveSettings); 1918d307f52SEvan Bacon } else if (options.runtime === 'web') { 1928d307f52SEvan Bacon return this.openWebProjectAsync(resolveSettings); 1938d307f52SEvan Bacon } else if (options.runtime === 'custom') { 1948d307f52SEvan Bacon return this.openProjectInCustomRuntimeAsync(resolveSettings, options.props); 1958d307f52SEvan Bacon } else { 1968d307f52SEvan Bacon throw new CommandError(`Invalid runtime target: ${options.runtime}`); 1978d307f52SEvan Bacon } 1988d307f52SEvan Bacon } 1998d307f52SEvan Bacon 2008d307f52SEvan Bacon /** Open the current web project (Webpack) in a device . */ 2018d307f52SEvan Bacon private async openWebProjectAsync(resolveSettings: Partial<IResolveDeviceProps> = {}): Promise<{ 2028d307f52SEvan Bacon url: string; 2038d307f52SEvan Bacon }> { 2048d307f52SEvan Bacon const url = this.props.getDevServerUrl(); 2058d307f52SEvan Bacon assert(url, 'Dev server is not running.'); 2068d307f52SEvan Bacon 2078d307f52SEvan Bacon const deviceManager = await this.props.resolveDeviceAsync(resolveSettings); 2088d307f52SEvan Bacon deviceManager.logOpeningUrl(url); 2098d307f52SEvan Bacon await deviceManager.activateWindowAsync(); 2108d307f52SEvan Bacon await deviceManager.openUrlAsync(url); 2118d307f52SEvan Bacon 2128d307f52SEvan Bacon return { url }; 2138d307f52SEvan Bacon } 2148d307f52SEvan Bacon 2158d307f52SEvan Bacon /** 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. */ 2168d307f52SEvan Bacon _resolveAlternativeLaunchUrl( 2178d307f52SEvan Bacon applicationId: string, 2188d307f52SEvan Bacon props: Partial<IOpenInCustomProps> = {} 2198d307f52SEvan Bacon ): string { 2208d307f52SEvan Bacon throw new UnimplementedError(); 2218d307f52SEvan Bacon } 2228d307f52SEvan Bacon} 223