web-ext-run
Version:
A tool to open and run web extensions
468 lines (444 loc) • 16 kB
JavaScript
/**
* 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