UNPKG

web-ext-run

Version:

A tool to open and run web extensions

468 lines (444 loc) 16 kB
/** * This module provide an ExtensionRunner subclass that manage an extension executed * in a Firefox for Android instance. */ import path from 'path'; import readline from 'readline'; import { withTempDir } from '../util/temp-dir.js'; import DefaultADBUtils from '../util/adb.js'; import { MultiExtensionsReloadError, UsageError, WebExtError } from '../errors.js'; import { findFreeTcpPort } from '../firefox/remote.js'; import { createLogger } from '../util/logger.js'; import { isTTY, setRawMode } from '../util/stdin.js'; const log = createLogger(import.meta.url); const ignoredParams = { profilePath: '--profile-path', keepProfileChanges: '--keep-profile-changes', browserConsole: '--browser-console', preInstall: '--pre-install', startUrl: '--start-url', args: '--args' }; // Default adbHost to 127.0.0.1 to prevent issues with nodejs 17 // (because if not specified adbkit may default to ipv6 while // adb may still only be listening on the ipv4 address), // see https://github.com/mozilla/web-ext/issues/2337. const DEFAULT_ADB_HOST = '127.0.0.1'; const getIgnoredParamsWarningsMessage = optionName => { return `The Firefox for Android target does not support ${optionName}`; }; /** * Implements an IExtensionRunner which manages a Firefox for Android instance. */ export class FirefoxAndroidExtensionRunner { // Wait 3s before the next unix socket discovery loop. static unixSocketDiscoveryRetryInterval = 3 * 1000; // Wait for at most 3 minutes before giving up. static unixSocketDiscoveryMaxTime = 3 * 60 * 1000; params; adbUtils; exiting; selectedAdbDevice; selectedFirefoxApk; selectedArtifactsDir; selectedRDPSocketFile; selectedTCPPort; cleanupCallbacks; adbExtensionsPathBySourceDir; reloadableExtensions; remoteFirefox; constructor(params) { this.params = params; this.cleanupCallbacks = new Set(); this.adbExtensionsPathBySourceDir = new Map(); this.reloadableExtensions = new Map(); // Print warning for not currently supported options (e.g. preInstall, // cloned profiles, browser console). this.printIgnoredParamsWarnings(); } async run() { const { adbBin, adbHost = DEFAULT_ADB_HOST, adbPort, ADBUtils = DefaultADBUtils } = this.params; this.adbUtils = new ADBUtils({ adbBin, adbHost, adbPort }); await this.adbDevicesDiscoveryAndSelect(); await this.apkPackagesDiscoveryAndSelect(); await this.adbForceStopSelectedPackage(); // Create profile prefs (with enabled remote RDP server), prepare the // artifacts and temporary directory on the selected device, and // push the profile preferences to the remote profile dir. await this.adbPrepareProfileDir(); // NOTE: running Firefox for Android on the Android Emulator can be // pretty slow, we can run the following 3 steps in parallel to speed up // it a bit. await Promise.all([ // Start Firefox for Android instance if not started yet. // (Fennec would run in an temporary profile and so it is explicitly // stopped, Fenix runs on its usual profile and so it may be already // running). this.adbStartSelectedPackage(), // Build and push to devices all the extension xpis // and keep track of the xpi built and uploaded by extension sourceDir. this.buildAndPushExtensions(), // Wait for RDP unix socket file created and // Create an ADB forward connection on a free tcp port this.adbDiscoveryAndForwardRDPUnixSocket()]); // Connect to RDP socket on the local tcp server, install all the pushed extension // and keep track of the built and installed extension by extension sourceDir. await this.rdpInstallExtensions(); } // Method exported from the IExtensionRunner interface. /** * Returns the runner name. */ getName() { return 'Firefox Android'; } /** * Reloads all the extensions, collect any reload error and resolves to * an array composed by a single ExtensionRunnerReloadResult object. */ async reloadAllExtensions() { const runnerName = this.getName(); const reloadErrors = new Map(); for (const { sourceDir } of this.params.extensions) { const [res] = await this.reloadExtensionBySourceDir(sourceDir); if (res.reloadError instanceof Error) { reloadErrors.set(sourceDir, res.reloadError); } } if (reloadErrors.size > 0) { return [{ runnerName, reloadError: new MultiExtensionsReloadError(reloadErrors) }]; } return [{ runnerName }]; } /** * Reloads a single extension, collect any reload error and resolves to * an array composed by a single ExtensionRunnerReloadResult object. */ async reloadExtensionBySourceDir(extensionSourceDir) { const runnerName = this.getName(); const addonId = this.reloadableExtensions.get(extensionSourceDir); if (!addonId) { return [{ sourceDir: extensionSourceDir, reloadError: new WebExtError('Extension not reloadable: ' + `no addonId has been mapped to "${extensionSourceDir}"`), runnerName }]; } try { await this.buildAndPushExtension(extensionSourceDir); await this.remoteFirefox.reloadAddon(addonId); } catch (error) { return [{ sourceDir: extensionSourceDir, reloadError: error, runnerName }]; } return [{ runnerName, sourceDir: extensionSourceDir }]; } /** * Register a callback to be called when the runner has been exited * (e.g. the Firefox instance exits or the user has requested web-ext * to exit). */ registerCleanup(fn) { this.cleanupCallbacks.add(fn); } /** * Exits the runner, by closing the managed Firefox instance. */ async exit() { const { adbUtils, selectedAdbDevice, selectedArtifactsDir } = this; this.exiting = true; // If a Firefox for Android instance has been started, // we should ensure that it has been stopped when we exit. await this.adbForceStopSelectedPackage(); if (selectedArtifactsDir) { log.debug('Cleaning up artifacts directory on the Android device...'); await adbUtils.clearArtifactsDir(selectedAdbDevice); } // Call all the registered cleanup callbacks. for (const fn of this.cleanupCallbacks) { try { fn(); } catch (error) { log.error(error); } } } // Private helper methods. getDeviceProfileDir() { return `${this.selectedArtifactsDir}/profile`; } printIgnoredParamsWarnings() { Object.keys(ignoredParams).forEach(ignoredParam => { if (this.params[ignoredParam]) { log.warn(getIgnoredParamsWarningsMessage(ignoredParams[ignoredParam])); } }); } async adbDevicesDiscoveryAndSelect() { const { adbUtils } = this; const { adbDevice } = this.params; let devices = []; log.debug('Listing android devices'); devices = await adbUtils.discoverDevices(); if (devices.length === 0) { throw new UsageError('No Android device found through ADB. ' + 'Make sure the device is connected and USB debugging is enabled.'); } if (!adbDevice) { const devicesMsg = devices.map(dev => ` - ${dev}`).join('\n'); log.info(`\nAndroid devices found:\n${devicesMsg}`); throw new UsageError('Select an android device using --android-device=<name>'); } const foundDevices = devices.filter(device => { return device === adbDevice; }); if (foundDevices.length === 0) { const devicesMsg = JSON.stringify(devices); throw new UsageError(`Android device ${adbDevice} was not found in list: ${devicesMsg}`); } this.selectedAdbDevice = foundDevices[0]; log.info(`Selected ADB device: ${this.selectedAdbDevice}`); } async apkPackagesDiscoveryAndSelect() { const { adbUtils, selectedAdbDevice, params: { firefoxApk } } = this; // Discovery and select a Firefox for Android version. const packages = await adbUtils.discoverInstalledFirefoxAPKs(selectedAdbDevice, firefoxApk); if (packages.length === 0) { throw new UsageError('No Firefox packages were found on the selected Android device'); } const pkgsListMsg = pkgs => { return pkgs.map(pkg => ` - ${pkg}`).join('\n'); }; if (!firefoxApk) { log.info(`\nPackages found:\n${pkgsListMsg(packages)}`); if (packages.length > 1) { throw new UsageError('Select one of the packages using --firefox-apk'); } // If only one APK has been found, select it even if it has not been // specified explicitly on the comment line. this.selectedFirefoxApk = packages[0]; log.info(`Selected Firefox for Android APK: ${this.selectedFirefoxApk}`); return; } const filteredPackages = packages.filter(line => line === firefoxApk); if (filteredPackages.length === 0) { const pkgsList = pkgsListMsg(filteredPackages); throw new UsageError(`Package ${firefoxApk} was not found in list: ${pkgsList}`); } this.selectedFirefoxApk = filteredPackages[0]; log.debug(`Selected Firefox for Android APK: ${this.selectedFirefoxApk}`); } async adbForceStopSelectedPackage() { const { adbUtils, selectedAdbDevice, selectedFirefoxApk } = this; log.info(`Stopping existing instances of ${selectedFirefoxApk}...`); await adbUtils.amForceStopAPK(selectedAdbDevice, selectedFirefoxApk); } async adbPrepareProfileDir() { const { adbUtils, selectedAdbDevice, selectedFirefoxApk, params: { customPrefs, firefoxApp, adbRemoveOldArtifacts } } = this; // Create the preferences file and the Fennec temporary profile. log.debug(`Preparing a temporary profile for ${selectedFirefoxApk}...`); const profile = await firefoxApp.createProfile({ app: 'fennec', customPrefs }); // Check if there are any artifacts dirs from previous runs and // automatically remove them if adbRemoteOldArtifacts is true. const foundOldArtifacts = await adbUtils.detectOrRemoveOldArtifacts(selectedAdbDevice, adbRemoveOldArtifacts); if (foundOldArtifacts) { if (adbRemoveOldArtifacts) { log.info('Old web-ext artifacts have been found and removed ' + `from ${selectedAdbDevice} device`); } else { log.warn(`Old artifacts directories have been found on ${selectedAdbDevice} ` + 'device. Use --adb-remove-old-artifacts to remove them automatically.'); } } // Choose a artifacts dir name for the assets pushed to the // Android device. this.selectedArtifactsDir = await adbUtils.getOrCreateArtifactsDir(selectedAdbDevice); const deviceProfileDir = this.getDeviceProfileDir(); await adbUtils.runShellCommand(selectedAdbDevice, ['mkdir', '-p', deviceProfileDir]); await adbUtils.pushFile(selectedAdbDevice, path.join(profile.profileDir, 'user.js'), `${deviceProfileDir}/user.js`); log.debug(`Created temporary profile at ${deviceProfileDir}.`); } async adbStartSelectedPackage() { const { adbUtils, selectedFirefoxApk, selectedAdbDevice, params: { firefoxApkComponent } } = this; const deviceProfileDir = this.getDeviceProfileDir(); log.info(`Starting ${selectedFirefoxApk}...`); log.debug(`Using profile ${deviceProfileDir} (ignored by Fenix)`); await adbUtils.startFirefoxAPK(selectedAdbDevice, selectedFirefoxApk, firefoxApkComponent, deviceProfileDir); } async buildAndPushExtension(sourceDir) { const { adbUtils, selectedAdbDevice, selectedArtifactsDir, params: { buildSourceDir } } = this; await withTempDir(async tmpDir => { const { extensionPath } = await buildSourceDir(sourceDir, tmpDir.path()); const extFileName = path.basename(extensionPath, '.zip'); let adbExtensionPath = this.adbExtensionsPathBySourceDir.get(sourceDir); if (!adbExtensionPath) { adbExtensionPath = `${selectedArtifactsDir}/${extFileName}.xpi`; } log.debug(`Uploading ${extFileName} on the android device`); await adbUtils.pushFile(selectedAdbDevice, extensionPath, adbExtensionPath); log.debug(`Upload completed: ${adbExtensionPath}`); this.adbExtensionsPathBySourceDir.set(sourceDir, adbExtensionPath); }); } async buildAndPushExtensions() { for (const { sourceDir } of this.params.extensions) { await this.buildAndPushExtension(sourceDir); } } async adbDiscoveryAndForwardRDPUnixSocket() { const { adbUtils, selectedAdbDevice, selectedFirefoxApk, params: { adbDiscoveryTimeout } } = this; const stdin = this.params.stdin || process.stdin; const { unixSocketDiscoveryRetryInterval } = FirefoxAndroidExtensionRunner; let { unixSocketDiscoveryMaxTime } = FirefoxAndroidExtensionRunner; if (typeof adbDiscoveryTimeout === 'number') { unixSocketDiscoveryMaxTime = adbDiscoveryTimeout; } const handleCtrlC = (str, key) => { if (key.ctrl && key.name === 'c') { adbUtils.setUserAbortDiscovery(true); } }; // TODO: use noInput property to decide if we should // disable direct keypress handling. if (isTTY(stdin)) { readline.emitKeypressEvents(stdin); setRawMode(stdin, true); stdin.on('keypress', handleCtrlC); } try { // Got a debugger socket file to connect. this.selectedRDPSocketFile = await adbUtils.discoverRDPUnixSocket(selectedAdbDevice, selectedFirefoxApk, { maxDiscoveryTime: unixSocketDiscoveryMaxTime, retryInterval: unixSocketDiscoveryRetryInterval }); } finally { if (isTTY(stdin)) { stdin.removeListener('keypress', handleCtrlC); } } log.debug(`RDP Socket File selected: ${this.selectedRDPSocketFile}`); const tcpPort = await findFreeTcpPort(); // Log the chosen tcp port at info level (useful to the user to be able // to connect the Firefox DevTools to the Firefox for Android instance). log.info(`You can connect to this Android device on TCP port ${tcpPort}`); const forwardSocketSpec = this.selectedRDPSocketFile.startsWith('@') ? `localabstract:${this.selectedRDPSocketFile.substr(1)}` : `localfilesystem:${this.selectedRDPSocketFile}`; await adbUtils.setupForward(selectedAdbDevice, forwardSocketSpec, `tcp:${tcpPort}`); this.selectedTCPPort = tcpPort; } async rdpInstallExtensions() { const { selectedTCPPort, params: { extensions, firefoxClient } } = this; const remoteFirefox = this.remoteFirefox = await firefoxClient({ port: selectedTCPPort }); // Exit and cleanup the extension runner if the connection to the // remote Firefox for Android instance has been closed. remoteFirefox.client.on('end', () => { if (!this.exiting) { log.info('Exiting the device because Firefox for Android disconnected'); this.exit(); } }); // Install all the temporary addons. for (const extension of extensions) { const { sourceDir } = extension; const adbExtensionPath = this.adbExtensionsPathBySourceDir.get(sourceDir); if (!adbExtensionPath) { throw new WebExtError(`ADB extension path for "${sourceDir}" was unexpectedly empty`); } const addonId = await remoteFirefox.installTemporaryAddon(adbExtensionPath).then(installResult => { return installResult.addon.id; }); if (!addonId) { throw new WebExtError('Received an empty addonId from ' + `remoteFirefox.installTemporaryAddon("${adbExtensionPath}")`); } this.reloadableExtensions.set(extension.sourceDir, addonId); } } } //# sourceMappingURL=firefox-android.js.map