diff options
Diffstat (limited to 'devtools/server/actors/webbrowser.js')
-rw-r--r-- | devtools/server/actors/webbrowser.js | 790 |
1 files changed, 790 insertions, 0 deletions
diff --git a/devtools/server/actors/webbrowser.js b/devtools/server/actors/webbrowser.js new file mode 100644 index 0000000000..abcc3fa19b --- /dev/null +++ b/devtools/server/actors/webbrowser.js @@ -0,0 +1,790 @@ +/* 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 { Ci } = require("chrome"); +var Services = require("Services"); +var { DevToolsServer } = require("devtools/server/devtools-server"); +var { ActorRegistry } = require("devtools/server/actors/utils/actor-registry"); +var DevToolsUtils = require("devtools/shared/DevToolsUtils"); + +loader.lazyRequireGetter( + this, + "RootActor", + "devtools/server/actors/root", + true +); +loader.lazyRequireGetter( + this, + "TabDescriptorActor", + "devtools/server/actors/descriptors/tab", + true +); +loader.lazyRequireGetter( + this, + "WebExtensionDescriptorActor", + "devtools/server/actors/descriptors/webextension", + true +); +loader.lazyRequireGetter( + this, + "WorkerDescriptorActorList", + "devtools/server/actors/worker/worker-descriptor-actor-list", + true +); +loader.lazyRequireGetter( + this, + "ServiceWorkerRegistrationActorList", + "devtools/server/actors/worker/service-worker-registration-list", + true +); +loader.lazyRequireGetter( + this, + "ProcessActorList", + "devtools/server/actors/process", + true +); +loader.lazyImporter( + this, + "AddonManager", + "resource://gre/modules/AddonManager.jsm" +); + +/** + * Browser-specific actors. + */ + +/** + * Retrieve the window type of the top-level window |window|. + */ +function appShellDOMWindowType(window) { + /* This is what nsIWindowMediator's enumerator checks. */ + return window.document.documentElement.getAttribute("windowtype"); +} + +/** + * Send Debugger:Shutdown events to all "navigator:browser" windows. + */ +function sendShutdownEvent() { + for (const win of Services.wm.getEnumerator( + DevToolsServer.chromeWindowType + )) { + const evt = win.document.createEvent("Event"); + evt.initEvent("Debugger:Shutdown", true, false); + win.document.documentElement.dispatchEvent(evt); + } +} + +exports.sendShutdownEvent = sendShutdownEvent; + +/** + * Construct a root actor appropriate for use in a server running in a + * browser. The returned root actor: + * - respects the factories registered with ActorRegistry.addGlobalActor, + * - uses a BrowserTabList to supply target actors for tabs, + * - sends all navigator:browser window documents a Debugger:Shutdown event + * when it exits. + * + * * @param connection DevToolsServerConnection + * The conection to the client. + */ +exports.createRootActor = function createRootActor(connection) { + return new RootActor(connection, { + tabList: new BrowserTabList(connection), + addonList: new BrowserAddonList(connection), + workerList: new WorkerDescriptorActorList(connection, {}), + serviceWorkerRegistrationList: new ServiceWorkerRegistrationActorList( + connection + ), + processList: new ProcessActorList(), + globalActorFactories: ActorRegistry.globalActorFactories, + onShutdown: sendShutdownEvent, + }); +}; + +/** + * A live list of TabDescriptorActors representing the current browser tabs, + * to be provided to the root actor to answer 'listTabs' requests. + * + * This object also takes care of listening for TabClose events and + * onCloseWindow notifications, and exiting the target actors concerned. + * + * (See the documentation for RootActor for the definition of the "live + * list" interface.) + * + * @param connection DevToolsServerConnection + * The connection in which this list's target actors may participate. + * + * Some notes: + * + * This constructor is specific to the desktop browser environment; it + * maintains the tab list by tracking XUL windows and their XUL documents' + * "tabbrowser", "tab", and "browser" elements. What's entailed in maintaining + * an accurate list of open tabs in this context? + * + * - Opening and closing XUL windows: + * + * An nsIWindowMediatorListener is notified when new XUL windows (i.e., desktop + * windows) are opened and closed. It is not notified of individual content + * browser tabs coming and going within such a XUL window. That seems + * reasonable enough; it's concerned with XUL windows, not tab elements in the + * window's XUL document. + * + * However, even if we attach TabOpen and TabClose event listeners to each XUL + * window as soon as it is created: + * + * - we do not receive a TabOpen event for the initial empty tab of a new XUL + * window; and + * + * - we do not receive TabClose events for the tabs of a XUL window that has + * been closed. + * + * This means that TabOpen and TabClose events alone are not sufficient to + * maintain an accurate list of live tabs and mark target actors as closed + * promptly. Our nsIWindowMediatorListener onCloseWindow handler must find and + * exit all actors for tabs that were in the closing window. + * + * Since this is a bit hairy, we don't make each individual attached target + * actor responsible for noticing when it has been closed; we watch for that, + * and promise to call each actor's 'exit' method when it's closed, regardless + * of how we learn the news. + * + * - nsIWindowMediator locks + * + * nsIWindowMediator holds a lock protecting its list of top-level windows + * while it calls nsIWindowMediatorListener methods. nsIWindowMediator's + * GetEnumerator method also tries to acquire that lock. Thus, enumerating + * windows from within a listener method deadlocks (bug 873589). Rah. One + * can sometimes work around this by leaving the enumeration for a later + * tick. + * + * - Dragging tabs between windows: + * + * When a tab is dragged from one desktop window to another, we receive a + * TabOpen event for the new tab, and a TabClose event for the old tab; tab XUL + * elements do not really move from one document to the other (although their + * linked browser's content window objects do). + * + * However, while we could thus assume that each tab stays with the XUL window + * it belonged to when it was created, I'm not sure this is behavior one should + * rely upon. When a XUL window is closed, we take the less efficient, more + * conservative approach of simply searching the entire table for actors that + * belong to the closing XUL window, rather than trying to somehow track which + * XUL window each tab belongs to. + */ +function BrowserTabList(connection) { + this._connection = connection; + + /* + * The XUL document of a tabbed browser window has "tab" elements, whose + * 'linkedBrowser' JavaScript properties are "browser" elements; those + * browsers' 'contentWindow' properties are wrappers on the tabs' content + * window objects. + * + * This map's keys are "browser" XUL elements; it maps each browser element + * to the target actor we've created for its content window, if we've created + * one. This map serves several roles: + * + * - During iteration, we use it to find actors we've created previously. + * + * - On a TabClose event, we use it to find the tab's target actor and exit it. + * + * - When the onCloseWindow handler is called, we iterate over it to find all + * tabs belonging to the closing XUL window, and exit them. + * + * - When it's empty, and the onListChanged hook is null, we know we can + * stop listening for events and notifications. + * + * We listen for TabClose events and onCloseWindow notifications in order to + * send onListChanged notifications, but also to tell actors when their + * referent has gone away and remove entries for dead browsers from this map. + * If that code is working properly, neither this map nor the actors in it + * should ever hold dead tabs alive. + */ + this._actorByBrowser = new Map(); + + /* The current onListChanged handler, or null. */ + this._onListChanged = null; + + /* + * True if we've been iterated over since we last called our onListChanged + * hook. + */ + this._mustNotify = false; + + /* True if we're testing, and should throw if consistency checks fail. */ + this._testing = false; + + this._onPageTitleChangedEvent = this._onPageTitleChangedEvent.bind(this); +} + +BrowserTabList.prototype.constructor = BrowserTabList; + +BrowserTabList.prototype.destroy = function() { + this._actorByBrowser.clear(); + this.onListChanged = null; +}; + +/** + * Get the selected browser for the given navigator:browser window. + * @private + * @param window nsIChromeWindow + * The navigator:browser window for which you want the selected browser. + * @return Element|null + * The currently selected xul:browser element, if any. Note that the + * browser window might not be loaded yet - the function will return + * |null| in such cases. + */ +BrowserTabList.prototype._getSelectedBrowser = function(window) { + return window.gBrowser ? window.gBrowser.selectedBrowser : null; +}; + +/** + * Produces an iterable (in this case a generator) to enumerate all available + * browser tabs. + */ +BrowserTabList.prototype._getBrowsers = function*() { + // Iterate over all navigator:browser XUL windows. + for (const win of Services.wm.getEnumerator( + DevToolsServer.chromeWindowType + )) { + // For each tab in this XUL window, ensure that we have an actor for + // it, reusing existing actors where possible. + for (const browser of this._getChildren(win)) { + yield browser; + } + } +}; + +BrowserTabList.prototype._getChildren = function(window) { + if (!window.gBrowser) { + return []; + } + const { gBrowser } = window; + if (!gBrowser.browsers) { + return []; + } + return gBrowser.browsers.filter(browser => { + // Filter tabs that are closing. listTabs calls made right after TabClose + // events still list tabs in process of being closed. + const tab = gBrowser.getTabForBrowser(browser); + return !tab.closing; + }); +}; + +BrowserTabList.prototype.getList = async function() { + // As a sanity check, make sure all the actors presently in our map get + // picked up when we iterate over all windows' tabs. + const initialMapSize = this._actorByBrowser.size; + this._foundCount = 0; + + const actors = []; + + for (const browser of this._getBrowsers()) { + try { + const actor = await this._getActorForBrowser(browser); + actors.push(actor); + } catch (e) { + if (e.error === "tabDestroyed") { + // Ignore the error if a tab was destroyed while retrieving the tab list. + continue; + } + + // Forward unexpected errors. + throw e; + } + } + + if (this._testing && initialMapSize !== this._foundCount) { + throw new Error("_actorByBrowser map contained actors for dead tabs"); + } + + this._mustNotify = true; + this._checkListening(); + + return actors; +}; + +BrowserTabList.prototype._getActorForBrowser = async function(browser) { + // Do we have an existing actor for this browser? If not, create one. + let actor = this._actorByBrowser.get(browser); + if (actor) { + this._foundCount++; + return actor; + } + + actor = new TabDescriptorActor(this._connection, browser); + this._actorByBrowser.set(browser, actor); + this._checkListening(); + return actor; +}; + +BrowserTabList.prototype.getTab = function({ outerWindowID, tabId }) { + if (typeof outerWindowID == "number") { + // First look for in-process frames with this ID + const window = Services.wm.getOuterWindowWithId(outerWindowID); + // Safety check to prevent debugging top level window via getTab + if (window?.isChromeWindow) { + return Promise.reject({ + error: "forbidden", + message: "Window with outerWindowID '" + outerWindowID + "' is chrome", + }); + } + if (window) { + const iframe = window.browsingContext.embedderElement; + if (iframe) { + return this._getActorForBrowser(iframe); + } + } + // Then also look on registered <xul:browsers> when using outerWindowID for + // OOP tabs + for (const browser of this._getBrowsers()) { + if (browser.outerWindowID == outerWindowID) { + return this._getActorForBrowser(browser); + } + } + return Promise.reject({ + error: "noTab", + message: "Unable to find tab with outerWindowID '" + outerWindowID + "'", + }); + } else if (typeof tabId == "number") { + // Tabs OOP + for (const browser of this._getBrowsers()) { + if ( + browser.frameLoader?.remoteTab && + browser.frameLoader.remoteTab.tabId === tabId + ) { + return this._getActorForBrowser(browser); + } + } + return Promise.reject({ + error: "noTab", + message: "Unable to find tab with tabId '" + tabId + "'", + }); + } + + const topAppWindow = Services.wm.getMostRecentWindow( + DevToolsServer.chromeWindowType + ); + if (topAppWindow) { + const selectedBrowser = this._getSelectedBrowser(topAppWindow); + return this._getActorForBrowser(selectedBrowser); + } + return Promise.reject({ + error: "noTab", + message: "Unable to find any selected browser", + }); +}; + +Object.defineProperty(BrowserTabList.prototype, "onListChanged", { + enumerable: true, + configurable: true, + get() { + return this._onListChanged; + }, + set(v) { + if (v !== null && typeof v !== "function") { + throw new Error( + "onListChanged property may only be set to 'null' or a function" + ); + } + this._onListChanged = v; + this._checkListening(); + }, +}); + +/** + * The set of tabs has changed somehow. Call our onListChanged handler, if + * one is set, and if we haven't already called it since the last iteration. + */ +BrowserTabList.prototype._notifyListChanged = function() { + if (!this._onListChanged) { + return; + } + if (this._mustNotify) { + this._onListChanged(); + this._mustNotify = false; + } +}; + +/** + * Exit |actor|, belonging to |browser|, and notify the onListChanged + * handle if needed. + */ +BrowserTabList.prototype._handleActorClose = function(actor, browser) { + if (this._testing) { + if (this._actorByBrowser.get(browser) !== actor) { + throw new Error( + "TabDescriptorActor not stored in map under given browser" + ); + } + if (actor.browser !== browser) { + throw new Error("actor's browser and map key don't match"); + } + } + + this._actorByBrowser.delete(browser); + actor.destroy(); + + this._notifyListChanged(); + this._checkListening(); +}; + +/** + * Make sure we are listening or not listening for activity elsewhere in + * the browser, as appropriate. Other than setting up newly created XUL + * windows, all listener / observer management should happen here. + */ +BrowserTabList.prototype._checkListening = function() { + /* + * If we have an onListChanged handler that we haven't sent an announcement + * to since the last iteration, we need to watch for tab creation as well as + * change of the currently selected tab and tab title changes of tabs in + * parent process via TabAttrModified (tabs oop uses DOMTitleChanges). + * + * Oddly, we don't need to watch for 'close' events here. If our actor list + * is empty, then either it was empty the last time we iterated, and no + * close events are possible, or it was not empty the last time we + * iterated, but all the actors have since been closed, and we must have + * sent a notification already when they closed. + */ + this._listenForEventsIf( + this._onListChanged && this._mustNotify, + "_listeningForTabOpen", + ["TabOpen", "TabSelect", "TabAttrModified"] + ); + + /* If we have live actors, we need to be ready to mark them dead. */ + this._listenForEventsIf( + this._actorByBrowser.size > 0, + "_listeningForTabClose", + ["TabClose"] + ); + + /* + * We must listen to the window mediator in either case, since that's the + * only way to find out about tabs that come and go when top-level windows + * are opened and closed. + */ + this._listenToMediatorIf( + (this._onListChanged && this._mustNotify) || this._actorByBrowser.size > 0 + ); + + /* + * We also listen for title changed events on the browser. + */ + this._listenForEventsIf( + this._onListChanged && this._mustNotify, + "_listeningForTitleChange", + ["pagetitlechanged"], + this._onPageTitleChangedEvent + ); +}; + +/* + * Add or remove event listeners for all XUL windows. + * + * @param shouldListen boolean + * True if we should add event handlers; false if we should remove them. + * @param guard string + * The name of a guard property of 'this', indicating whether we're + * already listening for those events. + * @param eventNames array of strings + * An array of event names. + */ +BrowserTabList.prototype._listenForEventsIf = function( + shouldListen, + guard, + eventNames, + listener = this +) { + if (!shouldListen !== !this[guard]) { + const op = shouldListen ? "addEventListener" : "removeEventListener"; + for (const win of Services.wm.getEnumerator( + DevToolsServer.chromeWindowType + )) { + for (const name of eventNames) { + win[op](name, listener, false); + } + } + this[guard] = shouldListen; + } +}; + +/* + * Event listener for pagetitlechanged event. + */ +BrowserTabList.prototype._onPageTitleChangedEvent = function(event) { + switch (event.type) { + case "pagetitlechanged": { + const window = event.currentTarget.ownerGlobal; + this._onDOMTitleChanged(window.browser); + break; + } + } +}; + +/** + * Handle "DOMTitleChanged" event. + */ +BrowserTabList.prototype._onDOMTitleChanged = DevToolsUtils.makeInfallible( + function(browser) { + const actor = this._actorByBrowser.get(browser); + if (actor) { + this._notifyListChanged(); + this._checkListening(); + } + } +); + +/** + * Implement nsIDOMEventListener. + */ +BrowserTabList.prototype.handleEvent = DevToolsUtils.makeInfallible(function( + event +) { + // If event target has `linkedBrowser`, the event target can be assumed <tab> element. + // Else, event target is assumed <browser> element, use the target as it is. + const browser = event.target.linkedBrowser || event.target; + switch (event.type) { + case "TabOpen": + case "TabSelect": { + /* Don't create a new actor; iterate will take care of that. Just notify. */ + this._notifyListChanged(); + this._checkListening(); + break; + } + case "TabClose": { + const actor = this._actorByBrowser.get(browser); + if (actor) { + this._handleActorClose(actor, browser); + } + break; + } + case "TabAttrModified": { + // Remote <browser> title changes are handled via DOMTitleChange message + // TabAttrModified is only here for browsers in parent process which + // don't send this message. + if (browser.isRemoteBrowser) { + break; + } + const actor = this._actorByBrowser.get(browser); + if (actor) { + // TabAttrModified is fired in various cases, here only care about title + // changes + if (event.detail.changed.includes("label")) { + this._notifyListChanged(); + this._checkListening(); + } + } + break; + } + } +}, +"BrowserTabList.prototype.handleEvent"); + +/* + * If |shouldListen| is true, ensure we've registered a listener with the + * window mediator. Otherwise, ensure we haven't registered a listener. + */ +BrowserTabList.prototype._listenToMediatorIf = function(shouldListen) { + if (!shouldListen !== !this._listeningToMediator) { + const op = shouldListen ? "addListener" : "removeListener"; + Services.wm[op](this); + this._listeningToMediator = shouldListen; + } +}; + +/** + * nsIWindowMediatorListener implementation. + * + * See _onTabClosed for explanation of why we needn't actually tweak any + * actors or tables here. + * + * An nsIWindowMediatorListener's methods get passed all sorts of windows; we + * only care about the tab containers. Those have 'gBrowser' members. + */ +BrowserTabList.prototype.onOpenWindow = DevToolsUtils.makeInfallible(function( + window +) { + const handleLoad = DevToolsUtils.makeInfallible(() => { + /* We don't want any further load events from this window. */ + window.removeEventListener("load", handleLoad); + + if (appShellDOMWindowType(window) !== DevToolsServer.chromeWindowType) { + return; + } + + // Listen for future tab activity. + if (this._listeningForTabOpen) { + window.addEventListener("TabOpen", this); + window.addEventListener("TabSelect", this); + window.addEventListener("TabAttrModified", this); + } + if (this._listeningForTabClose) { + window.addEventListener("TabClose", this); + } + if (this._listeningForTitleChange) { + window.messageManager.addMessageListener("DOMTitleChanged", this); + } + + // As explained above, we will not receive a TabOpen event for this + // document's initial tab, so we must notify our client of the new tab + // this will have. + this._notifyListChanged(); + }); + + /* + * You can hardly do anything at all with a XUL window at this point; it + * doesn't even have its document yet. Wait until its document has + * loaded, and then see what we've got. This also avoids + * nsIWindowMediator enumeration from within listeners (bug 873589). + */ + window = window + .QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDOMWindow); + + window.addEventListener("load", handleLoad); +}, +"BrowserTabList.prototype.onOpenWindow"); + +BrowserTabList.prototype.onCloseWindow = DevToolsUtils.makeInfallible(function( + window +) { + if (window instanceof Ci.nsIAppWindow) { + window = window.docShell.domWindow; + } + + if (appShellDOMWindowType(window) !== DevToolsServer.chromeWindowType) { + return; + } + + /* + * nsIWindowMediator deadlocks if you call its GetEnumerator method from + * a nsIWindowMediatorListener's onCloseWindow hook (bug 873589), so + * handle the close in a different tick. + */ + Services.tm.dispatchToMainThread( + DevToolsUtils.makeInfallible(() => { + /* + * Scan the entire map for actors representing tabs that were in this + * top-level window, and exit them. + */ + for (const [browser, actor] of this._actorByBrowser) { + /* The browser document of a closed window has no default view. */ + if (!browser.ownerGlobal) { + this._handleActorClose(actor, browser); + } + } + }, "BrowserTabList.prototype.onCloseWindow's delayed body") + ); +}, +"BrowserTabList.prototype.onCloseWindow"); + +exports.BrowserTabList = BrowserTabList; + +function BrowserAddonList(connection) { + this._connection = connection; + this._actorByAddonId = new Map(); + this._onListChanged = null; +} + +BrowserAddonList.prototype.getList = async function() { + const addons = await AddonManager.getAllAddons(); + for (const addon of addons) { + let actor = this._actorByAddonId.get(addon.id); + if (!actor) { + actor = new WebExtensionDescriptorActor(this._connection, addon); + this._actorByAddonId.set(addon.id, actor); + } + } + + return Array.from(this._actorByAddonId, ([_, actor]) => actor); +}; + +Object.defineProperty(BrowserAddonList.prototype, "onListChanged", { + enumerable: true, + configurable: true, + get() { + return this._onListChanged; + }, + set(v) { + if (v !== null && typeof v != "function") { + throw new Error( + "onListChanged property may only be set to 'null' or a function" + ); + } + this._onListChanged = v; + this._adjustListener(); + }, +}); + +/** + * AddonManager listener must implement onDisabled. + */ +BrowserAddonList.prototype.onDisabled = function(addon) { + this._onAddonManagerUpdated(); +}; + +/** + * AddonManager listener must implement onEnabled. + */ +BrowserAddonList.prototype.onEnabled = function(addon) { + this._onAddonManagerUpdated(); +}; + +/** + * AddonManager listener must implement onInstalled. + */ +BrowserAddonList.prototype.onInstalled = function(addon) { + this._onAddonManagerUpdated(); +}; + +/** + * AddonManager listener must implement onOperationCancelled. + */ +BrowserAddonList.prototype.onOperationCancelled = function(addon) { + this._onAddonManagerUpdated(); +}; + +/** + * AddonManager listener must implement onUninstalling. + */ +BrowserAddonList.prototype.onUninstalling = function(addon) { + this._onAddonManagerUpdated(); +}; + +/** + * AddonManager listener must implement onUninstalled. + */ +BrowserAddonList.prototype.onUninstalled = function(addon) { + this._actorByAddonId.delete(addon.id); + this._onAddonManagerUpdated(); +}; + +BrowserAddonList.prototype._onAddonManagerUpdated = function(addon) { + this._notifyListChanged(); + this._adjustListener(); +}; + +BrowserAddonList.prototype._notifyListChanged = function() { + if (this._onListChanged) { + this._onListChanged(); + } +}; + +BrowserAddonList.prototype._adjustListener = function() { + if (this._onListChanged) { + // As long as the callback exists, we need to listen for changes + // so we can notify about add-on changes. + AddonManager.addAddonListener(this); + } else if (this._actorByAddonId.size === 0) { + // When the callback does not exist, we only need to keep listening + // if the actor cache will need adjusting when add-ons change. + AddonManager.removeAddonListener(this); + } +}; + +exports.BrowserAddonList = BrowserAddonList; |