diff --git a/cli/src/index.ts b/cli/src/index.ts index 2826d469b..f358b6fe5 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -210,13 +210,26 @@ export function runProgram(config: Config): void { '--forwardPorts ', 'Automatically run "adb reverse" for better live-reloading support', ) + .option('-l, --live-reload', 'Enable Live Reload') + .option('--host ', 'Host used for live reload') + .option('--port ', 'Port used for live reload') .action( wrapAction( telemetryAction( config, async ( platform, - { scheme, flavor, list, target, sync, forwardPorts }, + { + scheme, + flavor, + list, + target, + sync, + forwardPorts, + liveReload, + host, + port, + }, ) => { const { runCommand } = await import('./tasks/run'); await runCommand(config, platform, { @@ -226,6 +239,9 @@ export function runProgram(config: Config): void { target, sync, forwardPorts, + liveReload, + host, + port, }); }, ), diff --git a/cli/src/tasks/run.ts b/cli/src/tasks/run.ts index a205c4fe5..c1b676d56 100644 --- a/cli/src/tasks/run.ts +++ b/cli/src/tasks/run.ts @@ -1,3 +1,4 @@ +import { sleepForever } from '@ionic/utils-process'; import { columnar } from '@ionic/utils-terminal'; import { runAndroid } from '../android/run'; @@ -10,10 +11,11 @@ import { promptForPlatform, getPlatformTargetName, } from '../common'; -import type { Config } from '../definitions'; +import type { AppConfig, Config } from '../definitions'; import { fatal, isFatal } from '../errors'; import { runIOS } from '../ios/run'; import { logger, output } from '../log'; +import { CapLiveReloadHelper } from '../util/livereload'; import { getPlatformTargets } from '../util/native-run'; import { sync } from './sync'; @@ -25,6 +27,9 @@ export interface RunCommandOptions { target?: string; sync?: boolean; forwardPorts?: string; + liveReload?: boolean; + host?: string; + port?: string; } export async function runCommand( @@ -32,6 +37,9 @@ export async function runCommand( selectedPlatformName: string, options: RunCommandOptions, ): Promise { + options.host = + options.host ?? CapLiveReloadHelper.getIpAddress() ?? 'localhost'; + options.port = options.port ?? '3000'; if (selectedPlatformName && !(await isValidPlatform(selectedPlatformName))) { const platformDir = resolvePlatform(config, selectedPlatformName); if (platformDir) { @@ -83,10 +91,47 @@ export async function runCommand( try { if (options.sync) { - await sync(config, platformName, false, true); + if (options.liveReload) { + const newExtConfig = + await CapLiveReloadHelper.editExtConfigForLiveReload( + config, + platformName, + options, + ); + const cfg: { + -readonly [K in keyof Config]: Config[K]; + } = config; + const cfgapp: { + -readonly [K in keyof AppConfig]: AppConfig[K]; + } = config.app; + cfgapp.extConfig = newExtConfig; + cfg.app = cfgapp; + await sync(cfg, platformName, false, true); + } else { + await sync(config, platformName, false, true); + } + } else { + if (options.liveReload) { + await CapLiveReloadHelper.editCapConfigForLiveReload( + config, + platformName, + options, + ); + } } - await run(config, platformName, options); + if (options.liveReload) { + process.on('SIGINT', async () => { + if (options.liveReload) { + await CapLiveReloadHelper.revertCapConfigForLiveReload(); + } + process.exit(); + }); + console.log( + `\nApp running with live reload listing for: http://${options.host}:${options.port}. Press Ctrl+C to quit.`, + ); + await sleepForever(); + } } catch (e: any) { if (!isFatal(e)) { fatal(e.stack ?? e); diff --git a/cli/src/util/livereload.ts b/cli/src/util/livereload.ts new file mode 100644 index 000000000..07ebe7d29 --- /dev/null +++ b/cli/src/util/livereload.ts @@ -0,0 +1,191 @@ +import { readJSONSync, writeJSONSync } from '@ionic/utils-fs'; +import { networkInterfaces } from 'os'; +import { join } from 'path'; + +import type { Config } from '../definitions'; +import type { RunCommandOptions } from '../tasks/run'; + +class CapLiveReload { + configJsonToRevertTo: { + json: string | null; + platformPath: string | null; + } = { + json: null, + platformPath: null, + }; + + constructor() { + // nothing to do + } + + getIpAddress(name?: string, family?: any) { + const interfaces: any = networkInterfaces() ?? {}; + + const _normalizeFamily = (family?: any) => { + if (family === 4) { + return 'ipv4'; + } + if (family === 6) { + return 'ipv6'; + } + return family ? family.toLowerCase() : 'ipv4'; + }; + const isLoopback = (addr: string) => { + return ( + /^(::f{4}:)?127\.([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})/.test(addr) || + /^fe80::1$/.test(addr) || + /^::1$/.test(addr) || + /^::$/.test(addr) + ); + }; + const isPrivate = (addr: string) => { + return ( + /^(::f{4}:)?10\.([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})$/i.test( + addr, + ) || + /^(::f{4}:)?192\.168\.([0-9]{1,3})\.([0-9]{1,3})$/i.test(addr) || + /^(::f{4}:)?172\.(1[6-9]|2\d|30|31)\.([0-9]{1,3})\.([0-9]{1,3})$/i.test( + addr, + ) || + /^(::f{4}:)?127\.([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})$/i.test( + addr, + ) || + /^(::f{4}:)?169\.254\.([0-9]{1,3})\.([0-9]{1,3})$/i.test(addr) || + /^f[cd][0-9a-f]{2}:/i.test(addr) || + /^fe80:/i.test(addr) || + /^::1$/.test(addr) || + /^::$/.test(addr) + ); + }; + const isPublic = (addr: string) => { + return !isPrivate(addr); + }; + const loopback = (family?: any) => { + // + // Default to `ipv4` + // + family = _normalizeFamily(family); + + if (family !== 'ipv4' && family !== 'ipv6') { + throw new Error('family must be ipv4 or ipv6'); + } + + return family === 'ipv4' ? '127.0.0.1' : 'fe80::1'; + }; + + // + // Default to `ipv4` + // + family = _normalizeFamily(family); + + // + // If a specific network interface has been named, + // return the address. + // + if (name && name !== 'private' && name !== 'public') { + const res = interfaces[name].filter((details: any) => { + const itemFamily = _normalizeFamily(details.family); + return itemFamily === family; + }); + if (res.length === 0) { + return undefined; + } + return res[0].address; + } + + const all = Object.keys(interfaces) + .map(nic => { + // + // Note: name will only be `public` or `private` + // when this is called. + // + const addresses = interfaces[nic].filter((details: any) => { + details.family = _normalizeFamily(details.family); + if (details.family !== family || isLoopback(details.address)) { + return false; + } + if (!name) { + return true; + } + + return name === 'public' + ? isPrivate(details.address) + : isPublic(details.address); + }); + + return addresses.length ? addresses[0].address : undefined; + }) + .filter(Boolean); + + return !all.length ? loopback(family) : all[0]; + } + + async editExtConfigForLiveReload( + config: Config, + platformName: string, + options: RunCommandOptions, + rootConfigChange = false, + ): Promise { + const platformAbsPath = + platformName == config.ios.name + ? config.ios.nativeTargetDirAbs + : platformName == config.android.name + ? config.android.assetsDirAbs + : null; + if (platformAbsPath == null) throw new Error('Platform not found.'); + const capConfigPath = rootConfigChange + ? config.app.extConfigFilePath + : join(platformAbsPath, 'capacitor.config.json'); + + const configJson = { ...config.app.extConfig }; + this.configJsonToRevertTo.json = JSON.stringify(configJson, null, 2); + this.configJsonToRevertTo.platformPath = capConfigPath; + const url = `http://${options.host}:${options.port}`; + configJson.server = { + url, + }; + return configJson; + } + + async editCapConfigForLiveReload( + config: Config, + platformName: string, + options: RunCommandOptions, + rootConfigChange = false, + ): Promise { + const platformAbsPath = + platformName == config.ios.name + ? config.ios.nativeTargetDirAbs + : platformName == config.android.name + ? config.android.assetsDirAbs + : null; + if (platformAbsPath == null) throw new Error('Platform not found.'); + const capConfigPath = rootConfigChange + ? config.app.extConfigFilePath + : join(platformAbsPath, 'capacitor.config.json'); + + const configJson = readJSONSync(capConfigPath); + this.configJsonToRevertTo.json = JSON.stringify(configJson, null, 2); + this.configJsonToRevertTo.platformPath = capConfigPath; + const url = `http://${options.host}:${options.port}`; + configJson.server = { + url, + }; + writeJSONSync(capConfigPath, configJson, { spaces: '\t' }); + } + + async revertCapConfigForLiveReload(): Promise { + if ( + this.configJsonToRevertTo.json == null || + this.configJsonToRevertTo.platformPath == null + ) + return; + const capConfigPath = this.configJsonToRevertTo.platformPath; + const configJson = this.configJsonToRevertTo.json; + writeJSONSync(capConfigPath, JSON.parse(configJson), { spaces: '\t' }); + this.configJsonToRevertTo.json = null; + this.configJsonToRevertTo.platformPath = null; + } +} + +export const CapLiveReloadHelper = new CapLiveReload();