diff options
Diffstat (limited to 'devtools/server/actors/resources')
29 files changed, 4556 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..8ad97b366c --- /dev/null +++ b/devtools/server/actors/resources/console-messages.js @@ -0,0 +1,278 @@ +/* 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 }) { + // 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 window = + targetActor.targetType === Targets.TYPES.FRAME && + targetActor.typeName != "parentProcessTarget" && + targetActor.typeName != "webExtensionTarget" + ? targetActor.window + : null; + + 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(); + } + } + + /** + * Called by devtools/server/actors/utils/logEvent.js, whenever a new + * log point is triggered and request to spawn a console message + * + * @param Object message + * A fake nsIConsoleMessage, which looks like the one being generated by + * the platform API. + */ + onLogPoint(message) { + if (!this.listener) { + throw new Error("This target actor isn't listening to console messages"); + } + this.listener.handler(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, + 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..6c4f28c646 --- /dev/null +++ b/devtools/server/actors/resources/css-messages.js @@ -0,0 +1,139 @@ +/* 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"); + +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. + if (targetActor.ensureCSSErrorReportingEnabled) { + targetActor.ensureCSSErrorReportingEnabled(); + } + } + + /** + * 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, + }; + } +} +module.exports = CSSMessageWatcher; 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..c3dee037f0 --- /dev/null +++ b/devtools/server/actors/resources/index.js @@ -0,0 +1,438 @@ +/* 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", + 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", + WEBSOCKET: "websocket", + + // storage types + CACHE_STORAGE: "Cache", + COOKIE: "cookies", + 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.DOCUMENT_EVENT]: { + path: "devtools/server/actors/resources/document-event", + }, + [TYPES.ERROR_MESSAGE]: { + path: "devtools/server/actors/resources/error-messages", + }, + [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.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.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.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; + 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); + } + 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(); + await 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); + } +} +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/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..c5f4c5e4c4 --- /dev/null +++ b/devtools/server/actors/resources/moz.build @@ -0,0 +1,39 @@ +# -*- 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 += [ + "utils", +] + +DevToolsModules( + "console-messages.js", + "css-changes.js", + "css-messages.js", + "document-event.js", + "error-messages.js", + "extensions-backgroundscript-status.js", + "index.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-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..7d5607faec --- /dev/null +++ b/devtools/server/actors/resources/network-events-content.js @@ -0,0 +1,258 @@ +/* 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 }) { + 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(); + } + + get conn() { + return this.targetActor.conn; + } + + 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, + }, + resourceOverrides: null, + onNetworkEventUpdate: this.onFailedNetworkEventUpdated.bind(this), + }); + } + + 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 hasNetworkEventForURI = Array.from(this._networkEvents.values()).find( + networkEvent => networkEvent.url === channel.URI.spec + ); + + if (hasNetworkEventForURI) { + return; + } + + this.onNetworkEventAvailable(channel, { + networkEventOptions: { fromCache: true }, + resourceOverrides: { + status: 200, + statusText: "OK", + totalTime: 0, + mimeType: channel.contentType, + contentSize: channel.contentLength, + }, + onNetworkEventUpdate: this.onImageCacheNetworkEventUpdated.bind(this), + }); + } + + onNetworkEventAvailable( + channel, + { networkEventOptions, resourceOverrides, onNetworkEventUpdate } + ) { + const event = lazy.NetworkUtils.createNetworkEvent( + channel, + networkEventOptions + ); + + const actor = new NetworkEventActor( + this.conn, + this.targetActor.sessionContext, + { + onNetworkEventUpdate, + onNetworkEventDestroy: this.onNetworkEventDestroyed.bind(this), + }, + event + ); + this.targetActor.manage(actor); + + const resource = actor.asResource(); + + this._networkEvents.set(resource.resourceId, { + browsingContextID: resource.browsingContextID, + innerWindowId: resource.innerWindowId, + resourceId: resource.resourceId, + resourceType: resource.resourceType, + url: resource.url, + types: [], + resourceUpdates: {}, + }); + + // Override the default resource property values if need be + if (resourceOverrides) { + for (const prop in resourceOverrides) { + resource[prop] = resourceOverrides[prop]; + } + } + + this.onAvailable([resource]); + const { + cookies, + headers, + } = lazy.NetworkUtils.fetchRequestHeadersAndCookies(channel); + actor.addRequestHeaders(headers); + actor.addRequestCookies(cookies); + } + + /* + * When an update is needed for a network event. + * + * @param {Object} updateResource + * The resource to be updated + * @param {Array} allRequiredUpdates + * The updates that are essential to be received before notifying + * the client. Other updates may or may not be available. + */ + + onNetworkEventUpdated(updateResource, allRequiredUpdates) { + const networkEvent = this._networkEvents.get(updateResource.resourceId); + + if (!networkEvent) { + return; + } + + const { + browsingContextID, + innerWindowId, + resourceId, + resourceType, + resourceUpdates, + types, + } = networkEvent; + + resourceUpdates[`${updateResource.updateType}Available`] = true; + types.push(updateResource.updateType); + + if (allRequiredUpdates.every(header => types.includes(header))) { + this.onUpdated([ + { + browsingContextID, + innerWindowId, + resourceType, + resourceId, + resourceUpdates, + }, + ]); + } + } + + onFailedNetworkEventUpdated(updateResource) { + this.onNetworkEventUpdated(updateResource, [ + "requestHeaders", + "requestCookies", + ]); + } + + onImageCacheNetworkEventUpdated(updateResource) { + this.onNetworkEventUpdated(updateResource, ["requestHeaders"]); + } + + 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..075c7d3ccd --- /dev/null +++ b/devtools/server/actors/resources/network-events-stacktraces.js @@ -0,0 +1,207 @@ +/* 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) { + // 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..c781ecc45f --- /dev/null +++ b/devtools/server/actors/resources/network-events.js @@ -0,0 +1,392 @@ +/* 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.pool = new Pool(watcherActor.conn, "network-events"); + this.watcherActor.manage(this.pool); + 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. + */ + clear() { + this.networkEvents.clear(); + this.listener.clear(); + this.pool.destroy(); + } + + get conn() { + return this.watcherActor.conn; + } + + /** + * 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(); + } + + /** + * 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.innerWindowId == 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.innerWindowId && + child.innerWindowId != 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(event) { + const { channelId } = event; + + if (this.networkEvents.has(channelId)) { + throw new Error( + `Got notified about 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), + }, + event + ); + this.pool.manage(actor); + + const resource = actor.asResource(); + + this.networkEvents.set(resource.resourceId, { + browsingContextID: resource.browsingContextID, + innerWindowId: resource.innerWindowId, + resourceId: resource.resourceId, + resourceType: resource.resourceType, + isBlocked: !!resource.blockedReason, + types: [], + resourceUpdates: {}, + }); + + this.onNetworkEventAvailable([resource]); + return actor; + } + + onNetworkEventUpdate(updateResource) { + const networkEvent = this.networkEvents.get(updateResource.resourceId); + + if (!networkEvent) { + return; + } + + const { + browsingContextID, + innerWindowId, + resourceId, + resourceType, + resourceUpdates, + types, + isBlocked, + } = 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; + 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; + types.push(updateResource.updateType); + + if (isBlocked) { + // Blocked requests + if ( + !types.includes("requestHeaders") || + !types.includes("requestCookies") + ) { + return; + } + } else if ( + // Un-blocked requests + !types.includes("requestHeaders") || + !types.includes("requestCookies") || + !types.includes("eventTimings") || + !types.includes("responseContent") || + !types.includes("securityInfo") + ) { + return; + } + + this.onNetworkEventUpdated([ + { + resourceType, + resourceId, + resourceUpdates, + browsingContextID, + 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..7d7b14fbc5 --- /dev/null +++ b/devtools/server/actors/resources/parent-process-document-event.js @@ -0,0 +1,177 @@ +/* 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 { getAllBrowsingContextsForContext } = ChromeUtils.importESModule( + "resource://devtools/server/actors/watcher/browsing-context-helpers.sys.mjs" +); +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 = getAllBrowsingContextsForContext( + this.watcherActor.sessionContext + ).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..6076e333c9 --- /dev/null +++ b/devtools/server/actors/resources/sources.js @@ -0,0 +1,97 @@ +/* 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); + + // If the thread actors isn't bootstraped yet, + // (this might be the case when this watcher is created on target creation) + // attach the thread actor automatically. + // Otherwise it would not notify about future sources. + // 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) + // Content process targets don't have attach method or sequence. + // Instead their thread actor is instantiated immediately, when generating their + // form. Which is called immediately when we notify the target actor to the TargetList. + const isTargetCreation = threadActor.state == THREAD_STATES.DETACHED; + if (isTargetCreation && !targetActor.targetType.endsWith("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..2a1153a962 --- /dev/null +++ b/devtools/server/actors/resources/storage-cache.js @@ -0,0 +1,19 @@ +/* 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"); + +class CacheWatcher extends ContentProcessStorage { + constructor() { + super("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..4f26c97039 --- /dev/null +++ b/devtools/server/actors/resources/storage-cookie.js @@ -0,0 +1,19 @@ +/* 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"); + +class CookiesWatcher extends ParentProcessStorage { + constructor() { + super("cookies", COOKIE); + } +} + +module.exports = CookiesWatcher; 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..a3ba37d612 --- /dev/null +++ b/devtools/server/actors/resources/storage-indexed-db.js @@ -0,0 +1,19 @@ +/* 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"); + +class IndexedDBWatcher extends ParentProcessStorage { + constructor() { + super("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..26df65943d --- /dev/null +++ b/devtools/server/actors/resources/storage-local-storage.js @@ -0,0 +1,19 @@ +/* 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"); + +class LocalStorageWatcher extends ContentProcessStorage { + constructor() { + super("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..a8930eb6b9 --- /dev/null +++ b/devtools/server/actors/resources/storage-session-storage.js @@ -0,0 +1,19 @@ +/* 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"); + +class SessionStorageWatcher extends ContentProcessStorage { + constructor() { + super("sessionStorage", SESSION_STORAGE); + } +} + +module.exports = SessionStorageWatcher; diff --git a/devtools/server/actors/resources/stylesheets.js b/devtools/server/actors/resources/stylesheets.js new file mode 100644 index 0000000000..9c5e32cd3d --- /dev/null +++ b/devtools/server/actors/resources/stylesheets.js @@ -0,0 +1,138 @@ +/* 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); + } + + /** + * 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 }) { + this._targetActor = targetActor; + this._onAvailable = onAvailable; + this._onUpdated = onUpdated; + + this._styleSheetsManager = targetActor.getStyleSheetManager(); + + // Add event listener for new additions and updates + this._styleSheetsManager.on( + "applicable-stylesheet-added", + this._onApplicableStylesheetAdded + ); + this._styleSheetsManager.on( + "stylesheet-updated", + this._onStylesheetUpdated + ); + + // startWatching will emit applicable-stylesheet-added for already existing stylesheet + await this._styleSheetsManager.startWatching(); + } + + _onApplicableStylesheetAdded(styleSheetData) { + return this._notifyResourcesAvailable([styleSheetData]); + } + + _onStylesheetUpdated({ resourceId, updateKind, updates = {} }) { + this._notifyResourceUpdated(resourceId, updateKind, updates); + } + + async _toResource( + styleSheet, + { isCreatedByDevTools = false, fileName = null, resourceId } = {} + ) { + const resource = { + resourceId, + resourceType: STYLESHEET, + disabled: styleSheet.disabled, + constructed: styleSheet.constructed, + fileName, + href: styleSheet.href, + isNew: isCreatedByDevTools, + atRules: await this._styleSheetsManager.getAtRules(styleSheet), + nodeHref: this._styleSheetsManager._getNodeHref(styleSheet), + ruleCount: styleSheet.cssRules.length, + sourceMapBaseURL: this._styleSheetsManager._getSourcemapBaseURL( + styleSheet + ), + sourceMapURL: styleSheet.sourceMapURL, + styleSheetIndex: this._styleSheetsManager._getStyleSheetIndex(styleSheet), + 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, + }, + ]); + } + + destroy() { + this._styleSheetsManager.off( + "applicable-stylesheet-added", + this._onApplicableStylesheetAdded + ); + this._styleSheetsManager.off( + "stylesheet-updated", + this._onStylesheetUpdated + ); + } +} + +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..d4884a99f3 --- /dev/null +++ b/devtools/server/actors/resources/utils/content-process-storage.js @@ -0,0 +1,458 @@ +/* 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 { + storageTypePool, +} = require("resource://devtools/server/actors/storage.js"); +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(storageKey, storageType) { + this.storageKey = storageKey; + this.storageType = storageType; + + this.onStoresUpdate = this.onStoresUpdate.bind(this); + this.onStoresCleared = this.onStoresCleared.bind(this); + } + + async watch(targetActor, { onAvailable }) { + const ActorConstructor = storageTypePool.get(this.storageKey); + const storageActor = new StorageActorMock(targetActor); + this.storageActor = storageActor; + this.actor = new ActorConstructor(storageActor); + + // Some storage types require to prelist their stores + if (typeof this.actor.preListStores === "function") { + await this.actor.preListStores(); + } + + // We have to manage the actor manually, because ResourceCommand doesn't + // use the protocol.js specification. + // resource-available-form is typed as "json" + // So that we have to manually handle stuff that would normally be + // automagically done by procotol.js + // 1) Manage the actor in order to have an actorID on it + targetActor.manage(this.actor); + // 2) Convert to JSON "form" + const form = this.actor.form(); + + // NOTE: this is hoisted, so the `update` method above may use it. + const storage = form; + + // All resources should have a resourceType, resourceId and resourceKey + // attributes, so available/updated/destroyed callbacks work properly. + storage.resourceType = this.storageType; + storage.resourceId = this.storageType; + storage.resourceKey = this.storageKey; + + onAvailable([storage]); + + // Maps global events from `storageActor` shared for all storage-types, + // down to storage-type's specific actor `storage`. + storageActor.on("stores-update", this.onStoresUpdate); + + // When a store gets cleared + storageActor.on("stores-cleared", this.onStoresCleared); + } + + onStoresUpdate(response) { + response = getFilteredStorageEvents(response, this.storageKey); + if (!response) { + return; + } + this.actor.emit("single-store-update", { + changed: response.changed, + added: response.added, + deleted: response.deleted, + }); + } + + onStoresCleared(response) { + const cleared = response[this.storageKey]; + + if (!cleared) { + return; + } + + this.actor.emit("single-store-cleared", { + clearedHostsOrPaths: cleared, + }); + } + + destroy() { + this.actor?.destroy(); + this.actor = null; + if (this.storageActor) { + this.storageActor.on("stores-update", this.onStoresUpdate); + this.storageActor.on("stores-cleared", this.onStoresCleared); + this.storageActor.destroy(); + this.storageActor = null; + } + } +} + +module.exports = ContentProcessStorage; + +// This class mocks what used to be implement in devtools/server/actors/storage.js: StorageActor +// But without being a protocol.js actor, nor implement any RDP method/event. +// An instance of this class is passed to each storage type actor and named `storageActor`. +// Once we implement all storage type in watcher classes, we can get rid of the original +// StorageActor in devtools/server/actors/storage.js +class StorageActorMock extends EventEmitter { + constructor(targetActor) { + super(); + // Storage classes fetch conn from storageActor + this.conn = targetActor.conn; + this.targetActor = targetActor; + + this.childWindowPool = new Set(); + + // Fetch all the inner iframe windows in this tab. + this.fetchChildWindows(this.targetActor.docShell); + + // Notifications that help us keep track of newly added windows and windows + // that got removed + Services.obs.addObserver(this, "content-document-global-created"); + Services.obs.addObserver(this, "inner-window-destroyed"); + this.onPageChange = this.onPageChange.bind(this); + + const handler = targetActor.chromeEventHandler; + handler.addEventListener("pageshow", this.onPageChange, true); + handler.addEventListener("pagehide", this.onPageChange, true); + + this.destroyed = false; + this.boundUpdate = {}; + } + + destroy() { + clearTimeout(this.batchTimer); + this.batchTimer = null; + // Remove observers + Services.obs.removeObserver(this, "content-document-global-created"); + Services.obs.removeObserver(this, "inner-window-destroyed"); + this.destroyed = true; + if (this.targetActor.browser) { + this.targetActor.browser.removeEventListener( + "pageshow", + this.onPageChange, + true + ); + this.targetActor.browser.removeEventListener( + "pagehide", + this.onPageChange, + true + ); + } + this.childWindowPool.clear(); + + this.childWindowPool = null; + this.targetActor = null; + this.boundUpdate = null; + } + + get window() { + return this.targetActor.window; + } + + get document() { + return this.targetActor.window.document; + } + + get windows() { + return this.childWindowPool; + } + + /** + * Given a docshell, recursively find out all the child windows from it. + * + * @param {nsIDocShell} item + * The docshell from which all inner windows need to be extracted. + */ + fetchChildWindows(item) { + const docShell = item + .QueryInterface(Ci.nsIDocShell) + .QueryInterface(Ci.nsIDocShellTreeItem); + if (!docShell.contentViewer) { + return null; + } + const window = docShell.contentViewer.DOMDocument.defaultView; + if (window.location.href == "about:blank") { + // Skip out about:blank windows as Gecko creates them multiple times while + // creating any global. + return null; + } + if (!this.isIncludedInTopLevelWindow(window)) { + return null; + } + this.childWindowPool.add(window); + for (let i = 0; i < docShell.childCount; i++) { + const child = docShell.getChildAt(i); + this.fetchChildWindows(child); + } + return null; + } + + isIncludedInTargetExtension(subject) { + const addonId = lazy.getAddonIdForWindowGlobal(subject.windowGlobalChild); + return addonId && addonId === this.targetActor.addonId; + } + + isIncludedInTopLevelWindow(window) { + return this.targetActor.windows.includes(window); + } + + getWindowFromInnerWindowID(innerID) { + innerID = innerID.QueryInterface(Ci.nsISupportsPRUint64).data; + for (const win of this.childWindowPool.values()) { + const id = win.windowGlobalChild.innerWindowId; + if (id == innerID) { + return win; + } + } + return null; + } + + getWindowFromHost(host) { + for (const win of this.childWindowPool.values()) { + const origin = win.document.nodePrincipal.originNoSuffix; + const url = win.document.URL; + if (origin === host || url === host) { + return win; + } + } + return null; + } + + /** + * Event handler for any docshell update. This lets us figure out whenever + * any new window is added, or an existing window is removed. + */ + observe(subject, topic) { + if ( + subject.location && + (!subject.location.href || subject.location.href == "about:blank") + ) { + return null; + } + + // We don't want to try to find a top level window for an extension page, as + // in many cases (e.g. background page), it is not loaded in a tab, and + // 'isIncludedInTopLevelWindow' throws an error + if ( + topic == "content-document-global-created" && + (this.isIncludedInTargetExtension(subject) || + this.isIncludedInTopLevelWindow(subject)) + ) { + this.childWindowPool.add(subject); + this.emit("window-ready", subject); + } else if (topic == "inner-window-destroyed") { + const window = this.getWindowFromInnerWindowID(subject); + if (window) { + this.childWindowPool.delete(window); + this.emit("window-destroyed", window); + } + } + return null; + } + + /** + * Called on "pageshow" or "pagehide" event on the chromeEventHandler of + * current tab. + * + * @param {event} The event object passed to the handler. We are using these + * three properties from the event: + * - target {document} The document corresponding to the event. + * - type {string} Name of the event - "pageshow" or "pagehide". + * - persisted {boolean} true if there was no + * "content-document-global-created" notification along + * this event. + */ + onPageChange({ target, type, persisted }) { + if (this.destroyed) { + return; + } + + const window = target.defaultView; + + if (type == "pagehide" && this.childWindowPool.delete(window)) { + this.emit("window-destroyed", window); + } else if ( + type == "pageshow" && + persisted && + window.location.href && + window.location.href != "about:blank" && + this.isIncludedInTopLevelWindow(window) + ) { + this.childWindowPool.add(window); + this.emit("window-ready", window); + } + } + + /** + * This method is called by the registered storage types so as to tell the + * Storage Actor that there are some changes in the stores. Storage Actor then + * notifies the client front about these changes at regular (BATCH_DELAY) + * interval. + * + * @param {string} action + * The type of change. One of "added", "changed" or "deleted" + * @param {string} storeType + * The storage actor in which this change has occurred. + * @param {object} data + * The update object. This object is of the following format: + * - { + * <host1>: [<store_names1>, <store_name2>...], + * <host2>: [<store_names34>...], + * } + * Where host1, host2 are the host in which this change happened and + * [<store_namesX] is an array of the names of the changed store objects. + * Pass an empty array if the host itself was affected: either completely + * removed or cleared. + */ + // eslint-disable-next-line complexity + update(action, storeType, data) { + if (action == "cleared") { + this.emit("stores-cleared", { [storeType]: data }); + return null; + } + + if (this.batchTimer) { + clearTimeout(this.batchTimer); + } + if (!this.boundUpdate[action]) { + this.boundUpdate[action] = {}; + } + if (!this.boundUpdate[action][storeType]) { + this.boundUpdate[action][storeType] = {}; + } + for (const host in data) { + if (!this.boundUpdate[action][storeType][host]) { + this.boundUpdate[action][storeType][host] = []; + } + for (const name of data[host]) { + if (!this.boundUpdate[action][storeType][host].includes(name)) { + this.boundUpdate[action][storeType][host].push(name); + } + } + } + if (action == "added") { + // If the same store name was previously deleted or changed, but now is + // added somehow, dont send the deleted or changed update. + this.removeNamesFromUpdateList("deleted", storeType, data); + this.removeNamesFromUpdateList("changed", storeType, data); + } else if ( + action == "changed" && + this.boundUpdate.added && + this.boundUpdate.added[storeType] + ) { + // If something got added and changed at the same time, then remove those + // items from changed instead. + this.removeNamesFromUpdateList( + "changed", + storeType, + this.boundUpdate.added[storeType] + ); + } else if (action == "deleted") { + // If any item got delete, or a host got delete, no point in sending + // added or changed update + this.removeNamesFromUpdateList("added", storeType, data); + this.removeNamesFromUpdateList("changed", storeType, data); + + for (const host in data) { + if ( + !data[host].length && + this.boundUpdate.added && + this.boundUpdate.added[storeType] && + this.boundUpdate.added[storeType][host] + ) { + delete this.boundUpdate.added[storeType][host]; + } + if ( + !data[host].length && + this.boundUpdate.changed && + this.boundUpdate.changed[storeType] && + this.boundUpdate.changed[storeType][host] + ) { + delete this.boundUpdate.changed[storeType][host]; + } + } + } + + this.batchTimer = setTimeout(() => { + clearTimeout(this.batchTimer); + this.emit("stores-update", this.boundUpdate); + this.boundUpdate = {}; + }, BATCH_DELAY); + + return null; + } + + /** + * This method removes data from the this.boundUpdate object in the same + * manner like this.update() adds data to it. + * + * @param {string} action + * The type of change. One of "added", "changed" or "deleted" + * @param {string} storeType + * The storage actor for which you want to remove the updates data. + * @param {object} data + * The update object. This object is of the following format: + * - { + * <host1>: [<store_names1>, <store_name2>...], + * <host2>: [<store_names34>...], + * } + * Where host1, host2 are the hosts which you want to remove and + * [<store_namesX] is an array of the names of the store objects. + */ + removeNamesFromUpdateList(action, storeType, data) { + for (const host in data) { + if ( + this.boundUpdate[action] && + this.boundUpdate[action][storeType] && + this.boundUpdate[action][storeType][host] + ) { + for (const name in data[host]) { + const index = this.boundUpdate[action][storeType][host].indexOf(name); + if (index > -1) { + this.boundUpdate[action][storeType][host].splice(index, 1); + } + } + if (!this.boundUpdate[action][storeType][host].length) { + delete this.boundUpdate[action][storeType][host]; + } + } + } + return null; + } +} diff --git a/devtools/server/actors/resources/utils/moz.build b/devtools/server/actors/resources/utils/moz.build new file mode 100644 index 0000000000..0e6f9d1baa --- /dev/null +++ b/devtools/server/actors/resources/utils/moz.build @@ -0,0 +1,14 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +DevToolsModules( + "content-process-storage.js", + "nsi-console-listener-watcher.js", + "parent-process-storage.js", +) + +with Files("nsi-console-listener-watcher.js"): + BUG_COMPONENT = ("DevTools", "Console") diff --git a/devtools/server/actors/resources/utils/nsi-console-listener-watcher.js b/devtools/server/actors/resources/utils/nsi-console-listener-watcher.js new file mode 100644 index 0000000000..9d88721900 --- /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..ac897b565a --- /dev/null +++ b/devtools/server/actors/resources/utils/parent-process-storage.js @@ -0,0 +1,584 @@ +/* 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 { + storageTypePool, +} = require("resource://devtools/server/actors/storage.js"); +const EventEmitter = require("resource://devtools/shared/event-emitter.js"); +const { + getAllBrowsingContextsForContext, + 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(storageKey, storageType) { + 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 { + 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 ActorConstructor = storageTypePool.get(this.storageKey); + + const storageActor = new StorageActorMock(this.watcherActor); + this.storageActor = storageActor; + this.actor = new ActorConstructor(storageActor); + + // Some storage types require to prelist their stores + if (typeof this.actor.preListStores === "function") { + try { + await this.actor.preListStores(); + } catch (e) { + // It can happen that the actor gets destroyed while preListStores 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 ( + getAllBrowsingContextsForContext(this.watcherActor.sessionContext, { + 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 = getAllBrowsingContextsForContext( + this.watcherActor.sessionContext, + { 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" }; + } + + /** + * 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; |