web-ext-run
Version:
A tool to open and run web extensions
386 lines (362 loc) • 13.4 kB
JavaScript
import nodeFs from 'fs';
import path from 'path';
import { promisify } from 'util';
import fs from 'fs/promises';
import { default as defaultFxRunner } from 'fx-runner';
import FirefoxProfile from 'firefox-profile';
import fromEvent from 'promise-toolbox/fromEvent';
import isDirectory from '../util/is-directory.js';
import { isErrorWithCode, UsageError, WebExtError } from '../errors.js';
import { getPrefs as defaultPrefGetter } from './preferences.js';
import { getManifestId } from '../util/manifest.js';
import { findFreeTcpPort as defaultRemotePortFinder } from './remote.js';
import { createLogger } from '../util/logger.js';
const log = createLogger(import.meta.url);
const defaultAsyncFsStat = fs.stat.bind(fs);
const defaultUserProfileCopier = FirefoxProfile.copyFromUserProfile;
export const defaultFirefoxEnv = {
XPCOM_DEBUG_BREAK: 'stack',
NS_TRACE_MALLOC_DISABLE_STACKS: '1'
};
/*
* Runs Firefox with the given profile object and resolves a promise on exit.
*/
export async function run(profile, {
fxRunner = defaultFxRunner,
findRemotePort = defaultRemotePortFinder,
firefoxBinary,
binaryArgs,
extensions,
devtools
} = {}) {
log.debug(`Running Firefox with profile at ${profile.path()}`);
const remotePort = await findRemotePort();
if (firefoxBinary && firefoxBinary.startsWith('flatpak:')) {
const flatpakAppId = firefoxBinary.substring(8);
log.debug(`Configuring Firefox with flatpak: appId=${flatpakAppId}`);
// This should be resolved by the fx-runner.
firefoxBinary = 'flatpak';
binaryArgs = ['run', `--filesystem=${profile.path()}`, ...extensions.map(({
sourceDir
}) => `--filesystem=${sourceDir}:ro`),
// We need to share the network namespace because we want to connect to
// Firefox with the remote protocol. There is no way to tell flatpak to
// only expose a port AFAIK.
'--share=network',
// Kill the entire sandbox when the launching process dies, which is what
// we want since exiting web-ext involves `kill` and the process executed
// here is `flatpak run`.
'--die-with-parent', flatpakAppId].concat(...(binaryArgs || []));
}
const results = await fxRunner({
// if this is falsey, fxRunner tries to find the default one.
binary: firefoxBinary,
'binary-args': binaryArgs,
// For Flatpak we need to respect the order of the command arguments because
// we have arguments for Flapack (first) and then Firefox.
'binary-args-first': firefoxBinary === 'flatpak',
// This ensures a new instance of Firefox is created. It has nothing
// to do with the devtools remote debugger.
'no-remote': true,
listen: remotePort,
foreground: true,
profile: profile.path(),
env: {
...process.env,
...defaultFirefoxEnv
},
verbose: true
});
const firefox = results.process;
log.debug(`Executing Firefox binary: ${results.binary}`);
log.debug(`Firefox args: ${results.args.join(' ')}`);
firefox.on('error', error => {
// TODO: show a nice error when it can't find Firefox.
// if (/No such file/.test(err) || err.code === 'ENOENT') {
log.error(`Firefox error: ${error}`);
throw error;
});
if (!devtools) {
log.info('Use --verbose or --devtools to see logging');
}
if (devtools) {
log.info('More info about WebExtensions debugging:');
log.info('https://extensionworkshop.com/documentation/develop/debugging/');
}
firefox.stderr.on('data', data => {
log.debug(`Firefox stderr: ${data.toString().trim()}`);
});
firefox.stdout.on('data', data => {
log.debug(`Firefox stdout: ${data.toString().trim()}`);
});
firefox.on('close', () => {
log.debug('Firefox closed');
});
return {
firefox,
debuggerPort: remotePort
};
}
// isDefaultProfile types and implementation.
const DEFAULT_PROFILES_NAMES = ['default', 'dev-edition-default'];
/*
* Tests if a profile is a default Firefox profile (both as a profile name or
* profile path).
*
* Returns a promise that resolves to true if the profile is one of default Firefox profile.
*/
export async function isDefaultProfile(profilePathOrName, ProfileFinder = FirefoxProfile.Finder, fsStat = fs.stat) {
if (DEFAULT_PROFILES_NAMES.includes(profilePathOrName)) {
return true;
}
const baseProfileDir = ProfileFinder.locateUserDirectory();
const profilesIniPath = path.join(baseProfileDir, 'profiles.ini');
try {
await fsStat(profilesIniPath);
} catch (error) {
if (isErrorWithCode('ENOENT', error)) {
log.debug(`profiles.ini not found: ${error}`);
// No profiles exist yet, default to false (the default profile name contains a
// random generated component).
return false;
}
// Re-throw any unexpected exception.
throw error;
}
// Check for profile dir path.
const finder = new ProfileFinder(baseProfileDir);
const readProfiles = promisify((...args) => finder.readProfiles(...args));
await readProfiles();
const normalizedProfileDirPath = path.normalize(path.join(path.resolve(profilePathOrName), path.sep));
for (const profile of finder.profiles) {
// Check if the profile dir path or name is one of the default profiles
// defined in the profiles.ini file.
if (DEFAULT_PROFILES_NAMES.includes(profile.Name) || profile.Default === '1') {
let profileFullPath;
// Check for profile name.
if (profile.Name === profilePathOrName) {
return true;
}
// Check for profile path.
if (profile.IsRelative === '1') {
profileFullPath = path.join(baseProfileDir, profile.Path, path.sep);
} else {
profileFullPath = path.join(profile.Path, path.sep);
}
if (path.normalize(profileFullPath) === normalizedProfileDirPath) {
return true;
}
}
}
// Profile directory not found.
return false;
}
// configureProfile types and implementation.
/*
* Configures a profile with common preferences that are required to
* activate extension development.
*
* Returns a promise that resolves with the original profile object.
*/
export function configureProfile(profile, {
app = 'firefox',
getPrefs = defaultPrefGetter,
customPrefs = {}
} = {}) {
// Set default preferences. Some of these are required for the add-on to
// operate, such as disabling signatures.
const prefs = getPrefs(app);
Object.keys(prefs).forEach(pref => {
profile.setPreference(pref, prefs[pref]);
});
if (Object.keys(customPrefs).length > 0) {
const customPrefsStr = JSON.stringify(customPrefs, null, 2);
log.info(`Setting custom Firefox preferences: ${customPrefsStr}`);
Object.keys(customPrefs).forEach(custom => {
profile.setPreference(custom, customPrefs[custom]);
});
}
profile.updatePreferences();
return Promise.resolve(profile);
}
export function defaultCreateProfileFinder({
userDirectoryPath,
FxProfile = FirefoxProfile
} = {}) {
const finder = new FxProfile.Finder(userDirectoryPath);
const readProfiles = promisify((...args) => finder.readProfiles(...args));
const getPath = promisify((...args) => finder.getPath(...args));
return async profileName => {
try {
await readProfiles();
const hasProfileName = finder.profiles.filter(profileDef => profileDef.Name === profileName).length !== 0;
if (hasProfileName) {
return await getPath(profileName);
}
} catch (error) {
if (!isErrorWithCode('ENOENT', error)) {
throw error;
}
log.warn('Unable to find Firefox profiles.ini');
}
};
}
// useProfile types and implementation.
// Use the target path as a Firefox profile without cloning it
export async function useProfile(profilePath, {
app,
configureThisProfile = configureProfile,
isFirefoxDefaultProfile = isDefaultProfile,
customPrefs = {},
createProfileFinder = defaultCreateProfileFinder
} = {}) {
const isForbiddenProfile = await isFirefoxDefaultProfile(profilePath);
if (isForbiddenProfile) {
throw new UsageError('Cannot use --keep-profile-changes on a default profile' + ` ("${profilePath}")` + ' because web-ext will make it insecure and unsuitable for daily use.' + '\nSee https://github.com/mozilla/web-ext/issues/1005');
}
let destinationDirectory;
const getProfilePath = createProfileFinder();
const profileIsDirPath = await isDirectory(profilePath);
if (profileIsDirPath) {
log.debug(`Using profile directory "${profilePath}"`);
destinationDirectory = profilePath;
} else {
log.debug(`Assuming ${profilePath} is a named profile`);
destinationDirectory = await getProfilePath(profilePath);
if (!destinationDirectory) {
throw new UsageError(`The request "${profilePath}" profile name ` + 'cannot be resolved to a profile path');
}
}
const profile = new FirefoxProfile({
destinationDirectory
});
return await configureThisProfile(profile, {
app,
customPrefs
});
}
// createProfile types and implementation.
/*
* Creates a new temporary profile and resolves with the profile object.
*
* The profile will be deleted when the system process exits.
*/
export async function createProfile({
app,
configureThisProfile = configureProfile,
customPrefs = {}
} = {}) {
const profile = new FirefoxProfile();
return await configureThisProfile(profile, {
app,
customPrefs
});
}
// copyProfile types and implementation.
/*
* Copies an existing Firefox profile and creates a new temporary profile.
* The new profile will be configured with some preferences required to
* activate extension development.
*
* It resolves with the new profile object.
*
* The temporary profile will be deleted when the system process exits.
*
* The existing profile can be specified as a directory path or a name of
* one that exists in the current user's Firefox directory.
*/
export async function copyProfile(profileDirectory, {
app,
configureThisProfile = configureProfile,
copyFromUserProfile = defaultUserProfileCopier,
customPrefs = {}
} = {}) {
const copy = promisify(FirefoxProfile.copy);
const copyByName = promisify(copyFromUserProfile);
try {
const dirExists = await isDirectory(profileDirectory);
let profile;
if (dirExists) {
log.debug(`Copying profile directory from "${profileDirectory}"`);
profile = await copy({
profileDirectory
});
} else {
log.debug(`Assuming ${profileDirectory} is a named profile`);
profile = await copyByName({
name: profileDirectory
});
}
return configureThisProfile(profile, {
app,
customPrefs
});
} catch (error) {
throw new WebExtError(`Could not copy Firefox profile from ${profileDirectory}: ${error}`);
}
}
// installExtension types and implementation.
/*
* Installs an extension into the given Firefox profile object.
* Resolves when complete.
*
* The extension is copied into a special location and you need to turn
* on some preferences to allow this. See extensions.autoDisableScopes in
* ./preferences.js.
*
* When asProxy is true, a special proxy file will be installed. This is a
* text file that contains the path to the extension source.
*/
export async function installExtension({
asProxy = false,
manifestData,
profile,
extensionPath,
asyncFsStat = defaultAsyncFsStat
}) {
// This more or less follows
// https://github.com/saadtazi/firefox-profile-js/blob/master/lib/firefox_profile.js#L531
// (which is broken for web extensions).
// TODO: maybe uplift a patch that supports web extensions instead?
if (!profile.extensionsDir) {
throw new WebExtError('profile.extensionsDir was unexpectedly empty');
}
try {
await asyncFsStat(profile.extensionsDir);
} catch (error) {
if (isErrorWithCode('ENOENT', error)) {
log.debug(`Creating extensions directory: ${profile.extensionsDir}`);
await fs.mkdir(profile.extensionsDir);
} else {
throw error;
}
}
const id = getManifestId(manifestData);
if (!id) {
throw new UsageError('An explicit extension ID is required when installing to ' + 'a profile (applications.gecko.id not found in manifest.json)');
}
if (asProxy) {
log.debug(`Installing as an extension proxy; source: ${extensionPath}`);
const isDir = await isDirectory(extensionPath);
if (!isDir) {
throw new WebExtError('proxy install: extensionPath must be the extension source ' + `directory; got: ${extensionPath}`);
}
// Write a special extension proxy file containing the source
// directory. See:
// https://developer.mozilla.org/en-US/Add-ons/Setting_up_extension_development_environment#Firefox_extension_proxy_file
const destPath = path.join(profile.extensionsDir, `${id}`);
const writeStream = nodeFs.createWriteStream(destPath);
writeStream.write(extensionPath);
writeStream.end();
return await fromEvent(writeStream, 'close');
} else {
// Write the XPI file to the profile.
const readStream = nodeFs.createReadStream(extensionPath);
const destPath = path.join(profile.extensionsDir, `${id}.xpi`);
const writeStream = nodeFs.createWriteStream(destPath);
log.debug(`Installing extension from ${extensionPath} to ${destPath}`);
readStream.pipe(writeStream);
return await Promise.all([fromEvent(readStream, 'close'), fromEvent(writeStream, 'close')]);
}
}
//# sourceMappingURL=index.js.map