diff options
Diffstat (limited to 'browser/components/extensions/parent/ext-devtools-panels.js')
-rw-r--r-- | browser/components/extensions/parent/ext-devtools-panels.js | 691 |
1 files changed, 691 insertions, 0 deletions
diff --git a/browser/components/extensions/parent/ext-devtools-panels.js b/browser/components/extensions/parent/ext-devtools-panels.js new file mode 100644 index 0000000000..9f0dba5c25 --- /dev/null +++ b/browser/components/extensions/parent/ext-devtools-panels.js @@ -0,0 +1,691 @@ +/* -*- 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"; + +var { ExtensionParent } = ChromeUtils.importESModule( + "resource://gre/modules/ExtensionParent.sys.mjs" +); + +ChromeUtils.defineESModuleGetters(this, { + BroadcastConduit: "resource://gre/modules/ConduitsParent.sys.mjs", +}); + +var { watchExtensionProxyContextLoad } = ExtensionParent; + +var { promiseDocumentLoaded } = ExtensionUtils; + +const WEBEXT_PANELS_URL = "chrome://browser/content/webext-panels.xhtml"; + +class BaseDevToolsPanel { + constructor(context, panelOptions) { + const toolbox = context.devToolsToolbox; + if (!toolbox) { + // This should never happen when this constructor is called with a valid + // devtools extension context. + throw Error("Missing mandatory toolbox"); + } + + this.context = context; + this.extension = context.extension; + this.toolbox = toolbox; + this.viewType = "devtools_panel"; + this.panelOptions = panelOptions; + this.id = panelOptions.id; + + this.unwatchExtensionProxyContextLoad = null; + + // References to the panel browser XUL element and the toolbox window global which + // contains the devtools panel UI. + this.browser = null; + this.browserContainerWindow = null; + } + + async createBrowserElement(window) { + const { toolbox } = this; + const { extension } = this.context; + const { url } = this.panelOptions || { url: "about:blank" }; + + this.browser = await window.getBrowser({ + extension, + extensionUrl: url, + browserStyle: false, + viewType: "devtools_panel", + browserInsertedData: { + devtoolsToolboxInfo: { + toolboxPanelId: this.id, + inspectedWindowTabId: getTargetTabIdForToolbox(toolbox), + }, + }, + }); + + let hasTopLevelContext = false; + + // 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 = toolbox; + + if (!hasTopLevelContext) { + hasTopLevelContext = true; + + // Resolve the promise when the root devtools_panel context has been created. + if (this._resolveTopLevelContext) { + this._resolveTopLevelContext(context); + } + } + } + ); + + this.browser.fixupAndLoadURIString(url, { + triggeringPrincipal: this.context.principal, + }); + } + + destroyBrowserElement() { + const { browser, unwatchExtensionProxyContextLoad } = this; + if (unwatchExtensionProxyContextLoad) { + this.unwatchExtensionProxyContextLoad = null; + unwatchExtensionProxyContextLoad(); + } + + if (browser) { + browser.remove(); + this.browser = null; + } + } +} + +/** + * Represents an addon devtools panel in the main process. + * + * @param {ExtensionChildProxyContext} context + * A devtools extension proxy context running in a main process. + * @param {object} options + * @param {string} options.id + * The id of the addon devtools panel. + * @param {string} options.icon + * The icon of the addon devtools panel. + * @param {string} options.title + * The title of the addon devtools panel. + * @param {string} options.url + * The url of the addon devtools panel, relative to the extension base URL. + */ +class ParentDevToolsPanel extends BaseDevToolsPanel { + constructor(context, panelOptions) { + super(context, panelOptions); + + this.visible = false; + this.destroyed = false; + + this.context.callOnClose(this); + + this.conduit = new BroadcastConduit(this, { + id: `${this.id}-parent`, + send: ["PanelHidden", "PanelShown"], + }); + + this.onToolboxPanelSelect = this.onToolboxPanelSelect.bind(this); + this.onToolboxHostWillChange = this.onToolboxHostWillChange.bind(this); + this.onToolboxHostChanged = this.onToolboxHostChanged.bind(this); + + this.waitTopLevelContext = new Promise(resolve => { + this._resolveTopLevelContext = resolve; + }); + + this.panelAdded = false; + this.addPanel(); + } + + addPanel() { + const { icon, title } = this.panelOptions; + const extensionName = this.context.extension.name; + + this.toolbox.addAdditionalTool({ + id: this.id, + extensionId: this.context.extension.id, + url: WEBEXT_PANELS_URL, + icon: icon, + label: title, + // panelLabel is used to set the aria-label attribute (See Bug 1570645). + panelLabel: title, + tooltip: `DevTools Panel added by "${extensionName}" add-on.`, + isToolSupported: toolbox => toolbox.commands.descriptorFront.isLocalTab, + build: (window, toolbox) => { + if (toolbox !== this.toolbox) { + throw new Error( + "Unexpected toolbox received on addAdditionalTool build property" + ); + } + + const destroy = this.buildPanel(window); + + return { toolbox, destroy }; + }, + }); + + this.panelAdded = true; + } + + buildPanel(window) { + const { toolbox } = this; + + this.createBrowserElement(window); + + // Store the last panel's container element (used to restore it when the toolbox + // host is switched between docked and undocked). + this.browserContainerWindow = window; + + toolbox.on("select", this.onToolboxPanelSelect); + toolbox.on("host-will-change", this.onToolboxHostWillChange); + toolbox.on("host-changed", this.onToolboxHostChanged); + + // Return a cleanup method that is when the panel is destroyed, e.g. + // - when addon devtool panel has been disabled by the user from the toolbox preferences, + // its ParentDevToolsPanel instance is still valid, but the built devtools panel is removed from + // the toolbox (and re-built again if the user re-enables it from the toolbox preferences panel) + // - when the creator context has been destroyed, the ParentDevToolsPanel close method is called, + // it removes the tool definition from the toolbox, which will call this destroy method. + return () => { + this.destroyBrowserElement(); + this.browserContainerWindow = null; + toolbox.off("select", this.onToolboxPanelSelect); + toolbox.off("host-will-change", this.onToolboxHostWillChange); + toolbox.off("host-changed", this.onToolboxHostChanged); + }; + } + + onToolboxHostWillChange() { + // NOTE: Using a content iframe here breaks the devtools panel + // switching between docked and undocked mode, + // because of a swapFrameLoader exception (see bug 1075490), + // destroy the browser and recreate it after the toolbox host has been + // switched is a reasonable workaround to fix the issue on release and beta + // Firefox versions (at least until the underlying bug can be fixed). + if (this.browser) { + // Fires a panel.onHidden event before destroying the browser element because + // the toolbox hosts is changing. + if (this.visible) { + this.conduit.sendPanelHidden(this.id); + } + + this.destroyBrowserElement(); + } + } + + async onToolboxHostChanged() { + if (this.browserContainerWindow) { + this.createBrowserElement(this.browserContainerWindow); + + // Fires a panel.onShown event once the browser element has been recreated + // after the toolbox hosts has been changed (needed to provide the new window + // object to the extension page that has created the devtools panel). + if (this.visible) { + await this.waitTopLevelContext; + this.conduit.sendPanelShown(this.id); + } + } + } + + async onToolboxPanelSelect(id) { + if (!this.waitTopLevelContext || !this.panelAdded) { + return; + } + + // Wait that the panel is fully loaded and emit show. + await this.waitTopLevelContext; + + if (!this.visible && id === this.id) { + this.visible = true; + this.conduit.sendPanelShown(this.id); + } else if (this.visible && id !== this.id) { + this.visible = false; + this.conduit.sendPanelHidden(this.id); + } + } + + close() { + const { toolbox } = this; + + if (!toolbox) { + throw new Error("Unable to destroy a closed devtools panel"); + } + + this.conduit.close(); + + // Explicitly remove the panel if it is registered and the toolbox is not + // closing itself. + if (this.panelAdded && toolbox.isToolRegistered(this.id)) { + this.destroyBrowserElement(); + toolbox.removeAdditionalTool(this.id); + } + + this.waitTopLevelContext = null; + this._resolveTopLevelContext = null; + this.context = null; + this.toolbox = null; + this.browser = null; + this.browserContainerWindow = null; + } + + destroyBrowserElement() { + super.destroyBrowserElement(); + + // If the panel has been removed or disabled (e.g. from the toolbox preferences + // or during the toolbox switching between docked and undocked), + // we need to re-initialize the waitTopLevelContext Promise. + this.waitTopLevelContext = new Promise(resolve => { + this._resolveTopLevelContext = resolve; + }); + } +} + +class DevToolsSelectionObserver extends EventEmitter { + constructor(context) { + if (!context.devToolsToolbox) { + // This should never happen when this constructor is called with a valid + // devtools extension context. + throw Error("Missing mandatory toolbox"); + } + + super(); + context.callOnClose(this); + + this.toolbox = context.devToolsToolbox; + this.onSelected = this.onSelected.bind(this); + this.initialized = false; + } + + on(...args) { + this.lazyInit(); + super.on.apply(this, args); + } + + once(...args) { + this.lazyInit(); + super.once.apply(this, args); + } + + async lazyInit() { + if (!this.initialized) { + this.initialized = true; + this.toolbox.on("selection-changed", this.onSelected); + } + } + + close() { + if (this.destroyed) { + throw new Error("Unable to close a destroyed DevToolsSelectionObserver"); + } + + if (this.initialized) { + this.toolbox.off("selection-changed", this.onSelected); + } + + this.toolbox = null; + this.destroyed = true; + } + + onSelected() { + this.emit("selectionChanged"); + } +} + +/** + * Represents an addon devtools inspector sidebar in the main process. + * + * @param {ExtensionChildProxyContext} context + * A devtools extension proxy context running in a main process. + * @param {object} options + * @param {string} options.id + * The id of the addon devtools sidebar. + * @param {string} options.title + * The title of the addon devtools sidebar. + */ +class ParentDevToolsInspectorSidebar extends BaseDevToolsPanel { + constructor(context, panelOptions) { + super(context, panelOptions); + + this.visible = false; + this.destroyed = false; + + this.context.callOnClose(this); + + this.conduit = new BroadcastConduit(this, { + id: `${this.id}-parent`, + send: ["InspectorSidebarHidden", "InspectorSidebarShown"], + }); + + this.onSidebarSelect = this.onSidebarSelect.bind(this); + this.onSidebarCreated = this.onSidebarCreated.bind(this); + this.onExtensionPageMount = this.onExtensionPageMount.bind(this); + this.onExtensionPageUnmount = this.onExtensionPageUnmount.bind(this); + this.onToolboxHostWillChange = this.onToolboxHostWillChange.bind(this); + this.onToolboxHostChanged = this.onToolboxHostChanged.bind(this); + + this.toolbox.once( + `extension-sidebar-created-${this.id}`, + this.onSidebarCreated + ); + this.toolbox.on("inspector-sidebar-select", this.onSidebarSelect); + this.toolbox.on("host-will-change", this.onToolboxHostWillChange); + this.toolbox.on("host-changed", this.onToolboxHostChanged); + + // Set by setObject if the sidebar has not been created yet. + this._initializeSidebar = null; + + // Set by _updateLastExpressionResult to keep track of the last + // object value grip (to release the previous selected actor + // on the remote debugging server when the actor changes). + this._lastExpressionResult = null; + + this.toolbox.registerInspectorExtensionSidebar(this.id, { + title: panelOptions.title, + }); + } + + close() { + if (this.destroyed) { + throw new Error("Unable to close a destroyed DevToolsSelectionObserver"); + } + + this.conduit.close(); + + if (this.extensionSidebar) { + this.extensionSidebar.off( + "extension-page-mount", + this.onExtensionPageMount + ); + this.extensionSidebar.off( + "extension-page-unmount", + this.onExtensionPageUnmount + ); + } + + if (this.browser) { + this.destroyBrowserElement(); + this.browser = null; + this.containerEl = null; + } + + this.toolbox.off( + `extension-sidebar-created-${this.id}`, + this.onSidebarCreated + ); + this.toolbox.off("inspector-sidebar-select", this.onSidebarSelect); + this.toolbox.off("host-changed", this.onToolboxHostChanged); + this.toolbox.off("host-will-change", this.onToolboxHostWillChange); + + this.toolbox.unregisterInspectorExtensionSidebar(this.id); + this.extensionSidebar = null; + this._lazySidebarInit = null; + + this.destroyed = true; + } + + onToolboxHostWillChange() { + if (this.browser) { + this.destroyBrowserElement(); + } + } + + onToolboxHostChanged() { + if (this.containerEl && this.panelOptions.url) { + this.createBrowserElement(this.containerEl.contentWindow); + } + } + + onExtensionPageMount(containerEl) { + this.containerEl = containerEl; + + // Wait the webext-panel.xhtml page to have been loaded in the + // inspector sidebar panel. + promiseDocumentLoaded(containerEl.contentDocument).then(() => { + this.createBrowserElement(containerEl.contentWindow); + }); + } + + onExtensionPageUnmount() { + this.containerEl = null; + this.destroyBrowserElement(); + } + + onSidebarCreated(sidebar) { + this.extensionSidebar = sidebar; + + sidebar.on("extension-page-mount", this.onExtensionPageMount); + sidebar.on("extension-page-unmount", this.onExtensionPageUnmount); + + const { _lazySidebarInit } = this; + this._lazySidebarInit = null; + + if (typeof _lazySidebarInit === "function") { + _lazySidebarInit(); + } + } + + onSidebarSelect(id) { + if (!this.extensionSidebar) { + return; + } + + if (!this.visible && id === this.id) { + this.visible = true; + this.conduit.sendInspectorSidebarShown(this.id); + } else if (this.visible && id !== this.id) { + this.visible = false; + this.conduit.sendInspectorSidebarHidden(this.id); + } + } + + setPage(extensionPageURL) { + this.panelOptions.url = extensionPageURL; + + if (this.extensionSidebar) { + if (this.browser) { + // Just load the new extension page url in the existing browser, if + // it already exists. + this.browser.fixupAndLoadURIString(this.panelOptions.url, { + triggeringPrincipal: this.context.extension.principal, + }); + } else { + // The browser element doesn't exist yet, but the sidebar has been + // already created (e.g. because the inspector was already selected + // in a open toolbox and the extension has been installed/reloaded/updated). + this.extensionSidebar.setExtensionPage(WEBEXT_PANELS_URL); + } + } else { + // Defer the sidebar.setExtensionPage call. + this._setLazySidebarInit(() => + this.extensionSidebar.setExtensionPage(WEBEXT_PANELS_URL) + ); + } + } + + setObject(object, rootTitle) { + delete this.panelOptions.url; + + this._updateLastExpressionResult(null); + + // Nest the object inside an object, as the value of the `rootTitle` property. + if (rootTitle) { + object = { [rootTitle]: object }; + } + + if (this.extensionSidebar) { + this.extensionSidebar.setObject(object); + } else { + // Defer the sidebar.setObject call. + this._setLazySidebarInit(() => this.extensionSidebar.setObject(object)); + } + } + + _setLazySidebarInit(cb) { + this._lazySidebarInit = cb; + } + + setExpressionResult(expressionResult, rootTitle) { + delete this.panelOptions.url; + + this._updateLastExpressionResult(expressionResult); + + if (this.extensionSidebar) { + this.extensionSidebar.setExpressionResult(expressionResult, rootTitle); + } else { + // Defer the sidebar.setExpressionResult call. + this._setLazySidebarInit(() => { + this.extensionSidebar.setExpressionResult(expressionResult, rootTitle); + }); + } + } + + _updateLastExpressionResult(newExpressionResult = null) { + const { _lastExpressionResult } = this; + + this._lastExpressionResult = newExpressionResult; + + const oldActor = _lastExpressionResult && _lastExpressionResult.actorID; + const newActor = newExpressionResult && newExpressionResult.actorID; + + // Release the previously active actor on the remote debugging server. + if ( + oldActor && + oldActor !== newActor && + typeof _lastExpressionResult.release === "function" + ) { + _lastExpressionResult.release(); + } + } +} + +const sidebarsById = new Map(); + +this.devtools_panels = class extends ExtensionAPI { + getAPI(context) { + // TODO - Bug 1448878: retrieve a more detailed callerInfo object, + // like the filename and lineNumber of the actual extension called + // in the child process. + const callerInfo = { + addonId: context.extension.id, + url: context.extension.baseURI.spec, + }; + + // An incremental "per context" id used in the generated devtools panel id. + let nextPanelId = 0; + + const toolboxSelectionObserver = new DevToolsSelectionObserver(context); + + function newBasePanelId() { + return `${context.extension.id}-${context.contextId}-${nextPanelId++}`; + } + + return { + devtools: { + panels: { + elements: { + onSelectionChanged: new EventManager({ + context, + name: "devtools.panels.elements.onSelectionChanged", + register: fire => { + const listener = eventName => { + fire.async(); + }; + toolboxSelectionObserver.on("selectionChanged", listener); + return () => { + toolboxSelectionObserver.off("selectionChanged", listener); + }; + }, + }).api(), + createSidebarPane(title) { + const id = `devtools-inspector-sidebar-${makeWidgetId( + newBasePanelId() + )}`; + + const parentSidebar = new ParentDevToolsInspectorSidebar( + context, + { title, id } + ); + sidebarsById.set(id, parentSidebar); + + context.callOnClose({ + close() { + sidebarsById.delete(id); + }, + }); + + // Resolved to the devtools sidebar id into the child addon process, + // where it will be used to identify the messages related + // to the panel API onShown/onHidden events. + return Promise.resolve(id); + }, + // The following methods are used internally to allow the sidebar API + // piece that is running in the child process to asks the parent process + // to execute the sidebar methods. + Sidebar: { + setPage(sidebarId, extensionPageURL) { + const sidebar = sidebarsById.get(sidebarId); + return sidebar.setPage(extensionPageURL); + }, + setObject(sidebarId, jsonObject, rootTitle) { + const sidebar = sidebarsById.get(sidebarId); + return sidebar.setObject(jsonObject, rootTitle); + }, + async setExpression(sidebarId, evalExpression, rootTitle) { + const sidebar = sidebarsById.get(sidebarId); + + const toolboxEvalOptions = await getToolboxEvalOptions(context); + + const commands = await context.getDevToolsCommands(); + const target = commands.targetCommand.targetFront; + const consoleFront = await target.getFront("console"); + toolboxEvalOptions.consoleFront = consoleFront; + + const evalResult = await commands.inspectedWindowCommand.eval( + callerInfo, + evalExpression, + toolboxEvalOptions + ); + + let jsonObject; + + if (evalResult.exceptionInfo) { + jsonObject = evalResult.exceptionInfo; + + return sidebar.setObject(jsonObject, rootTitle); + } + + return sidebar.setExpressionResult(evalResult, rootTitle); + }, + }, + }, + create(title, icon, url) { + // Get a fallback icon from the manifest data. + if (icon === "") { + icon = context.extension.getPreferredIcon(128); + } + + icon = context.extension.baseURI.resolve(icon); + url = context.extension.baseURI.resolve(url); + + const id = `webext-devtools-panel-${makeWidgetId( + newBasePanelId() + )}`; + + new ParentDevToolsPanel(context, { title, icon, url, id }); + + // Resolved to the devtools panel id into the child addon process, + // where it will be used to identify the messages related + // to the panel API onShown/onHidden events. + return Promise.resolve(id); + }, + }, + }, + }; + } +}; |