UNPKG

web-ext-run

Version:

A tool to open and run web extensions

380 lines (359 loc) 12.8 kB
/** * This module provide an ExtensionRunner subclass that manage an extension executed * in a Chromium-based browser instance. */ import fs from 'fs/promises'; import path from 'path'; import { Launcher as ChromeLauncher, launch as defaultChromiumLaunch } from 'chrome-launcher'; import WebSocket, { WebSocketServer } from 'ws'; import set from 'set-value'; import { createLogger } from '../util/logger.js'; import { TempDir } from '../util/temp-dir.js'; import isDirectory from '../util/is-directory.js'; import fileExists from '../util/file-exists.js'; const log = createLogger(import.meta.url); const EXCLUDED_CHROME_FLAGS = ['--disable-extensions', '--mute-audio', '--disable-component-update']; export const DEFAULT_CHROME_FLAGS = ChromeLauncher.defaultFlags().filter(flag => !EXCLUDED_CHROME_FLAGS.includes(flag)); const DEFAULT_PREFS = { 'extensions.ui.developer_mode': true }; /** * Implements an IExtensionRunner which manages a Chromium instance. */ export class ChromiumExtensionRunner { cleanupCallbacks; params; chromiumInstance; chromiumLaunch; reloadManagerExtension; wss; exiting; _promiseSetupDone; constructor(params) { const { chromiumLaunch = defaultChromiumLaunch } = params; this.params = params; this.chromiumLaunch = chromiumLaunch; this.cleanupCallbacks = new Set(); } // Method exported from the IExtensionRunner interface. /** * Returns the runner name. */ getName() { return 'Chromium'; } async run() { // Run should never be called more than once. this._promiseSetupDone = this.setupInstance(); await this._promiseSetupDone; } static async isUserDataDir(dirPath) { const localStatePath = path.join(dirPath, 'Local State'); const defaultPath = path.join(dirPath, 'Default'); // Local State and Default are typical for the user-data-dir return (await fileExists(localStatePath)) && (await isDirectory(defaultPath)); } static async isProfileDir(dirPath) { const securePreferencesPath = path.join(dirPath, 'Secure Preferences'); //Secure Preferences is typical for a profile dir inside a user data dir return await fileExists(securePreferencesPath); } static async getProfilePaths(chromiumProfile) { if (!chromiumProfile) { return { userDataDir: null, profileDirName: null }; } const isProfileDirAndNotUserData = (await ChromiumExtensionRunner.isProfileDir(chromiumProfile)) && !(await ChromiumExtensionRunner.isUserDataDir(chromiumProfile)); if (isProfileDirAndNotUserData) { const { dir: userDataDir, base: profileDirName } = path.parse(chromiumProfile); return { userDataDir, profileDirName }; } return { userDataDir: chromiumProfile, profileDirName: null }; } /** * Setup the Chromium Profile and run a Chromium instance. */ async setupInstance() { // Start a websocket server on a free localhost TCP port. this.wss = await new Promise(resolve => { const server = new WebSocketServer( // Use a ipv4 host so we don't need to escape ipv6 address // https://github.com/mozilla/web-ext/issues/2331 { port: 0, host: '127.0.0.1', clientTracking: true }, // Wait the server to be listening (so that the extension // runner can successfully retrieve server address and port). () => resolve(server)); }); // Prevent unhandled socket error (e.g. when chrome // is exiting, See https://github.com/websockets/ws/issues/1256). this.wss.on('connection', function (socket) { socket.on('error', err => { log.debug(`websocket connection error: ${err}`); }); }); // Create the extension that will manage the addon reloads this.reloadManagerExtension = await this.createReloadManagerExtension(); // Start chrome pointing it to a given profile dir const extensions = (this.params.noReloadManagerExtension ? [] : [this.reloadManagerExtension]).concat(this.params.extensions.map(({ sourceDir }) => sourceDir)).join(','); const { chromiumBinary } = this.params; log.debug('Starting Chromium instance...'); if (chromiumBinary) { log.debug(`(chromiumBinary: ${chromiumBinary})`); } const chromeFlags = [...DEFAULT_CHROME_FLAGS]; chromeFlags.push(`--load-extension=${extensions}`); if (this.params.args) { chromeFlags.push(...this.params.args); } // eslint-disable-next-line prefer-const let { userDataDir, profileDirName } = await ChromiumExtensionRunner.getProfilePaths(this.params.chromiumProfile); if (userDataDir && this.params.keepProfileChanges) { if (profileDirName && !(await ChromiumExtensionRunner.isUserDataDir(userDataDir))) { throw new Error('The profile you provided is not in a ' + 'user-data-dir. The changes cannot be kept. Please either ' + 'remove --keep-profile-changes or use a profile in a ' + 'user-data-dir directory'); } } else if (!this.params.keepProfileChanges) { // the user provided an existing profile directory but doesn't want // the changes to be kept. we copy this directory to a temporary // user data dir. const tmpDir = new TempDir(); await tmpDir.create(); const tmpDirPath = tmpDir.path(); if (userDataDir && profileDirName) { // copy profile dir to this temp user data dir. await fs.cp(path.join(userDataDir, profileDirName), path.join(tmpDirPath, profileDirName), { recursive: true }); } else if (userDataDir) { await fs.cp(userDataDir, tmpDirPath, { recursive: true }); } userDataDir = tmpDirPath; } if (profileDirName) { chromeFlags.push(`--profile-directory=${profileDirName}`); } let startingUrl; if (this.params.startUrl) { const startingUrls = Array.isArray(this.params.startUrl) ? this.params.startUrl : [this.params.startUrl]; startingUrl = startingUrls.shift(); chromeFlags.push(...startingUrls); } let port; if (this.params.chromiumPort && !isNaN(this.params.chromiumPort)) { port = this.params.chromiumPort; log.debug(`(port: ${port})`); } this.chromiumInstance = await this.chromiumLaunch({ enableExtensions: true, chromePath: chromiumBinary, chromeFlags, startingUrl, userDataDir, // Ignore default flags to keep the extension enabled. ignoreDefaultFlags: true, prefs: this.getPrefs(), port }); this.chromiumInstance.process.once('close', () => { this.chromiumInstance = null; if (!this.exiting) { log.info('Exiting on Chromium instance disconnected.'); this.exit(); } }); } async wssBroadcast(data) { return new Promise(resolve => { const clients = this.wss ? new Set(this.wss.clients) : new Set(); function cleanWebExtReloadComplete() { const client = this; client.removeEventListener('message', webExtReloadComplete); client.removeEventListener('close', cleanWebExtReloadComplete); clients.delete(client); } const webExtReloadComplete = async message => { const msg = JSON.parse(message.data); if (msg.type === 'webExtReloadExtensionComplete') { for (const client of clients) { cleanWebExtReloadComplete.call(client); } resolve(); } }; for (const client of clients) { if (client.readyState === WebSocket.OPEN) { client.addEventListener('message', webExtReloadComplete); client.addEventListener('close', cleanWebExtReloadComplete); client.send(JSON.stringify(data)); } else { clients.delete(client); } } if (clients.size === 0) { resolve(); } }); } async createReloadManagerExtension() { const tmpDir = new TempDir(); await tmpDir.create(); this.registerCleanup(() => tmpDir.remove()); const extPath = path.join(tmpDir.path(), `reload-manager-extension-${Date.now()}`); log.debug(`Creating reload-manager-extension in ${extPath}`); await fs.mkdir(extPath, { recursive: true }); await fs.writeFile(path.join(extPath, 'manifest.json'), JSON.stringify({ manifest_version: 2, name: 'web-ext Reload Manager Extension', version: '1.0', permissions: ['management', 'tabs'], background: { scripts: ['bg.js'] } })); const wssInfo = this.wss.address(); const bgPage = `(function bgPage() { async function getAllDevExtensions() { const allExtensions = await new Promise( r => chrome.management.getAll(r)); return allExtensions.filter((extension) => { return extension.enabled && extension.installType === "development" && extension.id !== chrome.runtime.id; }); } const setEnabled = (extensionId, value) => chrome.runtime.id == extensionId ? new Promise.resolve() : new Promise(r => chrome.management.setEnabled(extensionId, value, r)); async function reloadExtension(extensionId) { await setEnabled(extensionId, false); await setEnabled(extensionId, true); } const ws = new window.WebSocket( "ws://${wssInfo.address}:${wssInfo.port}"); ws.onmessage = async (evt) => { const msg = JSON.parse(evt.data); if (msg.type === 'webExtReloadAllExtensions') { const devExtensions = await getAllDevExtensions(); await Promise.all(devExtensions.map(ext => reloadExtension(ext.id))); ws.send(JSON.stringify({ type: 'webExtReloadExtensionComplete' })); } }; })()`; await fs.writeFile(path.join(extPath, 'bg.js'), bgPage); return extPath; } /** * 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(); await this.wssBroadcast({ type: 'webExtReloadAllExtensions' }); process.stdout.write(`\rLast extension reload: ${new Date().toTimeString()}`); log.debug('\n'); return [{ runnerName }]; } /** * Reloads a single extension, collect any reload error and resolves to * an array composed by a single ExtensionRunnerReloadResult object. */ async reloadExtensionBySourceDir(extensionSourceDir // eslint-disable-line no-unused-vars ) { // TODO(rpl): detect the extension ids assigned to the // target extensions and map it to the extensions source dir // (https://github.com/mozilla/web-ext/issues/1687). return this.reloadAllExtensions(); } /** * Register a callback to be called when the runner has been exited * (e.g. the Chromium instance exits or the user has requested web-ext * to exit). */ registerCleanup(fn) { this.cleanupCallbacks.add(fn); } /** * Exits the runner, by closing the managed Chromium instance. */ async exit() { this.exiting = true; // Wait for the setup to complete if the extension runner is already // being started. if (this._promiseSetupDone) { // Ignore initialization errors if any. await this._promiseSetupDone.catch(err => { log.debug(`ignored setup error on chromium runner shutdown: ${err}`); }); } if (this.chromiumInstance) { await this.chromiumInstance.kill(); this.chromiumInstance = null; } if (this.wss) { // Close all websocket clients, closing the WebSocketServer // does not terminate the existing connection and it wouldn't // resolve until all of the existing connections are closed. for (const wssClient of this.wss?.clients || []) { if (wssClient.readyState === WebSocket.OPEN) { wssClient.terminate(); } } await new Promise(resolve => this.wss ? this.wss.close(resolve) : resolve()); this.wss = null; } // Call all the registered cleanup callbacks. for (const fn of this.cleanupCallbacks) { try { fn(); } catch (error) { log.error(error); } } } /** * Returns a deep preferences object based on a set of flat preferences, like * "extensions.ui.developer_mode". */ getPrefs() { return Object.entries({ ...DEFAULT_PREFS, ...(this.params.customChromiumPrefs || {}) }).reduce((prefs, [key, value]) => { set(prefs, key, value); return prefs; }, {}); } } //# sourceMappingURL=chromium.js.map