summaryrefslogtreecommitdiffstats
path: root/devtools/server/actors/resources/utils
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-19 00:47:55 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-19 00:47:55 +0000
commit26a029d407be480d791972afb5975cf62c9360a6 (patch)
treef435a8308119effd964b339f76abb83a57c29483 /devtools/server/actors/resources/utils
parentInitial commit. (diff)
downloadfirefox-26a029d407be480d791972afb5975cf62c9360a6.tar.xz
firefox-26a029d407be480d791972afb5975cf62c9360a6.zip
Adding upstream version 124.0.1.upstream/124.0.1
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'devtools/server/actors/resources/utils')
-rw-r--r--devtools/server/actors/resources/utils/content-process-storage.js453
-rw-r--r--devtools/server/actors/resources/utils/moz.build14
-rw-r--r--devtools/server/actors/resources/utils/nsi-console-listener-watcher.js192
-rw-r--r--devtools/server/actors/resources/utils/parent-process-storage.js580
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..7e126ce3f7
--- /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.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:
+ * - {
+ * <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;
+ }
+}