We were only using BrowsingContext (derivated) attributes. Also BrowsingContext is more convenient to use as it doesn't change when moving the tab to another window. Differential Revision: https://phabricator.services.mozilla.com/D307200
603 lines
21 KiB
JavaScript
603 lines
21 KiB
JavaScript
/* 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.
|
|
// (all three modes should now enabled all together)
|
|
loader.lazyGetter(this, "isBfcacheInParentEnabled", () =>
|
|
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 } = watcherActor;
|
|
await this._spawnActor(
|
|
browsingContext.id,
|
|
browsingContext.currentWindowGlobal.innerWindowId
|
|
);
|
|
} else if (watcherActor.sessionContext.type == "webextension") {
|
|
// As the top level actor may change over time for the web extension,
|
|
// we don't have a good browsingContextID/innerWindowId to reference.
|
|
// Passing a `browsingContext` set to -1 will be interpreted by the frontend as a resource
|
|
// bound to the current top level target and will be automatically assigned to it.
|
|
await this._spawnActor(-1, null);
|
|
} else if (watcherActor.sessionContext.type == "all") {
|
|
// Note that there should be only one such target in the browser toolbox.
|
|
// The Parent Process Target Actor.
|
|
for (const targetActor of this.watcherActor.getTargetActorsInParentProcess()) {
|
|
const { browsingContextID, innerWindowId } = targetActor.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.
|
|
// resources-available-array 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 resourceId and resourceKey
|
|
// attributes, so available/updated/destroyed callbacks work properly.
|
|
storage.resourceId = `${this.storageKey}-${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) {
|
|
// We instantiate only one instance of parent process storage actors per toolbox
|
|
// when debugging addons as they don't really have any top level target
|
|
// which cause to switch to a brand new context and require to hook on that new context.
|
|
if (this.watcherActor.sessionContext.type == "webextension") {
|
|
return;
|
|
}
|
|
|
|
// Only process WindowGlobals which are related to the debugged scope.
|
|
if (
|
|
!isWindowGlobalPartOfContext(
|
|
windowGlobal,
|
|
this.watcherActor.sessionContext,
|
|
{ acceptNoWindowGlobal: 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()
|
|
.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()
|
|
.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 the browsing contexts matching the given host.
|
|
*
|
|
* @param {string} host: The host for which we want the browsing contexts
|
|
* @returns Array<BrowsingContext>
|
|
*/
|
|
getBrowsingContextsFromHost(host) {
|
|
return this.watcherActor
|
|
.getAllBrowsingContexts()
|
|
.filter(
|
|
bc => this.getHostName(bc.currentWindowGlobal.documentURI) === host
|
|
);
|
|
}
|
|
|
|
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 }
|
|
)
|
|
) {
|
|
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;
|
|
}
|
|
}
|