diff options
Diffstat (limited to 'browser/components/extensions/parent/ext-devtools.js')
-rw-r--r-- | browser/components/extensions/parent/ext-devtools.js | 510 |
1 files changed, 510 insertions, 0 deletions
diff --git a/browser/components/extensions/parent/ext-devtools.js b/browser/components/extensions/parent/ext-devtools.js new file mode 100644 index 0000000000..98efd25489 --- /dev/null +++ b/browser/components/extensions/parent/ext-devtools.js @@ -0,0 +1,510 @@ +/* -*- 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(context) { + 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(); + } + } +}; |