510 lines
16 KiB
JavaScript
510 lines
16 KiB
JavaScript
/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
|
|
/* vim: set sts=2 sw=2 et tw=80: */
|
|
/* This Source Code Form is subject to the terms of the Mozilla Public
|
|
* License, v. 2.0. If a copy of the MPL was not distributed with this file,
|
|
* You can obtain one at http://mozilla.org/MPL/2.0/. */
|
|
|
|
"use strict";
|
|
|
|
/**
|
|
* This module provides helpers used by the other specialized `ext-devtools-*.js` modules
|
|
* and the implementation of the `devtools_page`.
|
|
*/
|
|
|
|
ChromeUtils.defineESModuleGetters(this, {
|
|
DevToolsShim: "chrome://devtools-startup/content/DevToolsShim.sys.mjs",
|
|
});
|
|
|
|
var { ExtensionParent } = ChromeUtils.importESModule(
|
|
"resource://gre/modules/ExtensionParent.sys.mjs"
|
|
);
|
|
|
|
var { HiddenExtensionPage, watchExtensionProxyContextLoad } = ExtensionParent;
|
|
|
|
// Get the devtools preference given the extension id.
|
|
function getDevToolsPrefBranchName(extensionId) {
|
|
return `devtools.webextensions.${extensionId}`;
|
|
}
|
|
|
|
/**
|
|
* Retrieve the tabId for the given devtools toolbox.
|
|
*
|
|
* @param {Toolbox} toolbox
|
|
* A devtools toolbox instance.
|
|
*
|
|
* @returns {number}
|
|
* The corresponding WebExtensions tabId.
|
|
*/
|
|
global.getTargetTabIdForToolbox = toolbox => {
|
|
let { descriptorFront } = toolbox.commands;
|
|
|
|
if (!descriptorFront.isLocalTab) {
|
|
throw new Error(
|
|
"Unexpected target type: only local tabs are currently supported."
|
|
);
|
|
}
|
|
|
|
let parentWindow = descriptorFront.localTab.linkedBrowser.ownerGlobal;
|
|
let tab = parentWindow.gBrowser.getTabForBrowser(
|
|
descriptorFront.localTab.linkedBrowser
|
|
);
|
|
|
|
return tabTracker.getId(tab);
|
|
};
|
|
|
|
// Get the WebExtensionInspectedWindowActor eval options (needed to provide the $0 and inspect
|
|
// binding provided to the evaluated js code).
|
|
global.getToolboxEvalOptions = async function (context) {
|
|
const options = {};
|
|
const toolbox = context.devToolsToolbox;
|
|
const selectedNode = toolbox.selection;
|
|
|
|
if (selectedNode && selectedNode.nodeFront) {
|
|
// If there is a selected node in the inspector, we hand over
|
|
// its actor id to the eval request in order to provide the "$0" binding.
|
|
options.toolboxSelectedNodeActorID = selectedNode.nodeFront.actorID;
|
|
}
|
|
|
|
// Provide the console actor ID to implement the "inspect" binding.
|
|
const consoleFront = await toolbox.target.getFront("console");
|
|
options.toolboxConsoleActorID = consoleFront.actor;
|
|
|
|
return options;
|
|
};
|
|
|
|
/**
|
|
* The DevToolsPage represents the "devtools_page" related to a particular
|
|
* Toolbox and WebExtension.
|
|
*
|
|
* The devtools_page contexts are invisible WebExtensions contexts, similar to the
|
|
* background page, associated to a single developer toolbox (e.g. If an add-on
|
|
* registers a devtools_page and the user opens 3 developer toolbox in 3 webpages,
|
|
* 3 devtools_page contexts will be created for that add-on).
|
|
*
|
|
* @param {Extension} extension
|
|
* The extension that owns the devtools_page.
|
|
* @param {object} options
|
|
* @param {Toolbox} options.toolbox
|
|
* The developer toolbox instance related to this devtools_page.
|
|
* @param {string} options.url
|
|
* The path to the devtools page html page relative to the extension base URL.
|
|
* @param {DevToolsPageDefinition} options.devToolsPageDefinition
|
|
* The instance of the devToolsPageDefinition class related to this DevToolsPage.
|
|
*/
|
|
class DevToolsPage extends HiddenExtensionPage {
|
|
constructor(extension, options) {
|
|
super(extension, "devtools_page");
|
|
|
|
this.url = extension.baseURI.resolve(options.url);
|
|
this.toolbox = options.toolbox;
|
|
this.devToolsPageDefinition = options.devToolsPageDefinition;
|
|
|
|
this.unwatchExtensionProxyContextLoad = null;
|
|
|
|
this.waitForTopLevelContext = new Promise(resolve => {
|
|
this.resolveTopLevelContext = resolve;
|
|
});
|
|
}
|
|
|
|
async build() {
|
|
await this.createBrowserElement();
|
|
|
|
// Listening to new proxy contexts.
|
|
this.unwatchExtensionProxyContextLoad = watchExtensionProxyContextLoad(
|
|
this,
|
|
context => {
|
|
// Keep track of the toolbox and target associated to the context, which is
|
|
// needed by the API methods implementation.
|
|
context.devToolsToolbox = this.toolbox;
|
|
|
|
if (!this.topLevelContext) {
|
|
this.topLevelContext = context;
|
|
|
|
// Ensure this devtools page is destroyed, when the top level context proxy is
|
|
// closed.
|
|
this.topLevelContext.callOnClose(this);
|
|
|
|
this.resolveTopLevelContext(context);
|
|
}
|
|
}
|
|
);
|
|
|
|
extensions.emit("extension-browser-inserted", this.browser, {
|
|
devtoolsToolboxInfo: {
|
|
inspectedWindowTabId: getTargetTabIdForToolbox(this.toolbox),
|
|
themeName: DevToolsShim.getTheme(),
|
|
},
|
|
});
|
|
|
|
this.browser.fixupAndLoadURIString(this.url, {
|
|
triggeringPrincipal: this.extension.principal,
|
|
});
|
|
|
|
await this.waitForTopLevelContext;
|
|
}
|
|
|
|
close() {
|
|
if (this.closed) {
|
|
throw new Error("Unable to shutdown a closed DevToolsPage instance");
|
|
}
|
|
|
|
this.closed = true;
|
|
|
|
// Unregister the devtools page instance from the devtools page definition.
|
|
this.devToolsPageDefinition.forgetForToolbox(this.toolbox);
|
|
|
|
// Unregister it from the resources to cleanup when the context has been closed.
|
|
if (this.topLevelContext) {
|
|
this.topLevelContext.forgetOnClose(this);
|
|
}
|
|
|
|
// Stop watching for any new proxy contexts from the devtools page.
|
|
if (this.unwatchExtensionProxyContextLoad) {
|
|
this.unwatchExtensionProxyContextLoad();
|
|
this.unwatchExtensionProxyContextLoad = null;
|
|
}
|
|
|
|
super.shutdown();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The DevToolsPageDefinitions class represents the "devtools_page" manifest property
|
|
* of a WebExtension.
|
|
*
|
|
* A DevToolsPageDefinition instance is created automatically when a WebExtension
|
|
* which contains the "devtools_page" manifest property has been loaded, and it is
|
|
* automatically destroyed when the related WebExtension has been unloaded,
|
|
* and so there will be at most one DevtoolsPageDefinition per add-on.
|
|
*
|
|
* Every time a developer tools toolbox is opened, the DevToolsPageDefinition creates
|
|
* and keep track of a DevToolsPage instance (which represents the actual devtools_page
|
|
* instance related to that particular toolbox).
|
|
*
|
|
* @param {Extension} extension
|
|
* The extension that owns the devtools_page.
|
|
* @param {string} url
|
|
* The path to the devtools page html page relative to the extension base URL.
|
|
*/
|
|
class DevToolsPageDefinition {
|
|
constructor(extension, url) {
|
|
this.url = url;
|
|
this.extension = extension;
|
|
|
|
// Map[Toolbox -> DevToolsPage]
|
|
this.devtoolsPageForToolbox = new Map();
|
|
}
|
|
|
|
onThemeChanged(themeName) {
|
|
Services.ppmm.broadcastAsyncMessage("Extension:DevToolsThemeChanged", {
|
|
themeName,
|
|
});
|
|
}
|
|
|
|
buildForToolbox(toolbox) {
|
|
if (
|
|
!this.extension.canAccessWindow(
|
|
toolbox.commands.descriptorFront.localTab.ownerGlobal
|
|
)
|
|
) {
|
|
// We should never create a devtools page for a toolbox related to a private browsing window
|
|
// if the extension is not allowed to access it.
|
|
return;
|
|
}
|
|
|
|
if (this.devtoolsPageForToolbox.has(toolbox)) {
|
|
return Promise.reject(
|
|
new Error("DevtoolsPage has been already created for this toolbox")
|
|
);
|
|
}
|
|
|
|
const devtoolsPage = new DevToolsPage(this.extension, {
|
|
toolbox,
|
|
url: this.url,
|
|
devToolsPageDefinition: this,
|
|
});
|
|
|
|
// If this is the first DevToolsPage, subscribe to the theme-changed event
|
|
if (this.devtoolsPageForToolbox.size === 0) {
|
|
DevToolsShim.on("theme-changed", this.onThemeChanged);
|
|
}
|
|
this.devtoolsPageForToolbox.set(toolbox, devtoolsPage);
|
|
|
|
return devtoolsPage.build();
|
|
}
|
|
|
|
shutdownForToolbox(toolbox) {
|
|
if (this.devtoolsPageForToolbox.has(toolbox)) {
|
|
const devtoolsPage = this.devtoolsPageForToolbox.get(toolbox);
|
|
devtoolsPage.close();
|
|
|
|
// `devtoolsPage.close()` should remove the instance from the map,
|
|
// raise an exception if it is still there.
|
|
if (this.devtoolsPageForToolbox.has(toolbox)) {
|
|
throw new Error(
|
|
`Leaked DevToolsPage instance for target "${toolbox.commands.descriptorFront.url}", extension "${this.extension.policy.debugName}"`
|
|
);
|
|
}
|
|
|
|
// If this was the last DevToolsPage, unsubscribe from the theme-changed event
|
|
if (this.devtoolsPageForToolbox.size === 0) {
|
|
DevToolsShim.off("theme-changed", this.onThemeChanged);
|
|
}
|
|
this.extension.emit("devtools-page-shutdown", toolbox);
|
|
}
|
|
}
|
|
|
|
forgetForToolbox(toolbox) {
|
|
this.devtoolsPageForToolbox.delete(toolbox);
|
|
}
|
|
|
|
/**
|
|
* Build the devtools_page instances for all the existing toolboxes
|
|
* (if the toolbox target is supported).
|
|
*/
|
|
build() {
|
|
// Iterate over the existing toolboxes and create the devtools page for them
|
|
// (if the toolbox target is supported).
|
|
for (let toolbox of DevToolsShim.getToolboxes()) {
|
|
if (
|
|
!toolbox.commands.descriptorFront.isLocalTab ||
|
|
!this.extension.canAccessWindow(
|
|
toolbox.commands.descriptorFront.localTab.ownerGlobal
|
|
)
|
|
) {
|
|
// Skip any non-local tab and private browsing windows if the extension
|
|
// is not allowed to access them.
|
|
continue;
|
|
}
|
|
|
|
// Ensure that the WebExtension is listed in the toolbox options.
|
|
toolbox.registerWebExtension(this.extension.uuid, {
|
|
name: this.extension.name,
|
|
pref: `${getDevToolsPrefBranchName(this.extension.id)}.enabled`,
|
|
});
|
|
|
|
this.buildForToolbox(toolbox);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Shutdown all the devtools_page instances.
|
|
*/
|
|
shutdown() {
|
|
for (let toolbox of this.devtoolsPageForToolbox.keys()) {
|
|
this.shutdownForToolbox(toolbox);
|
|
}
|
|
|
|
if (this.devtoolsPageForToolbox.size > 0) {
|
|
throw new Error(
|
|
`Leaked ${this.devtoolsPageForToolbox.size} DevToolsPage instances in devtoolsPageForToolbox Map`
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
this.devtools = class extends ExtensionAPI {
|
|
constructor(extension) {
|
|
super(extension);
|
|
|
|
this._initialized = false;
|
|
|
|
// DevToolsPageDefinition instance (created in onManifestEntry).
|
|
this.pageDefinition = null;
|
|
|
|
this.onToolboxReady = this.onToolboxReady.bind(this);
|
|
this.onToolboxDestroy = this.onToolboxDestroy.bind(this);
|
|
|
|
/* eslint-disable mozilla/balanced-listeners */
|
|
extension.on("add-permissions", (ignoreEvent, permissions) => {
|
|
if (permissions.permissions.includes("devtools")) {
|
|
Services.prefs.setBoolPref(
|
|
`${getDevToolsPrefBranchName(extension.id)}.enabled`,
|
|
true
|
|
);
|
|
|
|
this._initialize();
|
|
}
|
|
});
|
|
|
|
extension.on("remove-permissions", (ignoreEvent, permissions) => {
|
|
if (permissions.permissions.includes("devtools")) {
|
|
Services.prefs.setBoolPref(
|
|
`${getDevToolsPrefBranchName(extension.id)}.enabled`,
|
|
false
|
|
);
|
|
|
|
this._uninitialize();
|
|
}
|
|
});
|
|
}
|
|
|
|
onManifestEntry() {
|
|
this._initialize();
|
|
}
|
|
|
|
static onUninstall(extensionId) {
|
|
// Remove the preference branch on uninstall.
|
|
const prefBranch = Services.prefs.getBranch(
|
|
`${getDevToolsPrefBranchName(extensionId)}.`
|
|
);
|
|
|
|
prefBranch.deleteBranch("");
|
|
}
|
|
|
|
_initialize() {
|
|
const { extension } = this;
|
|
|
|
if (!extension.hasPermission("devtools") || this._initialized) {
|
|
return;
|
|
}
|
|
|
|
this.initDevToolsPref();
|
|
|
|
// Create the devtools_page definition.
|
|
this.pageDefinition = new DevToolsPageDefinition(
|
|
extension,
|
|
extension.manifest.devtools_page
|
|
);
|
|
|
|
// Build the extension devtools_page on all existing toolboxes (if the extension
|
|
// devtools_page is not disabled by the related preference).
|
|
if (!this.isDevToolsPageDisabled()) {
|
|
this.pageDefinition.build();
|
|
}
|
|
|
|
DevToolsShim.on("toolbox-ready", this.onToolboxReady);
|
|
DevToolsShim.on("toolbox-destroy", this.onToolboxDestroy);
|
|
this._initialized = true;
|
|
}
|
|
|
|
_uninitialize() {
|
|
// devtoolsPrefBranch is set in onManifestEntry, and nullified
|
|
// later in onShutdown. If it isn't set, then onManifestEntry
|
|
// did not initialize devtools for the extension.
|
|
if (!this._initialized) {
|
|
return;
|
|
}
|
|
|
|
DevToolsShim.off("toolbox-ready", this.onToolboxReady);
|
|
DevToolsShim.off("toolbox-destroy", this.onToolboxDestroy);
|
|
|
|
// Shutdown the extension devtools_page from all existing toolboxes.
|
|
this.pageDefinition.shutdown();
|
|
this.pageDefinition = null;
|
|
|
|
// Iterate over the existing toolboxes and unlist the devtools webextension from them.
|
|
for (let toolbox of DevToolsShim.getToolboxes()) {
|
|
toolbox.unregisterWebExtension(this.extension.uuid);
|
|
}
|
|
|
|
this.uninitDevToolsPref();
|
|
this._initialized = false;
|
|
}
|
|
|
|
onShutdown() {
|
|
this._uninitialize();
|
|
}
|
|
|
|
getAPI() {
|
|
return {
|
|
devtools: {},
|
|
};
|
|
}
|
|
|
|
onToolboxReady(toolbox) {
|
|
if (
|
|
!toolbox.commands.descriptorFront.isLocalTab ||
|
|
!this.extension.canAccessWindow(
|
|
toolbox.commands.descriptorFront.localTab.ownerGlobal
|
|
)
|
|
) {
|
|
// Skip any non-local (as remote tabs are not yet supported, see Bug 1304378 for additional details
|
|
// related to remote targets support), and private browsing windows if the extension
|
|
// is not allowed to access them.
|
|
return;
|
|
}
|
|
|
|
// Ensure that the WebExtension is listed in the toolbox options.
|
|
toolbox.registerWebExtension(this.extension.uuid, {
|
|
name: this.extension.name,
|
|
pref: `${getDevToolsPrefBranchName(this.extension.id)}.enabled`,
|
|
});
|
|
|
|
// Do not build the devtools page if the extension has been disabled
|
|
// (e.g. based on the devtools preference).
|
|
if (toolbox.isWebExtensionEnabled(this.extension.uuid)) {
|
|
this.pageDefinition.buildForToolbox(toolbox);
|
|
}
|
|
}
|
|
|
|
onToolboxDestroy(toolbox) {
|
|
if (!toolbox.commands.descriptorFront.isLocalTab) {
|
|
// Only local tabs are currently supported (See Bug 1304378 for additional details
|
|
// related to remote targets support).
|
|
return;
|
|
}
|
|
|
|
this.pageDefinition.shutdownForToolbox(toolbox);
|
|
}
|
|
|
|
/**
|
|
* Initialize the DevTools preferences branch for the extension and
|
|
* start to observe it for changes on the "enabled" preference.
|
|
*/
|
|
initDevToolsPref() {
|
|
const prefBranch = Services.prefs.getBranch(
|
|
`${getDevToolsPrefBranchName(this.extension.id)}.`
|
|
);
|
|
|
|
// Initialize the devtools extension preference if it doesn't exist yet.
|
|
if (prefBranch.getPrefType("enabled") === prefBranch.PREF_INVALID) {
|
|
prefBranch.setBoolPref("enabled", true);
|
|
}
|
|
|
|
this.devtoolsPrefBranch = prefBranch;
|
|
this.devtoolsPrefBranch.addObserver("enabled", this);
|
|
}
|
|
|
|
/**
|
|
* Stop from observing the DevTools preferences branch for the extension.
|
|
*/
|
|
uninitDevToolsPref() {
|
|
this.devtoolsPrefBranch.removeObserver("enabled", this);
|
|
this.devtoolsPrefBranch = null;
|
|
}
|
|
|
|
/**
|
|
* Test if the extension's devtools_page has been disabled using the
|
|
* DevTools preference.
|
|
*
|
|
* @returns {boolean}
|
|
* true if the devtools_page for this extension is disabled.
|
|
*/
|
|
isDevToolsPageDisabled() {
|
|
return !this.devtoolsPrefBranch.getBoolPref("enabled", false);
|
|
}
|
|
|
|
/**
|
|
* Observes the changed preferences on the DevTools preferences branch
|
|
* related to the extension.
|
|
*
|
|
* @param {nsIPrefBranch} subject The observed preferences branch.
|
|
* @param {string} topic The notified topic.
|
|
* @param {string} prefName The changed preference name.
|
|
*/
|
|
observe(subject, topic, prefName) {
|
|
// We are currently interested only in the "enabled" preference from the
|
|
// WebExtension devtools preferences branch.
|
|
if (subject !== this.devtoolsPrefBranch || prefName !== "enabled") {
|
|
return;
|
|
}
|
|
|
|
// Shutdown or build the devtools_page on any existing toolbox.
|
|
if (this.isDevToolsPageDisabled()) {
|
|
this.pageDefinition.shutdown();
|
|
} else {
|
|
this.pageDefinition.build();
|
|
}
|
|
}
|
|
};
|