Skip to content

Instantly share code, notes, and snippets.

@rpl
Created October 7, 2016 14:03
Show Gist options
  • Select an option

  • Save rpl/41495946e857701c82b1e1317e468970 to your computer and use it in GitHub Desktop.

Select an option

Save rpl/41495946e857701c82b1e1317e468970 to your computer and use it in GitHub Desktop.
WebExtension devtools API (in the new webext-oop style)
/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
/* vim: set sts=2 sw=2 et tw=80: */
"use strict";
/* global getTabIdForToolbox */
/**
* This module provides helpers used by the other specialized `ext-devtools-*.js` modules
* and the implementation of the `devtools_page`.
*/
XPCOMUtils.defineLazyModuleGetter(this, "Task",
"resource://gre/modules/Task.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "gDevTools",
"resource://devtools/client/framework/gDevTools.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "Task",
"resource://gre/modules/Task.jsm");
Cu.import("resource://gre/modules/ExtensionUtils.jsm");
const {
promiseDocumentLoaded,
promiseObserved,
} = ExtensionUtils;
// Map[extension -> DevtoolsPage]
let devtoolsPageMap = new Map();
const DevtoolsContextsManager = {
contextDataMap: new Map(),
addContext(context, toolbox) {
if (this.contextDataMap.has(context)) {
throw new Error("Extension Context cannot be assigned to a developer toolbox twice");
}
this.contextDataMap.set(context, {
toolbox, target: toolbox.target,
});
// Remove any reference to the toolbox when the context is closed.
context.callOnClose({
close: () => this.cleanupContext(context),
});
},
cleanupContext(context) {
const contextData = this.contextDataMap.get(context);
if (contextData.contextToolboxTarget) {
contextData.contextToolboxTarget.close();
}
this.contextDataMap.delete(context);
},
getToolboxForContext(context) {
if (!this.contextDataMap.has(context)) {
return null;
}
return this.contextDataMap.get(context).toolbox;
},
getLocalTabTargetForContext(context) {
const {TabTarget} = require("devtools/client/framework/target");
const contextDataMap = this.contextDataMap;
const contextData = contextDataMap.get(context) || {};
if (contextData.contextToolboxTarget) {
return Promise.resolve(contextData.contextToolboxTarget);
}
return Task.spawn(function* asyncConnectLocalTabTartget() {
contextData.contextToolboxTarget = new TabTarget(contextData.target.tab);
contextDataMap.set(context, contextData);
yield contextData.contextToolboxTarget.makeRemote();
return contextData.contextToolboxTarget;
});
},
};
global.DevtoolsContextsManager = DevtoolsContextsManager;
global.getTabIdForToolbox = (toolbox) => {
let {target} = toolbox;
let parentWindow = target.tab.linkedBrowser.ownerDocument.defaultView;
let tab = parentWindow.gBrowser.getTabForBrowser(target.tab.linkedBrowser);
return TabManager.getId(tab);
};
/**
* The DevtoolsPage class manages the `devtools_page` that an WebExtensions add-on can
* declare in its manifest.
*
* 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 {string} url The relative path to the devtools page html page.
*/
class DevtoolsPage {
constructor(extension, url) {
this.url = url;
this.extension = extension;
// Map[Devtools target -> {webNav, windowlessBrowser}]
this.devtoolsPageChromeForTarget = new Map();
}
createWindowlessExtensionPage(contextType) {
function* asyncCreateWindowlessExtensionPage() {
// The invisible page is currently wrapped in a XUL window to fix an issue
// with using the canvas API from a background page (See Bug 1274775).
const XUL_URL = "data:application/vnd.mozilla.xul+xml;charset=utf-8," +
encodeURI(`<?xml version="1.0"?><window id="documentElement"/>`);
const windowlessBrowser = Services.appShell.createWindowlessBrowser(true);
const system = Services.scriptSecurityManager.getSystemPrincipal();
// The windowless browser is a thin wrapper around a docShell that keeps
// its related resources alive. It implements nsIWebNavigation and
// forwards its methods to the underlying docShell, but cannot act as a
// docShell itself. Calling `getInterface(nsIDocShell)` gives us the
// underlying docShell, and `QueryInterface(nsIWebNavigation)` gives us
// access to the webNav methods that are already available on the
// windowless browser, but contrary to appearances, they are not the same
// object.
const chromeShell = windowlessBrowser.QueryInterface(Ci.nsIInterfaceRequestor)
.getInterface(Ci.nsIDocShell)
.QueryInterface(Ci.nsIWebNavigation);
chromeShell.useGlobalHistory = false;
chromeShell.createAboutBlankContentViewer(system);
chromeShell.loadURI(XUL_URL, 0, null, null, null);
yield promiseObserved("chrome-document-global-created",
win => win.document == chromeShell.document);
const chromeDoc = yield promiseDocumentLoaded(chromeShell.document);
const browser = chromeDoc.createElement("browser");
browser.setAttribute("type", "content");
browser.setAttribute("disableglobalhistory", "true");
browser.setAttribute("webextension-view-type", contextType);
chromeDoc.documentElement.appendChild(browser);
return {windowlessBrowser, browser};
}
return Task.spawn(asyncCreateWindowlessExtensionPage.bind(this));
}
buildForToolbox(toolbox) {
function* asyncBuildForToolbox() {
if (this.devtoolsPageChromeForTarget.has(toolbox.target)) {
return;
}
let url = this.extension.baseURI.resolve(this.url);
if (!this.extension.isExtensionURL(url)) {
this.extension.manifestError("DevTools page must be a file within the extension");
url = this.extension.baseURI.resolve("_blank.html");
}
const {
windowlessBrowser,
browser,
} = yield this.createWindowlessExtensionPage("devtools_page");
let topLevelContextInitialized = false;
const waitForContext = new Promise(resolve => {
let listener = (event, context) => {
if (context.viewType == "devtools_page" && context.xulBrowser == browser) {
// Keep track of the toolbox and target associated to the context, which is
// needed by the API methods implementation.
DevtoolsContextsManager.addContext(context, toolbox);
if (!topLevelContextInitialized) {
topLevelContextInitialized = true;
// Remove listeners when the top level context has been destroyed.
context.callOnClose({
close: () => {
this.extension.off("extension-proxy-context-load", listener);
},
});
}
// Resolve the promise when the root devtools_page context has been created.
resolve(context);
}
};
this.extension.on("extension-proxy-context-load", listener);
});
extensions.emit("extension-browser-inserted", browser);
browser.messageManager.sendAsyncMessage("Extension:InitExtensionView", {
viewType: "devtools_page",
url,
devtoolsToolboxInfo: {
inspectedWindowTabId: getTabIdForToolbox(toolbox),
},
});
yield waitForContext;
const webNav = browser.docShell.QueryInterface(Ci.nsIWebNavigation);
// Keep track of the webNav and windowlessBrowser created, so that we can
// close them once the context is closed (e.g. the toolbox has been closed)
// or the extension has been uninstalled.
this.devtoolsPageChromeForTarget.set(toolbox.target, {
webNav,
windowlessBrowser,
});
}
return Task.spawn(asyncBuildForToolbox.bind(this));
}
shutdownForTarget(target) {
if (this.devtoolsPageChromeForTarget.has(target)) {
const {
webNav,
windowlessBrowser,
} = this.devtoolsPageChromeForTarget.get(target);
// Navigate away from the devtools page to invalidate any
// setTimeouts or other callbacks.
if (webNav) {
webNav.loadURI("about:blank", 0, null, null, null);
}
if (windowlessBrowser) {
windowlessBrowser.loadURI("about:blank", 0, null, null, null);
windowlessBrowser.close();
}
this.devtoolsPageChromeForTarget.delete(target);
}
}
shutdown() {
for (let target of this.devtoolsPageChromeForTarget.keys()) {
this.shutdownForTarget(target);
}
this.devtoolsPageChromeForTarget.clear();
}
}
/* eslint-disable mozilla/balanced-listeners */
// Create a devtools page context for a new opened toolbox.
gDevTools.on("toolbox-created", (evt, toolbox) => {
if (!toolbox.target.isLocalTab) {
// Only local tabs are currently supported (See Bug 1304378 for additional details
// related to remote targets support).
return;
}
for (let devtoolsPage of devtoolsPageMap.values()) {
devtoolsPage.buildForToolbox(toolbox);
}
});
// Destroy a devtools page context for a destroyed toolbox.
gDevTools.on("toolbox-destroy", (evt, target) => {
if (!target.isLocalTab) {
// Only local tabs are currently supported (See Bug 1304378 for additional details
// related to remote targets support).
return;
}
for (let devtoolsPage of devtoolsPageMap.values()) {
devtoolsPage.shutdownForTarget(target);
}
});
// Define a new DevTools API defined in the manifest.
extensions.on("manifest_devtools_page", (type, directive, extension, manifest) => {
let devtoolsPage = new DevtoolsPage(extension, manifest[directive]);
devtoolsPageMap.set(extension, devtoolsPage);
});
// Destroy the registered devtools_page on extension shutdown.
extensions.on("shutdown", (type, extension) => {
if (devtoolsPageMap.has(extension)) {
devtoolsPageMap.get(extension).shutdown();
devtoolsPageMap.delete(extension);
}
});
/* eslint-enable mozilla/balanced-listeners */
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment