diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-19 00:47:55 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-19 00:47:55 +0000 |
commit | 26a029d407be480d791972afb5975cf62c9360a6 (patch) | |
tree | f435a8308119effd964b339f76abb83a57c29483 /devtools/server/actors/resources | |
parent | Initial commit. (diff) | |
download | firefox-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')
40 files changed, 8027 insertions, 0 deletions
diff --git a/devtools/server/actors/resources/console-messages.js b/devtools/server/actors/resources/console-messages.js new file mode 100644 index 0000000000..a643546692 --- /dev/null +++ b/devtools/server/actors/resources/console-messages.js @@ -0,0 +1,302 @@ +/* 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 { + TYPES: { CONSOLE_MESSAGE }, +} = require("devtools/server/actors/resources/index"); +const Targets = require("devtools/server/actors/targets/index"); + +const consoleAPIListenerModule = isWorker + ? "devtools/server/actors/webconsole/worker-listeners" + : "devtools/server/actors/webconsole/listeners/console-api"; +const { ConsoleAPIListener } = require(consoleAPIListenerModule); + +const { isArray } = require("devtools/server/actors/object/utils"); + +const { + makeDebuggeeValue, + createValueGripForTarget, +} = require("devtools/server/actors/object/utils"); + +const { + getActorIdForInternalSourceId, +} = require("devtools/server/actors/utils/dbg-source"); + +const { + isSupportedByConsoleTable, +} = require("devtools/shared/webconsole/messages"); + +/** + * Start watching for all console messages related to a given Target Actor. + * This will notify about existing console messages, but also the one created in future. + * + * @param TargetActor targetActor + * The target actor from which we should observe console messages + * @param Object options + * Dictionary object with following attributes: + * - onAvailable: mandatory function + * This will be called for each resource. + */ +class ConsoleMessageWatcher { + async watch(targetActor, { onAvailable }) { + this.targetActor = targetActor; + this.onAvailable = onAvailable; + + // Bug 1642297: Maybe we could merge ConsoleAPI Listener into this module? + const onConsoleAPICall = message => { + onAvailable([ + { + resourceType: CONSOLE_MESSAGE, + message: prepareConsoleMessageForRemote(targetActor, message), + }, + ]); + }; + + const isTargetActorContentProcess = + targetActor.targetType === Targets.TYPES.PROCESS; + + // Only consider messages from a given window for all FRAME targets (this includes + // WebExt and ParentProcess which inherits from WindowGlobalTargetActor) + // But ParentProcess should be ignored as we want all messages emitted directly from + // that process (window and window-less). + // To do that we pass a null window and ConsoleAPIListener will catch everything. + // And also ignore WebExtension as we will filter out only by addonId, which is + // passed via consoleAPIListenerOptions. WebExtension may have multiple windows/documents + // but all of them will be flagged with the same addon ID. + const messagesShouldMatchWindow = + targetActor.targetType === Targets.TYPES.FRAME && + targetActor.typeName != "parentProcessTarget" && + targetActor.typeName != "webExtensionTarget"; + const window = messagesShouldMatchWindow ? targetActor.window : null; + + // If we should match messages for a given window but for some reason, targetActor.window + // did not return a window, bail out. Otherwise we wouldn't have anything to match against + // and would consume all the messages, which could lead to issue (e.g. infinite loop, + // see Bug 1828026). + if (messagesShouldMatchWindow && !window) { + return; + } + + const listener = new ConsoleAPIListener(window, onConsoleAPICall, { + excludeMessagesBoundToWindow: isTargetActorContentProcess, + matchExactWindow: targetActor.ignoreSubFrames, + ...(targetActor.consoleAPIListenerOptions || {}), + }); + this.listener = listener; + listener.init(); + + // It can happen that the targetActor does not have a window reference (e.g. in worker + // thread, targetActor exposes a workerGlobal property) + const winStartTime = + targetActor.window?.performance?.timing?.navigationStart || 0; + + const cachedMessages = listener.getCachedMessages(!targetActor.isRootActor); + const messages = []; + // Filter out messages that came from a ServiceWorker but happened + // before the page was requested. + for (const message of cachedMessages) { + if ( + message.innerID === "ServiceWorker" && + winStartTime > message.timeStamp + ) { + continue; + } + messages.push({ + resourceType: CONSOLE_MESSAGE, + message: prepareConsoleMessageForRemote(targetActor, message), + }); + } + onAvailable(messages); + } + + /** + * Stop watching for console messages. + */ + destroy() { + if (this.listener) { + this.listener.destroy(); + this.listener = null; + } + this.targetActor = null; + this.onAvailable = null; + } + + /** + * Spawn some custom console messages. + * This is used for example for log points and JS tracing. + * + * @param Array<Object> messages + * A list of fake nsIConsoleMessage, which looks like the one being generated by + * the platform API. + */ + emitMessages(messages) { + if (!this.listener) { + throw new Error("This target actor isn't listening to console messages"); + } + this.onAvailable( + messages.map(message => { + if (!message.timeStamp) { + throw new Error("timeStamp property is mandatory"); + } + + return { + resourceType: CONSOLE_MESSAGE, + message: prepareConsoleMessageForRemote(this.targetActor, message), + }; + }) + ); + } +} + +module.exports = ConsoleMessageWatcher; + +/** + * Return the properties needed to display the appropriate table for a given + * console.table call. + * This function does a little more than creating an ObjectActor for the first + * parameter of the message. When layout out the console table in the output, we want + * to be able to look into sub-properties so the table can have a different layout ( + * for arrays of arrays, objects with objects properties, arrays of objects, …). + * So here we need to retrieve the properties of the first parameter, and also all the + * sub-properties we might need. + * + * @param {TargetActor} targetActor: The Target Actor from which this object originates. + * @param {Object} result: The console.table message. + * @returns {Object} An object containing the properties of the first argument of the + * console.table call. + */ +function getConsoleTableMessageItems(targetActor, result) { + const [tableItemGrip] = result.arguments; + const dataType = tableItemGrip.class; + const needEntries = ["Map", "WeakMap", "Set", "WeakSet"].includes(dataType); + const ignoreNonIndexedProperties = isArray(tableItemGrip); + + const tableItemActor = targetActor.getActorByID(tableItemGrip.actor); + if (!tableItemActor) { + return null; + } + + // Retrieve the properties (or entries for Set/Map) of the console table first arg. + const iterator = needEntries + ? tableItemActor.enumEntries() + : tableItemActor.enumProperties({ + ignoreNonIndexedProperties, + }); + const { ownProperties } = iterator.all(); + + // The iterator returns a descriptor for each property, wherein the value could be + // in one of those sub-property. + const descriptorKeys = ["safeGetterValues", "getterValue", "value"]; + + Object.values(ownProperties).forEach(desc => { + if (typeof desc !== "undefined") { + descriptorKeys.forEach(key => { + if (desc && desc.hasOwnProperty(key)) { + const grip = desc[key]; + + // We need to load sub-properties as well to render the table in a nice way. + const actor = grip && targetActor.getActorByID(grip.actor); + if (actor) { + const res = actor + .enumProperties({ + ignoreNonIndexedProperties: isArray(grip), + }) + .all(); + if (res?.ownProperties) { + desc[key].ownProperties = res.ownProperties; + } + } + } + }); + } + }); + + return ownProperties; +} + +/** + * Prepare a message from the console API to be sent to the remote Web Console + * instance. + * + * @param TargetActor targetActor + * The related target actor + * @param object message + * The original message received from the console storage listener. + * @return object + * The object that can be sent to the remote client. + */ +function prepareConsoleMessageForRemote(targetActor, message) { + const result = { + arguments: message.arguments + ? message.arguments.map(obj => { + const dbgObj = makeDebuggeeValue(targetActor, obj); + return createValueGripForTarget(targetActor, dbgObj); + }) + : [], + columnNumber: message.columnNumber, + filename: message.filename, + level: message.level, + lineNumber: message.lineNumber, + // messages emitted from Console.sys.mjs don't have a microSecondTimeStamp property + timeStamp: message.microSecondTimeStamp + ? message.microSecondTimeStamp / 1000 + : message.timeStamp || ChromeUtils.dateNow(), + sourceId: getActorIdForInternalSourceId(targetActor, message.sourceId), + innerWindowID: message.innerID, + }; + + // This can be a hot path when loading lots of messages, and it only make sense to + // include the following properties in the message when they have a meaningful value. + // Otherwise we simply don't include them so we save cycles in JSActor communication. + if (message.chromeContext) { + result.chromeContext = message.chromeContext; + } + + if (message.counter) { + result.counter = message.counter; + } + if (message.private) { + result.private = message.private; + } + if (message.prefix) { + result.prefix = message.prefix; + } + + if (message.stacktrace) { + result.stacktrace = message.stacktrace.map(frame => { + return { + ...frame, + sourceId: getActorIdForInternalSourceId(targetActor, frame.sourceId), + }; + }); + } + + if (message.styles && message.styles.length) { + result.styles = message.styles.map(string => { + return createValueGripForTarget(targetActor, string); + }); + } + + if (message.timer) { + result.timer = message.timer; + } + + if (message.level === "table") { + if (result && isSupportedByConsoleTable(result.arguments)) { + const tableItems = getConsoleTableMessageItems(targetActor, result); + if (tableItems) { + result.arguments[0].ownProperties = tableItems; + result.arguments[0].preview = null; + + // Only return the 2 first params. + result.arguments = result.arguments.slice(0, 2); + } + } + // NOTE: See transformConsoleAPICallResource for not-supported case. + } + + return result; +} diff --git a/devtools/server/actors/resources/css-changes.js b/devtools/server/actors/resources/css-changes.js new file mode 100644 index 0000000000..e86503be87 --- /dev/null +++ b/devtools/server/actors/resources/css-changes.js @@ -0,0 +1,42 @@ +/* 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 { + TYPES: { CSS_CHANGE }, +} = require("resource://devtools/server/actors/resources/index.js"); +const TrackChangeEmitter = require("resource://devtools/server/actors/utils/track-change-emitter.js"); + +/** + * Start watching for all css changes related to a given Target Actor. + * + * @param TargetActor targetActor + * The target actor from which we should observe css changes. + * @param Object options + * Dictionary object with following attributes: + * - onAvailable: mandatory function + * This will be called for each resource. + */ +class CSSChangeWatcher { + constructor() { + this.onTrackChange = this.onTrackChange.bind(this); + } + + async watch(targetActor, { onAvailable }) { + this.onAvailable = onAvailable; + TrackChangeEmitter.on("track-change", this.onTrackChange); + } + + onTrackChange(change) { + change.resourceType = CSS_CHANGE; + this.onAvailable([change]); + } + + destroy() { + TrackChangeEmitter.off("track-change", this.onTrackChange); + } +} + +module.exports = CSSChangeWatcher; diff --git a/devtools/server/actors/resources/css-messages.js b/devtools/server/actors/resources/css-messages.js new file mode 100644 index 0000000000..0bc6e7ac8a --- /dev/null +++ b/devtools/server/actors/resources/css-messages.js @@ -0,0 +1,202 @@ +/* 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 nsIConsoleListenerWatcher = require("resource://devtools/server/actors/resources/utils/nsi-console-listener-watcher.js"); +const { + DevToolsServer, +} = require("resource://devtools/server/devtools-server.js"); +const { + createStringGrip, +} = require("resource://devtools/server/actors/object/utils.js"); +const { + getActorIdForInternalSourceId, +} = require("resource://devtools/server/actors/utils/dbg-source.js"); +const { + WebConsoleUtils, +} = require("resource://devtools/server/actors/webconsole/utils.js"); + +loader.lazyRequireGetter( + this, + ["getStyleSheetText"], + "resource://devtools/server/actors/utils/stylesheet-utils.js", + true +); + +const { + TYPES: { CSS_MESSAGE }, +} = require("resource://devtools/server/actors/resources/index.js"); + +const { MESSAGE_CATEGORY } = require("resource://devtools/shared/constants.js"); + +class CSSMessageWatcher extends nsIConsoleListenerWatcher { + /** + * Start watching for all CSS messages related to a given Target Actor. + * This will notify about existing messages, but also the one created in 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 }) { + super.watch(targetActor, { onAvailable }); + + // Calling ensureCSSErrorReportingEnabled will make the server parse the stylesheets to + // retrieve the warnings if the docShell wasn't already watching for CSS messages. + await this.#ensureCSSErrorReportingEnabled(targetActor); + } + + /** + * Returns true if the message is considered a CSS message, and as a result, should + * be sent to the client. + * + * @param {nsIConsoleMessage|nsIScriptError} message + */ + shouldHandleMessage(targetActor, message) { + // The listener we use can be called either with a nsIConsoleMessage or as nsIScriptError. + // In this file, we want to ignore anything but nsIScriptError. + if ( + // We only care about CSS Parser nsIScriptError + !(message instanceof Ci.nsIScriptError) || + message.category !== MESSAGE_CATEGORY.CSS_PARSER + ) { + return false; + } + + // Filter specific to CONTENT PROCESS targets + // Process targets listen for everything but messages from private windows. + if (this.isProcessTarget(targetActor)) { + return !message.isFromPrivateWindow; + } + + if (!message.innerWindowID) { + return false; + } + + const ids = targetActor.windows.map(window => + WebConsoleUtils.getInnerWindowId(window) + ); + return ids.includes(message.innerWindowID); + } + + /** + * Prepare an nsIScriptError to be sent to the client. + * + * @param nsIScriptError error + * The page error we need to send to the client. + * @return object + * The object you can send to the remote client. + */ + buildResource(targetActor, error) { + const stack = this.prepareStackForRemote(targetActor, error.stack); + let lineText = error.sourceLine; + if ( + lineText && + lineText.length > DevToolsServer.LONG_STRING_INITIAL_LENGTH + ) { + lineText = lineText.substr(0, DevToolsServer.LONG_STRING_INITIAL_LENGTH); + } + + const notesArray = this.prepareNotesForRemote(targetActor, error.notes); + + // If there is no location information in the error but we have a stack, + // fill in the location with the first frame on the stack. + let { sourceName, sourceId, lineNumber, columnNumber } = error; + if (!sourceName && !sourceId && !lineNumber && !columnNumber && stack) { + sourceName = stack[0].filename; + sourceId = stack[0].sourceId; + lineNumber = stack[0].lineNumber; + columnNumber = stack[0].columnNumber; + } + + const pageError = { + errorMessage: createStringGrip(targetActor, error.errorMessage), + sourceName, + sourceId: getActorIdForInternalSourceId(targetActor, sourceId), + lineText, + lineNumber, + columnNumber, + category: error.category, + innerWindowID: error.innerWindowID, + timeStamp: error.microSecondTimeStamp / 1000, + warning: !!(error.flags & error.warningFlag), + error: !(error.flags & (error.warningFlag | error.infoFlag)), + info: !!(error.flags & error.infoFlag), + private: error.isFromPrivateWindow, + stacktrace: stack, + notes: notesArray, + chromeContext: error.isFromChromeContext, + isForwardedFromContentProcess: error.isForwardedFromContentProcess, + }; + + return { + pageError, + resourceType: CSS_MESSAGE, + cssSelectors: error.cssSelectors, + }; + } + + /** + * Ensure that CSS error reporting is enabled for the provided target actor. + * + * @param {TargetActor} targetActor + * The target actor for which CSS Error Reporting should be enabled. + * @return {Promise} Promise that resolves when cssErrorReportingEnabled was + * set in all the docShells owned by the provided target, and existing + * stylesheets have been re-parsed if needed. + */ + async #ensureCSSErrorReportingEnabled(targetActor) { + const docShells = targetActor.docShells; + if (!docShells) { + // If the target actor does not expose a docShells getter (ie is not an + // instance of WindowGlobalTargetActor), nothing to do here. + return; + } + + const promises = docShells.map(async docShell => { + if (docShell.cssErrorReportingEnabled) { + // CSS Error Reporting already enabled here, nothing to do. + return; + } + + try { + docShell.cssErrorReportingEnabled = true; + } catch (e) { + return; + } + + // After enabling CSS Error Reporting, reparse existing stylesheets to + // detect potential CSS errors. + + // Ensure docShell.document is available. + docShell.QueryInterface(Ci.nsIWebNavigation); + // We don't really want to reparse UA sheets and such, but want to do + // Shadow DOM / XBL. + const sheets = InspectorUtils.getAllStyleSheets( + docShell.document, + /* documentOnly = */ true + ); + for (const sheet of sheets) { + if (InspectorUtils.hasRulesModifiedByCSSOM(sheet)) { + continue; + } + + try { + // Reparse the sheet so that we see the existing errors. + const text = await getStyleSheetText(sheet); + InspectorUtils.parseStyleSheet(sheet, text, /* aUpdate = */ false); + } catch (e) { + console.error("Error while parsing stylesheet"); + } + } + }); + + await Promise.all(promises); + } +} +module.exports = CSSMessageWatcher; diff --git a/devtools/server/actors/resources/css-registered-properties.js b/devtools/server/actors/resources/css-registered-properties.js new file mode 100644 index 0000000000..7ac2871a11 --- /dev/null +++ b/devtools/server/actors/resources/css-registered-properties.js @@ -0,0 +1,270 @@ +/* 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 { + TYPES: { CSS_REGISTERED_PROPERTIES }, +} = require("resource://devtools/server/actors/resources/index.js"); + +/** + * @typedef InspectorCSSPropertyDefinition (see InspectorUtils.webidl) + * @type {object} + * @property {string} name + * @property {string} syntax + * @property {boolean} inherits + * @property {string} initialValue + * @property {boolean} fromJS - true if property was registered via CSS.registerProperty + */ + +class CSSRegisteredPropertiesWatcher { + #abortController; + #onAvailable; + #onUpdated; + #onDestroyed; + #registeredPropertiesCache = new Map(); + #styleSheetsManager; + #targetActor; + + /** + * Start watching for all registered CSS properties (@property/CSS.registerProperty) + * related to a given Target Actor. + * + * @param TargetActor targetActor + * The target actor from which we should observe css changes. + * @param Object options + * Dictionary object with following attributes: + * - onAvailable: mandatory function + * - onUpdated: mandatory function + * - onDestroyed: mandatory function + * This will be called for each resource. + */ + async watch(targetActor, { onAvailable, onUpdated, onDestroyed }) { + this.#targetActor = targetActor; + this.#onAvailable = onAvailable; + this.#onUpdated = onUpdated; + this.#onDestroyed = onDestroyed; + + // Notify about existing properties + const registeredProperties = this.#getRegisteredProperties(); + for (const registeredProperty of registeredProperties) { + this.#registeredPropertiesCache.set( + registeredProperty.name, + registeredProperty + ); + } + + this.#notifyResourcesAvailable(registeredProperties); + + // Listen for new properties being registered via CSS.registerProperty + this.#abortController = new AbortController(); + const { signal } = this.#abortController; + this.#targetActor.chromeEventHandler.addEventListener( + "csscustompropertyregistered", + this.#onCssCustomPropertyRegistered, + { capture: true, signal } + ); + + // Watch for stylesheets being added/modified or destroyed, but don't handle existing + // stylesheets, as we already have the existing properties from this.#getRegisteredProperties. + this.#styleSheetsManager = targetActor.getStyleSheetsManager(); + await this.#styleSheetsManager.watch({ + onAvailable: this.#refreshCacheAndNotify, + onUpdated: this.#refreshCacheAndNotify, + onDestroyed: this.#refreshCacheAndNotify, + ignoreExisting: true, + }); + } + + /** + * Get all the registered properties for the target actor document. + * + * @returns Array<InspectorCSSPropertyDefinition> + */ + #getRegisteredProperties() { + return InspectorUtils.getCSSRegisteredProperties( + this.#targetActor.window.document + ); + } + + /** + * Compute a resourceId from a given property definition + * + * @param {InspectorCSSPropertyDefinition} propertyDefinition + * @returns string + */ + #getRegisteredPropertyResourceId(propertyDefinition) { + return `${this.#targetActor.actorID}:css-registered-property:${ + propertyDefinition.name + }`; + } + + /** + * Called when a stylesheet is added, removed or modified. + * This will retrieve the registered properties at this very moment, and notify + * about new, updated and removed registered properties. + */ + #refreshCacheAndNotify = async () => { + const registeredProperties = this.#getRegisteredProperties(); + const existingPropertiesNames = new Set( + this.#registeredPropertiesCache.keys() + ); + + const added = []; + const updated = []; + const removed = []; + + for (const registeredProperty of registeredProperties) { + // If the property isn't in the cache already, this is a new one. + if (!this.#registeredPropertiesCache.has(registeredProperty.name)) { + added.push(registeredProperty); + this.#registeredPropertiesCache.set( + registeredProperty.name, + registeredProperty + ); + continue; + } + + // Removing existing property from the Set so we can then later get the properties + // that don't exist anymore. + existingPropertiesNames.delete(registeredProperty.name); + + // The property already existed, so we need to check if its definition was modified + const cachedRegisteredProperty = this.#registeredPropertiesCache.get( + registeredProperty.name + ); + + const resourceUpdates = {}; + let wasUpdated = false; + if (registeredProperty.syntax !== cachedRegisteredProperty.syntax) { + resourceUpdates.syntax = registeredProperty.syntax; + wasUpdated = true; + } + if (registeredProperty.inherits !== cachedRegisteredProperty.inherits) { + resourceUpdates.inherits = registeredProperty.inherits; + wasUpdated = true; + } + if ( + registeredProperty.initialValue !== + cachedRegisteredProperty.initialValue + ) { + resourceUpdates.initialValue = registeredProperty.initialValue; + wasUpdated = true; + } + + if (wasUpdated === true) { + updated.push({ + registeredProperty, + resourceUpdates, + }); + this.#registeredPropertiesCache.set( + registeredProperty.name, + registeredProperty + ); + } + } + + // If there are items left in the Set, it means they weren't processed in the for loop + // before, meaning they don't exist anymore. + for (const registeredPropertyName of existingPropertiesNames) { + removed.push(this.#registeredPropertiesCache.get(registeredPropertyName)); + this.#registeredPropertiesCache.delete(registeredPropertyName); + } + + this.#notifyResourcesAvailable(added); + this.#notifyResourcesUpdated(updated); + this.#notifyResourcesDestroyed(removed); + }; + + /** + * csscustompropertyregistered event listener callback (fired when a property + * is registered via CSS.registerProperty). + * + * @param {CSSCustomPropertyRegisteredEvent} event + */ + #onCssCustomPropertyRegistered = event => { + // Ignore event if property was registered from a global different from the target global. + if ( + this.#targetActor.ignoreSubFrames && + event.target.ownerGlobal !== this.#targetActor.window + ) { + return; + } + + const registeredProperty = event.propertyDefinition; + this.#registeredPropertiesCache.set( + registeredProperty.name, + registeredProperty + ); + this.#notifyResourcesAvailable([registeredProperty]); + }; + + /** + * @param {Array<InspectorCSSPropertyDefinition>} registeredProperties + */ + #notifyResourcesAvailable = registeredProperties => { + if (!registeredProperties.length) { + return; + } + + for (const registeredProperty of registeredProperties) { + registeredProperty.resourceId = + this.#getRegisteredPropertyResourceId(registeredProperty); + registeredProperty.resourceType = CSS_REGISTERED_PROPERTIES; + } + this.#onAvailable(registeredProperties); + }; + + /** + * @param {Array<Object>} updates: Array of update object, which have the following properties: + * - {InspectorCSSPropertyDefinition} registeredProperty: The property definition + * of the updated property + * - {Object} resourceUpdates: An object containing all the fields that are + * modified for the registered property. + */ + #notifyResourcesUpdated = updates => { + if (!updates.length) { + return; + } + + for (const update of updates) { + update.resourceId = this.#getRegisteredPropertyResourceId( + update.registeredProperty + ); + update.resourceType = CSS_REGISTERED_PROPERTIES; + // We don't need to send the property definition + delete update.registeredProperty; + } + + this.#onUpdated(updates); + }; + + /** + * @param {Array<InspectorCSSPropertyDefinition>} registeredProperties + */ + #notifyResourcesDestroyed = registeredProperties => { + if (!registeredProperties.length) { + return; + } + + this.#onDestroyed( + registeredProperties.map(registeredProperty => ({ + resourceType: CSS_REGISTERED_PROPERTIES, + resourceId: this.#getRegisteredPropertyResourceId(registeredProperty), + })) + ); + }; + + destroy() { + this.#styleSheetsManager.unwatch({ + onAvailable: this.#refreshCacheAndNotify, + onUpdated: this.#refreshCacheAndNotify, + onDestroyed: this.#refreshCacheAndNotify, + }); + + this.#abortController.abort(); + } +} + +module.exports = CSSRegisteredPropertiesWatcher; diff --git a/devtools/server/actors/resources/document-event.js b/devtools/server/actors/resources/document-event.js new file mode 100644 index 0000000000..bd6667b2b5 --- /dev/null +++ b/devtools/server/actors/resources/document-event.js @@ -0,0 +1,112 @@ +/* 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 { + TYPES: { DOCUMENT_EVENT }, +} = require("resource://devtools/server/actors/resources/index.js"); +const { + DocumentEventsListener, +} = require("resource://devtools/server/actors/webconsole/listeners/document-events.js"); + +class DocumentEventWatcher { + #abortController = new AbortController(); + /** + * Start watching for all document event related to a given Target Actor. + * + * @param TargetActor targetActor + * The target actor from which we should observe document event + * @param Object options + * Dictionary object with following attributes: + * - onAvailable: mandatory function + * This will be called for each resource. + */ + async watch(targetActor, { onAvailable }) { + if (isWorker) { + return; + } + + const onDocumentEvent = ( + name, + { + time, + // This will be `true` when the user selected a document in the frame picker tool, + // in the toolbox toolbar. + isFrameSwitching, + // This is only passed for dom-complete event + hasNativeConsoleAPI, + // This is only passed for will-navigate event + newURI, + } = {} + ) => { + // Ignore will-navigate as that's managed by parent-process-document-event.js. + // Except frame switching, when selecting an iframe document via the dropdown menu, + // this is handled by the target actor in the content process and the parent process + // doesn't know about it. + if (name == "will-navigate" && !isFrameSwitching) { + return; + } + onAvailable([ + { + resourceType: DOCUMENT_EVENT, + name, + time, + isFrameSwitching, + // only send `title` on dom interactive (once the HTML was parsed) so we don't + // make the payload bigger for events where we either don't have a title yet, + // or where we already had a chance to get the title. + title: name === "dom-interactive" ? targetActor.title : undefined, + // only send `url` on dom loading and dom-interactive so we don't make the + // payload bigger for other events + url: + name === "dom-loading" || name === "dom-interactive" + ? targetActor.url + : undefined, + // only send `newURI` on will navigate so we don't make the payload bigger for + // other events + newURI: name === "will-navigate" ? newURI : null, + // only send `hasNativeConsoleAPI` on dom complete so we don't make the payload bigger for + // other events + hasNativeConsoleAPI: + name == "dom-complete" ? hasNativeConsoleAPI : null, + }, + ]); + }; + + this.listener = new DocumentEventsListener(targetActor); + + this.listener.on( + "will-navigate", + data => onDocumentEvent("will-navigate", data), + { signal: this.#abortController.signal } + ); + this.listener.on( + "dom-loading", + data => onDocumentEvent("dom-loading", data), + { signal: this.#abortController.signal } + ); + this.listener.on( + "dom-interactive", + data => onDocumentEvent("dom-interactive", data), + { signal: this.#abortController.signal } + ); + this.listener.on( + "dom-complete", + data => onDocumentEvent("dom-complete", data), + { signal: this.#abortController.signal } + ); + + this.listener.listen(); + } + + destroy() { + this.#abortController.abort(); + if (this.listener) { + this.listener.destroy(); + } + } +} + +module.exports = DocumentEventWatcher; diff --git a/devtools/server/actors/resources/error-messages.js b/devtools/server/actors/resources/error-messages.js new file mode 100644 index 0000000000..7628d7fd6d --- /dev/null +++ b/devtools/server/actors/resources/error-messages.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 nsIConsoleListenerWatcher = require("resource://devtools/server/actors/resources/utils/nsi-console-listener-watcher.js"); +const { + DevToolsServer, +} = require("resource://devtools/server/devtools-server.js"); +const ErrorDocs = require("resource://devtools/server/actors/errordocs.js"); +const { + createStringGrip, + makeDebuggeeValue, + createValueGripForTarget, +} = require("resource://devtools/server/actors/object/utils.js"); +const { + getActorIdForInternalSourceId, +} = require("resource://devtools/server/actors/utils/dbg-source.js"); +const { + WebConsoleUtils, +} = require("resource://devtools/server/actors/webconsole/utils.js"); + +const { + TYPES: { ERROR_MESSAGE }, +} = require("resource://devtools/server/actors/resources/index.js"); +const Targets = require("resource://devtools/server/actors/targets/index.js"); + +const { MESSAGE_CATEGORY } = require("resource://devtools/shared/constants.js"); + +const PLATFORM_SPECIFIC_CATEGORIES = [ + "XPConnect JavaScript", + "component javascript", + "chrome javascript", + "chrome registration", +]; + +class ErrorMessageWatcher extends nsIConsoleListenerWatcher { + shouldHandleMessage(targetActor, message, isCachedMessage = false) { + // The listener we use can be called either with a nsIConsoleMessage or a nsIScriptError. + // In this file, we only want to handle nsIScriptError. + if ( + // We only care about nsIScriptError + !(message instanceof Ci.nsIScriptError) || + !this.isCategoryAllowed(targetActor, message.category) || + // Block any error that was triggered by eager evaluation + message.sourceName === "debugger eager eval code" + ) { + return false; + } + + // Filter specific to CONTENT PROCESS targets + if (this.isProcessTarget(targetActor)) { + // Don't want to display cached messages from private windows. + const isCachedFromPrivateWindow = + isCachedMessage && message.isFromPrivateWindow; + if (isCachedFromPrivateWindow) { + return false; + } + + // `ContentChild` forwards all errors to the parent process (via IPC) all errors up + // the parent process and sets a `isForwardedFromContentProcess` property on them. + // Ignore these forwarded messages as the original ones will be logged either in a + // content process target (if window-less message) or frame target (if related to a window) + if (message.isForwardedFromContentProcess) { + return false; + } + + // Ignore all messages related to a given window for content process targets + // These messages will be handled by Watchers instantiated for the related frame targets + if ( + targetActor.targetType == Targets.TYPES.PROCESS && + message.innerWindowID + ) { + return false; + } + + return true; + } + + if (!message.innerWindowID) { + return false; + } + + const ids = targetActor.windows.map(window => + WebConsoleUtils.getInnerWindowId(window) + ); + return ids.includes(message.innerWindowID); + } + + /** + * Check if the given message category is allowed to be tracked or not. + * We ignore chrome-originating errors as we only care about content. + * + * @param string category + * The message category you want to check. + * @return boolean + * True if the category is allowed to be logged, false otherwise. + */ + isCategoryAllowed(targetActor, category) { + // CSS Parser errors will be handled by the CSSMessageWatcher. + if (!category || category === MESSAGE_CATEGORY.CSS_PARSER) { + return false; + } + + // We listen for everything on Process targets + if (this.isProcessTarget(targetActor)) { + return true; + } + + // Don't restrict any categories in the Browser Toolbox/Browser Console + if (targetActor.sessionContext.type == "all") { + return true; + } + + // For non-process targets in other toolboxes, we filter-out platform-specific errors. + return !PLATFORM_SPECIFIC_CATEGORIES.includes(category); + } + + /** + * Prepare an nsIScriptError to be sent to the client. + * + * @param nsIScriptError error + * The page error we need to send to the client. + * @return object + * The object you can send to the remote client. + */ + buildResource(targetActor, error) { + const stack = this.prepareStackForRemote(targetActor, error.stack); + let lineText = error.sourceLine; + if ( + lineText && + lineText.length > DevToolsServer.LONG_STRING_INITIAL_LENGTH + ) { + lineText = lineText.substr(0, DevToolsServer.LONG_STRING_INITIAL_LENGTH); + } + + const notesArray = this.prepareNotesForRemote(targetActor, error.notes); + + // If there is no location information in the error but we have a stack, + // fill in the location with the first frame on the stack. + let { sourceName, sourceId, lineNumber, columnNumber } = error; + if (!sourceName && !sourceId && !lineNumber && !columnNumber && stack) { + sourceName = stack[0].filename; + sourceId = stack[0].sourceId; + lineNumber = stack[0].lineNumber; + columnNumber = stack[0].columnNumber; + } + + const pageError = { + errorMessage: createStringGrip(targetActor, error.errorMessage), + errorMessageName: error.errorMessageName, + exceptionDocURL: ErrorDocs.GetURL(error), + sourceName, + sourceId: getActorIdForInternalSourceId(targetActor, sourceId), + lineText, + lineNumber, + columnNumber, + category: error.category, + innerWindowID: error.innerWindowID, + timeStamp: error.microSecondTimeStamp / 1000, + warning: !!(error.flags & error.warningFlag), + error: !(error.flags & (error.warningFlag | error.infoFlag)), + info: !!(error.flags & error.infoFlag), + private: error.isFromPrivateWindow, + stacktrace: stack, + notes: notesArray, + chromeContext: error.isFromChromeContext, + isPromiseRejection: error.isPromiseRejection, + isForwardedFromContentProcess: error.isForwardedFromContentProcess, + }; + + // If the pageError does have an exception object, we want to return the grip for it, + // but only if we do manage to get the grip, as we're checking the property on the + // client to render things differently. + if (error.hasException) { + try { + const obj = makeDebuggeeValue(targetActor, error.exception); + if (obj?.class !== "DeadObject") { + pageError.exception = createValueGripForTarget(targetActor, obj); + pageError.hasException = true; + } + } catch (e) {} + } + + return { + pageError, + resourceType: ERROR_MESSAGE, + }; + } +} +module.exports = ErrorMessageWatcher; diff --git a/devtools/server/actors/resources/extensions-backgroundscript-status.js b/devtools/server/actors/resources/extensions-backgroundscript-status.js new file mode 100644 index 0000000000..08f51a23f5 --- /dev/null +++ b/devtools/server/actors/resources/extensions-backgroundscript-status.js @@ -0,0 +1,68 @@ +/* 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 { + TYPES: { EXTENSIONS_BGSCRIPT_STATUS }, +} = require("resource://devtools/server/actors/resources/index.js"); + +class ExtensionsBackgroundScriptStatusWatcher { + /** + * Start watching for the status updates related to a background + * scripts extension context (either an event page or a background + * service worker). + * + * This is used in about:debugging to update the background script + * row updated visible in Extensions details cards (only for extensions + * with a non persistent background script defined in the manifest) + * when the background contex is terminated on idle or started back + * to handle a persistent WebExtensions API event. + * + * @param RootActor rootActor + * The root actor in the parent process from which we should + * observe root resources. + * @param Object options + * Dictionary object with following attributes: + * - onAvailable: mandatory function + * This will be called for each resource. + */ + async watch(rootActor, { onAvailable }) { + this.rootActor = rootActor; + this.onAvailable = onAvailable; + + Services.obs.addObserver(this, "extension:background-script-status"); + } + + observe(subject, topic, data) { + switch (topic) { + case "extension:background-script-status": { + const { addonId, isRunning } = subject.wrappedJSObject; + this.onBackgroundScriptStatus(addonId, isRunning); + break; + } + } + } + + onBackgroundScriptStatus(addonId, isRunning) { + this.onAvailable([ + { + resourceType: EXTENSIONS_BGSCRIPT_STATUS, + payload: { + addonId, + isRunning, + }, + }, + ]); + } + + destroy() { + if (this.onAvailable) { + this.onAvailable = null; + Services.obs.removeObserver(this, "extension:background-script-status"); + } + } +} + +module.exports = ExtensionsBackgroundScriptStatusWatcher; diff --git a/devtools/server/actors/resources/index.js b/devtools/server/actors/resources/index.js new file mode 100644 index 0000000000..e2857502ad --- /dev/null +++ b/devtools/server/actors/resources/index.js @@ -0,0 +1,471 @@ +/* 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 Targets = require("resource://devtools/server/actors/targets/index.js"); + +const TYPES = { + CONSOLE_MESSAGE: "console-message", + CSS_CHANGE: "css-change", + CSS_MESSAGE: "css-message", + CSS_REGISTERED_PROPERTIES: "css-registered-properties", + DOCUMENT_EVENT: "document-event", + ERROR_MESSAGE: "error-message", + LAST_PRIVATE_CONTEXT_EXIT: "last-private-context-exit", + NETWORK_EVENT: "network-event", + NETWORK_EVENT_STACKTRACE: "network-event-stacktrace", + PLATFORM_MESSAGE: "platform-message", + REFLOW: "reflow", + SERVER_SENT_EVENT: "server-sent-event", + SOURCE: "source", + STYLESHEET: "stylesheet", + THREAD_STATE: "thread-state", + JSTRACER_TRACE: "jstracer-trace", + JSTRACER_STATE: "jstracer-state", + WEBSOCKET: "websocket", + + // storage types + CACHE_STORAGE: "Cache", + COOKIE: "cookies", + EXTENSION_STORAGE: "extension-storage", + INDEXED_DB: "indexed-db", + LOCAL_STORAGE: "local-storage", + SESSION_STORAGE: "session-storage", + + // root types + EXTENSIONS_BGSCRIPT_STATUS: "extensions-backgroundscript-status", +}; +exports.TYPES = TYPES; + +// Helper dictionaries, which will contain data specific to each resource type. +// - `path` is the absolute path to the module defining the Resource Watcher class. +// +// Also see the attributes added by `augmentResourceDictionary` for each type: +// - `watchers` is a weak map which will store Resource Watchers +// (i.e. devtools/server/actors/resources/ class instances) +// keyed by target actor -or- watcher actor. +// - `WatcherClass` is a shortcut to the Resource Watcher module. +// Each module exports a Resource Watcher class. +// +// These are several dictionaries, which depend how the resource watcher classes are instantiated. + +// Frame target resources are spawned via a BrowsingContext Target Actor. +// Their watcher class receives a target actor as first argument. +// They are instantiated for each observed BrowsingContext, from the content process where it runs. +// They are meant to observe all resources related to a given Browsing Context. +const FrameTargetResources = augmentResourceDictionary({ + [TYPES.CACHE_STORAGE]: { + path: "devtools/server/actors/resources/storage-cache", + }, + [TYPES.CONSOLE_MESSAGE]: { + path: "devtools/server/actors/resources/console-messages", + }, + [TYPES.CSS_CHANGE]: { + path: "devtools/server/actors/resources/css-changes", + }, + [TYPES.CSS_MESSAGE]: { + path: "devtools/server/actors/resources/css-messages", + }, + [TYPES.CSS_REGISTERED_PROPERTIES]: { + path: "devtools/server/actors/resources/css-registered-properties", + }, + [TYPES.DOCUMENT_EVENT]: { + path: "devtools/server/actors/resources/document-event", + }, + [TYPES.ERROR_MESSAGE]: { + path: "devtools/server/actors/resources/error-messages", + }, + [TYPES.JSTRACER_STATE]: { + path: "devtools/server/actors/resources/jstracer-state", + }, + [TYPES.JSTRACER_TRACE]: { + path: "devtools/server/actors/resources/jstracer-trace", + }, + [TYPES.LOCAL_STORAGE]: { + path: "devtools/server/actors/resources/storage-local-storage", + }, + [TYPES.PLATFORM_MESSAGE]: { + path: "devtools/server/actors/resources/platform-messages", + }, + [TYPES.SESSION_STORAGE]: { + path: "devtools/server/actors/resources/storage-session-storage", + }, + [TYPES.STYLESHEET]: { + path: "devtools/server/actors/resources/stylesheets", + }, + [TYPES.NETWORK_EVENT]: { + path: "devtools/server/actors/resources/network-events-content", + }, + [TYPES.NETWORK_EVENT_STACKTRACE]: { + path: "devtools/server/actors/resources/network-events-stacktraces", + }, + [TYPES.REFLOW]: { + path: "devtools/server/actors/resources/reflow", + }, + [TYPES.SOURCE]: { + path: "devtools/server/actors/resources/sources", + }, + [TYPES.THREAD_STATE]: { + path: "devtools/server/actors/resources/thread-states", + }, + [TYPES.SERVER_SENT_EVENT]: { + path: "devtools/server/actors/resources/server-sent-events", + }, + [TYPES.WEBSOCKET]: { + path: "devtools/server/actors/resources/websockets", + }, +}); + +// Process target resources are spawned via a Process Target Actor. +// Their watcher class receives a process target actor as first argument. +// They are instantiated for each observed Process (parent and all content processes). +// They are meant to observe all resources related to a given process. +const ProcessTargetResources = augmentResourceDictionary({ + [TYPES.CONSOLE_MESSAGE]: { + path: "devtools/server/actors/resources/console-messages", + }, + [TYPES.JSTRACER_TRACE]: { + path: "devtools/server/actors/resources/jstracer-trace", + }, + [TYPES.JSTRACER_STATE]: { + path: "devtools/server/actors/resources/jstracer-state", + }, + [TYPES.ERROR_MESSAGE]: { + path: "devtools/server/actors/resources/error-messages", + }, + [TYPES.PLATFORM_MESSAGE]: { + path: "devtools/server/actors/resources/platform-messages", + }, + [TYPES.SOURCE]: { + path: "devtools/server/actors/resources/sources", + }, + [TYPES.THREAD_STATE]: { + path: "devtools/server/actors/resources/thread-states", + }, +}); + +// Worker target resources are spawned via a Worker Target Actor. +// Their watcher class receives a worker target actor as first argument. +// They are instantiated for each observed worker, from the worker thread. +// They are meant to observe all resources related to a given worker. +// +// We'll only support a few resource types in Workers (console-message, source, +// thread state, …) as error and platform messages are not supported since we need access +// to Ci, which isn't available in worker context. +// Errors are emitted from the content process main thread so the user would still get them. +const WorkerTargetResources = augmentResourceDictionary({ + [TYPES.CONSOLE_MESSAGE]: { + path: "devtools/server/actors/resources/console-messages", + }, + [TYPES.JSTRACER_TRACE]: { + path: "devtools/server/actors/resources/jstracer-trace", + }, + [TYPES.JSTRACER_STATE]: { + path: "devtools/server/actors/resources/jstracer-state", + }, + [TYPES.SOURCE]: { + path: "devtools/server/actors/resources/sources", + }, + [TYPES.THREAD_STATE]: { + path: "devtools/server/actors/resources/thread-states", + }, +}); + +// Parent process resources are spawned via the Watcher Actor. +// Their watcher class receives the watcher actor as first argument. +// They are instantiated once per watcher from the parent process. +// They are meant to observe all resources related to a given context designated by the Watcher (and its sessionContext) +// they should be observed from the parent process. +const ParentProcessResources = augmentResourceDictionary({ + [TYPES.NETWORK_EVENT]: { + path: "devtools/server/actors/resources/network-events", + }, + [TYPES.COOKIE]: { + path: "devtools/server/actors/resources/storage-cookie", + }, + [TYPES.EXTENSION_STORAGE]: { + path: "devtools/server/actors/resources/storage-extension", + }, + [TYPES.INDEXED_DB]: { + path: "devtools/server/actors/resources/storage-indexed-db", + }, + [TYPES.DOCUMENT_EVENT]: { + path: "devtools/server/actors/resources/parent-process-document-event", + }, + [TYPES.LAST_PRIVATE_CONTEXT_EXIT]: { + path: "devtools/server/actors/resources/last-private-context-exit", + }, +}); + +// Root resources are spawned via the Root Actor. +// Their watcher class receives the root actor as first argument. +// They are instantiated only once from the parent process. +// They are meant to observe anything easily observable from the parent process +// that isn't related to any particular context/target. +// This is especially useful when you need to observe something without having to instantiate a Watcher actor. +const RootResources = augmentResourceDictionary({ + [TYPES.EXTENSIONS_BGSCRIPT_STATUS]: { + path: "devtools/server/actors/resources/extensions-backgroundscript-status", + }, +}); +exports.RootResources = RootResources; + +function augmentResourceDictionary(dict) { + for (const resource of Object.values(dict)) { + resource.watchers = new WeakMap(); + + loader.lazyRequireGetter(resource, "WatcherClass", resource.path); + } + return dict; +} + +/** + * For a given actor, return the related dictionary defined just before, + * that contains info about how to listen for a given resource type, from a given actor. + * + * @param Actor rootOrWatcherOrTargetActor + * Either a RootActor or WatcherActor or a TargetActor which can be listening to a resource. + */ +function getResourceTypeDictionary(rootOrWatcherOrTargetActor) { + const { typeName } = rootOrWatcherOrTargetActor; + if (typeName == "root") { + return RootResources; + } + if (typeName == "watcher") { + return ParentProcessResources; + } + const { targetType } = rootOrWatcherOrTargetActor; + return getResourceTypeDictionaryForTargetType(targetType); +} + +/** + * For a targetType, return the related dictionary. + * + * @param String targetType + * A targetType string (See Targets.TYPES) + */ +function getResourceTypeDictionaryForTargetType(targetType) { + switch (targetType) { + case Targets.TYPES.FRAME: + return FrameTargetResources; + case Targets.TYPES.PROCESS: + return ProcessTargetResources; + case Targets.TYPES.WORKER: + return WorkerTargetResources; + case Targets.TYPES.SERVICE_WORKER: + return WorkerTargetResources; + default: + throw new Error(`Unsupported target actor typeName '${targetType}'`); + } +} + +/** + * For a given actor, return the object stored in one of the previous dictionary + * that contains info about how to listen for a given resource type, from a given actor. + * + * @param Actor rootOrWatcherOrTargetActor + * Either a RootActor or WatcherActor or a TargetActor which can be listening to a resource. + * @param String resourceType + * The resource type to be observed. + */ +function getResourceTypeEntry(rootOrWatcherOrTargetActor, resourceType) { + const dict = getResourceTypeDictionary(rootOrWatcherOrTargetActor); + if (!(resourceType in dict)) { + throw new Error( + `Unsupported resource type '${resourceType}' for ${rootOrWatcherOrTargetActor.typeName}` + ); + } + return dict[resourceType]; +} + +/** + * Start watching for a new list of resource types. + * This will also emit all already existing resources before resolving. + * + * @param Actor rootOrWatcherOrTargetActor + * Either a RootActor or WatcherActor or a TargetActor which can be listening to a resource: + * * RootActor will be used for resources observed from the parent process and aren't related to any particular + * context/descriptor. They can be observed right away when connecting to the RDP server + * without instantiating any actor other than the root actor. + * * WatcherActor will be used for resources listened from the parent process. + * * TargetActor will be used for resources listened from the content process. + * This actor: + * - defines what context to observe (browsing context, process, worker, ...) + * Via browsingContextID, windows, docShells attributes for the target actor. + * Via the `sessionContext` object for the watcher actor. + * (only for Watcher and Target actors. Root actor is context-less.) + * - exposes `notifyResources` method to be notified about all the resources updates + * This method will receive two arguments: + * - {String} updateType, which can be "available", "updated", or "destroyed" + * - {Array<Object>} resources, which will be the list of resource's forms + * or special update object for "updated" scenario. + * @param Array<String> resourceTypes + * List of all type of resource to listen to. + */ +async function watchResources(rootOrWatcherOrTargetActor, resourceTypes) { + // If we are given a target actor, filter out the resource types supported by the target. + // When using sharedData to pass types between processes, we are passing them for all target types. + const { targetType } = rootOrWatcherOrTargetActor; + // Only target actors usecase will have a target type. + // For Root and Watcher we process the `resourceTypes` list unfiltered. + if (targetType) { + resourceTypes = getResourceTypesForTargetType(resourceTypes, targetType); + } + const promises = []; + for (const resourceType of resourceTypes) { + const { watchers, WatcherClass } = getResourceTypeEntry( + rootOrWatcherOrTargetActor, + resourceType + ); + + // Ignore resources we're already listening to + if (watchers.has(rootOrWatcherOrTargetActor)) { + continue; + } + + // Don't watch for console messages from the worker target if worker messages are still + // being cloned to the main process, otherwise we'll get duplicated messages in the + // console output (See Bug 1778852). + if ( + resourceType == TYPES.CONSOLE_MESSAGE && + rootOrWatcherOrTargetActor.workerConsoleApiMessagesDispatchedToMainThread + ) { + continue; + } + + const watcher = new WatcherClass(); + promises.push( + watcher.watch(rootOrWatcherOrTargetActor, { + onAvailable: rootOrWatcherOrTargetActor.notifyResources.bind( + rootOrWatcherOrTargetActor, + "available" + ), + onUpdated: rootOrWatcherOrTargetActor.notifyResources.bind( + rootOrWatcherOrTargetActor, + "updated" + ), + onDestroyed: rootOrWatcherOrTargetActor.notifyResources.bind( + rootOrWatcherOrTargetActor, + "destroyed" + ), + }) + ); + watchers.set(rootOrWatcherOrTargetActor, watcher); + } + await Promise.all(promises); +} +exports.watchResources = watchResources; + +function getParentProcessResourceTypes(resourceTypes) { + return resourceTypes.filter(resourceType => { + return resourceType in ParentProcessResources; + }); +} +exports.getParentProcessResourceTypes = getParentProcessResourceTypes; + +function getResourceTypesForTargetType(resourceTypes, targetType) { + const resourceDictionnary = + getResourceTypeDictionaryForTargetType(targetType); + return resourceTypes.filter(resourceType => { + return resourceType in resourceDictionnary; + }); +} +exports.getResourceTypesForTargetType = getResourceTypesForTargetType; + +function hasResourceTypesForTargets(resourceTypes) { + return resourceTypes.some(resourceType => { + return resourceType in FrameTargetResources; + }); +} +exports.hasResourceTypesForTargets = hasResourceTypesForTargets; + +/** + * Stop watching for a list of resource types. + * + * @param Actor rootOrWatcherOrTargetActor + * The related actor, already passed to watchResources. + * @param Array<String> resourceTypes + * List of all type of resource to stop listening to. + */ +function unwatchResources(rootOrWatcherOrTargetActor, resourceTypes) { + for (const resourceType of resourceTypes) { + // Pull all info about this resource type from `Resources` global object + const { watchers } = getResourceTypeEntry( + rootOrWatcherOrTargetActor, + resourceType + ); + + const watcher = watchers.get(rootOrWatcherOrTargetActor); + if (watcher) { + watcher.destroy(); + watchers.delete(rootOrWatcherOrTargetActor); + } + } +} +exports.unwatchResources = unwatchResources; + +/** + * Clear resources for a list of resource types. + * + * @param Actor rootOrWatcherOrTargetActor + * The related actor, already passed to watchResources. + * @param Array<String> resourceTypes + * List of all type of resource to clear. + */ +function clearResources(rootOrWatcherOrTargetActor, resourceTypes) { + for (const resourceType of resourceTypes) { + const { watchers } = getResourceTypeEntry( + rootOrWatcherOrTargetActor, + resourceType + ); + + const watcher = watchers.get(rootOrWatcherOrTargetActor); + if (watcher && typeof watcher.clear == "function") { + watcher.clear(); + } + } +} + +exports.clearResources = clearResources; + +/** + * Stop watching for all watched resources on a given actor. + * + * @param Actor rootOrWatcherOrTargetActor + * The related actor, already passed to watchResources. + */ +function unwatchAllResources(rootOrWatcherOrTargetActor) { + for (const { watchers } of Object.values( + getResourceTypeDictionary(rootOrWatcherOrTargetActor) + )) { + const watcher = watchers.get(rootOrWatcherOrTargetActor); + if (watcher) { + watcher.destroy(); + watchers.delete(rootOrWatcherOrTargetActor); + } + } +} +exports.unwatchAllResources = unwatchAllResources; + +/** + * If we are watching for the given resource type, + * return the current ResourceWatcher instance used by this target actor + * in order to observe this resource type. + * + * @param Actor watcherOrTargetActor + * Either a WatcherActor or a TargetActor which can be listening to a resource. + * WatcherActor will be used for resources listened from the parent process, + * and TargetActor will be used for resources listened from the content process. + * @param String resourceType + * The resource type to query + * @return ResourceWatcher + * The resource watcher instance, defined in devtools/server/actors/resources/ + */ +function getResourceWatcher(watcherOrTargetActor, resourceType) { + const { watchers } = getResourceTypeEntry(watcherOrTargetActor, resourceType); + + return watchers.get(watcherOrTargetActor); +} +exports.getResourceWatcher = getResourceWatcher; diff --git a/devtools/server/actors/resources/jstracer-state.js b/devtools/server/actors/resources/jstracer-state.js new file mode 100644 index 0000000000..74491a6ced --- /dev/null +++ b/devtools/server/actors/resources/jstracer-state.js @@ -0,0 +1,96 @@ +/* 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 { + TYPES: { JSTRACER_STATE }, +} = require("resource://devtools/server/actors/resources/index.js"); + +// Bug 1827382, as this module can be used from the worker thread, +// the following JSM may be loaded by the worker loader until +// we have proper support for ESM from workers. +const { + addTracingListener, + removeTracingListener, +} = require("resource://devtools/server/tracer/tracer.jsm"); + +const { LOG_METHODS } = require("resource://devtools/server/actors/tracer.js"); +const Targets = require("resource://devtools/server/actors/targets/index.js"); + +class TracingStateWatcher { + /** + * Start watching for tracing state changes for a given target actor. + * + * @param TargetActor targetActor + * The target actor from which we should observe + * @param Object options + * Dictionary object with following attributes: + * - onAvailable: mandatory function + * This will be called for each resource. + */ + async watch(targetActor, { onAvailable }) { + // Bug 1874204: tracer doesn't support tracing content process from the browser toolbox just yet + if (targetActor.targetType == Targets.TYPES.PROCESS) { + return; + } + + this.targetActor = targetActor; + this.onAvailable = onAvailable; + + this.tracingListener = { + onTracingToggled: this.onTracingToggled.bind(this), + }; + addTracingListener(this.tracingListener); + } + + /** + * Stop watching for tracing state + */ + destroy() { + if (!this.tracingListener) { + return; + } + removeTracingListener(this.tracingListener); + } + + /** + * Be notified by the underlying JavaScriptTracer class + * in case it stops by itself, instead of being stopped when the Actor's stopTracing + * method is called by the user. + * + * @param {Boolean} enabled + * True if the tracer starts tracing, false it it stops. + * @param {String} reason + * Optional string to justify why the tracer stopped. + */ + onTracingToggled(enabled, reason) { + const tracerActor = this.targetActor.getTargetScopedActor("tracer"); + const logMethod = tracerActor?.getLogMethod(); + + // JavascriptTracer only supports recording once in the same process/thread. + // If we open another DevTools, on the same process, we would receive notification + // about a JavascriptTracer controlled by another toolbox's tracer actor. + // Ignore them as our current tracer actor didn't start tracing. + if (!logMethod) { + return; + } + + this.onAvailable([ + { + resourceType: JSTRACER_STATE, + enabled, + logMethod, + profile: + logMethod == LOG_METHODS.PROFILER && !enabled + ? tracerActor.getProfile() + : undefined, + timeStamp: ChromeUtils.dateNow(), + reason, + }, + ]); + } +} + +module.exports = TracingStateWatcher; diff --git a/devtools/server/actors/resources/jstracer-trace.js b/devtools/server/actors/resources/jstracer-trace.js new file mode 100644 index 0000000000..0a614fd6a9 --- /dev/null +++ b/devtools/server/actors/resources/jstracer-trace.js @@ -0,0 +1,43 @@ +/* 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"; + +class JSTraceWatcher { + /** + * Start watching for traces for a given target actor. + * + * @param TargetActor targetActor + * The target actor from which we should observe + * @param Object options + * Dictionary object with following attributes: + * - onAvailable: mandatory function + * This will be called for each resource. + */ + async watch(targetActor, { onAvailable }) { + this.#onAvailable = onAvailable; + } + + #onAvailable; + + /** + * Stop watching for traces + */ + destroy() { + // The traces are being emitted by the TracerActor via `emitTraces` method, + // we start and stop recording and emitting tracer from this actor. + // Watching for JSTRACER_TRACE only allows receiving these trace events. + } + + /** + * Emit a JSTRACER_TRACE resource. + * + * This is being called by the Tracer Actor. + */ + emitTraces(traces) { + this.#onAvailable(traces); + } +} + +module.exports = JSTraceWatcher; diff --git a/devtools/server/actors/resources/last-private-context-exit.js b/devtools/server/actors/resources/last-private-context-exit.js new file mode 100644 index 0000000000..ec9ee6b91d --- /dev/null +++ b/devtools/server/actors/resources/last-private-context-exit.js @@ -0,0 +1,46 @@ +/* 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 { + TYPES: { LAST_PRIVATE_CONTEXT_EXIT }, +} = require("resource://devtools/server/actors/resources/index.js"); + +class LastPrivateContextExitWatcher { + #onAvailable; + + /** + * Start watching for all times where we close a private browsing top level window. + * Meaning we should clear the console for all logs generated from these private browsing contexts. + * + * @param WatcherActor watcherActor + * The watcher actor in the parent process from which we should + * observe these events. + * @param Object options + * Dictionary object with following attributes: + * - onAvailable: mandatory function + * This will be called for each resource. + */ + async watch(watcherActor, { onAvailable }) { + this.#onAvailable = onAvailable; + Services.obs.addObserver(this, "last-pb-context-exited"); + } + + observe(subject, topic) { + if (topic === "last-pb-context-exited") { + this.#onAvailable([ + { + resourceType: LAST_PRIVATE_CONTEXT_EXIT, + }, + ]); + } + } + + destroy() { + Services.obs.removeObserver(this, "last-pb-context-exited"); + } +} + +module.exports = LastPrivateContextExitWatcher; diff --git a/devtools/server/actors/resources/moz.build b/devtools/server/actors/resources/moz.build new file mode 100644 index 0000000000..b3d2656b94 --- /dev/null +++ b/devtools/server/actors/resources/moz.build @@ -0,0 +1,44 @@ +# -*- 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/. + +DIRS += [ + "storage", + "utils", +] + +DevToolsModules( + "console-messages.js", + "css-changes.js", + "css-messages.js", + "css-registered-properties.js", + "document-event.js", + "error-messages.js", + "extensions-backgroundscript-status.js", + "index.js", + "jstracer-state.js", + "jstracer-trace.js", + "last-private-context-exit.js", + "network-events-content.js", + "network-events-stacktraces.js", + "network-events.js", + "parent-process-document-event.js", + "platform-messages.js", + "reflow.js", + "server-sent-events.js", + "sources.js", + "storage-cache.js", + "storage-cookie.js", + "storage-extension.js", + "storage-indexed-db.js", + "storage-local-storage.js", + "storage-session-storage.js", + "stylesheets.js", + "thread-states.js", + "websockets.js", +) + +with Files("*-messages.js"): + BUG_COMPONENT = ("DevTools", "Console") diff --git a/devtools/server/actors/resources/network-events-content.js b/devtools/server/actors/resources/network-events-content.js new file mode 100644 index 0000000000..5135583fab --- /dev/null +++ b/devtools/server/actors/resources/network-events-content.js @@ -0,0 +1,267 @@ +/* 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"; + +loader.lazyRequireGetter( + this, + "NetworkEventActor", + "resource://devtools/server/actors/network-monitor/network-event-actor.js", + true +); + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + NetworkUtils: + "resource://devtools/shared/network-observer/NetworkUtils.sys.mjs", +}); + +/** + * Handles network events from the content process + * This currently only handles events for requests (js/css) blocked by CSP. + */ +class NetworkEventContentWatcher { + /** + * Start watching for all network events related to a given Target Actor. + * + * @param TargetActor targetActor + * The target actor in the content process from which we should + * observe network events. + * @param Object options + * Dictionary object with following attributes: + * - onAvailable: mandatory function + * This will be called for each resource. + * - onUpdated: optional function + * This would be called multiple times for each resource. + */ + async watch(targetActor, { onAvailable, onUpdated }) { + // Map from channelId to network event objects. + this.networkEvents = new Map(); + + this.targetActor = targetActor; + this.onAvailable = onAvailable; + this.onUpdated = onUpdated; + + this.httpFailedOpeningRequest = this.httpFailedOpeningRequest.bind(this); + this.httpOnImageCacheResponse = this.httpOnImageCacheResponse.bind(this); + + Services.obs.addObserver( + this.httpFailedOpeningRequest, + "http-on-failed-opening-request" + ); + + Services.obs.addObserver( + this.httpOnImageCacheResponse, + "http-on-image-cache-response" + ); + } + /** + * Allows clearing of network events + */ + clear() { + this.networkEvents.clear(); + } + + httpFailedOpeningRequest(subject, topic) { + const channel = subject.QueryInterface(Ci.nsIHttpChannel); + + // Ignore preload requests to avoid duplicity request entries in + // the Network panel. If a preload fails (for whatever reason) + // then the platform kicks off another 'real' request. + if (lazy.NetworkUtils.isPreloadRequest(channel)) { + return; + } + + if ( + !lazy.NetworkUtils.matchRequest(channel, { + targetActor: this.targetActor, + }) + ) { + return; + } + + this.onNetworkEventAvailable(channel, { + networkEventOptions: { + blockedReason: channel.loadInfo.requestBlockingReason, + }, + }); + } + + httpOnImageCacheResponse(subject, topic) { + if ( + topic != "http-on-image-cache-response" || + !(subject instanceof Ci.nsIHttpChannel) + ) { + return; + } + + const channel = subject.QueryInterface(Ci.nsIHttpChannel); + + if ( + !lazy.NetworkUtils.matchRequest(channel, { + targetActor: this.targetActor, + }) + ) { + return; + } + + // Only one network request should be created per URI for images from the cache + const hasURI = Array.from(this.networkEvents.values()).some( + networkEvent => networkEvent.uri === channel.URI.spec + ); + + if (hasURI) { + return; + } + + this.onNetworkEventAvailable(channel, { + networkEventOptions: { fromCache: true }, + }); + } + + onNetworkEventAvailable(channel, { networkEventOptions }) { + const actor = new NetworkEventActor( + this.targetActor.conn, + this.targetActor.sessionContext, + { + onNetworkEventUpdate: this.onNetworkEventUpdate.bind(this), + onNetworkEventDestroy: this.onNetworkEventDestroyed.bind(this), + }, + networkEventOptions, + channel + ); + this.targetActor.manage(actor); + + const resource = actor.asResource(); + + const networkEvent = { + browsingContextID: resource.browsingContextID, + innerWindowId: resource.innerWindowId, + resourceId: resource.resourceId, + resourceType: resource.resourceType, + receivedUpdates: [], + resourceUpdates: { + // Requests already come with request cookies and headers, so those + // should always be considered as available. But the client still + // heavily relies on those `Available` flags to fetch additional data, + // so it is better to keep them for consistency. + requestCookiesAvailable: true, + requestHeadersAvailable: true, + }, + uri: channel.URI.spec, + }; + this.networkEvents.set(resource.resourceId, networkEvent); + + this.onAvailable([resource]); + const isBlocked = !!resource.blockedReason; + if (isBlocked) { + this._emitUpdate(networkEvent); + } else { + actor.addResponseStart({ channel, fromCache: true }); + actor.addEventTimings( + 0 /* totalTime */, + {} /* timings */, + {} /* offsets */ + ); + actor.addServerTimings({}); + actor.addResponseContent( + { + mimeType: channel.contentType, + size: channel.contentLength, + text: "", + transferredSize: 0, + }, + {} + ); + } + } + + onNetworkEventUpdate(updateResource) { + const networkEvent = this.networkEvents.get(updateResource.resourceId); + + if (!networkEvent) { + return; + } + + const { resourceUpdates, receivedUpdates } = networkEvent; + + switch (updateResource.updateType) { + case "responseStart": + // For cached image requests channel.responseStatus is set to 200 as + // expected. However responseStatusText is empty. In this case fallback + // to the expected statusText "OK". + let statusText = updateResource.statusText; + if (!statusText && updateResource.status === "200") { + statusText = "OK"; + } + resourceUpdates.httpVersion = updateResource.httpVersion; + resourceUpdates.status = updateResource.status; + resourceUpdates.statusText = statusText; + resourceUpdates.remoteAddress = updateResource.remoteAddress; + resourceUpdates.remotePort = updateResource.remotePort; + resourceUpdates.waitingTime = updateResource.waitingTime; + + resourceUpdates.responseHeadersAvailable = true; + resourceUpdates.responseCookiesAvailable = true; + break; + case "responseContent": + resourceUpdates.contentSize = updateResource.contentSize; + resourceUpdates.mimeType = updateResource.mimeType; + resourceUpdates.transferredSize = updateResource.transferredSize; + break; + case "eventTimings": + resourceUpdates.totalTime = updateResource.totalTime; + break; + } + + resourceUpdates[`${updateResource.updateType}Available`] = true; + receivedUpdates.push(updateResource.updateType); + + // Here we explicitly call all three `add` helpers on each network event + // actor so in theory we could check only the last one to be called, ie + // responseContent. + const isComplete = + receivedUpdates.includes("responseStart") && + receivedUpdates.includes("responseContent") && + receivedUpdates.includes("eventTimings"); + + if (isComplete) { + this._emitUpdate(networkEvent); + } + } + + _emitUpdate(networkEvent) { + this.onUpdated([ + { + resourceType: networkEvent.resourceType, + resourceId: networkEvent.resourceId, + resourceUpdates: networkEvent.resourceUpdates, + browsingContextID: networkEvent.browsingContextID, + innerWindowId: networkEvent.innerWindowId, + }, + ]); + } + + onNetworkEventDestroyed(channelId) { + if (this.networkEvents.has(channelId)) { + this.networkEvents.delete(channelId); + } + } + + destroy() { + this.clear(); + Services.obs.removeObserver( + this.httpFailedOpeningRequest, + "http-on-failed-opening-request" + ); + + Services.obs.removeObserver( + this.httpOnImageCacheResponse, + "http-on-image-cache-response" + ); + } +} + +module.exports = NetworkEventContentWatcher; diff --git a/devtools/server/actors/resources/network-events-stacktraces.js b/devtools/server/actors/resources/network-events-stacktraces.js new file mode 100644 index 0000000000..a458278680 --- /dev/null +++ b/devtools/server/actors/resources/network-events-stacktraces.js @@ -0,0 +1,214 @@ +/* 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 { + TYPES: { NETWORK_EVENT_STACKTRACE }, +} = require("resource://devtools/server/actors/resources/index.js"); + +loader.lazyRequireGetter( + this, + "ChannelEventSinkFactory", + "resource://devtools/server/actors/network-monitor/channel-event-sink.js", + true +); + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + NetworkUtils: + "resource://devtools/shared/network-observer/NetworkUtils.sys.mjs", +}); + +class NetworkEventStackTracesWatcher { + /** + * Start watching for all network event's stack traces related to a given Target actor. + * + * @param TargetActor targetActor + * The target actor from which we should observe the strack traces + * @param Object options + * Dictionary object with following attributes: + * - onAvailable: mandatory + * This will be called for each resource. + */ + async watch(targetActor, { onAvailable }) { + this.stacktraces = new Map(); + this.onStackTraceAvailable = onAvailable; + this.targetActor = targetActor; + + Services.obs.addObserver(this, "http-on-opening-request"); + Services.obs.addObserver(this, "document-on-opening-request"); + Services.obs.addObserver(this, "network-monitor-alternate-stack"); + ChannelEventSinkFactory.getService().registerCollector(this); + } + + /** + * Allows clearing of network stacktrace resources + */ + clear() { + this.stacktraces.clear(); + } + + /** + * Stop watching for network event's strack traces related to a given Target Actor. + * + * @param TargetActor targetActor + * The target actor from which we should stop observing the strack traces + */ + destroy(targetActor) { + this.clear(); + Services.obs.removeObserver(this, "http-on-opening-request"); + Services.obs.removeObserver(this, "document-on-opening-request"); + Services.obs.removeObserver(this, "network-monitor-alternate-stack"); + ChannelEventSinkFactory.getService().unregisterCollector(this); + } + + onChannelRedirect(oldChannel, newChannel, flags) { + // We can be called with any nsIChannel, but are interested only in HTTP channels + try { + oldChannel.QueryInterface(Ci.nsIHttpChannel); + newChannel.QueryInterface(Ci.nsIHttpChannel); + } catch (ex) { + return; + } + + const oldId = oldChannel.channelId; + const stacktrace = this.stacktraces.get(oldId); + if (stacktrace) { + this._setStackTrace(newChannel.channelId, stacktrace); + } + } + + observe(subject, topic, data) { + let channel, id; + try { + // We need to QI nsIHttpChannel in order to load the interface's + // methods / attributes for later code that could assume we are dealing + // with a nsIHttpChannel. + channel = subject.QueryInterface(Ci.nsIHttpChannel); + id = channel.channelId; + } catch (e1) { + try { + channel = subject.QueryInterface(Ci.nsIIdentChannel); + id = channel.channelId; + } catch (e2) { + // WebSocketChannels do not have IDs, so use the serial. When a WebSocket is + // opened in a content process, a channel is created locally but the HTTP + // channel for the connection lives entirely in the parent process. When + // the server code running in the parent sees that HTTP channel, it will + // look for the creation stack using the websocket's serial. + try { + channel = subject.QueryInterface(Ci.nsIWebSocketChannel); + id = channel.serial; + } catch (e3) { + // Try if the channel is a nsIWorkerChannelInfo which is the substitute + // of the channel in the parent process. + try { + channel = subject.QueryInterface(Ci.nsIWorkerChannelInfo); + id = channel.channelId; + } catch (e4) { + // Channels which don't implement the above interfaces can appear here, + // such as nsIFileChannel. Ignore these channels. + return; + } + } + } + } + + if ( + !lazy.NetworkUtils.matchRequest(channel, { + targetActor: this.targetActor, + }) + ) { + return; + } + + if (this.stacktraces.has(id)) { + // We can get up to two stack traces for the same channel: one each from + // the two observer topics we are listening to. Use the first stack trace + // which is specified, and ignore any later one. + return; + } + + const stacktrace = []; + switch (topic) { + case "http-on-opening-request": + case "document-on-opening-request": { + // The channel is being opened on the main thread, associate the current + // stack with it. + // + // Convert the nsIStackFrame XPCOM objects to a nice JSON that can be + // passed around through message managers etc. + let frame = Components.stack; + if (frame?.caller) { + frame = frame.caller; + while (frame) { + stacktrace.push({ + filename: frame.filename, + lineNumber: frame.lineNumber, + columnNumber: frame.columnNumber, + functionName: frame.name, + asyncCause: frame.asyncCause, + }); + frame = frame.caller || frame.asyncCaller; + } + } + break; + } + case "network-monitor-alternate-stack": { + // An alternate stack trace is being specified for this channel. + // The topic data is the JSON for the saved frame stack we should use, + // so convert this into the expected format. + // + // This topic is used in the following cases: + // + // - The HTTP channel is opened asynchronously or on a different thread + // from the code which triggered its creation, in which case the stack + // from Components.stack will be empty. The alternate stack will be + // for the point we want to associate with the channel. + // + // - The channel is not a nsIHttpChannel, and we will receive no + // opening request notification for it. + let frame = JSON.parse(data); + while (frame) { + stacktrace.push({ + filename: frame.source, + lineNumber: frame.line, + columnNumber: frame.column, + functionName: frame.functionDisplayName, + asyncCause: frame.asyncCause, + }); + frame = frame.parent || frame.asyncParent; + } + break; + } + default: + throw new Error("Unexpected observe() topic"); + } + + this._setStackTrace(id, stacktrace); + } + + _setStackTrace(resourceId, stacktrace) { + this.stacktraces.set(resourceId, stacktrace); + this.onStackTraceAvailable([ + { + resourceType: NETWORK_EVENT_STACKTRACE, + resourceId, + stacktraceAvailable: stacktrace && !!stacktrace.length, + lastFrame: stacktrace && stacktrace.length ? stacktrace[0] : undefined, + }, + ]); + } + + getStackTrace(id) { + let stacktrace = []; + if (this.stacktraces.has(id)) { + stacktrace = this.stacktraces.get(id); + } + return stacktrace; + } +} +module.exports = NetworkEventStackTracesWatcher; diff --git a/devtools/server/actors/resources/network-events.js b/devtools/server/actors/resources/network-events.js new file mode 100644 index 0000000000..c1440f2c8d --- /dev/null +++ b/devtools/server/actors/resources/network-events.js @@ -0,0 +1,420 @@ +/* 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 { Pool } = require("resource://devtools/shared/protocol/Pool.js"); +const { isWindowGlobalPartOfContext } = ChromeUtils.importESModule( + "resource://devtools/server/actors/watcher/browsing-context-helpers.sys.mjs" +); +const { WatcherRegistry } = ChromeUtils.importESModule( + "resource://devtools/server/actors/watcher/WatcherRegistry.sys.mjs", + { + // WatcherRegistry needs to be a true singleton and loads ActorManagerParent + // which also has to be a true singleton. + loadInDevToolsLoader: false, + } +); +const Targets = require("resource://devtools/server/actors/targets/index.js"); + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + NetworkObserver: + "resource://devtools/shared/network-observer/NetworkObserver.sys.mjs", + NetworkUtils: + "resource://devtools/shared/network-observer/NetworkUtils.sys.mjs", +}); + +loader.lazyRequireGetter( + this, + "NetworkEventActor", + "resource://devtools/server/actors/network-monitor/network-event-actor.js", + true +); + +/** + * Handles network events from the parent process + */ +class NetworkEventWatcher { + /** + * Start watching for all network events related to a given Watcher Actor. + * + * @param WatcherActor watcherActor + * The watcher actor in the parent process from which we should + * observe network events. + * @param Object options + * Dictionary object with following attributes: + * - onAvailable: mandatory function + * This will be called for each resource. + * - onUpdated: optional function + * This would be called multiple times for each resource. + */ + async watch(watcherActor, { onAvailable, onUpdated }) { + this.networkEvents = new Map(); + + this.watcherActor = watcherActor; + this.onNetworkEventAvailable = onAvailable; + this.onNetworkEventUpdated = onUpdated; + // Boolean to know if we keep previous document network events or not. + this.persist = false; + this.listener = new lazy.NetworkObserver({ + ignoreChannelFunction: this.shouldIgnoreChannel.bind(this), + onNetworkEvent: this.onNetworkEvent.bind(this), + }); + + Services.obs.addObserver(this, "window-global-destroyed"); + } + + /** + * Clear all the network events and the related actors. + * + * This is called on actor destroy, but also from WatcherActor.clearResources(NETWORK_EVENT) + */ + clear() { + this.networkEvents.clear(); + this.listener.clear(); + if (this._pool) { + this._pool.destroy(); + this._pool = null; + } + } + + /** + * A protocol.js Pool to store all NetworkEventActor's which may be destroyed on navigations. + */ + get pool() { + if (this._pool) { + return this._pool; + } + this._pool = new Pool(this.watcherActor.conn, "network-events"); + this.watcherActor.manage(this._pool); + return this._pool; + } + + /** + * Instruct to keep reference to previous document requests or not. + * If persist is disabled, we will clear all informations about previous document + * on each navigation. + * If persist is enabled, we will keep all informations for all documents, leading + * to lots of allocations! + * + * @param {Boolean} enabled + */ + setPersist(enabled) { + this.persist = enabled; + } + + /** + * Gets the throttle settings + * + * @return {*} data + * + */ + getThrottleData() { + return this.listener.getThrottleData(); + } + + /** + * Sets the throttle data + * + * @param {*} data + * + */ + setThrottleData(data) { + this.listener.setThrottleData(data); + } + + /** + * Instruct to save or ignore request and response bodies + * @param {Boolean} save + */ + setSaveRequestAndResponseBodies(save) { + this.listener.setSaveRequestAndResponseBodies(save); + } + + /** + * Block requests based on the filters + * @param {Object} filters + */ + blockRequest(filters) { + this.listener.blockRequest(filters); + } + + /** + * Unblock requests based on the fitlers + * @param {Object} filters + */ + unblockRequest(filters) { + this.listener.unblockRequest(filters); + } + + /** + * Calls the listener to set blocked urls + * + * @param {Array} urls + * The urls to block + */ + + setBlockedUrls(urls) { + this.listener.setBlockedUrls(urls); + } + + /** + * Calls the listener to get the blocked urls + * + * @return {Array} urls + * The blocked urls + */ + + getBlockedUrls() { + return this.listener.getBlockedUrls(); + } + + override(url, path) { + this.listener.override(url, path); + } + + removeOverride(url) { + this.listener.removeOverride(url); + } + + /** + * Watch for previous document being unloaded in order to clear + * all related network events, in case persist is disabled. + * (which is the default behavior) + */ + observe(windowGlobal, topic) { + if (topic !== "window-global-destroyed") { + return; + } + // If we persist, we will keep all requests allocated. + // For now, consider that the Browser console and toolbox persist all the requests. + if (this.persist || this.watcherActor.sessionContext.type == "all") { + return; + } + // Only process WindowGlobals which are related to the debugged scope. + if ( + !isWindowGlobalPartOfContext( + windowGlobal, + this.watcherActor.sessionContext + ) + ) { + return; + } + const { innerWindowId } = windowGlobal; + + for (const child of this.pool.poolChildren()) { + // Destroy all network events matching the destroyed WindowGlobal + if (!child.isNavigationRequest()) { + if (child.getInnerWindowId() == innerWindowId) { + child.destroy(); + } + // Avoid destroying the navigation request, which is flagged with previous document's innerWindowId. + // When navigating, the WindowGlobal we navigate *from* will be destroyed and notified here. + // We should explicitly avoid destroying it here. + // But, we still want to eventually destroy them. + // So do this when navigating a second time, we will navigate from a distinct WindowGlobal + // and check that this is the top level window global and not an iframe one. + // So that we avoid clearing the top navigation when an iframe navigates + // + // Avoid destroying the request if innerWindowId isn't set. This happens when we reload many times in a row. + // The previous navigation request will be cancelled and because of that its innerWindowId will be null. + // But the frontend will receive it after the navigation begins (after will-navigate) and will display it + // and try to fetch extra data about it. So, avoid destroying its NetworkEventActor. + } else if ( + child.getInnerWindowId() && + child.getInnerWindowId() != innerWindowId && + windowGlobal.browsingContext == + this.watcherActor.browserElement?.browsingContext + ) { + child.destroy(); + } + } + } + + /** + * Called by NetworkObserver in order to know if the channel should be ignored + */ + shouldIgnoreChannel(channel) { + // First of all, check if the channel matches the watcherActor's session. + const filters = { sessionContext: this.watcherActor.sessionContext }; + if (!lazy.NetworkUtils.matchRequest(channel, filters)) { + return true; + } + + // When we are in the browser toolbox in parent process scope, + // the session context is still "all", but we are no longer watching frame and process targets. + // In this case, we should ignore all requests belonging to a BrowsingContext that isn't in the parent process + // (i.e. the process where this Watcher runs) + const isParentProcessOnlyBrowserToolbox = + this.watcherActor.sessionContext.type == "all" && + !WatcherRegistry.isWatchingTargets( + this.watcherActor, + Targets.TYPES.FRAME + ); + if (isParentProcessOnlyBrowserToolbox) { + // We should ignore all requests coming from BrowsingContext running in another process + const browsingContextID = + lazy.NetworkUtils.getChannelBrowsingContextID(channel); + const browsingContext = BrowsingContext.get(browsingContextID); + // We accept any request that isn't bound to any BrowsingContext. + // This is most likely a privileged request done from a JSM/C++. + // `isInProcess` will be true, when the document executes in the parent process. + // + // Note that we will still accept all requests that aren't bound to any BrowsingContext + // See browser_resources_network_events_parent_process.js test with privileged request + // made from the content processes. + // We miss some attribute on channel/loadInfo to know that it comes from the content process. + if (browsingContext?.currentWindowGlobal.isInProcess === false) { + return true; + } + } + return false; + } + + onNetworkEvent(networkEventOptions, channel) { + if (channel.channelId && this.networkEvents.has(channel.channelId)) { + throw new Error( + `Got notified about channel ${channel.channelId} more than once.` + ); + } + + const actor = new NetworkEventActor( + this.watcherActor.conn, + this.watcherActor.sessionContext, + { + onNetworkEventUpdate: this.onNetworkEventUpdate.bind(this), + onNetworkEventDestroy: this.onNetworkEventDestroy.bind(this), + }, + networkEventOptions, + channel + ); + this.pool.manage(actor); + + const resource = actor.asResource(); + const isBlocked = !!resource.blockedReason; + const networkEvent = { + browsingContextID: resource.browsingContextID, + innerWindowId: resource.innerWindowId, + resourceId: resource.resourceId, + resourceType: resource.resourceType, + isBlocked, + isFileRequest: resource.isFileRequest, + receivedUpdates: [], + resourceUpdates: { + // Requests already come with request cookies and headers, so those + // should always be considered as available. But the client still + // heavily relies on those `Available` flags to fetch additional data, + // so it is better to keep them for consistency. + requestCookiesAvailable: true, + requestHeadersAvailable: true, + }, + }; + this.networkEvents.set(resource.resourceId, networkEvent); + + this.onNetworkEventAvailable([resource]); + + // Blocked requests will not receive further updates and should emit an + // update packet immediately. + // The frontend expects to receive a dedicated update to consider the + // request as completed. TODO: lift this restriction so that we can only + // emit a resource available notification if no update is needed. + if (isBlocked) { + this._emitUpdate(networkEvent); + } + + return actor; + } + + onNetworkEventUpdate(updateResource) { + const networkEvent = this.networkEvents.get(updateResource.resourceId); + + if (!networkEvent) { + return; + } + + const { resourceUpdates, receivedUpdates } = networkEvent; + + switch (updateResource.updateType) { + case "responseStart": + resourceUpdates.httpVersion = updateResource.httpVersion; + resourceUpdates.status = updateResource.status; + resourceUpdates.statusText = updateResource.statusText; + resourceUpdates.remoteAddress = updateResource.remoteAddress; + resourceUpdates.remotePort = updateResource.remotePort; + // The mimetype is only set when then the contentType is available + // in the _onResponseHeader and not for cached/service worker requests + // in _httpResponseExaminer. + resourceUpdates.mimeType = updateResource.mimeType; + resourceUpdates.waitingTime = updateResource.waitingTime; + resourceUpdates.isResolvedByTRR = updateResource.isResolvedByTRR; + resourceUpdates.proxyHttpVersion = updateResource.proxyHttpVersion; + resourceUpdates.proxyStatus = updateResource.proxyStatus; + resourceUpdates.proxyStatusText = updateResource.proxyStatusText; + + resourceUpdates.responseHeadersAvailable = true; + resourceUpdates.responseCookiesAvailable = true; + break; + case "responseContent": + resourceUpdates.contentSize = updateResource.contentSize; + resourceUpdates.transferredSize = updateResource.transferredSize; + resourceUpdates.mimeType = updateResource.mimeType; + resourceUpdates.blockingExtension = updateResource.blockingExtension; + resourceUpdates.blockedReason = updateResource.blockedReason; + break; + case "eventTimings": + resourceUpdates.totalTime = updateResource.totalTime; + break; + case "securityInfo": + resourceUpdates.securityState = updateResource.state; + resourceUpdates.isRacing = updateResource.isRacing; + break; + } + + resourceUpdates[`${updateResource.updateType}Available`] = true; + receivedUpdates.push(updateResource.updateType); + + const isComplete = networkEvent.isFileRequest + ? receivedUpdates.includes("responseStart") + : receivedUpdates.includes("eventTimings") && + receivedUpdates.includes("responseContent") && + receivedUpdates.includes("securityInfo"); + + if (isComplete) { + this._emitUpdate(networkEvent); + } + } + + _emitUpdate(networkEvent) { + this.onNetworkEventUpdated([ + { + resourceType: networkEvent.resourceType, + resourceId: networkEvent.resourceId, + resourceUpdates: networkEvent.resourceUpdates, + browsingContextID: networkEvent.browsingContextID, + innerWindowId: networkEvent.innerWindowId, + }, + ]); + } + + onNetworkEventDestroy(channelId) { + if (this.networkEvents.has(channelId)) { + this.networkEvents.delete(channelId); + } + } + + /** + * Stop watching for network event related to a given Watcher Actor. + */ + destroy() { + if (this.listener) { + this.clear(); + this.listener.destroy(); + Services.obs.removeObserver(this, "window-global-destroyed"); + } + } +} + +module.exports = NetworkEventWatcher; diff --git a/devtools/server/actors/resources/parent-process-document-event.js b/devtools/server/actors/resources/parent-process-document-event.js new file mode 100644 index 0000000000..e156a32fe5 --- /dev/null +++ b/devtools/server/actors/resources/parent-process-document-event.js @@ -0,0 +1,174 @@ +/* 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 { + TYPES: { DOCUMENT_EVENT }, +} = require("resource://devtools/server/actors/resources/index.js"); +const isEveryFrameTargetEnabled = Services.prefs.getBoolPref( + "devtools.every-frame-target.enabled", + false +); +const { + WILL_NAVIGATE_TIME_SHIFT, +} = require("resource://devtools/server/actors/webconsole/listeners/document-events.js"); + +class ParentProcessDocumentEventWatcher { + /** + * Start watching, from the parent process, for DOCUMENT_EVENT's "will-navigate" event related to a given Watcher Actor. + * + * All other DOCUMENT_EVENT events are implemented from another watcher class, running in the content process. + * Note that this other content process watcher will also emit one special edgecase of will-navigate + * retlated to the iframe dropdown menu. + * + * We have to move listen for navigation in the parent to better handle bfcache navigations + * and more generally all navigations which are initiated from the parent process. + * 'bfcacheInParent' feature enabled many types of navigations to be controlled from the parent process. + * + * This was especially important to have this implementation in the parent + * because the navigation event may be fired too late in the content process. + * Leading to will-navigate being emitted *after* the new target we navigate to is notified to the client. + * + * @param WatcherActor watcherActor + * The watcher actor from which we should observe document event + * @param Object options + * Dictionary object with following attributes: + * - onAvailable: mandatory function + * This will be called for each resource. + */ + async watch(watcherActor, { onAvailable }) { + this.watcherActor = watcherActor; + this.onAvailable = onAvailable; + + // List of listeners keyed by innerWindowId. + // Listeners are called as soon as we emitted the will-navigate + // resource for the related WindowGlobal. + this._onceWillNavigate = new Map(); + + // Filter browsing contexts to only have the top BrowsingContext of each tree of BrowsingContexts… + const topLevelBrowsingContexts = this.watcherActor + .getAllBrowsingContexts() + .filter(browsingContext => browsingContext.top == browsingContext); + + // Only register one WebProgressListener per BrowsingContext tree. + // We will be notified about children BrowsingContext navigations/state changes via the top level BrowsingContextWebProgressListener, + // and BrowsingContextWebProgress.browsingContext attribute will be updated dynamically everytime + // we get notified about a child BrowsingContext. + // Note that regular web page toolbox will only have one BrowsingContext tree, for the given tab. + // But the Browser Toolbox will have many trees to listen to, one per top-level Window, and also one per tab, + // as tabs's BrowsingContext context aren't children of their top level window! + // + // Also save the WebProgress and not the BrowsingContext because `BrowsingContext.webProgress` will be undefined in destroy(), + // while it is still valuable to call `webProgress.removeProgressListener`. Otherwise events keeps being notified!! + this.webProgresses = topLevelBrowsingContexts.map( + browsingContext => browsingContext.webProgress + ); + this.webProgresses.forEach(webProgress => { + webProgress.addProgressListener( + this, + Ci.nsIWebProgress.NOTIFY_STATE_DOCUMENT + ); + }); + } + + /** + * Wait for the emission of will-navigate for a given WindowGlobal + * + * @param Number innerWindowId + * WindowGlobal's id we want to track + * @return Promise + * Resolves immediatly if the WindowGlobal isn't tracked by any target + * -or- resolve later, once the WindowGlobal navigates to another document + * and will-navigate has been emitted. + */ + onceWillNavigateIsEmitted(innerWindowId) { + // Only delay the target-destroyed event if the target is for BrowsingContext for which we will emit will-navigate + const isTracked = this.webProgresses.find( + webProgress => + webProgress.browsingContext.currentWindowGlobal.innerWindowId == + innerWindowId + ); + if (isTracked) { + return new Promise(resolve => { + this._onceWillNavigate.set(innerWindowId, resolve); + }); + } + return Promise.resolve(); + } + + onStateChange(progress, request, flag, status) { + const isStart = flag & Ci.nsIWebProgressListener.STATE_START; + const isDocument = flag & Ci.nsIWebProgressListener.STATE_IS_DOCUMENT; + if (isDocument && isStart) { + const { browsingContext } = progress; + // Ignore navigation for same-process iframes when EFT is disabled + if ( + !browsingContext.currentWindowGlobal.isProcessRoot && + !isEveryFrameTargetEnabled + ) { + return; + } + // Ignore if we are still on the initial document, + // as that's the navigation from it (about:blank) to the actual first location. + // The target isn't created yet. + if (browsingContext.currentWindowGlobal.isInitialDocument) { + return; + } + + // Only emit will-navigate for top-level targets. + if ( + this.watcherActor.sessionContext.type == "all" && + browsingContext.isContent + ) { + // Never emit will-navigate for content browsing contexts in the Browser Toolbox. + // They might verify `browsingContext.top == browsingContext` because of the chrome/content + // boundary, but they do not represent a top-level target for this DevTools session. + return; + } + const isTopLevel = browsingContext.top == browsingContext; + if (!isTopLevel) { + return; + } + + const newURI = request instanceof Ci.nsIChannel ? request.URI.spec : null; + const { innerWindowId } = browsingContext.currentWindowGlobal; + this.onAvailable([ + { + browsingContextID: browsingContext.id, + innerWindowId, + resourceType: DOCUMENT_EVENT, + name: "will-navigate", + time: Date.now() - WILL_NAVIGATE_TIME_SHIFT, + isFrameSwitching: false, + newURI, + }, + ]); + const callback = this._onceWillNavigate.get(innerWindowId); + if (callback) { + this._onceWillNavigate.delete(innerWindowId); + callback(); + } + } + } + + get QueryInterface() { + return ChromeUtils.generateQI([ + "nsIWebProgressListener", + "nsISupportsWeakReference", + ]); + } + + destroy() { + this.webProgresses.forEach(webProgress => { + webProgress.removeProgressListener( + this, + Ci.nsIWebProgress.NOTIFY_STATE_DOCUMENT + ); + }); + this.webProgresses = null; + } +} + +module.exports = ParentProcessDocumentEventWatcher; diff --git a/devtools/server/actors/resources/platform-messages.js b/devtools/server/actors/resources/platform-messages.js new file mode 100644 index 0000000000..6d9750c0a2 --- /dev/null +++ b/devtools/server/actors/resources/platform-messages.js @@ -0,0 +1,60 @@ +/* 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 nsIConsoleListenerWatcher = require("resource://devtools/server/actors/resources/utils/nsi-console-listener-watcher.js"); + +const { + TYPES: { PLATFORM_MESSAGE }, +} = require("resource://devtools/server/actors/resources/index.js"); + +const { + createStringGrip, +} = require("resource://devtools/server/actors/object/utils.js"); + +class PlatformMessageWatcher extends nsIConsoleListenerWatcher { + shouldHandleTarget(targetActor) { + return this.isProcessTarget(targetActor); + } + + /** + * Returns true if the message is considered a platform message, and as a result, should + * be sent to the client. + * + * @param {TargetActor} targetActor + * @param {nsIConsoleMessage} message + */ + shouldHandleMessage(targetActor, message) { + // The listener we use can be called either with a nsIConsoleMessage or as nsIScriptError. + // In this file, we want to ignore nsIScriptError, which are handled by the + // error-messages resource handler (See Bug 1644186). + if (message instanceof Ci.nsIScriptError) { + return false; + } + + // Ignore message that were forwarded from the content process to the parent process, + // since we're getting those directly from the content process. + if (message.isForwardedFromContentProcess) { + return false; + } + + return true; + } + + /** + * Returns an object from the nsIConsoleMessage. + * + * @param {Actor} targetActor + * @param {nsIConsoleMessage} message + */ + buildResource(targetActor, message) { + return { + message: createStringGrip(targetActor, message.message), + timeStamp: message.microSecondTimeStamp / 1000, + resourceType: PLATFORM_MESSAGE, + }; + } +} +module.exports = PlatformMessageWatcher; diff --git a/devtools/server/actors/resources/reflow.js b/devtools/server/actors/resources/reflow.js new file mode 100644 index 0000000000..5be9d6e7b2 --- /dev/null +++ b/devtools/server/actors/resources/reflow.js @@ -0,0 +1,63 @@ +/* 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 { + TYPES: { REFLOW }, +} = require("resource://devtools/server/actors/resources/index.js"); +const Targets = require("resource://devtools/server/actors/targets/index.js"); + +const { + getLayoutChangesObserver, + releaseLayoutChangesObserver, +} = require("resource://devtools/server/actors/reflow.js"); + +class ReflowWatcher { + /** + * Start watching for reflows related to a given Target Actor. + * + * @param TargetActor targetActor + * The target actor from which we should observe reflows + * @param Object options + * Dictionary object with following attributes: + * - onAvailable: mandatory function + * This will be called for each resource. + */ + async watch(targetActor, { onAvailable }) { + // Only track reflow for non-ParentProcess FRAME targets + if ( + targetActor.targetType !== Targets.TYPES.FRAME || + targetActor.typeName === "parentProcessTarget" + ) { + return; + } + + this._targetActor = targetActor; + + const onReflows = reflows => { + onAvailable([ + { + resourceType: REFLOW, + reflows, + }, + ]); + }; + + this._observer = getLayoutChangesObserver(targetActor); + this._offReflows = this._observer.on("reflows", onReflows); + this._observer.start(); + } + + destroy() { + releaseLayoutChangesObserver(this._targetActor); + + if (this._offReflows) { + this._offReflows(); + this._offReflows = null; + } + } +} + +module.exports = ReflowWatcher; diff --git a/devtools/server/actors/resources/server-sent-events.js b/devtools/server/actors/resources/server-sent-events.js new file mode 100644 index 0000000000..5b16f8bb9f --- /dev/null +++ b/devtools/server/actors/resources/server-sent-events.js @@ -0,0 +1,135 @@ +/* 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 { + LongStringActor, +} = require("resource://devtools/server/actors/string.js"); + +const { + TYPES: { SERVER_SENT_EVENT }, +} = require("resource://devtools/server/actors/resources/index.js"); + +const eventSourceEventService = Cc[ + "@mozilla.org/eventsourceevent/service;1" +].getService(Ci.nsIEventSourceEventService); + +class ServerSentEventWatcher { + constructor() { + this.windowIds = new Set(); + // Register for backend events. + this.onWindowReady = this.onWindowReady.bind(this); + this.onWindowDestroy = this.onWindowDestroy.bind(this); + } + /** + * Start watching for all server sent events related to a given Target Actor. + * + * @param TargetActor targetActor + * The target actor on which we should observe server sent events. + * @param Object options + * Dictionary object with following attributes: + * - onAvailable: mandatory function + * This will be called for each resource. + */ + watch(targetActor, { onAvailable }) { + this.onAvailable = onAvailable; + this.targetActor = targetActor; + + for (const window of this.targetActor.windows) { + const { innerWindowId } = window.windowGlobalChild; + this.startListening(innerWindowId); + } + + // Listen for subsequent top-level-document reloads/navigations, + // new iframe additions or current iframe reloads/navigation. + this.targetActor.on("window-ready", this.onWindowReady); + this.targetActor.on("window-destroyed", this.onWindowDestroy); + } + + static createResource(messageType, eventParams) { + return { + resourceType: SERVER_SENT_EVENT, + messageType, + ...eventParams, + }; + } + + static prepareFramePayload(targetActor, frame) { + const payload = new LongStringActor(targetActor.conn, frame); + targetActor.manage(payload); + return payload.form(); + } + + onWindowReady({ window }) { + const { innerWindowId } = window.windowGlobalChild; + this.startListening(innerWindowId); + } + + onWindowDestroy({ id }) { + this.stopListening(id); + } + + startListening(innerWindowId) { + if (!this.windowIds.has(innerWindowId)) { + this.windowIds.add(innerWindowId); + eventSourceEventService.addListener(innerWindowId, this); + } + } + + stopListening(innerWindowId) { + if (this.windowIds.has(innerWindowId)) { + this.windowIds.delete(innerWindowId); + // The listener might have already been cleaned up on `window-destroy`. + if (!eventSourceEventService.hasListenerFor(innerWindowId)) { + console.warn( + "Already stopped listening to server sent events for this window." + ); + return; + } + eventSourceEventService.removeListener(innerWindowId, this); + } + } + + destroy() { + // cleanup any other listeners not removed on `window-destroy` + for (const id of this.windowIds) { + this.stopListening(id); + } + this.targetActor.off("window-ready", this.onWindowReady); + this.targetActor.off("window-destroyed", this.onWindowDestroy); + } + + // nsIEventSourceEventService specific functions + eventSourceConnectionOpened(httpChannelId) {} + + eventSourceConnectionClosed(httpChannelId) { + const resource = ServerSentEventWatcher.createResource( + "eventSourceConnectionClosed", + { httpChannelId } + ); + this.onAvailable([resource]); + } + + eventReceived(httpChannelId, eventName, lastEventId, data, retry, timeStamp) { + const payload = ServerSentEventWatcher.prepareFramePayload( + this.targetActor, + data + ); + const resource = ServerSentEventWatcher.createResource("eventReceived", { + httpChannelId, + data: { + payload, + eventName, + lastEventId, + retry, + timeStamp, + }, + }); + + this.onAvailable([resource]); + } +} + +module.exports = ServerSentEventWatcher; diff --git a/devtools/server/actors/resources/sources.js b/devtools/server/actors/resources/sources.js new file mode 100644 index 0000000000..6b3ab1d5e1 --- /dev/null +++ b/devtools/server/actors/resources/sources.js @@ -0,0 +1,100 @@ +/* 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 { + TYPES: { SOURCE }, +} = require("resource://devtools/server/actors/resources/index.js"); +const Targets = require("resource://devtools/server/actors/targets/index.js"); + +const { + STATES: THREAD_STATES, +} = require("resource://devtools/server/actors/thread.js"); + +/** + * Start watching for all JS sources related to a given Target Actor. + * This will notify about existing sources, but also the ones created in future. + * + * @param TargetActor targetActor + * The target actor from which we should observe sources + * @param Object options + * Dictionary object with following attributes: + * - onAvailable: mandatory function + * This will be called for each resource. + */ +class SourceWatcher { + constructor() { + this.onNewSource = this.onNewSource.bind(this); + } + + async watch(targetActor, { onAvailable }) { + // When debugging the whole browser, we instantiate both content process and browsing context targets. + // But sources will only be debugged the content process target, even browsing context sources. + if ( + targetActor.sessionContext.type == "all" && + targetActor.targetType === Targets.TYPES.FRAME && + targetActor.typeName != "parentProcessTarget" + ) { + return; + } + + const { threadActor } = targetActor; + this.sourcesManager = targetActor.sourcesManager; + this.onAvailable = onAvailable; + + // Disable `ThreadActor.newSource` RDP event in order to avoid unnecessary traffic + threadActor.disableNewSourceEvents(); + + threadActor.sourcesManager.on("newSource", this.onNewSource); + + // For WindowGlobal, Content process and Service Worker targets, + // the thread actor is fully managed by the server codebase. + // For these targets, the actor should be "attached" (initialized) right away in order + // to start observing the sources. + // + // For regular and shared Workers, the thread actor is still managed by the client. + // The client will call `attach` (bug 1691986) later, which will also resume worker execution. + const isTargetCreation = threadActor.state == THREAD_STATES.DETACHED; + const { targetType } = targetActor; + if ( + isTargetCreation && + targetType != Targets.TYPES.WORKER && + targetType != Targets.TYPES.SHARED_WORKER + ) { + await threadActor.attach({}); + } + + // Before fetching all sources, process existing ones. + // The ThreadActor is already up and running before this code runs + // and have sources already registered and for which newSource event already fired. + onAvailable( + threadActor.sourcesManager.iter().map(s => { + const resource = s.form(); + resource.resourceType = SOURCE; + return resource; + }) + ); + + // Requesting all sources should end up emitting newSource on threadActor.sourcesManager + threadActor.addAllSources(); + } + + /** + * Stop watching for sources + */ + destroy() { + if (this.sourcesManager) { + this.sourcesManager.off("newSource", this.onNewSource); + } + } + + onNewSource(source) { + const resource = source.form(); + resource.resourceType = SOURCE; + this.onAvailable([resource]); + } +} + +module.exports = SourceWatcher; diff --git a/devtools/server/actors/resources/storage-cache.js b/devtools/server/actors/resources/storage-cache.js new file mode 100644 index 0000000000..73a2bba40f --- /dev/null +++ b/devtools/server/actors/resources/storage-cache.js @@ -0,0 +1,22 @@ +/* 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 { + TYPES: { CACHE_STORAGE }, +} = require("resource://devtools/server/actors/resources/index.js"); + +const ContentProcessStorage = require("resource://devtools/server/actors/resources/utils/content-process-storage.js"); +const { + CacheStorageActor, +} = require("resource://devtools/server/actors/resources/storage/cache.js"); + +class CacheWatcher extends ContentProcessStorage { + constructor() { + super(CacheStorageActor, "Cache", CACHE_STORAGE); + } +} + +module.exports = CacheWatcher; diff --git a/devtools/server/actors/resources/storage-cookie.js b/devtools/server/actors/resources/storage-cookie.js new file mode 100644 index 0000000000..8d847a5bf0 --- /dev/null +++ b/devtools/server/actors/resources/storage-cookie.js @@ -0,0 +1,22 @@ +/* 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 { + TYPES: { COOKIE }, +} = require("resource://devtools/server/actors/resources/index.js"); + +const ParentProcessStorage = require("resource://devtools/server/actors/resources/utils/parent-process-storage.js"); +const { + CookiesStorageActor, +} = require("resource://devtools/server/actors/resources/storage/cookies.js"); + +class CookiesWatcher extends ParentProcessStorage { + constructor() { + super(CookiesStorageActor, "cookies", COOKIE); + } +} + +module.exports = CookiesWatcher; diff --git a/devtools/server/actors/resources/storage-extension.js b/devtools/server/actors/resources/storage-extension.js new file mode 100644 index 0000000000..daacd40778 --- /dev/null +++ b/devtools/server/actors/resources/storage-extension.js @@ -0,0 +1,30 @@ +/* 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 { + TYPES: { EXTENSION_STORAGE }, +} = require("resource://devtools/server/actors/resources/index.js"); + +const ParentProcessStorage = require("resource://devtools/server/actors/resources/utils/parent-process-storage.js"); +const { + ExtensionStorageActor, +} = require("resource://devtools/server/actors/resources/storage/extension-storage.js"); + +class ExtensionStorageWatcher extends ParentProcessStorage { + constructor() { + super(ExtensionStorageActor, "extensionStorage", EXTENSION_STORAGE); + } + async watch(watcherActor, { onAvailable }) { + if (watcherActor.sessionContext.type != "webextension") { + throw new Error( + "EXTENSION_STORAGE should only be listened when debugging a webextension" + ); + } + return super.watch(watcherActor, { onAvailable }); + } +} + +module.exports = ExtensionStorageWatcher; diff --git a/devtools/server/actors/resources/storage-indexed-db.js b/devtools/server/actors/resources/storage-indexed-db.js new file mode 100644 index 0000000000..88ee01a000 --- /dev/null +++ b/devtools/server/actors/resources/storage-indexed-db.js @@ -0,0 +1,22 @@ +/* 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 { + TYPES: { INDEXED_DB }, +} = require("resource://devtools/server/actors/resources/index.js"); + +const ParentProcessStorage = require("resource://devtools/server/actors/resources/utils/parent-process-storage.js"); +const { + IndexedDBStorageActor, +} = require("resource://devtools/server/actors/resources/storage/indexed-db.js"); + +class IndexedDBWatcher extends ParentProcessStorage { + constructor() { + super(IndexedDBStorageActor, "indexedDB", INDEXED_DB); + } +} + +module.exports = IndexedDBWatcher; diff --git a/devtools/server/actors/resources/storage-local-storage.js b/devtools/server/actors/resources/storage-local-storage.js new file mode 100644 index 0000000000..54b5ea4d5b --- /dev/null +++ b/devtools/server/actors/resources/storage-local-storage.js @@ -0,0 +1,22 @@ +/* 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 { + TYPES: { LOCAL_STORAGE }, +} = require("resource://devtools/server/actors/resources/index.js"); + +const ContentProcessStorage = require("resource://devtools/server/actors/resources/utils/content-process-storage.js"); +const { + LocalStorageActor, +} = require("resource://devtools/server/actors/resources/storage/local-and-session-storage.js"); + +class LocalStorageWatcher extends ContentProcessStorage { + constructor() { + super(LocalStorageActor, "localStorage", LOCAL_STORAGE); + } +} + +module.exports = LocalStorageWatcher; diff --git a/devtools/server/actors/resources/storage-session-storage.js b/devtools/server/actors/resources/storage-session-storage.js new file mode 100644 index 0000000000..fa980aa9f1 --- /dev/null +++ b/devtools/server/actors/resources/storage-session-storage.js @@ -0,0 +1,22 @@ +/* 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 { + TYPES: { SESSION_STORAGE }, +} = require("resource://devtools/server/actors/resources/index.js"); + +const ContentProcessStorage = require("resource://devtools/server/actors/resources/utils/content-process-storage.js"); +const { + SessionStorageActor, +} = require("resource://devtools/server/actors/resources/storage/local-and-session-storage.js"); + +class SessionStorageWatcher extends ContentProcessStorage { + constructor() { + super(SessionStorageActor, "sessionStorage", SESSION_STORAGE); + } +} + +module.exports = SessionStorageWatcher; diff --git a/devtools/server/actors/resources/storage/cache.js b/devtools/server/actors/resources/storage/cache.js new file mode 100644 index 0000000000..2066d181e0 --- /dev/null +++ b/devtools/server/actors/resources/storage/cache.js @@ -0,0 +1,195 @@ +/* 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 { + BaseStorageActor, +} = require("resource://devtools/server/actors/resources/storage/index.js"); + +class CacheStorageActor extends BaseStorageActor { + constructor(storageActor) { + super(storageActor, "Cache"); + } + + async populateStoresForHost(host) { + const storeMap = new Map(); + const caches = await this.getCachesForHost(host); + try { + for (const name of await caches.keys()) { + storeMap.set(name, await caches.open(name)); + } + } catch (ex) { + console.warn(`Failed to enumerate CacheStorage for host ${host}: ${ex}`); + } + this.hostVsStores.set(host, storeMap); + } + + async getCachesForHost(host) { + const win = this.storageActor.getWindowFromHost(host); + if (!win) { + return null; + } + + const principal = win.document.effectiveStoragePrincipal; + + // The first argument tells if you want to get |content| cache or |chrome| + // cache. + // The |content| cache is the cache explicitely named by the web content + // (service worker or web page). + // The |chrome| cache is the cache implicitely cached by the platform, + // hosting the source file of the service worker. + const { CacheStorage } = win; + + if (!CacheStorage) { + return null; + } + + const cache = new CacheStorage("content", principal); + return cache; + } + + form() { + const hosts = {}; + for (const host of this.hosts) { + hosts[host] = this.getNamesForHost(host); + } + + return { + actor: this.actorID, + hosts, + traits: this._getTraits(), + }; + } + + getNamesForHost(host) { + // UI code expect each name to be a JSON string of an array :/ + return [...this.hostVsStores.get(host).keys()].map(a => { + return JSON.stringify([a]); + }); + } + + async getValuesForHost(host, name) { + if (!name) { + // if we get here, we most likely clicked on the refresh button + // which called getStoreObjects, itself calling this method, + // all that, without having selected any particular cache name. + // + // Try to detect if a new cache has been added and notify the client + // asynchronously, via a RDP event. + const previousCaches = [...this.hostVsStores.get(host).keys()]; + await this.populateStoresForHosts(); + const updatedCaches = [...this.hostVsStores.get(host).keys()]; + const newCaches = updatedCaches.filter( + cacheName => !previousCaches.includes(cacheName) + ); + newCaches.forEach(cacheName => + this.onItemUpdated("added", host, [cacheName]) + ); + const removedCaches = previousCaches.filter( + cacheName => !updatedCaches.includes(cacheName) + ); + removedCaches.forEach(cacheName => + this.onItemUpdated("deleted", host, [cacheName]) + ); + return []; + } + // UI is weird and expect a JSON stringified array... and pass it back :/ + name = JSON.parse(name)[0]; + + const cache = this.hostVsStores.get(host).get(name); + const requests = await cache.keys(); + const results = []; + for (const request of requests) { + let response = await cache.match(request); + // Unwrap the response to get access to all its properties if the + // response happen to be 'opaque', when it is a Cross Origin Request. + response = response.cloneUnfiltered(); + results.push(await this.processEntry(request, response)); + } + return results; + } + + async processEntry(request, response) { + return { + url: String(request.url), + status: String(response.statusText), + }; + } + + async getFields() { + return [ + { name: "url", editable: false }, + { name: "status", editable: false }, + ]; + } + + /** + * Given a url, correctly determine its protocol + hostname part. + */ + getSchemaAndHost(url) { + const uri = Services.io.newURI(url); + return uri.scheme + "://" + uri.hostPort; + } + + toStoreObject(item) { + return item; + } + + async removeItem(host, name) { + const cacheMap = this.hostVsStores.get(host); + if (!cacheMap) { + return; + } + + const parsedName = JSON.parse(name); + + if (parsedName.length == 1) { + // Delete the whole Cache object + const [cacheName] = parsedName; + cacheMap.delete(cacheName); + const cacheStorage = await this.getCachesForHost(host); + await cacheStorage.delete(cacheName); + this.onItemUpdated("deleted", host, [cacheName]); + } else if (parsedName.length == 2) { + // Delete one cached request + const [cacheName, url] = parsedName; + const cache = cacheMap.get(cacheName); + if (cache) { + await cache.delete(url); + this.onItemUpdated("deleted", host, [cacheName, url]); + } + } + } + + async removeAll(host, name) { + const cacheMap = this.hostVsStores.get(host); + if (!cacheMap) { + return; + } + + const parsedName = JSON.parse(name); + + // Only a Cache object is a valid object to clear + if (parsedName.length == 1) { + const [cacheName] = parsedName; + const cache = cacheMap.get(cacheName); + if (cache) { + const keys = await cache.keys(); + await Promise.all(keys.map(key => cache.delete(key))); + this.onItemUpdated("cleared", host, [cacheName]); + } + } + } + + /** + * CacheStorage API doesn't support any notifications, we must fake them + */ + onItemUpdated(action, host, path) { + this.storageActor.update(action, "Cache", { + [host]: [JSON.stringify(path)], + }); + } +} +exports.CacheStorageActor = CacheStorageActor; diff --git a/devtools/server/actors/resources/storage/cookies.js b/devtools/server/actors/resources/storage/cookies.js new file mode 100644 index 0000000000..6a7d90414a --- /dev/null +++ b/devtools/server/actors/resources/storage/cookies.js @@ -0,0 +1,559 @@ +/* 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 { + BaseStorageActor, + DEFAULT_VALUE, + SEPARATOR_GUID, +} = require("resource://devtools/server/actors/resources/storage/index.js"); +const { + LongStringActor, +} = require("resource://devtools/server/actors/string.js"); + +// "Lax", "Strict" and "None" are special values of the SameSite property +// that should not be translated. +const COOKIE_SAMESITE = { + LAX: "Lax", + STRICT: "Strict", + NONE: "None", +}; + +// MAX_COOKIE_EXPIRY should be 2^63-1, but JavaScript can't handle that +// precision. +const MAX_COOKIE_EXPIRY = Math.pow(2, 62); + +/** + * General helpers + */ +function trimHttpHttpsPort(url) { + const match = url.match(/(.+):\d+$/); + + if (match) { + url = match[1]; + } + if (url.startsWith("http://")) { + return url.substr(7); + } + if (url.startsWith("https://")) { + return url.substr(8); + } + return url; +} + +class CookiesStorageActor extends BaseStorageActor { + constructor(storageActor) { + super(storageActor, "cookies"); + + Services.obs.addObserver(this, "cookie-changed"); + Services.obs.addObserver(this, "private-cookie-changed"); + } + + destroy() { + Services.obs.removeObserver(this, "cookie-changed"); + Services.obs.removeObserver(this, "private-cookie-changed"); + + super.destroy(); + } + + populateStoresForHost(host) { + this.hostVsStores.set(host, new Map()); + + const originAttributes = this.getOriginAttributesFromHost(host); + const cookies = this.getCookiesFromHost(host, originAttributes); + + for (const cookie of cookies) { + if (this.isCookieAtHost(cookie, host)) { + const uniqueKey = + `${cookie.name}${SEPARATOR_GUID}${cookie.host}` + + `${SEPARATOR_GUID}${cookie.path}`; + + this.hostVsStores.get(host).set(uniqueKey, cookie); + } + } + } + + getOriginAttributesFromHost(host) { + const win = this.storageActor.getWindowFromHost(host); + let originAttributes; + if (win) { + originAttributes = + win.document.effectiveStoragePrincipal.originAttributes; + } else { + // If we can't find the window by host, fallback to the top window + // origin attributes. + originAttributes = + this.storageActor.document?.effectiveStoragePrincipal.originAttributes; + } + + return originAttributes; + } + + getCookiesFromHost(host, originAttributes) { + // Local files have no host. + if (host.startsWith("file:///")) { + host = ""; + } + + host = trimHttpHttpsPort(host); + + return Services.cookies.getCookiesFromHost(host, originAttributes); + } + + /** + * Given a cookie object, figure out all the matching hosts from the page that + * the cookie belong to. + */ + getMatchingHosts(cookies) { + if (!cookies) { + return []; + } + if (!cookies.length) { + cookies = [cookies]; + } + const hosts = new Set(); + for (const host of this.hosts) { + for (const cookie of cookies) { + if (this.isCookieAtHost(cookie, host)) { + hosts.add(host); + } + } + } + return [...hosts]; + } + + /** + * Given a cookie object and a host, figure out if the cookie is valid for + * that host. + */ + isCookieAtHost(cookie, host) { + if (cookie.host == null) { + return host == null; + } + + host = trimHttpHttpsPort(host); + + if (cookie.host.startsWith(".")) { + return ("." + host).endsWith(cookie.host); + } + if (cookie.host === "") { + return host.startsWith("file://" + cookie.path); + } + + return cookie.host == host; + } + + toStoreObject(cookie) { + if (!cookie) { + return null; + } + + return { + uniqueKey: + `${cookie.name}${SEPARATOR_GUID}${cookie.host}` + + `${SEPARATOR_GUID}${cookie.path}`, + name: cookie.name, + host: cookie.host || "", + path: cookie.path || "", + + // because expires is in seconds + expires: (cookie.expires || 0) * 1000, + + // because creationTime is in micro seconds + creationTime: cookie.creationTime / 1000, + + size: cookie.name.length + (cookie.value || "").length, + + // - do - + lastAccessed: cookie.lastAccessed / 1000, + value: new LongStringActor(this.conn, cookie.value || ""), + hostOnly: !cookie.isDomain, + isSecure: cookie.isSecure, + isHttpOnly: cookie.isHttpOnly, + sameSite: this.getSameSiteStringFromCookie(cookie), + }; + } + + getSameSiteStringFromCookie(cookie) { + switch (cookie.sameSite) { + case cookie.SAMESITE_LAX: + return COOKIE_SAMESITE.LAX; + case cookie.SAMESITE_STRICT: + return COOKIE_SAMESITE.STRICT; + } + // cookie.SAMESITE_NONE + return COOKIE_SAMESITE.NONE; + } + + /** + * Notification observer for "cookie-change". + * + * @param {(nsICookie|nsICookie[])} cookie - Cookie/s changed. Depending on the action + * this is either null, a single cookie or an array of cookies. + * @param {nsICookieNotification_Action} action - The cookie operation, see + * nsICookieNotification for details. + **/ + onCookieChanged(cookie, action) { + const { + COOKIE_ADDED, + COOKIE_CHANGED, + COOKIE_DELETED, + COOKIES_BATCH_DELETED, + ALL_COOKIES_CLEARED, + } = Ci.nsICookieNotification; + + const hosts = this.getMatchingHosts(cookie); + if (!hosts.length) { + return; + } + + const data = {}; + + switch (action) { + case COOKIE_ADDED: + case COOKIE_CHANGED: + if (hosts.length) { + for (const host of hosts) { + const uniqueKey = + `${cookie.name}${SEPARATOR_GUID}${cookie.host}` + + `${SEPARATOR_GUID}${cookie.path}`; + + this.hostVsStores.get(host).set(uniqueKey, cookie); + data[host] = [uniqueKey]; + } + const actionStr = action == COOKIE_ADDED ? "added" : "changed"; + this.storageActor.update(actionStr, "cookies", data); + } + break; + + case COOKIE_DELETED: + if (hosts.length) { + for (const host of hosts) { + const uniqueKey = + `${cookie.name}${SEPARATOR_GUID}${cookie.host}` + + `${SEPARATOR_GUID}${cookie.path}`; + + this.hostVsStores.get(host).delete(uniqueKey); + data[host] = [uniqueKey]; + } + this.storageActor.update("deleted", "cookies", data); + } + break; + + case COOKIES_BATCH_DELETED: + if (hosts.length) { + for (const host of hosts) { + const stores = []; + // For COOKIES_BATCH_DELETED cookie is an array. + for (const batchCookie of cookie) { + const uniqueKey = + `${batchCookie.name}${SEPARATOR_GUID}${batchCookie.host}` + + `${SEPARATOR_GUID}${batchCookie.path}`; + + this.hostVsStores.get(host).delete(uniqueKey); + stores.push(uniqueKey); + } + data[host] = stores; + } + this.storageActor.update("deleted", "cookies", data); + } + break; + + case ALL_COOKIES_CLEARED: + if (hosts.length) { + for (const host of hosts) { + data[host] = []; + } + this.storageActor.update("cleared", "cookies", data); + } + break; + } + } + + async getFields() { + return [ + { name: "uniqueKey", editable: false, private: true }, + { name: "name", editable: true, hidden: false }, + { name: "value", editable: true, hidden: false }, + { name: "host", editable: true, hidden: false }, + { name: "path", editable: true, hidden: false }, + { name: "expires", editable: true, hidden: false }, + { name: "size", editable: false, hidden: false }, + { name: "isHttpOnly", editable: true, hidden: false }, + { name: "isSecure", editable: true, hidden: false }, + { name: "sameSite", editable: false, hidden: false }, + { name: "lastAccessed", editable: false, hidden: false }, + { name: "creationTime", editable: false, hidden: true }, + { name: "hostOnly", editable: false, hidden: true }, + ]; + } + + /** + * Pass the editItem command from the content to the chrome process. + * + * @param {Object} data + * See editCookie() for format details. + */ + async editItem(data) { + data.originAttributes = this.getOriginAttributesFromHost(data.host); + this.editCookie(data); + } + + async addItem(guid, host) { + const window = this.storageActor.getWindowFromHost(host); + const principal = window.document.effectiveStoragePrincipal; + this.addCookie(guid, principal); + } + + async removeItem(host, name) { + const originAttributes = this.getOriginAttributesFromHost(host); + this.removeCookie(host, name, originAttributes); + } + + async removeAll(host, domain) { + const originAttributes = this.getOriginAttributesFromHost(host); + this.removeAllCookies(host, domain, originAttributes); + } + + async removeAllSessionCookies(host, domain) { + const originAttributes = this.getOriginAttributesFromHost(host); + this._removeCookies(host, { domain, originAttributes, session: true }); + } + + addCookie(guid, principal) { + // Set expiry time for cookie 1 day into the future + // NOTE: Services.cookies.add expects the time in seconds. + const ONE_DAY_IN_SECONDS = 60 * 60 * 24; + const time = Math.floor(Date.now() / 1000); + const expiry = time + ONE_DAY_IN_SECONDS; + + // principal throws an error when we try to access principal.host if it + // does not exist (which happens at about: pages). + // We check for asciiHost instead, which is always present, and has a + // value of "" when the host is not available. + const domain = principal.asciiHost ? principal.host : principal.baseDomain; + + Services.cookies.add( + domain, + "/", + guid, // name + DEFAULT_VALUE, // value + false, // isSecure + false, // isHttpOnly, + false, // isSession, + expiry, // expires, + principal.originAttributes, // originAttributes + Ci.nsICookie.SAMESITE_LAX, // sameSite + principal.scheme === "https" // schemeMap + ? Ci.nsICookie.SCHEME_HTTPS + : Ci.nsICookie.SCHEME_HTTP + ); + } + + /** + * Apply the results of a cookie edit. + * + * @param {Object} data + * An object in the following format: + * { + * host: "http://www.mozilla.org", + * field: "value", + * editCookie: "name", + * oldValue: "%7BHello%7D", + * newValue: "%7BHelloo%7D", + * items: { + * name: "optimizelyBuckets", + * path: "/", + * host: ".mozilla.org", + * expires: "Mon, 02 Jun 2025 12:37:37 GMT", + * creationTime: "Tue, 18 Nov 2014 16:21:18 GMT", + * lastAccessed: "Wed, 17 Feb 2016 10:06:23 GMT", + * value: "%7BHelloo%7D", + * isDomain: "true", + * isSecure: "false", + * isHttpOnly: "false" + * } + * } + */ + // eslint-disable-next-line complexity + editCookie(data) { + let { field, oldValue, newValue } = data; + const origName = field === "name" ? oldValue : data.items.name; + const origHost = field === "host" ? oldValue : data.items.host; + const origPath = field === "path" ? oldValue : data.items.path; + let cookie = null; + + const cookies = Services.cookies.getCookiesFromHost( + origHost, + data.originAttributes || {} + ); + for (const nsiCookie of cookies) { + if ( + nsiCookie.name === origName && + nsiCookie.host === origHost && + nsiCookie.path === origPath + ) { + cookie = { + host: nsiCookie.host, + path: nsiCookie.path, + name: nsiCookie.name, + value: nsiCookie.value, + isSecure: nsiCookie.isSecure, + isHttpOnly: nsiCookie.isHttpOnly, + isSession: nsiCookie.isSession, + expires: nsiCookie.expires, + originAttributes: nsiCookie.originAttributes, + schemeMap: nsiCookie.schemeMap, + }; + break; + } + } + + if (!cookie) { + return; + } + + // If the date is expired set it for 10 seconds in the future. + const now = new Date(); + if (!cookie.isSession && cookie.expires * 1000 <= now) { + const tenSecondsFromNow = (now.getTime() + 10 * 1000) / 1000; + + cookie.expires = tenSecondsFromNow; + } + + switch (field) { + case "isSecure": + case "isHttpOnly": + case "isSession": + newValue = newValue === "true"; + break; + + case "expires": + newValue = Date.parse(newValue) / 1000; + + if (isNaN(newValue)) { + newValue = MAX_COOKIE_EXPIRY; + } + break; + + case "host": + case "name": + case "path": + // Remove the edited cookie. + Services.cookies.remove( + origHost, + origName, + origPath, + cookie.originAttributes + ); + break; + } + + // Apply changes. + cookie[field] = newValue; + + // cookie.isSession is not always set correctly on session cookies so we + // need to trust cookie.expires instead. + cookie.isSession = !cookie.expires; + + // Add the edited cookie. + Services.cookies.add( + cookie.host, + cookie.path, + cookie.name, + cookie.value, + cookie.isSecure, + cookie.isHttpOnly, + cookie.isSession, + cookie.isSession ? MAX_COOKIE_EXPIRY : cookie.expires, + cookie.originAttributes, + cookie.sameSite, + cookie.schemeMap + ); + } + + _removeCookies(host, opts = {}) { + // We use a uniqueId to emulate compound keys for cookies. We need to + // extract the cookie name to remove the correct cookie. + if (opts.name) { + const split = opts.name.split(SEPARATOR_GUID); + + opts.name = split[0]; + opts.path = split[2]; + } + + host = trimHttpHttpsPort(host); + + function hostMatches(cookieHost, matchHost) { + if (cookieHost == null) { + return matchHost == null; + } + if (cookieHost.startsWith(".")) { + return ("." + matchHost).endsWith(cookieHost); + } + return cookieHost == host; + } + + const cookies = Services.cookies.getCookiesFromHost( + host, + opts.originAttributes || {} + ); + for (const cookie of cookies) { + if ( + hostMatches(cookie.host, host) && + (!opts.name || cookie.name === opts.name) && + (!opts.domain || cookie.host === opts.domain) && + (!opts.path || cookie.path === opts.path) && + (!opts.session || (!cookie.expires && !cookie.maxAge)) + ) { + Services.cookies.remove( + cookie.host, + cookie.name, + cookie.path, + cookie.originAttributes + ); + } + } + } + + removeCookie(host, name, originAttributes) { + if (name !== undefined) { + this._removeCookies(host, { name, originAttributes }); + } + } + + removeAllCookies(host, domain, originAttributes) { + this._removeCookies(host, { domain, originAttributes }); + } + + observe(subject, topic) { + if ( + !subject || + (topic != "cookie-changed" && topic != "private-cookie-changed") || + !this.storageActor || + !this.storageActor.windows + ) { + return; + } + + const notification = subject.QueryInterface(Ci.nsICookieNotification); + let cookie; + if (notification.action == Ci.nsICookieNotification.COOKIES_BATCH_DELETED) { + // Extract the batch deleted cookies from nsIArray. + const cookiesNoInterface = + notification.batchDeletedCookies.QueryInterface(Ci.nsIArray); + cookie = []; + for (let i = 0; i < cookiesNoInterface.length; i++) { + cookie.push(cookiesNoInterface.queryElementAt(i, Ci.nsICookie)); + } + } else if (notification.cookie) { + // Otherwise, get the single cookie affected by the operation. + cookie = notification.cookie.QueryInterface(Ci.nsICookie); + } + + this.onCookieChanged(cookie, notification.action); + } +} +exports.CookiesStorageActor = CookiesStorageActor; diff --git a/devtools/server/actors/resources/storage/extension-storage.js b/devtools/server/actors/resources/storage/extension-storage.js new file mode 100644 index 0000000000..d14d3320c7 --- /dev/null +++ b/devtools/server/actors/resources/storage/extension-storage.js @@ -0,0 +1,491 @@ +/* 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 { + BaseStorageActor, +} = require("resource://devtools/server/actors/resources/storage/index.js"); +const { + parseItemValue, +} = require("resource://devtools/shared/storage/utils.js"); +const { + LongStringActor, +} = require("resource://devtools/server/actors/string.js"); +// Use loadInDevToolsLoader: false for these extension modules, because these +// are singletons with shared state, and we must not create a new instance if a +// dedicated loader was used to load this module. +loader.lazyGetter(this, "ExtensionParent", () => { + return ChromeUtils.importESModule( + "resource://gre/modules/ExtensionParent.sys.mjs", + { loadInDevToolsLoader: false } + ).ExtensionParent; +}); +loader.lazyGetter(this, "ExtensionProcessScript", () => { + return ChromeUtils.importESModule( + "resource://gre/modules/ExtensionProcessScript.sys.mjs", + { loadInDevToolsLoader: false } + ).ExtensionProcessScript; +}); +loader.lazyGetter(this, "ExtensionStorageIDB", () => { + return ChromeUtils.importESModule( + "resource://gre/modules/ExtensionStorageIDB.sys.mjs", + { loadInDevToolsLoader: false } + ).ExtensionStorageIDB; +}); + +/** + * The Extension Storage actor. + */ +class ExtensionStorageActor extends BaseStorageActor { + constructor(storageActor) { + super(storageActor, "extensionStorage"); + + this.addonId = this.storageActor.parentActor.addonId; + + // Retrieve the base moz-extension url for the extension + // (and also remove the final '/' from it). + this.extensionHostURL = this.getExtensionPolicy().getURL().slice(0, -1); + + // Map<host, ExtensionStorageIDB db connection> + // Bug 1542038, 1542039: Each storage area will need its own + // dbConnectionForHost, as they each have different storage backends. + // Anywhere dbConnectionForHost is used, we need to know the storage + // area to access the correct database. + this.dbConnectionForHost = new Map(); + + this.onExtensionStartup = this.onExtensionStartup.bind(this); + + this.onStorageChange = this.onStorageChange.bind(this); + } + + getExtensionPolicy() { + return WebExtensionPolicy.getByID(this.addonId); + } + + destroy() { + ExtensionStorageIDB.removeOnChangedListener( + this.addonId, + this.onStorageChange + ); + ExtensionParent.apiManager.off("startup", this.onExtensionStartup); + + super.destroy(); + } + + /** + * We need to override this method as we ignore BaseStorageActor's hosts + * and only care about the extension host. + */ + async populateStoresForHosts() { + // Ensure the actor's target is an extension and it is enabled + if (!this.addonId || !this.getExtensionPolicy()) { + return; + } + + // Subscribe a listener for event notifications from the WE storage API when + // storage local data has been changed by the extension, and keep track of the + // listener to remove it when the debugger is being disconnected. + ExtensionStorageIDB.addOnChangedListener( + this.addonId, + this.onStorageChange + ); + + try { + // Make sure the extension storage APIs have been loaded, + // otherwise the DevTools storage panel would not be updated + // automatically when the extension storage data is being changed + // if the parent ext-storage.js module wasn't already loaded + // (See Bug 1802929). + const { extension } = WebExtensionPolicy.getByID(this.addonId); + await extension.apiManager.asyncGetAPI("storage", extension); + // Also watch for addon reload in order to also do that + // on next addon startup, otherwise we may also miss updates + ExtensionParent.apiManager.on("startup", this.onExtensionStartup); + } catch (e) { + console.error( + "Exception while trying to initialize webext storage API", + e + ); + } + + await this.populateStoresForHost(this.extensionHostURL); + } + + /** + * AddonManager listener used to force instantiating storage API + * implementation in the parent process so that it forward content process + * messages to ExtensionStorageIDB. + * + * Without this, we may miss storage updated after the addon reload. + */ + async onExtensionStartup(_evtName, extension) { + if (extension.id != this.addonId) { + return; + } + await extension.apiManager.asyncGetAPI("storage", extension); + } + + /** + * This method asynchronously reads the storage data for the target extension + * and caches this data into this.hostVsStores. + * @param {String} host - the hostname for the extension + */ + async populateStoresForHost(host) { + if (host !== this.extensionHostURL) { + return; + } + + const extension = ExtensionProcessScript.getExtensionChild(this.addonId); + if (!extension || !extension.hasPermission("storage")) { + return; + } + + // Make sure storeMap is defined and set in this.hostVsStores before subscribing + // a storage onChanged listener in the parent process + const storeMap = new Map(); + this.hostVsStores.set(host, storeMap); + + const storagePrincipal = await this.getStoragePrincipal(); + + if (!storagePrincipal) { + // This could happen if the extension fails to be migrated to the + // IndexedDB backend + return; + } + + const db = await ExtensionStorageIDB.open(storagePrincipal); + this.dbConnectionForHost.set(host, db); + const data = await db.get(); + + for (const [key, value] of Object.entries(data)) { + storeMap.set(key, value); + } + + if (this.storageActor.parentActor.fallbackWindow) { + // Show the storage actor in the add-on storage inspector even when there + // is no extension page currently open + // This strategy may need to change depending on the outcome of Bug 1597900 + const storageData = {}; + storageData[host] = this.getNamesForHost(host); + this.storageActor.update("added", this.typeName, storageData); + } + } + /** + * This fires when the extension changes storage data while the storage + * inspector is open. Ensures this.hostVsStores stays up-to-date and + * passes the changes on to update the client. + */ + onStorageChange(changes) { + const host = this.extensionHostURL; + const storeMap = this.hostVsStores.get(host); + + function isStructuredCloneHolder(value) { + return ( + value && + typeof value === "object" && + Cu.getClassName(value, true) === "StructuredCloneHolder" + ); + } + + for (const key in changes) { + const storageChange = changes[key]; + let { newValue, oldValue } = storageChange; + if (isStructuredCloneHolder(newValue)) { + newValue = newValue.deserialize(this); + } + if (isStructuredCloneHolder(oldValue)) { + oldValue = oldValue.deserialize(this); + } + + let action; + if (typeof newValue === "undefined") { + action = "deleted"; + storeMap.delete(key); + } else if (typeof oldValue === "undefined") { + action = "added"; + storeMap.set(key, newValue); + } else { + action = "changed"; + storeMap.set(key, newValue); + } + + this.storageActor.update(action, this.typeName, { [host]: [key] }); + } + } + + async getStoragePrincipal() { + const { extension } = this.getExtensionPolicy(); + const { backendEnabled, storagePrincipal } = + await ExtensionStorageIDB.selectBackend({ extension }); + + if (!backendEnabled) { + // IDB backend disabled; give up. + return null; + } + + // Received as a StructuredCloneHolder, so we need to deserialize + return storagePrincipal.deserialize(this, true); + } + + getValuesForHost(host, name) { + const result = []; + + if (!this.hostVsStores.has(host)) { + return result; + } + + if (name) { + return [{ name, value: this.hostVsStores.get(host).get(name) }]; + } + + for (const [key, value] of Array.from( + this.hostVsStores.get(host).entries() + )) { + result.push({ name: key, value }); + } + return result; + } + + /** + * Converts a storage item to an "extensionobject" as defined in + * devtools/shared/specs/storage.js. Behavior largely mirrors the "indexedDB" storage actor, + * except where it would throw an unhandled error (i.e. for a `BigInt` or `undefined` + * `item.value`). + * @param {Object} item - The storage item to convert + * @param {String} item.name - The storage item key + * @param {*} item.value - The storage item value + * @return {extensionobject} + */ + toStoreObject(item) { + if (!item) { + return null; + } + + let { name, value } = item; + const isValueEditable = extensionStorageHelpers.isEditable(value); + + // `JSON.stringify()` throws for `BigInt`, adds extra quotes to strings and `Date` strings, + // and doesn't modify `undefined`. + switch (typeof value) { + case "bigint": + value = `${value.toString()}n`; + break; + case "string": + break; + case "undefined": + value = "undefined"; + break; + default: + value = JSON.stringify(value); + if ( + // can't use `instanceof` across frame boundaries + Object.prototype.toString.call(item.value) === "[object Date]" + ) { + value = JSON.parse(value); + } + } + + return { + name, + value: new LongStringActor(this.conn, value), + area: "local", // Bug 1542038, 1542039: set the correct storage area + isValueEditable, + }; + } + + getFields() { + return [ + { name: "name", editable: false }, + { name: "value", editable: true }, + { name: "area", editable: false }, + { name: "isValueEditable", editable: false, private: true }, + ]; + } + + onItemUpdated(action, host, names) { + this.storageActor.update(action, this.typeName, { + [host]: names, + }); + } + + async editItem({ host, field, items, oldValue }) { + const db = this.dbConnectionForHost.get(host); + if (!db) { + return; + } + + const { name, value } = items; + + let parsedValue = parseItemValue(value); + if (parsedValue === value) { + const { typesFromString } = extensionStorageHelpers; + for (const { test, parse } of Object.values(typesFromString)) { + if (test(value)) { + parsedValue = parse(value); + break; + } + } + } + const changes = await db.set({ [name]: parsedValue }); + this.fireOnChangedExtensionEvent(host, changes); + + this.onItemUpdated("changed", host, [name]); + } + + async removeItem(host, name) { + const db = this.dbConnectionForHost.get(host); + if (!db) { + return; + } + + const changes = await db.remove(name); + this.fireOnChangedExtensionEvent(host, changes); + + this.onItemUpdated("deleted", host, [name]); + } + + async removeAll(host) { + const db = this.dbConnectionForHost.get(host); + if (!db) { + return; + } + + const changes = await db.clear(); + this.fireOnChangedExtensionEvent(host, changes); + + this.onItemUpdated("cleared", host, []); + } + + /** + * Let the extension know that storage data has been changed by the user from + * the storage inspector. + */ + fireOnChangedExtensionEvent(host, changes) { + // Bug 1542038, 1542039: Which message to send depends on the storage area + const uuid = new URL(host).host; + Services.cpmm.sendAsyncMessage( + `Extension:StorageLocalOnChanged:${uuid}`, + changes + ); + } +} +exports.ExtensionStorageActor = ExtensionStorageActor; + +const extensionStorageHelpers = { + /** + * Editing is supported only for serializable types. Examples of unserializable + * types include Map, Set and ArrayBuffer. + */ + isEditable(value) { + // Bug 1542038: the managed storage area is never editable + for (const { test } of Object.values(this.supportedTypes)) { + if (test(value)) { + return true; + } + } + return false; + }, + isPrimitive(value) { + const primitiveValueTypes = ["string", "number", "boolean"]; + return primitiveValueTypes.includes(typeof value) || value === null; + }, + isObjectLiteral(value) { + return ( + value && + typeof value === "object" && + Cu.getClassName(value, true) === "Object" + ); + }, + // Nested arrays or object literals are only editable 2 levels deep + isArrayOrObjectLiteralEditable(obj) { + const topLevelValuesArr = Array.isArray(obj) ? obj : Object.values(obj); + if ( + topLevelValuesArr.some( + value => + !this.isPrimitive(value) && + !Array.isArray(value) && + !this.isObjectLiteral(value) + ) + ) { + // At least one value is too complex to parse + return false; + } + const arrayOrObjects = topLevelValuesArr.filter( + value => Array.isArray(value) || this.isObjectLiteral(value) + ); + if (arrayOrObjects.length === 0) { + // All top level values are primitives + return true; + } + + // One or more top level values was an array or object literal. + // All of these top level values must themselves have only primitive values + // for the object to be editable + for (const nestedObj of arrayOrObjects) { + const secondLevelValuesArr = Array.isArray(nestedObj) + ? nestedObj + : Object.values(nestedObj); + if (secondLevelValuesArr.some(value => !this.isPrimitive(value))) { + return false; + } + } + return true; + }, + typesFromString: { + // Helper methods to parse string values in editItem + jsonifiable: { + test(str) { + try { + JSON.parse(str); + } catch (e) { + return false; + } + return true; + }, + parse(str) { + return JSON.parse(str); + }, + }, + }, + supportedTypes: { + // Helper methods to determine the value type of an item in isEditable + array: { + test(value) { + if (Array.isArray(value)) { + return extensionStorageHelpers.isArrayOrObjectLiteralEditable(value); + } + return false; + }, + }, + boolean: { + test(value) { + return typeof value === "boolean"; + }, + }, + null: { + test(value) { + return value === null; + }, + }, + number: { + test(value) { + return typeof value === "number"; + }, + }, + object: { + test(value) { + if (extensionStorageHelpers.isObjectLiteral(value)) { + return extensionStorageHelpers.isArrayOrObjectLiteralEditable(value); + } + return false; + }, + }, + string: { + test(value) { + return typeof value === "string"; + }, + }, + }, +}; diff --git a/devtools/server/actors/resources/storage/index.js b/devtools/server/actors/resources/storage/index.js new file mode 100644 index 0000000000..147f9056ea --- /dev/null +++ b/devtools/server/actors/resources/storage/index.js @@ -0,0 +1,404 @@ +/* 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 { Actor } = require("resource://devtools/shared/protocol.js"); +const specs = require("resource://devtools/shared/specs/storage.js"); + +loader.lazyRequireGetter( + this, + "naturalSortCaseInsensitive", + "resource://devtools/shared/natural-sort.js", + true +); + +// Maximum number of cookies/local storage key-value-pairs that can be sent +// over the wire to the client in one request. +const MAX_STORE_OBJECT_COUNT = 50; +exports.MAX_STORE_OBJECT_COUNT = MAX_STORE_OBJECT_COUNT; + +const DEFAULT_VALUE = "value"; +exports.DEFAULT_VALUE = DEFAULT_VALUE; + +// GUID to be used as a separator in compound keys. This must match the same +// constant in devtools/client/storage/ui.js, +// devtools/client/storage/test/head.js and +// devtools/server/tests/browser/head.js +const SEPARATOR_GUID = "{9d414cc5-8319-0a04-0586-c0a6ae01670a}"; +exports.SEPARATOR_GUID = SEPARATOR_GUID; + +class BaseStorageActor extends Actor { + /** + * Base class with the common methods required by all storage actors. + * + * This base class is missing a couple of required methods that should be + * implemented seperately for each actor. They are namely: + * - observe : Method which gets triggered on the notification of the watched + * topic. + * - getNamesForHost : Given a host, get list of all known store names. + * - getValuesForHost : Given a host (and optionally a name) get all known + * store objects. + * - toStoreObject : Given a store object, convert it to the required format + * so that it can be transferred over wire. + * - populateStoresForHost : Given a host, populate the map of all store + * objects for it + * - getFields: Given a subType(optional), get an array of objects containing + * column field info. The info includes, + * "name" is name of colume key. + * "editable" is 1 means editable field; 0 means uneditable. + * + * @param {string} typeName + * The typeName of the actor. + */ + constructor(storageActor, typeName) { + super(storageActor.conn, specs.childSpecs[typeName]); + + this.storageActor = storageActor; + + // Map keyed by host name whose values are nested Maps. + // Nested maps are keyed by store names and values are store values. + // Store values are specific to each sub class. + // Map(host name => stores <Map(name => values )>) + // Map(string => stores <Map(string => any )>) + this.hostVsStores = new Map(); + + this.onWindowReady = this.onWindowReady.bind(this); + this.onWindowDestroyed = this.onWindowDestroyed.bind(this); + this.storageActor.on("window-ready", this.onWindowReady); + this.storageActor.on("window-destroyed", this.onWindowDestroyed); + } + + destroy() { + if (!this.storageActor) { + return; + } + + this.storageActor.off("window-ready", this.onWindowReady); + this.storageActor.off("window-destroyed", this.onWindowDestroyed); + + this.hostVsStores.clear(); + + super.destroy(); + + this.storageActor = null; + } + + /** + * Returns a list of currently known hosts for the target window. This list + * contains unique hosts from the window + all inner windows. If + * this._internalHosts is defined then these will also be added to the list. + */ + get hosts() { + const hosts = new Set(); + for (const { location } of this.storageActor.windows) { + const host = this.getHostName(location); + + if (host) { + hosts.add(host); + } + } + if (this._internalHosts) { + for (const host of this._internalHosts) { + hosts.add(host); + } + } + return hosts; + } + + /** + * Returns all the windows present on the page. Includes main window + inner + * iframe windows. + */ + get windows() { + return this.storageActor.windows; + } + + /** + * Converts the window.location object into a URL (e.g. http://domain.com). + */ + getHostName(location) { + if (!location) { + // Debugging a legacy Firefox extension... no hostname available and no + // storage possible. + return null; + } + + if (this.storageActor.getHostName) { + return this.storageActor.getHostName(location); + } + + switch (location.protocol) { + case "about:": + return `${location.protocol}${location.pathname}`; + case "chrome:": + // chrome: URLs do not support storage of any type. + return null; + case "data:": + // data: URLs do not support storage of any type. + return null; + case "file:": + return `${location.protocol}//${location.pathname}`; + case "javascript:": + return location.href; + case "moz-extension:": + return location.origin; + case "resource:": + return `${location.origin}${location.pathname}`; + default: + // http: or unknown protocol. + return `${location.protocol}//${location.host}`; + } + } + + /** + * Populates a map of known hosts vs a map of stores vs value. + */ + async populateStoresForHosts() { + for (const host of this.hosts) { + await this.populateStoresForHost(host); + } + } + + getNamesForHost(host) { + return [...this.hostVsStores.get(host).keys()]; + } + + getValuesForHost(host, name) { + if (name) { + return [this.hostVsStores.get(host).get(name)]; + } + return [...this.hostVsStores.get(host).values()]; + } + + getObjectsSize(host, names) { + return names.length; + } + + /** + * When a new window is added to the page. This generally means that a new + * iframe is created, or the current window is completely reloaded. + * + * @param {window} window + * The window which was added. + */ + async onWindowReady(window) { + if (!this.hostVsStores) { + return; + } + const host = this.getHostName(window.location); + if (host && !this.hostVsStores.has(host)) { + await this.populateStoresForHost(host, window); + if (!this.storageActor) { + // The actor might be destroyed during populateStoresForHost. + return; + } + + const data = {}; + data[host] = this.getNamesForHost(host); + this.storageActor.update("added", this.typeName, data); + } + } + + /** + * When a window is removed from the page. This generally means that an + * iframe was removed, or the current window reload is triggered. + * + * @param {window} window + * The window which was removed. + * @param {Object} options + * @param {Boolean} options.dontCheckHost + * If set to true, the function won't check if the host still is in this.hosts. + * This is helpful in the case of the StorageActorMock, as the `hosts` getter + * uses its `windows` getter, and at this point in time the window which is + * going to be destroyed still exists. + */ + onWindowDestroyed(window, { dontCheckHost } = {}) { + if (!this.hostVsStores) { + return; + } + if (!window.location) { + // Nothing can be done if location object is null + return; + } + const host = this.getHostName(window.location); + if (host && (!this.hosts.has(host) || dontCheckHost)) { + this.hostVsStores.delete(host); + const data = {}; + data[host] = []; + this.storageActor.update("deleted", this.typeName, data); + } + } + + form() { + const hosts = {}; + for (const host of this.hosts) { + hosts[host] = []; + } + + return { + actor: this.actorID, + hosts, + traits: this._getTraits(), + }; + } + + // Share getTraits for child classes overriding form() + _getTraits() { + return { + // The supportsXXX traits are not related to backward compatibility + // Different storage actor types implement different APIs, the traits + // help the client to know what is supported or not. + supportsAddItem: typeof this.addItem === "function", + // Note: supportsRemoveItem and supportsRemoveAll are always defined + // for all actors. See Bug 1655001. + supportsRemoveItem: typeof this.removeItem === "function", + supportsRemoveAll: typeof this.removeAll === "function", + supportsRemoveAllSessionCookies: + typeof this.removeAllSessionCookies === "function", + }; + } + + /** + * Returns a list of requested store objects. Maximum values returned are + * MAX_STORE_OBJECT_COUNT. This method returns paginated values whose + * starting index and total size can be controlled via the options object + * + * @param {string} host + * The host name for which the store values are required. + * @param {array:string} names + * Array containing the names of required store objects. Empty if all + * items are required. + * @param {object} options + * Additional options for the request containing following + * properties: + * - offset {number} : The begin index of the returned array amongst + * the total values + * - size {number} : The number of values required. + * - sortOn {string} : The values should be sorted on this property. + * - index {string} : In case of indexed db, the IDBIndex to be used + * for fetching the values. + * - sessionString {string} : Client-side value of storage-expires-session + * l10n string. Since this function can be called from both + * the client and the server, and given that client and + * server might have different locales, we can't compute + * the localized string directly from here. + * @return {object} An object containing following properties: + * - offset - The actual offset of the returned array. This might + * be different from the requested offset if that was + * invalid + * - total - The total number of entries possible. + * - data - The requested values. + */ + async getStoreObjects(host, names, options = {}) { + const offset = options.offset || 0; + let size = options.size || MAX_STORE_OBJECT_COUNT; + if (size > MAX_STORE_OBJECT_COUNT) { + size = MAX_STORE_OBJECT_COUNT; + } + const sortOn = options.sortOn || "name"; + + const toReturn = { + offset, + total: 0, + data: [], + }; + + let principal = null; + if (this.typeName === "indexedDB") { + // We only acquire principal when the type of the storage is indexedDB + // because the principal only matters the indexedDB. + const win = this.storageActor.getWindowFromHost(host); + principal = this.getPrincipal(win); + } + + if (names) { + for (const name of names) { + const values = await this.getValuesForHost( + host, + name, + options, + this.hostVsStores, + principal + ); + + const { result, objectStores } = values; + + if (result && typeof result.objectsSize !== "undefined") { + for (const { key, count } of result.objectsSize) { + this.objectsSize[key] = count; + } + } + + if (result) { + toReturn.data.push(...result.data); + } else if (objectStores) { + toReturn.data.push(...objectStores); + } else { + toReturn.data.push(...values); + } + } + + if (this.typeName === "Cache") { + // Cache storage contains several items per name but misses a custom + // `getObjectsSize` implementation, as implemented for IndexedDB. + // See Bug 1745242. + toReturn.total = toReturn.data.length; + } else { + toReturn.total = this.getObjectsSize(host, names, options); + } + } else { + let obj = await this.getValuesForHost( + host, + undefined, + undefined, + this.hostVsStores, + principal + ); + if (obj.dbs) { + obj = obj.dbs; + } + + toReturn.total = obj.length; + toReturn.data = obj; + } + + if (offset > toReturn.total) { + // In this case, toReturn.data is an empty array. + toReturn.offset = toReturn.total; + toReturn.data = []; + } else { + // We need to use natural sort before slicing. + const sorted = toReturn.data.sort((a, b) => { + return naturalSortCaseInsensitive( + a[sortOn], + b[sortOn], + options.sessionString + ); + }); + let sliced; + if (this.typeName === "indexedDB") { + // indexedDB's getValuesForHost never returns *all* values available but only + // a slice, starting at the expected offset. Therefore the result is already + // sliced as expected. + sliced = sorted; + } else { + sliced = sorted.slice(offset, offset + size); + } + toReturn.data = sliced.map(a => this.toStoreObject(a)); + } + + return toReturn; + } + + getPrincipal(win) { + if (win) { + return win.document.effectiveStoragePrincipal; + } + // We are running in the browser toolbox and viewing system DBs so we + // need to use system principal. + return Cc["@mozilla.org/systemprincipal;1"].createInstance(Ci.nsIPrincipal); + } +} +exports.BaseStorageActor = BaseStorageActor; diff --git a/devtools/server/actors/resources/storage/indexed-db.js b/devtools/server/actors/resources/storage/indexed-db.js new file mode 100644 index 0000000000..8ded705c4f --- /dev/null +++ b/devtools/server/actors/resources/storage/indexed-db.js @@ -0,0 +1,984 @@ +/* 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 { + BaseStorageActor, + MAX_STORE_OBJECT_COUNT, + SEPARATOR_GUID, +} = require("resource://devtools/server/actors/resources/storage/index.js"); +const { + LongStringActor, +} = require("resource://devtools/server/actors/string.js"); +// We give this a funny name to avoid confusion with the global +// indexedDB. +loader.lazyGetter(this, "indexedDBForStorage", () => { + // On xpcshell, we can't instantiate indexedDB without crashing + try { + const sandbox = Cu.Sandbox( + Components.Constructor( + "@mozilla.org/systemprincipal;1", + "nsIPrincipal" + )(), + { wantGlobalProperties: ["indexedDB"] } + ); + return sandbox.indexedDB; + } catch (e) { + return {}; + } +}); +const lazy = {}; +ChromeUtils.defineESModuleGetters(lazy, { + Sqlite: "resource://gre/modules/Sqlite.sys.mjs", +}); + +/** + * An async method equivalent to setTimeout but using Promises + * + * @param {number} time + * The wait time in milliseconds. + */ +function sleep(time) { + return new Promise(resolve => { + setTimeout(() => { + resolve(null); + }, time); + }); +} + +const SAFE_HOSTS_PREFIXES_REGEX = /^(about\+|https?\+|file\+|moz-extension\+)/; + +// A RegExp for characters that cannot appear in a file/directory name. This is +// used to sanitize the host name for indexed db to lookup whether the file is +// present in <profileDir>/storage/default/ location +const illegalFileNameCharacters = [ + "[", + // Control characters \001 to \036 + "\\x00-\\x24", + // Special characters + '/:*?\\"<>|\\\\', + "]", +].join(""); +const ILLEGAL_CHAR_REGEX = new RegExp(illegalFileNameCharacters, "g"); + +/** + * Code related to the Indexed DB actor and front + */ + +// Metadata holder objects for various components of Indexed DB + +/** + * Meta data object for a particular index in an object store + * + * @param {IDBIndex} index + * The particular index from the object store. + */ +function IndexMetadata(index) { + this._name = index.name; + this._keyPath = index.keyPath; + this._unique = index.unique; + this._multiEntry = index.multiEntry; +} +IndexMetadata.prototype = { + toObject() { + return { + name: this._name, + keyPath: this._keyPath, + unique: this._unique, + multiEntry: this._multiEntry, + }; + }, +}; + +/** + * Meta data object for a particular object store in a db + * + * @param {IDBObjectStore} objectStore + * The particular object store from the db. + */ +function ObjectStoreMetadata(objectStore) { + this._name = objectStore.name; + this._keyPath = objectStore.keyPath; + this._autoIncrement = objectStore.autoIncrement; + this._indexes = []; + + for (let i = 0; i < objectStore.indexNames.length; i++) { + const index = objectStore.index(objectStore.indexNames[i]); + + const newIndex = { + keypath: index.keyPath, + multiEntry: index.multiEntry, + name: index.name, + objectStore: { + autoIncrement: index.objectStore.autoIncrement, + indexNames: [...index.objectStore.indexNames], + keyPath: index.objectStore.keyPath, + name: index.objectStore.name, + }, + }; + + this._indexes.push([newIndex, new IndexMetadata(index)]); + } +} +ObjectStoreMetadata.prototype = { + toObject() { + return { + name: this._name, + keyPath: this._keyPath, + autoIncrement: this._autoIncrement, + indexes: JSON.stringify( + [...this._indexes.values()].map(index => index.toObject()) + ), + }; + }, +}; + +/** + * Meta data object for a particular indexed db in a host. + * + * @param {string} origin + * The host associated with this indexed db. + * @param {IDBDatabase} db + * The particular indexed db. + * @param {String} storage + * Storage type, either "temporary", "default" or "persistent". + */ +function DatabaseMetadata(origin, db, storage) { + this._origin = origin; + this._name = db.name; + this._version = db.version; + this._objectStores = []; + this.storage = storage; + + if (db.objectStoreNames.length) { + const transaction = db.transaction(db.objectStoreNames, "readonly"); + + for (let i = 0; i < transaction.objectStoreNames.length; i++) { + const objectStore = transaction.objectStore( + transaction.objectStoreNames[i] + ); + this._objectStores.push([ + transaction.objectStoreNames[i], + new ObjectStoreMetadata(objectStore), + ]); + } + } +} +DatabaseMetadata.prototype = { + get objectStores() { + return this._objectStores; + }, + + toObject() { + return { + uniqueKey: `${this._name}${SEPARATOR_GUID}${this.storage}`, + name: this._name, + storage: this.storage, + origin: this._origin, + version: this._version, + objectStores: this._objectStores.size, + }; + }, +}; + +class IndexedDBStorageActor extends BaseStorageActor { + constructor(storageActor) { + super(storageActor, "indexedDB"); + + this.objectsSize = {}; + this.storageActor = storageActor; + } + + destroy() { + this.objectsSize = null; + + super.destroy(); + } + + // We need to override this method because of custom, async getHosts method + async populateStoresForHosts() { + for (const host of await this.getHosts()) { + await this.populateStoresForHost(host); + } + } + + async populateStoresForHost(host) { + const storeMap = new Map(); + + const win = this.storageActor.getWindowFromHost(host); + const principal = this.getPrincipal(win); + + const { names } = await this.getDBNamesForHost(host, principal); + + for (const { name, storage } of names) { + let metadata = await this.getDBMetaData(host, principal, name, storage); + + metadata = this.patchMetadataMapsAndProtos(metadata); + + storeMap.set(`${name} (${storage})`, metadata); + } + + this.hostVsStores.set(host, storeMap); + } + + /** + * Returns a list of currently known hosts for the target window. This list + * contains unique hosts from the window, all inner windows and all permanent + * indexedDB hosts defined inside the browser. + */ + async getHosts() { + // Add internal hosts to this._internalHosts, which will be picked up by + // the this.hosts getter. Because this.hosts is a property on the default + // storage actor and inherited by all storage actors we have to do it this + // way. + // Only look up internal hosts if we are in the browser toolbox + const isBrowserToolbox = this.storageActor.parentActor.isRootActor; + + this._internalHosts = isBrowserToolbox ? await this.getInternalHosts() : []; + + return this.hosts; + } + + /** + * Remove an indexedDB database from given host with a given name. + */ + async removeDatabase(host, name) { + const win = this.storageActor.getWindowFromHost(host); + if (!win) { + return { error: `Window for host ${host} not found` }; + } + + const principal = win.document.effectiveStoragePrincipal; + return this.removeDB(host, principal, name); + } + + async removeAll(host, name) { + const [db, store] = JSON.parse(name); + + const win = this.storageActor.getWindowFromHost(host); + if (!win) { + return; + } + + const principal = win.document.effectiveStoragePrincipal; + this.clearDBStore(host, principal, db, store); + } + + async removeItem(host, name) { + const [db, store, id] = JSON.parse(name); + + const win = this.storageActor.getWindowFromHost(host); + if (!win) { + return; + } + + const principal = win.document.effectiveStoragePrincipal; + this.removeDBRecord(host, principal, db, store, id); + } + + getNamesForHost(host) { + const storesForHost = this.hostVsStores.get(host); + if (!storesForHost) { + return []; + } + + const names = []; + + for (const [dbName, { objectStores }] of storesForHost) { + if (objectStores.size) { + for (const objectStore of objectStores.keys()) { + names.push(JSON.stringify([dbName, objectStore])); + } + } else { + names.push(JSON.stringify([dbName])); + } + } + + return names; + } + + /** + * Returns the total number of entries for various types of requests to + * getStoreObjects for Indexed DB actor. + * + * @param {string} host + * The host for the request. + * @param {array:string} names + * Array of stringified name objects for indexed db actor. + * The request type depends on the length of any parsed entry from this + * array. 0 length refers to request for the whole host. 1 length + * refers to request for a particular db in the host. 2 length refers + * to a particular object store in a db in a host. 3 length refers to + * particular items of an object store in a db in a host. + * @param {object} options + * An options object containing following properties: + * - index {string} The IDBIndex for the object store in the db. + */ + getObjectsSize(host, names, options) { + // In Indexed DB, we are interested in only the first name, as the pattern + // should follow in all entries. + const name = names[0]; + const parsedName = JSON.parse(name); + + if (parsedName.length == 3) { + // This is the case where specific entries from an object store were + // requested + return names.length; + } else if (parsedName.length == 2) { + // This is the case where all entries from an object store are requested. + const index = options.index; + const [db, objectStore] = parsedName; + if (this.objectsSize[host + db + objectStore + index]) { + return this.objectsSize[host + db + objectStore + index]; + } + } else if (parsedName.length == 1) { + // This is the case where details of all object stores in a db are + // requested. + if ( + this.hostVsStores.has(host) && + this.hostVsStores.get(host).has(parsedName[0]) + ) { + return this.hostVsStores.get(host).get(parsedName[0]).objectStores.size; + } + } else if (!parsedName || !parsedName.length) { + // This is the case were details of all dbs in a host are requested. + if (this.hostVsStores.has(host)) { + return this.hostVsStores.get(host).size; + } + } + return 0; + } + + /** + * Returns the over-the-wire implementation of the indexed db entity. + */ + toStoreObject(item) { + if (!item) { + return null; + } + + if ("indexes" in item) { + // Object store meta data + return { + objectStore: item.name, + keyPath: item.keyPath, + autoIncrement: item.autoIncrement, + indexes: item.indexes, + }; + } + if ("objectStores" in item) { + // DB meta data + return { + uniqueKey: `${item.name} (${item.storage})`, + db: item.name, + storage: item.storage, + origin: item.origin, + version: item.version, + objectStores: item.objectStores, + }; + } + + const value = JSON.stringify(item.value); + + // Indexed db entry + return { + name: item.name, + value: new LongStringActor(this.conn, value), + }; + } + + form() { + const hosts = {}; + for (const host of this.hosts) { + hosts[host] = this.getNamesForHost(host); + } + + return { + actor: this.actorID, + hosts, + traits: this._getTraits(), + }; + } + + onItemUpdated(action, host, path) { + dump(" IDX.onItemUpdated(" + action + " - " + host + " - " + path + "\n"); + // Database was removed, remove it from stores map + if (action === "deleted" && path.length === 1) { + if (this.hostVsStores.has(host)) { + this.hostVsStores.get(host).delete(path[0]); + } + } + + this.storageActor.update(action, "indexedDB", { + [host]: [JSON.stringify(path)], + }); + } + + async getFields(subType) { + switch (subType) { + // Detail of database + case "database": + return [ + { name: "objectStore", editable: false }, + { name: "keyPath", editable: false }, + { name: "autoIncrement", editable: false }, + { name: "indexes", editable: false }, + ]; + + // Detail of object store + case "object store": + return [ + { name: "name", editable: false }, + { name: "value", editable: false }, + ]; + + // Detail of indexedDB for one origin + default: + return [ + { name: "uniqueKey", editable: false, private: true }, + { name: "db", editable: false }, + { name: "storage", editable: false }, + { name: "origin", editable: false }, + { name: "version", editable: false }, + { name: "objectStores", editable: false }, + ]; + } + } + + /** + * Fetches and stores all the metadata information for the given database + * `name` for the given `host` with its `principal`. The stored metadata + * information is of `DatabaseMetadata` type. + */ + async getDBMetaData(host, principal, name, storage) { + const request = this.openWithPrincipal(principal, name, storage); + return new Promise(resolve => { + request.onsuccess = event => { + const db = event.target.result; + const dbData = new DatabaseMetadata(host, db, storage); + db.close(); + + resolve(dbData); + }; + request.onerror = ({ target }) => { + console.error( + `Error opening indexeddb database ${name} for host ${host}`, + target.error + ); + resolve(null); + }; + }); + } + + splitNameAndStorage(name) { + const lastOpenBracketIndex = name.lastIndexOf("("); + const lastCloseBracketIndex = name.lastIndexOf(")"); + const delta = lastCloseBracketIndex - lastOpenBracketIndex - 1; + + const storage = name.substr(lastOpenBracketIndex + 1, delta); + + name = name.substr(0, lastOpenBracketIndex - 1); + + return { storage, name }; + } + + /** + * Get all "internal" hosts. Internal hosts are database namespaces used by + * the browser. + */ + async getInternalHosts() { + const profileDir = PathUtils.profileDir; + const storagePath = PathUtils.join(profileDir, "storage", "permanent"); + const children = await IOUtils.getChildren(storagePath); + const hosts = []; + + for (const path of children) { + const exists = await IOUtils.exists(path); + if (!exists) { + continue; + } + + const stats = await IOUtils.stat(path); + if ( + stats.type === "directory" && + !SAFE_HOSTS_PREFIXES_REGEX.test(stats.path) + ) { + const basename = PathUtils.filename(path); + hosts.push(basename); + } + } + + return hosts; + } + + /** + * Opens an indexed db connection for the given `principal` and + * database `name`. + */ + openWithPrincipal(principal, name, storage) { + return indexedDBForStorage.openForPrincipal(principal, name, { + storage, + }); + } + + async removeDB(host, principal, dbName) { + const result = new Promise(resolve => { + const { name, storage } = this.splitNameAndStorage(dbName); + const request = indexedDBForStorage.deleteForPrincipal(principal, name, { + storage, + }); + + request.onsuccess = () => { + resolve({}); + this.onItemUpdated("deleted", host, [dbName]); + }; + + request.onblocked = () => { + console.warn( + `Deleting indexedDB database ${name} for host ${host} is blocked` + ); + resolve({ blocked: true }); + }; + + request.onerror = () => { + const { error } = request; + console.warn( + `Error deleting indexedDB database ${name} for host ${host}: ${error}` + ); + resolve({ error: error.message }); + }; + + // If the database is blocked repeatedly, the onblocked event will not + // be fired again. To avoid waiting forever, report as blocked if nothing + // else happens after 3 seconds. + setTimeout(() => resolve({ blocked: true }), 3000); + }); + + return result; + } + + async removeDBRecord(host, principal, dbName, storeName, id) { + let db; + const { name, storage } = this.splitNameAndStorage(dbName); + + try { + db = await new Promise((resolve, reject) => { + const request = this.openWithPrincipal(principal, name, storage); + request.onsuccess = ev => resolve(ev.target.result); + request.onerror = ev => reject(ev.target.error); + }); + + const transaction = db.transaction(storeName, "readwrite"); + const store = transaction.objectStore(storeName); + + await new Promise((resolve, reject) => { + const request = store.delete(id); + request.onsuccess = () => resolve(); + request.onerror = ev => reject(ev.target.error); + }); + + this.onItemUpdated("deleted", host, [dbName, storeName, id]); + } catch (error) { + const recordPath = [dbName, storeName, id].join("/"); + console.error( + `Failed to delete indexedDB record: ${recordPath}: ${error}` + ); + } + + if (db) { + db.close(); + } + + return null; + } + + async clearDBStore(host, principal, dbName, storeName) { + let db; + const { name, storage } = this.splitNameAndStorage(dbName); + + try { + db = await new Promise((resolve, reject) => { + const request = this.openWithPrincipal(principal, name, storage); + request.onsuccess = ev => resolve(ev.target.result); + request.onerror = ev => reject(ev.target.error); + }); + + const transaction = db.transaction(storeName, "readwrite"); + const store = transaction.objectStore(storeName); + + await new Promise((resolve, reject) => { + const request = store.clear(); + request.onsuccess = () => resolve(); + request.onerror = ev => reject(ev.target.error); + }); + + this.onItemUpdated("cleared", host, [dbName, storeName]); + } catch (error) { + const storePath = [dbName, storeName].join("/"); + console.error(`Failed to clear indexedDB store: ${storePath}: ${error}`); + } + + if (db) { + db.close(); + } + + return null; + } + + /** + * Fetches all the databases and their metadata for the given `host`. + */ + async getDBNamesForHost(host, principal) { + const sanitizedHost = this.getSanitizedHost(host) + principal.originSuffix; + const profileDir = PathUtils.profileDir; + const storagePath = PathUtils.join(profileDir, "storage"); + const files = []; + const names = []; + + // We expect sqlite DB paths to look something like this: + // - PathToProfileDir/storage/default/http+++www.example.com/ + // idb/1556056096MeysDaabta.sqlite + // - PathToProfileDir/storage/permanent/http+++www.example.com/ + // idb/1556056096MeysDaabta.sqlite + // - PathToProfileDir/storage/temporary/http+++www.example.com/ + // idb/1556056096MeysDaabta.sqlite + // The subdirectory inside the storage folder is determined by the storage + // type: + // - default: { storage: "default" } or not specified. + // - permanent: { storage: "persistent" }. + // - temporary: { storage: "temporary" }. + const sqliteFiles = await this.findSqlitePathsForHost( + storagePath, + sanitizedHost + ); + + for (const file of sqliteFiles) { + const splitPath = PathUtils.split(file); + const idbIndex = splitPath.indexOf("idb"); + const storage = splitPath[idbIndex - 2]; + const relative = file.substr(profileDir.length + 1); + + files.push({ + file: relative, + storage: storage === "permanent" ? "persistent" : storage, + }); + } + + if (files.length) { + for (const { file, storage } of files) { + const name = await this.getNameFromDatabaseFile(file); + if (name) { + names.push({ + name, + storage, + }); + } + } + } + + return { names }; + } + + /** + * Find all SQLite files that hold IndexedDB data for a host, such as: + * storage/temporary/http+++www.example.com/idb/1556056096MeysDaabta.sqlite + */ + async findSqlitePathsForHost(storagePath, sanitizedHost) { + const sqlitePaths = []; + const idbPaths = await this.findIDBPathsForHost(storagePath, sanitizedHost); + for (const idbPath of idbPaths) { + const children = await IOUtils.getChildren(idbPath); + + for (const path of children) { + const exists = await IOUtils.exists(path); + if (!exists) { + continue; + } + + const stats = await IOUtils.stat(path); + if (stats.type !== "directory" && stats.path.endsWith(".sqlite")) { + sqlitePaths.push(path); + } + } + } + return sqlitePaths; + } + + /** + * Find all paths that hold IndexedDB data for a host, such as: + * storage/temporary/http+++www.example.com/idb + */ + async findIDBPathsForHost(storagePath, sanitizedHost) { + const idbPaths = []; + const typePaths = await this.findStorageTypePaths(storagePath); + for (const typePath of typePaths) { + const idbPath = PathUtils.join(typePath, sanitizedHost, "idb"); + if (await IOUtils.exists(idbPath)) { + idbPaths.push(idbPath); + } + } + return idbPaths; + } + + /** + * Find all the storage types, such as "default", "permanent", or "temporary". + * These names have changed over time, so it seems simpler to look through all + * types that currently exist in the profile. + */ + async findStorageTypePaths(storagePath) { + const children = await IOUtils.getChildren(storagePath); + const typePaths = []; + + for (const path of children) { + const exists = await IOUtils.exists(path); + if (!exists) { + continue; + } + + const stats = await IOUtils.stat(path); + if (stats.type === "directory") { + typePaths.push(path); + } + } + + return typePaths; + } + + /** + * Removes any illegal characters from the host name to make it a valid file + * name. + */ + getSanitizedHost(host) { + if (host.startsWith("about:")) { + host = "moz-safe-" + host; + } + return host.replace(ILLEGAL_CHAR_REGEX, "+"); + } + + /** + * Retrieves the proper indexed db database name from the provided .sqlite + * file location. + */ + async getNameFromDatabaseFile(path) { + let connection = null; + let retryCount = 0; + + // Content pages might be having an open transaction for the same indexed db + // which this sqlite file belongs to. In that case, sqlite.openConnection + // will throw. Thus we retry for some time to see if lock is removed. + while (!connection && retryCount++ < 25) { + try { + connection = await lazy.Sqlite.openConnection({ path }); + } catch (ex) { + // Continuously retrying is overkill. Waiting for 100ms before next try + await sleep(100); + } + } + + if (!connection) { + return null; + } + + const rows = await connection.execute("SELECT name FROM database"); + if (rows.length != 1) { + return null; + } + + const name = rows[0].getResultByName("name"); + + await connection.close(); + + return name; + } + + async getValuesForHost( + host, + name = "null", + options, + hostVsStores, + principal + ) { + name = JSON.parse(name); + if (!name || !name.length) { + // This means that details about the db in this particular host are + // requested. + const dbs = []; + if (hostVsStores.has(host)) { + for (let [, db] of hostVsStores.get(host)) { + db = this.patchMetadataMapsAndProtos(db); + dbs.push(db.toObject()); + } + } + return { dbs }; + } + + const [db2, objectStore, id] = name; + if (!objectStore) { + // This means that details about all the object stores in this db are + // requested. + const objectStores = []; + if (hostVsStores.has(host) && hostVsStores.get(host).has(db2)) { + let db = hostVsStores.get(host).get(db2); + + db = this.patchMetadataMapsAndProtos(db); + + const objectStores2 = db.objectStores; + + for (const objectStore2 of objectStores2) { + objectStores.push(objectStore2[1].toObject()); + } + } + return { + objectStores, + }; + } + // Get either all entries from the object store, or a particular id + const storage = hostVsStores.get(host).get(db2).storage; + const result = await this.getObjectStoreData( + host, + principal, + db2, + storage, + { + objectStore, + id, + index: options.index, + offset: options.offset, + size: options.size, + } + ); + return { result }; + } + + /** + * Returns requested entries (or at most MAX_STORE_OBJECT_COUNT) from a particular + * objectStore from the db in the given host. + * + * @param {string} host + * The given host. + * @param {nsIPrincipal} principal + * The principal of the given document. + * @param {string} dbName + * The name of the indexed db from the above host. + * @param {String} storage + * Storage type, either "temporary", "default" or "persistent". + * @param {Object} requestOptions + * An object in the following format: + * { + * objectStore: The name of the object store from the above db, + * id: Id of the requested entry from the above object + * store. null if all entries from the above object + * store are requested, + * index: Name of the IDBIndex to be iterated on while fetching + * entries. null or "name" if no index is to be + * iterated, + * offset: offset of the entries to be fetched, + * size: The intended size of the entries to be fetched + * } + */ + getObjectStoreData(host, principal, dbName, storage, requestOptions) { + const { name } = this.splitNameAndStorage(dbName); + const request = this.openWithPrincipal(principal, name, storage); + + return new Promise((resolve, reject) => { + let { objectStore, id, index, offset, size } = requestOptions; + const data = []; + let db; + + if (!size || size > MAX_STORE_OBJECT_COUNT) { + size = MAX_STORE_OBJECT_COUNT; + } + + request.onsuccess = event => { + db = event.target.result; + + const transaction = db.transaction(objectStore, "readonly"); + let source = transaction.objectStore(objectStore); + if (index && index != "name") { + source = source.index(index); + } + + source.count().onsuccess = event2 => { + const objectsSize = []; + const count = event2.target.result; + objectsSize.push({ + key: host + dbName + objectStore + index, + count, + }); + + if (!offset) { + offset = 0; + } else if (offset > count) { + db.close(); + resolve([]); + return; + } + + if (id) { + source.get(id).onsuccess = event3 => { + db.close(); + resolve([{ name: id, value: event3.target.result }]); + }; + } else { + source.openCursor().onsuccess = event4 => { + const cursor = event4.target.result; + + if (!cursor || data.length >= size) { + db.close(); + resolve({ + data, + objectsSize, + }); + return; + } + if (offset-- <= 0) { + data.push({ name: cursor.key, value: cursor.value }); + } + cursor.continue(); + }; + } + }; + }; + + request.onerror = () => { + db.close(); + resolve([]); + }; + }); + } + + /** + * When indexedDB metadata is parsed to and from JSON then the object's + * prototype is dropped and any Maps are changed to arrays of arrays. This + * method is used to repair the prototypes and fix any broken Maps. + */ + patchMetadataMapsAndProtos(metadata) { + const md = Object.create(DatabaseMetadata.prototype); + Object.assign(md, metadata); + + md._objectStores = new Map(metadata._objectStores); + + for (const [name, store] of md._objectStores) { + const obj = Object.create(ObjectStoreMetadata.prototype); + Object.assign(obj, store); + + md._objectStores.set(name, obj); + + if (typeof store._indexes.length !== "undefined") { + obj._indexes = new Map(store._indexes); + } + + for (const [name2, value] of obj._indexes) { + const obj2 = Object.create(IndexMetadata.prototype); + Object.assign(obj2, value); + + obj._indexes.set(name2, obj2); + } + } + + return md; + } +} +exports.IndexedDBStorageActor = IndexedDBStorageActor; diff --git a/devtools/server/actors/resources/storage/local-and-session-storage.js b/devtools/server/actors/resources/storage/local-and-session-storage.js new file mode 100644 index 0000000000..ba0f006d22 --- /dev/null +++ b/devtools/server/actors/resources/storage/local-and-session-storage.js @@ -0,0 +1,200 @@ +/* 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 { + BaseStorageActor, + DEFAULT_VALUE, +} = require("resource://devtools/server/actors/resources/storage/index.js"); +const { + LongStringActor, +} = require("resource://devtools/server/actors/string.js"); + +class LocalOrSessionStorageActor extends BaseStorageActor { + constructor(storageActor, typeName) { + super(storageActor, typeName); + + Services.obs.addObserver(this, "dom-storage2-changed"); + Services.obs.addObserver(this, "dom-private-storage2-changed"); + } + + destroy() { + if (this.isDestroyed()) { + return; + } + Services.obs.removeObserver(this, "dom-storage2-changed"); + Services.obs.removeObserver(this, "dom-private-storage2-changed"); + + super.destroy(); + } + + getNamesForHost(host) { + const storage = this.hostVsStores.get(host); + return storage ? Object.keys(storage) : []; + } + + getValuesForHost(host, name) { + const storage = this.hostVsStores.get(host); + if (!storage) { + return []; + } + if (name) { + const value = storage ? storage.getItem(name) : null; + return [{ name, value }]; + } + if (!storage) { + return []; + } + + // local and session storage cannot be iterated over using Object.keys() + // because it skips keys that are duplicated on the prototype + // e.g. "key", "getKeys" so we need to gather the real keys using the + // storage.key() function. + const storageArray = []; + for (let i = 0; i < storage.length; i++) { + const key = storage.key(i); + storageArray.push({ + name: key, + value: storage.getItem(key), + }); + } + return storageArray; + } + + // We need to override this method as populateStoresForHost expect the window object + populateStoresForHosts() { + this.hostVsStores = new Map(); + for (const window of this.windows) { + const host = this.getHostName(window.location); + if (host) { + this.populateStoresForHost(host, window); + } + } + } + + populateStoresForHost(host, window) { + try { + this.hostVsStores.set(host, window[this.typeName]); + } catch (ex) { + console.warn( + `Failed to enumerate ${this.typeName} for host ${host}: ${ex}` + ); + } + } + + async getFields() { + return [ + { name: "name", editable: true }, + { name: "value", editable: true }, + ]; + } + + async addItem(guid, host) { + const storage = this.hostVsStores.get(host); + if (!storage) { + return; + } + storage.setItem(guid, DEFAULT_VALUE); + } + + /** + * Edit localStorage or sessionStorage fields. + * + * @param {Object} data + * See editCookie() for format details. + */ + async editItem({ host, field, oldValue, items }) { + const storage = this.hostVsStores.get(host); + if (!storage) { + return; + } + + if (field === "name") { + storage.removeItem(oldValue); + } + + storage.setItem(items.name, items.value); + } + + async removeItem(host, name) { + const storage = this.hostVsStores.get(host); + if (!storage) { + return; + } + storage.removeItem(name); + } + + async removeAll(host) { + const storage = this.hostVsStores.get(host); + if (!storage) { + return; + } + storage.clear(); + } + + observe(subject, topic, data) { + if ( + (topic != "dom-storage2-changed" && + topic != "dom-private-storage2-changed") || + data != this.typeName + ) { + return null; + } + + const host = this.getSchemaAndHost(subject.url); + + if (!this.hostVsStores.has(host)) { + return null; + } + + let action = "changed"; + if (subject.key == null) { + return this.storageActor.update("cleared", this.typeName, [host]); + } else if (subject.oldValue == null) { + action = "added"; + } else if (subject.newValue == null) { + action = "deleted"; + } + const updateData = {}; + updateData[host] = [subject.key]; + return this.storageActor.update(action, this.typeName, updateData); + } + + /** + * Given a url, correctly determine its protocol + hostname part. + */ + getSchemaAndHost(url) { + const uri = Services.io.newURI(url); + if (!uri.host) { + return uri.spec; + } + return uri.scheme + "://" + uri.hostPort; + } + + toStoreObject(item) { + if (!item) { + return null; + } + + return { + name: item.name, + value: new LongStringActor(this.conn, item.value || ""), + }; + } +} + +class LocalStorageActor extends LocalOrSessionStorageActor { + constructor(storageActor) { + super(storageActor, "localStorage"); + } +} +exports.LocalStorageActor = LocalStorageActor; + +class SessionStorageActor extends LocalOrSessionStorageActor { + constructor(storageActor) { + super(storageActor, "sessionStorage"); + } +} +exports.SessionStorageActor = SessionStorageActor; diff --git a/devtools/server/actors/resources/storage/moz.build b/devtools/server/actors/resources/storage/moz.build new file mode 100644 index 0000000000..1615254759 --- /dev/null +++ b/devtools/server/actors/resources/storage/moz.build @@ -0,0 +1,17 @@ +# -*- 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( + "cache.js", + "cookies.js", + "extension-storage.js", + "index.js", + "indexed-db.js", + "local-and-session-storage.js", +) + +with Files("**"): + BUG_COMPONENT = ("DevTools", "Storage Inspector") diff --git a/devtools/server/actors/resources/stylesheets.js b/devtools/server/actors/resources/stylesheets.js new file mode 100644 index 0000000000..9e107e305b --- /dev/null +++ b/devtools/server/actors/resources/stylesheets.js @@ -0,0 +1,145 @@ +/* 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 { + TYPES: { STYLESHEET }, +} = require("resource://devtools/server/actors/resources/index.js"); + +loader.lazyRequireGetter( + this, + "CssLogic", + "resource://devtools/shared/inspector/css-logic.js" +); + +class StyleSheetWatcher { + constructor() { + this._onApplicableStylesheetAdded = + this._onApplicableStylesheetAdded.bind(this); + this._onStylesheetUpdated = this._onStylesheetUpdated.bind(this); + this._onStylesheetRemoved = this._onStylesheetRemoved.bind(this); + } + + /** + * Start watching for all stylesheets related to a given Target Actor. + * + * @param TargetActor targetActor + * The target actor from which we should observe css changes. + * @param Object options + * Dictionary object with following attributes: + * - onAvailable: mandatory function + * This will be called for each resource. + */ + async watch(targetActor, { onAvailable, onUpdated, onDestroyed }) { + this._targetActor = targetActor; + this._onAvailable = onAvailable; + this._onUpdated = onUpdated; + this._onDestroyed = onDestroyed; + + this._styleSheetsManager = targetActor.getStyleSheetsManager(); + + // watch will call onAvailable for already existing stylesheets + await this._styleSheetsManager.watch({ + onAvailable: this._onApplicableStylesheetAdded, + onUpdated: this._onStylesheetUpdated, + onDestroyed: this._onStylesheetRemoved, + }); + } + + _onApplicableStylesheetAdded(styleSheetData) { + return this._notifyResourcesAvailable([styleSheetData]); + } + + _onStylesheetUpdated({ resourceId, updateKind, updates = {} }) { + this._notifyResourceUpdated(resourceId, updateKind, updates); + } + + _onStylesheetRemoved({ resourceId }) { + return this._notifyResourcesDestroyed(resourceId); + } + + async _toResource( + styleSheet, + { isCreatedByDevTools = false, fileName = null, resourceId } = {} + ) { + const { atRules, ruleCount } = + this._styleSheetsManager.getStyleSheetRuleCountAndAtRules(styleSheet); + + const resource = { + resourceId, + resourceType: STYLESHEET, + disabled: styleSheet.disabled, + constructed: styleSheet.constructed, + fileName, + href: styleSheet.href, + isNew: isCreatedByDevTools, + atRules, + nodeHref: this._styleSheetsManager.getNodeHref(styleSheet), + ruleCount, + sourceMapBaseURL: + this._styleSheetsManager.getSourcemapBaseURL(styleSheet), + sourceMapURL: styleSheet.sourceMapURL, + styleSheetIndex: this._styleSheetsManager.getStyleSheetIndex(resourceId), + system: CssLogic.isAgentStylesheet(styleSheet), + title: styleSheet.title, + }; + + return resource; + } + + async _notifyResourcesAvailable(styleSheets) { + const resources = await Promise.all( + styleSheets.map(async ({ resourceId, styleSheet, creationData }) => { + const resource = await this._toResource(styleSheet, { + resourceId, + isCreatedByDevTools: creationData?.isCreatedByDevTools, + fileName: creationData?.fileName, + }); + + return resource; + }) + ); + + await this._onAvailable(resources); + } + + _notifyResourceUpdated( + resourceId, + updateType, + { resourceUpdates, nestedResourceUpdates, event } + ) { + this._onUpdated([ + { + browsingContextID: this._targetActor.browsingContextID, + innerWindowId: this._targetActor.innerWindowId, + resourceType: STYLESHEET, + resourceId, + updateType, + resourceUpdates, + nestedResourceUpdates, + event, + }, + ]); + } + + _notifyResourcesDestroyed(resourceId) { + this._onDestroyed([ + { + resourceType: STYLESHEET, + resourceId, + }, + ]); + } + + destroy() { + this._styleSheetsManager.unwatch({ + onAvailable: this._onApplicableStylesheetAdded, + onUpdated: this._onStylesheetUpdated, + onDestroyed: this._onStylesheetRemoved, + }); + } +} + +module.exports = StyleSheetWatcher; diff --git a/devtools/server/actors/resources/thread-states.js b/devtools/server/actors/resources/thread-states.js new file mode 100644 index 0000000000..9ac79088d2 --- /dev/null +++ b/devtools/server/actors/resources/thread-states.js @@ -0,0 +1,136 @@ +/* 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 { + TYPES: { THREAD_STATE }, +} = require("resource://devtools/server/actors/resources/index.js"); + +const { + PAUSE_REASONS, + STATES: THREAD_STATES, +} = require("resource://devtools/server/actors/thread.js"); + +// Possible values of breakpoint's resource's `state` attribute +const STATES = { + PAUSED: "paused", + RESUMED: "resumed", +}; + +/** + * Emit THREAD_STATE resources, which is emitted each time the target's thread pauses or resumes. + * So that there is two distinct values for this resource: pauses and resumes. + * These values are distinguished by `state` attribute which can be either "paused" or "resumed". + * + * Resume events, won't expose any other attribute other than `resourceType` and `state`. + * + * Pause events will expose the following attributes: + * - why {Object}: Description of why the thread pauses. See ThreadActor's PAUSE_REASONS definition for more information. + * - frame {Object}: Description of the frame where we just paused. This is a FrameActor's form. + */ +class BreakpointWatcher { + constructor() { + this.onPaused = this.onPaused.bind(this); + this.onResumed = this.onResumed.bind(this); + } + + /** + * Start watching for state changes of the thread actor. + * This will notify whenever the thread actor pause and resume. + * + * @param TargetActor targetActor + * The target actor from which we should observe breakpoints + * @param Object options + * Dictionary object with following attributes: + * - onAvailable: mandatory function + * This will be called for each resource. + */ + async watch(targetActor, { onAvailable }) { + const { threadActor } = targetActor; + this.threadActor = threadActor; + this.onAvailable = onAvailable; + + // If this watcher is created during target creation, attach the thread actor automatically. + // Otherwise it would not pause on anything (especially debugger statements). + // However, do not attach the thread actor for Workers. They use a codepath + // which releases the worker on `attach`. For them, the client will call `attach`. (bug 1691986) + const isTargetCreation = this.threadActor.state == THREAD_STATES.DETACHED; + if (isTargetCreation && !targetActor.targetType.endsWith("worker")) { + await this.threadActor.attach({}); + } + + this.isInterrupted = false; + + threadActor.on("paused", this.onPaused); + threadActor.on("resumed", this.onResumed); + + // For top-level targets, the thread actor may have been attached by the frontend + // on toolbox opening, and we start observing for thread state updates much later. + // In which case, the thread actor may already be paused and we handle this here. + // It will also occurs for all other targets once bug 1681698 lands, + // as the thread actor will be initialized before the target starts loading. + // And it will occur for all targets once bug 1686748 lands. + // + // Note that we have to check if we have a "lastPausedPacket", + // because the thread Actor is immediately set as being paused, + // but the pause packet is built asynchronously and available slightly later. + // If the "lastPausedPacket" is null, while the thread actor is paused, + // it is fine to ignore as the "paused" event will be fire later. + if (threadActor.isPaused() && threadActor.lastPausedPacket()) { + this.onPaused(threadActor.lastPausedPacket()); + } + } + + /** + * Stop watching for breakpoints + */ + destroy() { + this.threadActor.off("paused", this.onPaused); + this.threadActor.off("resumed", this.onResumed); + } + + onPaused(packet) { + // If paused by an explicit interrupt, which are generated by the + // slow script dialog and internal events such as setting + // breakpoints, ignore the event. + const { why } = packet; + if (why.type === PAUSE_REASONS.INTERRUPTED && !why.onNext) { + this.isInterrupted = true; + return; + } + + // Ignore attached events because they are not useful to the user. + if (why.type == PAUSE_REASONS.ALREADY_PAUSED) { + return; + } + + this.onAvailable([ + { + resourceType: THREAD_STATE, + state: STATES.PAUSED, + why, + frame: packet.frame.form(), + }, + ]); + } + + onResumed(packet) { + // NOTE: resumed events are suppressed while interrupted + // to prevent unintentional behavior. + if (this.isInterrupted) { + this.isInterrupted = false; + return; + } + + this.onAvailable([ + { + resourceType: THREAD_STATE, + state: STATES.RESUMED, + }, + ]); + } +} + +module.exports = BreakpointWatcher; 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; + } +} diff --git a/devtools/server/actors/resources/websockets.js b/devtools/server/actors/resources/websockets.js new file mode 100644 index 0000000000..5845357a9c --- /dev/null +++ b/devtools/server/actors/resources/websockets.js @@ -0,0 +1,196 @@ +/* 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 { + LongStringActor, +} = require("resource://devtools/server/actors/string.js"); + +const { + TYPES: { WEBSOCKET }, +} = require("resource://devtools/server/actors/resources/index.js"); + +const webSocketEventService = Cc[ + "@mozilla.org/websocketevent/service;1" +].getService(Ci.nsIWebSocketEventService); + +class WebSocketWatcher { + constructor() { + this.windowIds = new Set(); + // Maintains a map of all the connection channels per websocket + // The map item is keyed on the `webSocketSerialID` and stores + // the `httpChannelId` as value. + this.connections = new Map(); + this.onWindowReady = this.onWindowReady.bind(this); + this.onWindowDestroy = this.onWindowDestroy.bind(this); + } + + static createResource(wsMessageType, eventParams) { + return { + resourceType: WEBSOCKET, + wsMessageType, + ...eventParams, + }; + } + + static prepareFramePayload(targetActor, frame) { + const payload = new LongStringActor(targetActor.conn, frame.payload); + targetActor.manage(payload); + return payload.form(); + } + + watch(targetActor, { onAvailable }) { + this.targetActor = targetActor; + this.onAvailable = onAvailable; + + for (const window of this.targetActor.windows) { + const { innerWindowId } = window.windowGlobalChild; + this.startListening(innerWindowId); + } + + // On navigate/reload we should re-start listening with the + // new `innerWindowID` + this.targetActor.on("window-ready", this.onWindowReady); + this.targetActor.on("window-destroyed", this.onWindowDestroy); + } + + onWindowReady({ window }) { + if (!this.targetActor.followWindowGlobalLifeCycle) { + const { innerWindowId } = window.windowGlobalChild; + this.startListening(innerWindowId); + } + } + + onWindowDestroy({ id }) { + this.stopListening(id); + } + + startListening(innerWindowId) { + if (!this.windowIds.has(innerWindowId)) { + this.windowIds.add(innerWindowId); + webSocketEventService.addListener(innerWindowId, this); + } + } + + stopListening(innerWindowId) { + if (this.windowIds.has(innerWindowId)) { + this.windowIds.delete(innerWindowId); + if (!webSocketEventService.hasListenerFor(innerWindowId)) { + // The listener might have already been cleaned up on `window-destroy`. + console.warn( + "Already stopped listening to websocket events for this window." + ); + return; + } + webSocketEventService.removeListener(innerWindowId, this); + } + } + + destroy() { + for (const id of this.windowIds) { + this.stopListening(id); + } + this.targetActor.off("window-ready", this.onWindowReady); + this.targetActor.off("window-destroyed", this.onWindowDestroy); + } + + // methods for the nsIWebSocketEventService + webSocketCreated(webSocketSerialID, uri, protocols) {} + + webSocketOpened( + webSocketSerialID, + effectiveURI, + protocols, + extensions, + httpChannelId + ) { + this.connections.set(webSocketSerialID, httpChannelId); + const resource = WebSocketWatcher.createResource("webSocketOpened", { + httpChannelId, + effectiveURI, + protocols, + extensions, + }); + + this.onAvailable([resource]); + } + + webSocketMessageAvailable(webSocketSerialID, data, messageType) {} + + webSocketClosed(webSocketSerialID, wasClean, code, reason) { + const httpChannelId = this.connections.get(webSocketSerialID); + this.connections.delete(webSocketSerialID); + + const resource = WebSocketWatcher.createResource("webSocketClosed", { + httpChannelId, + wasClean, + code, + reason, + }); + + this.onAvailable([resource]); + } + + frameReceived(webSocketSerialID, frame) { + const httpChannelId = this.connections.get(webSocketSerialID); + if (!httpChannelId) { + return; + } + + const payload = WebSocketWatcher.prepareFramePayload( + this.targetActor, + frame + ); + const resource = WebSocketWatcher.createResource("frameReceived", { + httpChannelId, + data: { + type: "received", + payload, + timeStamp: frame.timeStamp, + finBit: frame.finBit, + rsvBit1: frame.rsvBit1, + rsvBit2: frame.rsvBit2, + rsvBit3: frame.rsvBit3, + opCode: frame.opCode, + mask: frame.mask, + maskBit: frame.maskBit, + }, + }); + + this.onAvailable([resource]); + } + + frameSent(webSocketSerialID, frame) { + const httpChannelId = this.connections.get(webSocketSerialID); + + if (!httpChannelId) { + return; + } + + const payload = WebSocketWatcher.prepareFramePayload( + this.targetActor, + frame + ); + const resource = WebSocketWatcher.createResource("frameSent", { + httpChannelId, + data: { + type: "sent", + payload, + timeStamp: frame.timeStamp, + finBit: frame.finBit, + rsvBit1: frame.rsvBit1, + rsvBit2: frame.rsvBit2, + rsvBit3: frame.rsvBit3, + opCode: frame.opCode, + mask: frame.mask, + maskBit: frame.maskBit, + }, + }); + + this.onAvailable([resource]); + } +} + +module.exports = WebSocketWatcher; |