diff options
Diffstat (limited to '')
4 files changed, 1239 insertions, 0 deletions
diff --git a/devtools/server/actors/resources/utils/content-process-storage.js b/devtools/server/actors/resources/utils/content-process-storage.js new file mode 100644 index 0000000000..8bb0016a3e --- /dev/null +++ b/devtools/server/actors/resources/utils/content-process-storage.js @@ -0,0 +1,453 @@ +/* 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", +}); + +// 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.contentViewer) { + return null; + } + const window = docShell.contentViewer.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: + * - { + * <host1>: [<store_names1>, <store_name2>...], + * <host2>: [<store_names34>...], + * } + * Where host1, host2 are the host in which this change happened and + * [<store_namesX] is an array of the names of the changed store objects. + * Pass an empty array if the host itself was affected: either completely + * removed or cleared. + */ + // eslint-disable-next-line complexity + update(action, storeType, data) { + if (action == "cleared") { + this.emit("stores-cleared", { [storeType]: data }); + return null; + } + + if (this.batchTimer) { + clearTimeout(this.batchTimer); + } + if (!this.boundUpdate[action]) { + this.boundUpdate[action] = {}; + } + if (!this.boundUpdate[action][storeType]) { + this.boundUpdate[action][storeType] = {}; + } + for (const host in data) { + if (!this.boundUpdate[action][storeType][host]) { + this.boundUpdate[action][storeType][host] = []; + } + for (const name of data[host]) { + if (!this.boundUpdate[action][storeType][host].includes(name)) { + this.boundUpdate[action][storeType][host].push(name); + } + } + } + if (action == "added") { + // If the same store name was previously deleted or changed, but now is + // added somehow, dont send the deleted or changed update. + this.removeNamesFromUpdateList("deleted", storeType, data); + this.removeNamesFromUpdateList("changed", storeType, data); + } else if ( + action == "changed" && + this.boundUpdate.added && + this.boundUpdate.added[storeType] + ) { + // If something got added and changed at the same time, then remove those + // items from changed instead. + this.removeNamesFromUpdateList( + "changed", + storeType, + this.boundUpdate.added[storeType] + ); + } else if (action == "deleted") { + // If any item got delete, or a host got delete, no point in sending + // added or changed update + this.removeNamesFromUpdateList("added", storeType, data); + this.removeNamesFromUpdateList("changed", storeType, data); + + for (const host in data) { + if ( + !data[host].length && + this.boundUpdate.added && + this.boundUpdate.added[storeType] && + this.boundUpdate.added[storeType][host] + ) { + delete this.boundUpdate.added[storeType][host]; + } + if ( + !data[host].length && + this.boundUpdate.changed && + this.boundUpdate.changed[storeType] && + this.boundUpdate.changed[storeType][host] + ) { + delete this.boundUpdate.changed[storeType][host]; + } + } + } + + this.batchTimer = setTimeout(() => { + 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: + * - { + * <host1>: [<store_names1>, <store_name2>...], + * <host2>: [<store_names34>...], + * } + * Where host1, host2 are the hosts which you want to remove and + * [<store_namesX] is an array of the names of the store objects. + */ + removeNamesFromUpdateList(action, storeType, data) { + for (const host in data) { + if ( + this.boundUpdate[action] && + this.boundUpdate[action][storeType] && + this.boundUpdate[action][storeType][host] + ) { + for (const name in data[host]) { + const index = this.boundUpdate[action][storeType][host].indexOf(name); + if (index > -1) { + this.boundUpdate[action][storeType][host].splice(index, 1); + } + } + if (!this.boundUpdate[action][storeType][host].length) { + delete this.boundUpdate[action][storeType][host]; + } + } + } + return null; + } +} diff --git a/devtools/server/actors/resources/utils/moz.build b/devtools/server/actors/resources/utils/moz.build new file mode 100644 index 0000000000..0e6f9d1baa --- /dev/null +++ b/devtools/server/actors/resources/utils/moz.build @@ -0,0 +1,14 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# 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/. + +DevToolsModules( + "content-process-storage.js", + "nsi-console-listener-watcher.js", + "parent-process-storage.js", +) + +with Files("nsi-console-listener-watcher.js"): + BUG_COMPONENT = ("DevTools", "Console") diff --git a/devtools/server/actors/resources/utils/nsi-console-listener-watcher.js b/devtools/server/actors/resources/utils/nsi-console-listener-watcher.js new file mode 100644 index 0000000000..8d1ed43612 --- /dev/null +++ b/devtools/server/actors/resources/utils/nsi-console-listener-watcher.js @@ -0,0 +1,192 @@ +/* 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 { + createStringGrip, +} = require("resource://devtools/server/actors/object/utils.js"); + +const { + getActorIdForInternalSourceId, +} = require("resource://devtools/server/actors/utils/dbg-source.js"); + +class nsIConsoleListenerWatcher { + /** + * Start watching for all messages related to a given Target Actor. + * This will notify about existing messages, as well as those created in the future. + * + * @param TargetActor targetActor + * The target actor from which we should observe messages + * @param Object options + * Dictionary object with following attributes: + * - onAvailable: mandatory function + * This will be called for each resource. + */ + async watch(targetActor, { onAvailable }) { + if (!this.shouldHandleTarget(targetActor)) { + return; + } + + let latestRetrievedCachedMessageTimestamp = -1; + + // Create the consoleListener. + const listener = { + QueryInterface: ChromeUtils.generateQI(["nsIConsoleListener"]), + observe: message => { + if ( + message.microSecondTimeStamp <= latestRetrievedCachedMessageTimestamp + ) { + return; + } + + if (!this.shouldHandleMessage(targetActor, message)) { + return; + } + + onAvailable([this.buildResource(targetActor, message)]); + }, + }; + + // Retrieve the cached messages and get the last cached message timestamp before + // registering the listener, so we can ignore messages we'd be notified about but that + // were already retrieved in the cache. + const cachedMessages = Services.console.getMessageArray() || []; + if (cachedMessages.length) { + latestRetrievedCachedMessageTimestamp = + cachedMessages.at(-1).microSecondTimeStamp; + } + + Services.console.registerListener(listener); + this.listener = listener; + + // Remove unwanted cache messages and send an array of resources. + const messages = []; + for (const message of cachedMessages) { + if (!this.shouldHandleMessage(targetActor, message, true)) { + continue; + } + + messages.push(this.buildResource(targetActor, message)); + } + onAvailable(messages); + } + + /** + * Return false if the watcher shouldn't be created. + * + * @param {TargetActor} targetActor + * @return {Boolean} + */ + shouldHandleTarget(targetActor) { + return true; + } + + /** + * Return true if you want the passed message to be handled by the watcher. This should + * be implemented on the child class. + * + * @param {TargetActor} targetActor + * @param {nsIScriptError|nsIConsoleMessage} message + * @return {Boolean} + */ + shouldHandleMessage(targetActor, message) { + throw new Error( + "'shouldHandleMessage' should be implemented in the class that extends nsIConsoleListenerWatcher" + ); + } + + /** + * Prepare the resource to be sent to the client. This should be implemented on the + * child class. + * + * @param targetActor + * @param nsIScriptError|nsIConsoleMessage message + * @return object + * The object you can send to the remote client. + */ + buildResource(targetActor, message) { + throw new Error( + "'buildResource' should be implemented in the class that extends nsIConsoleListenerWatcher" + ); + } + + /** + * Prepare a SavedFrame stack to be sent to the client. + * + * @param {TargetActor} targetActor + * @param {SavedFrame} errorStack + * Stack for an error we need to send to the client. + * @return object + * The object you can send to the remote client. + */ + prepareStackForRemote(targetActor, errorStack) { + // Convert stack objects to the JSON attributes expected by client code + // Bug 1348885: If the global from which this error came from has been + // nuked, stack is going to be a dead wrapper. + if (!errorStack || (Cu && Cu.isDeadWrapper(errorStack))) { + return null; + } + const stack = []; + let s = errorStack; + while (s) { + stack.push({ + filename: s.source, + sourceId: getActorIdForInternalSourceId(targetActor, s.sourceId), + lineNumber: s.line, + columnNumber: s.column, + functionName: s.functionDisplayName, + asyncCause: s.asyncCause ? s.asyncCause : undefined, + }); + s = s.parent || s.asyncParent; + } + return stack; + } + + /** + * Prepare error notes to be sent to the client. + * + * @param {TargetActor} targetActor + * @param {nsIArray<nsIScriptErrorNote>} errorNotes + * @return object + * The object you can send to the remote client. + */ + prepareNotesForRemote(targetActor, errorNotes) { + if (!errorNotes?.length) { + return null; + } + + const notes = []; + for (let i = 0, len = errorNotes.length; i < len; i++) { + const note = errorNotes.queryElementAt(i, Ci.nsIScriptErrorNote); + notes.push({ + messageBody: createStringGrip(targetActor, note.errorMessage), + frame: { + source: note.sourceName, + sourceId: getActorIdForInternalSourceId(targetActor, note.sourceId), + line: note.lineNumber, + column: note.columnNumber, + }, + }); + } + return notes; + } + + isProcessTarget(targetActor) { + const { typeName } = targetActor; + return ( + typeName === "parentProcessTarget" || typeName === "contentProcessTarget" + ); + } + + /** + * Stop watching for messages. + */ + destroy() { + if (this.listener) { + Services.console.unregisterListener(this.listener); + } + } +} +module.exports = nsIConsoleListenerWatcher; diff --git a/devtools/server/actors/resources/utils/parent-process-storage.js b/devtools/server/actors/resources/utils/parent-process-storage.js new file mode 100644 index 0000000000..423d13b6b5 --- /dev/null +++ b/devtools/server/actors/resources/utils/parent-process-storage.js @@ -0,0 +1,580 @@ +/* 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" +); + +// 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: + * - <window-global-created> (to cover regular navigations, with brand new documents) + * - <bf-cache-navigation-pageshow> (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: + * - { + * <host1>: [<store_names1>, <store_name2>...], + * <host2>: [<store_names34>...], + * } + * Where host1, host2 are the host in which this change happened and + * [<store_namesX] is an array of the names of the changed store objects. + * Pass an empty array if the host itself was affected: either completely + * removed or cleared. + */ + // eslint-disable-next-line complexity + update(action, storeType, data) { + if (action == "cleared") { + this.emit("stores-cleared", { [storeType]: data }); + return null; + } + + if (this.batchTimer) { + clearTimeout(this.batchTimer); + } + if (!this.boundUpdate[action]) { + this.boundUpdate[action] = {}; + } + if (!this.boundUpdate[action][storeType]) { + this.boundUpdate[action][storeType] = {}; + } + for (const host in data) { + if (!this.boundUpdate[action][storeType][host]) { + this.boundUpdate[action][storeType][host] = []; + } + for (const name of data[host]) { + if (!this.boundUpdate[action][storeType][host].includes(name)) { + this.boundUpdate[action][storeType][host].push(name); + } + } + } + if (action == "added") { + // If the same store name was previously deleted or changed, but now is + // added somehow, dont send the deleted or changed update. + this.removeNamesFromUpdateList("deleted", storeType, data); + this.removeNamesFromUpdateList("changed", storeType, data); + } else if ( + action == "changed" && + this.boundUpdate.added && + this.boundUpdate.added[storeType] + ) { + // If something got added and changed at the same time, then remove those + // items from changed instead. + this.removeNamesFromUpdateList( + "changed", + storeType, + this.boundUpdate.added[storeType] + ); + } else if (action == "deleted") { + // If any item got delete, or a host got delete, no point in sending + // added or changed update + this.removeNamesFromUpdateList("added", storeType, data); + this.removeNamesFromUpdateList("changed", storeType, data); + + for (const host in data) { + if ( + !data[host].length && + this.boundUpdate.added && + this.boundUpdate.added[storeType] && + this.boundUpdate.added[storeType][host] + ) { + delete this.boundUpdate.added[storeType][host]; + } + if ( + !data[host].length && + this.boundUpdate.changed && + this.boundUpdate.changed[storeType] && + this.boundUpdate.changed[storeType][host] + ) { + delete this.boundUpdate.changed[storeType][host]; + } + } + } + + this.batchTimer = setTimeout(() => { + 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: + * - { + * <host1>: [<store_names1>, <store_name2>...], + * <host2>: [<store_names34>...], + * } + * Where host1, host2 are the hosts which you want to remove and + * [<store_namesX] is an array of the names of the store objects. + */ + removeNamesFromUpdateList(action, storeType, data) { + for (const host in data) { + if ( + this.boundUpdate[action] && + this.boundUpdate[action][storeType] && + this.boundUpdate[action][storeType][host] + ) { + for (const name of data[host]) { + const index = this.boundUpdate[action][storeType][host].indexOf(name); + if (index > -1) { + this.boundUpdate[action][storeType][host].splice(index, 1); + } + } + if (!this.boundUpdate[action][storeType][host].length) { + delete this.boundUpdate[action][storeType][host]; + } + } + } + return null; + } +} |