Source code

Revision control

Copy as Markdown

Other Tools

/* -*- 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(
);
ChromeUtils.defineESModuleGetters(this, {
});
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.
*/
class ParentDevToolsPanel extends BaseDevToolsPanel {
/**
* @param {DevToolsExtensionPageContextParent} context
* A devtools extension proxy context running in a main process.
* @param {object} panelOptions
* @param {string} panelOptions.id
* The id of the addon devtools panel.
* @param {string} panelOptions.icon
* The icon of the addon devtools panel.
* @param {string} panelOptions.title
* The title of the addon devtools panel.
* @param {string} panelOptions.url
* The url of the addon devtools panel, relative to the extension base URL.
*/
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.
*/
class ParentDevToolsInspectorSidebar extends BaseDevToolsPanel {
/**
* @param {DevToolsExtensionPageContextParent} context
* A devtools extension proxy context running in a main process.
* @param {object} panelOptions
* @param {string} panelOptions.id
* The id of the addon devtools sidebar.
* @param {string} panelOptions.title
* The title of the addon devtools sidebar.
*/
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 = () => {
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);
},
},
},
};
}
};