/* 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 lazy = {}; ChromeUtils.defineESModuleGetters( lazy, { getAddonIdForWindowGlobal: "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 ContentProcessStorage { 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); } async watch(targetActor, { onAvailable }) { const storageActor = new StorageActorMock(targetActor); this.storageActor = storageActor; this.actor = new this.ActorConstructor(storageActor); // Some storage types require to prelist their stores await this.actor.populateStoresForHosts(); // 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 targetActor.manage(this.actor); // 2) Convert to JSON "form" const form = this.actor.form(); // NOTE: this is hoisted, so the `update` method above may use it. const storage = 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; storage.resourceKey = this.storageKey; 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); } 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() { this.actor?.destroy(); this.actor = null; if (this.storageActor) { this.storageActor.on("stores-update", this.onStoresUpdate); this.storageActor.on("stores-cleared", this.onStoresCleared); this.storageActor.destroy(); this.storageActor = null; } } } module.exports = ContentProcessStorage; // This class mocks what used to be implement in devtools/server/actors/storage.js: StorageActor // But without being a protocol.js actor, nor implement any RDP method/event. // An instance of this class is passed to each storage type actor and named `storageActor`. // Once we implement all storage type in watcher classes, we can get rid of the original // StorageActor in devtools/server/actors/storage.js class StorageActorMock extends EventEmitter { constructor(targetActor) { super(); // Storage classes fetch conn from storageActor this.conn = targetActor.conn; this.targetActor = targetActor; this.childWindowPool = new Set(); // Fetch all the inner iframe windows in this tab. this.fetchChildWindows(this.targetActor.docShell); // Notifications that help us keep track of newly added windows and windows // that got removed Services.obs.addObserver(this, "content-document-global-created"); Services.obs.addObserver(this, "inner-window-destroyed"); this.onPageChange = this.onPageChange.bind(this); const handler = targetActor.chromeEventHandler; handler.addEventListener("pageshow", this.onPageChange, true); handler.addEventListener("pagehide", this.onPageChange, true); this.destroyed = false; this.boundUpdate = {}; } destroy() { clearTimeout(this.batchTimer); this.batchTimer = null; // Remove observers Services.obs.removeObserver(this, "content-document-global-created"); Services.obs.removeObserver(this, "inner-window-destroyed"); this.destroyed = true; if (this.targetActor.browser) { this.targetActor.browser.removeEventListener( "pageshow", this.onPageChange, true ); this.targetActor.browser.removeEventListener( "pagehide", this.onPageChange, true ); } this.childWindowPool.clear(); this.childWindowPool = null; this.targetActor = null; this.boundUpdate = null; } get window() { return this.targetActor.window; } get document() { return this.targetActor.window.document; } get windows() { return this.childWindowPool; } /** * Given a docshell, recursively find out all the child windows from it. * * @param {nsIDocShell} item * The docshell from which all inner windows need to be extracted. */ fetchChildWindows(item) { const docShell = item .QueryInterface(Ci.nsIDocShell) .QueryInterface(Ci.nsIDocShellTreeItem); if (!docShell.docViewer) { return null; } const window = docShell.docViewer.DOMDocument.defaultView; if (window.location.href == "about:blank") { // Skip out about:blank windows as Gecko creates them multiple times while // creating any global. return null; } if (!this.isIncludedInTopLevelWindow(window)) { return null; } this.childWindowPool.add(window); for (let i = 0; i < docShell.childCount; i++) { const child = docShell.getChildAt(i); this.fetchChildWindows(child); } return null; } isIncludedInTargetExtension(subject) { const addonId = lazy.getAddonIdForWindowGlobal(subject.windowGlobalChild); return addonId && addonId === this.targetActor.addonId; } isIncludedInTopLevelWindow(window) { return this.targetActor.windows.includes(window); } getWindowFromInnerWindowID(innerID) { innerID = innerID.QueryInterface(Ci.nsISupportsPRUint64).data; for (const win of this.childWindowPool.values()) { const id = win.windowGlobalChild.innerWindowId; if (id == innerID) { return win; } } return null; } getWindowFromHost(host) { for (const win of this.childWindowPool.values()) { const origin = win.document.nodePrincipal.originNoSuffix; const url = win.document.URL; if (origin === host || url === host) { return win; } } return 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 ( subject.location && (!subject.location.href || subject.location.href == "about:blank") ) { return null; } // We don't want to try to find a top level window for an extension page, as // in many cases (e.g. background page), it is not loaded in a tab, and // 'isIncludedInTopLevelWindow' throws an error if ( topic == "content-document-global-created" && (this.isIncludedInTargetExtension(subject) || this.isIncludedInTopLevelWindow(subject)) ) { this.childWindowPool.add(subject); this.emit("window-ready", subject); } else if (topic == "inner-window-destroyed") { const window = this.getWindowFromInnerWindowID(subject); if (window) { this.childWindowPool.delete(window); this.emit("window-destroyed", window); } } return null; } /** * Called on "pageshow" or "pagehide" event on the chromeEventHandler of * current tab. * * @param {event} The event object passed to the handler. We are using these * three properties from the event: * - target {document} The document corresponding to the event. * - type {string} Name of the event - "pageshow" or "pagehide". * - persisted {boolean} true if there was no * "content-document-global-created" notification along * this event. */ onPageChange({ target, type, persisted }) { if (this.destroyed) { return; } const window = target.defaultView; if (type == "pagehide" && this.childWindowPool.delete(window)) { this.emit("window-destroyed", window); } else if ( type == "pageshow" && persisted && window.location.href && window.location.href != "about:blank" && this.isIncludedInTopLevelWindow(window) ) { this.childWindowPool.add(window); this.emit("window-ready", window); } } /** * 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; } }