summaryrefslogtreecommitdiffstats
path: root/browser/components/extensions/parent/ext-devtools-panels.js
diff options
context:
space:
mode:
Diffstat (limited to 'browser/components/extensions/parent/ext-devtools-panels.js')
-rw-r--r--browser/components/extensions/parent/ext-devtools-panels.js691
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);
+ },
+ },
+ },
+ };
+ }
+};