/* 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"; /* * Target actor for a WebExtension add-on. * * This actor extends ParentProcessTargetActor. * * See devtools/docs/backend/actor-hierarchy.md for more details. */ const { extend } = require("resource://devtools/shared/extend.js"); const { ParentProcessTargetActor, parentProcessTargetPrototype, } = require("resource://devtools/server/actors/targets/parent-process.js"); const makeDebugger = require("resource://devtools/server/actors/utils/make-debugger.js"); const { webExtensionTargetSpec, } = require("resource://devtools/shared/specs/targets/webextension.js"); const Targets = require("resource://devtools/server/actors/targets/index.js"); const TargetActorMixin = require("resource://devtools/server/actors/targets/target-actor-mixin.js"); const { getChildDocShells, } = require("resource://devtools/server/actors/targets/window-global.js"); loader.lazyRequireGetter( this, "unwrapDebuggerObjectGlobal", "resource://devtools/server/actors/thread.js", true ); const lazy = {}; ChromeUtils.defineESModuleGetters(lazy, { getAddonIdForWindowGlobal: "resource://devtools/server/actors/watcher/browsing-context-helpers.sys.mjs", }); const FALLBACK_DOC_URL = "chrome://devtools/content/shared/webextension-fallback.html"; /** * Protocol.js expects only the prototype object, and does not maintain the prototype * chain when it constructs the ActorClass. For this reason we are using `extend` to * maintain the properties of WindowGlobalTargetActor.prototype */ const webExtensionTargetPrototype = extend({}, parentProcessTargetPrototype); /** * Creates a target actor for debugging all the contexts associated to a target * WebExtensions add-on running in a child extension process. Most of the implementation * is inherited from ParentProcessTargetActor (which inherits most of its implementation * from WindowGlobalTargetActor). * * WebExtensionTargetActor is created by a WebExtensionActor counterpart, when its * parent actor's `connect` method has been called (on the listAddons RDP package), * it runs in the same process that the extension is running into (which can be the main * process if the extension is running in non-oop mode, or the child extension process * if the extension is running in oop-mode). * * A WebExtensionTargetActor contains all target-scoped actors, like a regular * ParentProcessTargetActor or WindowGlobalTargetActor. * * History lecture: * - The add-on actors used to not inherit WindowGlobalTargetActor because of the * different way the add-on APIs where exposed to the add-on itself, and for this reason * the Addon Debugger has only a sub-set of the feature available in the Tab or in the * Browser Toolbox. * - In a WebExtensions add-on all the provided contexts (background, popups etc.), * besides the Content Scripts which run in the content process, hooked to an existent * tab, by creating a new WebExtensionActor which inherits from * ParentProcessTargetActor, we can provide a full features Addon Toolbox (which is * basically like a BrowserToolbox which filters the visible sources and frames to the * one that are related to the target add-on). * - When the WebExtensions OOP mode has been introduced, this actor has been refactored * and moved from the main process to the new child extension process. * * @param {DevToolsServerConnection} conn * The connection to the client. * @param {nsIMessageSender} chromeGlobal. * The chromeGlobal where this actor has been injected by the * frame-connector.js connectToFrame method. * @param {Object} options * - addonId: {String} the addonId of the target WebExtension. * - addonBrowsingContextGroupId: {String} the BrowsingContextGroupId used by this addon. * - chromeGlobal: {nsIMessageSender} The chromeGlobal where this actor * has been injected by the frame-connector.js connectToFrame method. * - isTopLevelTarget: {Boolean} flag to indicate if this is the top * level target of the DevTools session * - prefix: {String} the custom RDP prefix to use. * - sessionContext Object * The Session Context to help know what is debugged. * See devtools/server/actors/watcher/session-context.js */ webExtensionTargetPrototype.initialize = function( conn, { addonId, addonBrowsingContextGroupId, chromeGlobal, isTopLevelTarget, prefix, sessionContext, } ) { this.addonId = addonId; this.addonBrowsingContextGroupId = addonBrowsingContextGroupId; this._chromeGlobal = chromeGlobal; this._prefix = prefix; // Expose the BrowsingContext of the fallback document, // which is the one this target actor will always refer to via its form() // and all resources should be related to this one as we currently spawn // only just this one target actor to debug all webextension documents. this.devtoolsSpawnedBrowsingContextForWebExtension = chromeGlobal.browsingContext; // Try to discovery an existent extension page to attach (which will provide the initial // URL shown in the window tittle when the addon debugger is opened). const extensionWindow = this._searchForExtensionWindow(); parentProcessTargetPrototype.initialize.call(this, conn, { isTopLevelTarget, window: extensionWindow, sessionContext, }); // Redefine the messageManager getter to return the chromeGlobal // as the messageManager for this actor (which is the browser XUL // element used by the parent actor running in the main process to // connect to the extension process). Object.defineProperty(this, "messageManager", { enumerable: true, configurable: true, get: () => { return this._chromeGlobal; }, }); this._onParentExit = this._onParentExit.bind(this); this._chromeGlobal.addMessageListener( "debug:webext_parent_exit", this._onParentExit ); // Set the consoleAPIListener filtering options // (retrieved and used in the related webconsole child actor). this.consoleAPIListenerOptions = { addonId: this.addonId, }; // This creates a Debugger instance for debugging all the add-on globals. this.makeDebugger = makeDebugger.bind(null, { findDebuggees: dbg => { return dbg.findAllGlobals().filter(this._shouldAddNewGlobalAsDebuggee); }, shouldAddNewGlobalAsDebuggee: this._shouldAddNewGlobalAsDebuggee.bind(this), }); }; // NOTE: This is needed to catch in the webextension webconsole all the // errors raised by the WebExtension internals that are not currently // associated with any window. webExtensionTargetPrototype.isRootActor = true; // Override the ParentProcessTargetActor's override in order to only iterate // over the docshells specific to this add-on Object.defineProperty(webExtensionTargetPrototype, "docShells", { get() { // Iterate over all top-level windows and all their docshells. let docShells = []; for (const window of Services.ww.getWindowEnumerator(null)) { docShells = docShells.concat(getChildDocShells(window.docShell)); } // Then filter out the ones specific to the add-on return docShells.filter(docShell => { return this.isExtensionWindowDescendent(docShell.domWindow); }); }, }); /** * Called when the actor is removed from the connection. */ webExtensionTargetPrototype.destroy = function() { if (this._chromeGlobal) { const chromeGlobal = this._chromeGlobal; this._chromeGlobal = null; chromeGlobal.removeMessageListener( "debug:webext_parent_exit", this._onParentExit ); chromeGlobal.sendAsyncMessage("debug:webext_child_exit", { actor: this.actorID, }); } if (this.fallbackWindow) { this.fallbackWindow = null; } this.addon = null; this.addonId = null; return ParentProcessTargetActor.prototype.destroy.apply(this); }; // Private helpers. webExtensionTargetPrototype._searchFallbackWindow = function() { if (this.fallbackWindow) { // Skip if there is already an existent fallback window. return this.fallbackWindow; } // Set and initialize the fallbackWindow (which initially is a empty // about:blank browser), this window is related to a XUL browser element // specifically created for the devtools server and it is never used // or navigated anywhere else. this.fallbackWindow = this._chromeGlobal.content; // Add the addonId in the URL to retrieve this information in other devtools // helpers. The addonId is usually populated in the principal, but this will // not be the case for the fallback window because it is loaded from chrome:// // instead of moz-extension://${addonId} this.fallbackWindow.document.location.href = `${FALLBACK_DOC_URL}#${this.addonId}`; return this.fallbackWindow; }; // Discovery an extension page to use as a default target window. // NOTE: This currently fail to discovery an extension page running in a // windowless browser when running in non-oop mode, and the background page // is set later using _onNewExtensionWindow. webExtensionTargetPrototype._searchForExtensionWindow = function() { // Looks if there is any top level add-on document: // (we do not want to pass any nested add-on iframe) const docShell = this.docShells.find(d => this.isTopLevelExtensionWindow(d.domWindow) ); if (docShell) { return docShell.domWindow; } return this._searchFallbackWindow(); }; // Customized ParentProcessTargetActor/WindowGlobalTargetActor hooks. webExtensionTargetPrototype._onDocShellCreated = function(docShell) { // Compare against the BrowsingContext's group ID as the document's principal addonId // won't be set yet for freshly created docshells. It will be later set, when loading the addon URL. // But right now, it is still on the initial about:blank document and the principal isn't related to the add-on. if (docShell.browsingContext.group.id != this.addonBrowsingContextGroupId) { return; } ParentProcessTargetActor.prototype._onDocShellCreated.call(this, docShell); }; webExtensionTargetPrototype._onDocShellDestroy = function(docShell) { if (docShell.browsingContext.group.id != this.addonBrowsingContextGroupId) { return; } // Stop watching this docshell (the unwatch() method will check if we // started watching it before). this._unwatchDocShell(docShell); // Let the _onDocShellDestroy notify that the docShell has been destroyed. const webProgress = docShell .QueryInterface(Ci.nsIInterfaceRequestor) .getInterface(Ci.nsIWebProgress); this._notifyDocShellDestroy(webProgress); // If the destroyed docShell was the current docShell and the actor is // not destroyed, switch to the fallback window if (!this.isDestroyed() && docShell == this.docShell) { this._changeTopLevelDocument(this._searchForExtensionWindow()); } }; webExtensionTargetPrototype._onNewExtensionWindow = function(window) { if (!this.window || this.window === this.fallbackWindow) { this._changeTopLevelDocument(window); // For new extension windows, the BrowsingContext group id might have // changed, for instance when reloading the addon. this.addonBrowsingContextGroupId = window.docShell.browsingContext.group.id; } }; webExtensionTargetPrototype.isTopLevelExtensionWindow = function(window) { const { docShell } = window; const isTopLevel = docShell.sameTypeRootTreeItem == docShell; // Note: We are not using getAddonIdForWindowGlobal here because the // fallback window should not be considered as a top level extension window. return isTopLevel && window.document.nodePrincipal.addonId == this.addonId; }; webExtensionTargetPrototype.isExtensionWindowDescendent = function(window) { // Check if the source is coming from a descendant docShell of an extension window. // We may have an iframe that loads http content which won't use the add-on principal. const rootWin = window.docShell.sameTypeRootTreeItem.domWindow; const addonId = lazy.getAddonIdForWindowGlobal(rootWin.windowGlobalChild); return addonId == this.addonId; }; /** * Return true if the given global is associated with this addon and should be * added as a debuggee, false otherwise. */ webExtensionTargetPrototype._shouldAddNewGlobalAsDebuggee = function( newGlobal ) { const global = unwrapDebuggerObjectGlobal(newGlobal); if (global instanceof Ci.nsIDOMWindow) { try { global.document; } catch (e) { // The global might be a sandbox with a window object in its proto chain. If the // window navigated away since the sandbox was created, it can throw a security // exception during this property check as the sandbox no longer has access to // its own proto. return false; } // When `global` is a sandbox it may be a nsIDOMWindow object, // but won't be the real Window object. Retrieve it via document's ownerGlobal. const window = global.document.ownerGlobal; if (!window) { return false; } // Change top level document as a simulated frame switching. if (this.isTopLevelExtensionWindow(window)) { this._onNewExtensionWindow(window); } return this.isExtensionWindowDescendent(window); } try { // This will fail for non-Sandbox objects, hence the try-catch block. const metadata = Cu.getSandboxMetadata(global); if (metadata) { return metadata.addonID === this.addonId; } } catch (e) { // Unable to retrieve the sandbox metadata. } return false; }; // Handlers for the messages received from the parent actor. webExtensionTargetPrototype._onParentExit = function(msg) { if (msg.json.actor !== this.actorID) { return; } this.destroy(); }; exports.WebExtensionTargetActor = TargetActorMixin( Targets.TYPES.FRAME, webExtensionTargetSpec, webExtensionTargetPrototype );