/* 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"; const EventEmitter = require("resource://devtools/shared/event-emitter.js"); const { isWindowGlobalPartOfContext } = ChromeUtils.importESModule( "resource://devtools/server/actors/watcher/browsing-context-helpers.sys.mjs", { global: "contextual" } ); // ms of delay to throttle updates const BATCH_DELAY = 200; // Filters "stores-update" response to only include events for // the storage type we desire function getFilteredStorageEvents(updates, storageType) { const filteredUpdate = Object.create(null); // updateType will be "added", "changed", or "deleted" for (const updateType in updates) { if (updates[updateType][storageType]) { if (!filteredUpdate[updateType]) { filteredUpdate[updateType] = {}; } filteredUpdate[updateType][storageType] = updates[updateType][storageType]; } } return Object.keys(filteredUpdate).length ? filteredUpdate : null; } class ParentProcessStorage { constructor(ActorConstructor, storageKey, storageType) { this.ActorConstructor = ActorConstructor; this.storageKey = storageKey; this.storageType = storageType; this.onStoresUpdate = this.onStoresUpdate.bind(this); this.onStoresCleared = this.onStoresCleared.bind(this); this.observe = this.observe.bind(this); // Notifications that help us keep track of newly added windows and windows // that got removed Services.obs.addObserver(this, "window-global-created"); Services.obs.addObserver(this, "window-global-destroyed"); // bfcacheInParent is only enabled when fission is enabled // and when Session History In Parent is enabled. (all three modes should now enabled all together) loader.lazyGetter( this, "isBfcacheInParentEnabled", () => Services.appinfo.sessionHistoryInParent && Services.prefs.getBoolPref("fission.bfcacheInParent", false) ); } async watch(watcherActor, { onAvailable }) { this.watcherActor = watcherActor; this.onAvailable = onAvailable; // When doing a bfcache navigation with Fission disabled or with Fission + bfCacheInParent enabled, // we're not getting a the window-global-created events. // In such case, the watcher emits specific events that we can use instead. this._offPageShow = watcherActor.on( "bf-cache-navigation-pageshow", ({ windowGlobal }) => this._onNewWindowGlobal(windowGlobal, true) ); if (watcherActor.sessionContext.type == "browser-element") { const { browsingContext, innerWindowID: innerWindowId } = watcherActor.browserElement; await this._spawnActor(browsingContext.id, innerWindowId); } else if (watcherActor.sessionContext.type == "webextension") { const { addonBrowsingContextID, addonInnerWindowId } = watcherActor.sessionContext; await this._spawnActor(addonBrowsingContextID, addonInnerWindowId); } else if (watcherActor.sessionContext.type == "all") { const parentProcessTargetActor = this.watcherActor.getTargetActorInParentProcess(); const { browsingContextID, innerWindowId } = parentProcessTargetActor.form(); await this._spawnActor(browsingContextID, innerWindowId); } else { throw new Error( "Unsupported session context type=" + watcherActor.sessionContext.type ); } } onStoresUpdate(response) { response = getFilteredStorageEvents(response, this.storageKey); if (!response) { return; } this.actor.emit("single-store-update", { changed: response.changed, added: response.added, deleted: response.deleted, }); } onStoresCleared(response) { const cleared = response[this.storageKey]; if (!cleared) { return; } this.actor.emit("single-store-cleared", { clearedHostsOrPaths: cleared, }); } destroy() { // Remove observers Services.obs.removeObserver(this, "window-global-created"); Services.obs.removeObserver(this, "window-global-destroyed"); this._offPageShow(); this._cleanActor(); } async _spawnActor(browsingContextID, innerWindowId) { const storageActor = new StorageActorMock(this.watcherActor); this.storageActor = storageActor; this.actor = new this.ActorConstructor(storageActor); // Some storage types require to prelist their stores try { await this.actor.populateStoresForHosts(); } catch (e) { // It can happen that the actor gets destroyed while populateStoresForHosts is being // executed. if (this.actor) { throw e; } } // If the actor was destroyed, we don't need to go further. if (!this.actor) { return; } // We have to manage the actor manually, because ResourceCommand doesn't // use the protocol.js specification. // resource-available-form is typed as "json" // So that we have to manually handle stuff that would normally be // automagically done by procotol.js // 1) Manage the actor in order to have an actorID on it this.watcherActor.manage(this.actor); // 2) Convert to JSON "form" const storage = this.actor.form(); // All resources should have a resourceType, resourceId and resourceKey // attributes, so available/updated/destroyed callbacks work properly. storage.resourceType = this.storageType; storage.resourceId = `${this.storageType}-${innerWindowId}`; storage.resourceKey = this.storageKey; // NOTE: the resource command needs this attribute storage.browsingContextID = browsingContextID; this.onAvailable([storage]); // Maps global events from `storageActor` shared for all storage-types, // down to storage-type's specific actor `storage`. storageActor.on("stores-update", this.onStoresUpdate); // When a store gets cleared storageActor.on("stores-cleared", this.onStoresCleared); } _cleanActor() { this.actor?.destroy(); this.actor = null; if (this.storageActor) { this.storageActor.off("stores-update", this.onStoresUpdate); this.storageActor.off("stores-cleared", this.onStoresCleared); this.storageActor.destroy(); this.storageActor = null; } } /** * Event handler for any docshell update. This lets us figure out whenever * any new window is added, or an existing window is removed. */ observe(subject, topic) { if (topic === "window-global-created") { this._onNewWindowGlobal(subject); } } /** * Handle WindowGlobal received via: * - (to cover regular navigations, with brand new documents) * - (to cover history navications) * * @param {WindowGlobal} windowGlobal * @param {Boolean} isBfCacheNavigation */ async _onNewWindowGlobal(windowGlobal, isBfCacheNavigation) { // Only process WindowGlobals which are related to the debugged scope. if ( !isWindowGlobalPartOfContext( windowGlobal, this.watcherActor.sessionContext, { acceptNoWindowGlobal: true, acceptSameProcessIframes: true } ) ) { return; } // Ignore about:blank if (windowGlobal.documentURI.displaySpec === "about:blank") { return; } // Only process top BrowsingContext (ignore same-process iframe ones) const isTopContext = windowGlobal.browsingContext.top == windowGlobal.browsingContext; if (!isTopContext) { return; } // We only want to spawn a new StorageActor if a new target is being created, i.e. // - target switching is enabled and we're notified about a new top-level window global, // via window-global-created // - target switching is enabled OR bfCacheInParent is enabled, and a bfcache navigation // is performed (See handling of "pageshow" event in DevToolsFrameChild) const isNewTargetBeingCreated = this.watcherActor.sessionContext.isServerTargetSwitchingEnabled || (isBfCacheNavigation && this.isBfcacheInParentEnabled); if (!isNewTargetBeingCreated) { return; } // When server side target switching is enabled, we replace the StorageActor // with a new one. // On the frontend, the navigation will destroy the previous target, which // will destroy the previous storage front, so we must notify about a new one. // When we are target switching we keep the storage watcher, so we need // to send a new resource to the client. // However, we must ensure that we do this when the new target is // already available, so we check innerWindowId to do it. await new Promise(resolve => { const listener = targetActorForm => { if (targetActorForm.innerWindowId != windowGlobal.innerWindowId) { return; } this.watcherActor.off("target-available-form", listener); resolve(); }; this.watcherActor.on("target-available-form", listener); }); this._cleanActor(); this._spawnActor( windowGlobal.browsingContext.id, windowGlobal.innerWindowId ); } } module.exports = ParentProcessStorage; class StorageActorMock extends EventEmitter { constructor(watcherActor) { super(); this.conn = watcherActor.conn; this.watcherActor = watcherActor; this.boundUpdate = {}; // Notifications that help us keep track of newly added windows and windows // that got removed this.observe = this.observe.bind(this); Services.obs.addObserver(this, "window-global-created"); Services.obs.addObserver(this, "window-global-destroyed"); // When doing a bfcache navigation with Fission disabled or with Fission + bfCacheInParent enabled, // we're not getting a the window-global-created/window-global-destroyed events. // In such case, the watcher emits specific events that we can use as equivalent to // window-global-created/window-global-destroyed. // We only need to react to those events here if target switching is not enabled; when // it is enabled, ParentProcessStorage will spawn a whole new actor which will allow // the client to get the information it needs. if (!this.watcherActor.sessionContext.isServerTargetSwitchingEnabled) { this._offPageShow = watcherActor.on( "bf-cache-navigation-pageshow", ({ windowGlobal }) => { // if a new target is created in the content process as a result of the bfcache // navigation, we don't need to emit window-ready as a new StorageActorMock will // be created by ParentProcessStorage. // When server targets are disabled, this only happens when bfcache in parent is enabled. if (this.isBfcacheInParentEnabled) { return; } const windowMock = { location: windowGlobal.documentURI }; this.emit("window-ready", windowMock); } ); this._offPageHide = watcherActor.on( "bf-cache-navigation-pagehide", ({ windowGlobal }) => { const windowMock = { location: windowGlobal.documentURI }; // The listener of this events usually check that there are no other windows // with the same host before notifying the client that it can remove it from // the UI. The windows are retrieved from the `windows` getter, and in this case // we still have a reference to the window we're navigating away from. // We pass a `dontCheckHost` parameter alongside the window-destroyed event to // always notify the client. this.emit("window-destroyed", windowMock, { dontCheckHost: true }); } ); } } destroy() { // clear update throttle timeout clearTimeout(this.batchTimer); this.batchTimer = null; // Remove observers Services.obs.removeObserver(this, "window-global-created"); Services.obs.removeObserver(this, "window-global-destroyed"); if (this._offPageShow) { this._offPageShow(); } if (this._offPageHide) { this._offPageHide(); } } get windows() { return ( this.watcherActor .getAllBrowsingContexts({ acceptSameProcessIframes: true, }) .map(x => { const uri = x.currentWindowGlobal.documentURI; return { location: uri }; }) // NOTE: we are removing about:blank because we might get them for iframes // whose src attribute has not been set yet. .filter(x => x.location.displaySpec !== "about:blank") ); } // NOTE: this uri argument is not a real window.Location, but the // `currentWindowGlobal.documentURI` object passed from `windows` getter. getHostName(uri) { switch (uri.scheme) { case "about": case "file": case "javascript": case "resource": return uri.displaySpec; case "moz-extension": case "http": case "https": return uri.prePath; default: // chrome: and data: do not support storage return null; } } getWindowFromHost(host) { const hostBrowsingContext = this.watcherActor .getAllBrowsingContexts({ acceptSameProcessIframes: true }) .find(x => { const hostName = this.getHostName(x.currentWindowGlobal.documentURI); return hostName === host; }); // In case of WebExtension or BrowserToolbox, we may pass privileged hosts // which don't relate to any particular window. // Like "indexeddb+++fx-devtools" or "chrome". // (callsites of this method are used to handle null returned values) if (!hostBrowsingContext) { return null; } const principal = hostBrowsingContext.currentWindowGlobal.documentStoragePrincipal; return { document: { effectiveStoragePrincipal: principal } }; } get parentActor() { return { isRootActor: this.watcherActor.sessionContext.type == "all", addonId: this.watcherActor.sessionContext.addonId, }; } /** * Event handler for any docshell update. This lets us figure out whenever * any new window is added, or an existing window is removed. */ async observe(windowGlobal, topic) { // Only process WindowGlobals which are related to the debugged scope. if ( !isWindowGlobalPartOfContext( windowGlobal, this.watcherActor.sessionContext, { acceptNoWindowGlobal: true, acceptSameProcessIframes: true } ) ) { return; } // Ignore about:blank if (windowGlobal.documentURI.displaySpec === "about:blank") { return; } // Only notify about remote iframe windows when JSWindowActor based targets are enabled // We will create a new StorageActor for the top level tab documents when server side target // switching is enabled const isTopContext = windowGlobal.browsingContext.top == windowGlobal.browsingContext; if ( isTopContext && this.watcherActor.sessionContext.isServerTargetSwitchingEnabled ) { return; } // emit window-wready and window-destroyed events when needed const windowMock = { location: windowGlobal.documentURI }; if (topic === "window-global-created") { this.emit("window-ready", windowMock); } else if (topic === "window-global-destroyed") { this.emit("window-destroyed", windowMock); } } /** * This method is called by the registered storage types so as to tell the * Storage Actor that there are some changes in the stores. Storage Actor then * notifies the client front about these changes at regular (BATCH_DELAY) * interval. * * @param {string} action * The type of change. One of "added", "changed" or "deleted" * @param {string} storeType * The storage actor in which this change has occurred. * @param {object} data * The update object. This object is of the following format: * - { * : [, ...], * : [...], * } * Where host1, host2 are the host in which this change happened and * [ { clearTimeout(this.batchTimer); this.emit("stores-update", this.boundUpdate); this.boundUpdate = {}; }, BATCH_DELAY); return null; } /** * This method removes data from the this.boundUpdate object in the same * manner like this.update() adds data to it. * * @param {string} action * The type of change. One of "added", "changed" or "deleted" * @param {string} storeType * The storage actor for which you want to remove the updates data. * @param {object} data * The update object. This object is of the following format: * - { * : [, ...], * : [...], * } * Where host1, host2 are the hosts which you want to remove and * [ -1) { this.boundUpdate[action][storeType][host].splice(index, 1); } } if (!this.boundUpdate[action][storeType][host].length) { delete this.boundUpdate[action][storeType][host]; } } } return null; } }