diff options
Diffstat (limited to 'devtools/server/actors/resources')
17 files changed, 2678 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..e5d92d729c --- /dev/null +++ b/devtools/server/actors/resources/console-messages.js @@ -0,0 +1,247 @@ +/* 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 { WebConsoleUtils } = require("devtools/server/actors/webconsole/utils"); + +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"); + +/** + * 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 }) { + // The following code expects the ThreadActor to be instantiated, via: + // prepareConsoleMessageForRemote > SourcesManager.getActorIdForInternalSourceId + // The Thread Actor is instantiated via Target.attach, but we should + // probably review this and only instantiate the actor instead of attaching the target. + if (!targetActor.threadActor) { + targetActor.attach(); + } + + // Bug 1642297: Maybe we could merge ConsoleAPI Listener into this module? + const onConsoleAPICall = message => { + onAvailable([ + { + resourceType: CONSOLE_MESSAGE, + message: prepareConsoleMessageForRemote(targetActor, message), + }, + ]); + }; + + // Create the consoleAPIListener + // (and apply the filtering options defined in the target actor). + const listener = new ConsoleAPIListener( + targetActor.window, + onConsoleAPICall, + 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) { + if ( + !result || + !Array.isArray(result.arguments) || + result.arguments.length == 0 + ) { + return null; + } + + 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 console-api-log-event. + * @return object + * The object that can be sent to the remote client. + */ +function prepareConsoleMessageForRemote(targetActor, message) { + const result = WebConsoleUtils.cloneObject(message); + + result.workerType = WebConsoleUtils.getWorkerType(result) || "none"; + result.sourceId = getActorIdForInternalSourceId(targetActor, result.sourceId); + + delete result.wrappedJSObject; + delete result.ID; + delete result.innerID; + delete result.consoleID; + + if (result.stacktrace) { + result.stacktrace = result.stacktrace.map(frame => { + return { + ...frame, + sourceId: getActorIdForInternalSourceId(targetActor, frame.sourceId), + }; + }); + } + + result.arguments = (message.arguments || []).map(obj => { + const dbgObj = makeDebuggeeValue(targetActor, obj); + return createValueGripForTarget(targetActor, dbgObj); + }); + + result.styles = (message.styles || []).map(string => { + return createValueGripForTarget(targetActor, string); + }); + + if (result.level === "table") { + 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); + } + + result.category = message.category || "webdev"; + result.innerWindowID = message.innerID; + + 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..ba4d424ffb --- /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("devtools/server/actors/resources/index"); +const TrackChangeEmitter = require("devtools/server/actors/utils/track-change-emitter"); + +/** + * 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..1e3f901880 --- /dev/null +++ b/devtools/server/actors/resources/css-messages.js @@ -0,0 +1,133 @@ +/* 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("devtools/server/actors/resources/utils/nsi-console-listener-watcher"); +const { Ci } = require("chrome"); +const { DevToolsServer } = require("devtools/server/devtools-server"); +const { createStringGrip } = require("devtools/server/actors/object/utils"); +const { + getActorIdForInternalSourceId, +} = require("devtools/server/actors/utils/dbg-source"); + +const { + TYPES: { CSS_MESSAGE }, +} = require("devtools/server/actors/resources/index"); + +const { MESSAGE_CATEGORY } = require("devtools/shared/constants"); + +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; + } + + // Process targets listen for everything but messages from private windows. + if (this.isProcessTarget(targetActor)) { + return !message.isFromPrivateWindow; + } + + if (!message.innerWindowID) { + return false; + } + + const { window } = targetActor; + const win = window?.WindowGlobalChild?.getByInnerWindowId( + message.innerWindowID + ); + return targetActor.browserId === win?.browsingContext?.browserId; + } + + /** + * 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.timeStamp, + 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..f39d7f3f9a --- /dev/null +++ b/devtools/server/actors/resources/document-event.js @@ -0,0 +1,52 @@ +/* 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("devtools/server/actors/resources/index"); +const { + DocumentEventsListener, +} = require("devtools/server/actors/webconsole/listeners/document-events"); + +class DocumentEventWatcher { + /** + * 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) => { + onAvailable([ + { + resourceType: DOCUMENT_EVENT, + name, + time, + }, + ]); + }; + + this.listener = new DocumentEventsListener(targetActor); + this.listener.on("*", onDocumentEvent); + this.listener.listen(); + } + + destroy() { + 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..bd388bb415 --- /dev/null +++ b/devtools/server/actors/resources/error-messages.js @@ -0,0 +1,158 @@ +/* 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("devtools/server/actors/resources/utils/nsi-console-listener-watcher"); +const { Ci } = require("chrome"); +const { DevToolsServer } = require("devtools/server/devtools-server"); +const ErrorDocs = require("devtools/server/actors/errordocs"); +const { + createStringGrip, + makeDebuggeeValue, + createValueGripForTarget, +} = require("devtools/server/actors/object/utils"); +const { + getActorIdForInternalSourceId, +} = require("devtools/server/actors/utils/dbg-source"); + +const { + TYPES: { ERROR_MESSAGE }, +} = require("devtools/server/actors/resources/index"); +const { MESSAGE_CATEGORY } = require("devtools/shared/constants"); + +const PLATFORM_SPECIFIC_CATEGORIES = [ + "XPConnect JavaScript", + "component javascript", + "chrome javascript", + "chrome registration", +]; + +class ErrorMessageWatcher extends nsIConsoleListenerWatcher { + shouldHandleMessage(targetActor, message) { + // 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; + } + + // Process targets listen for everything but messages from private windows + if (this.isProcessTarget(targetActor)) { + return !message.isFromPrivateWindow; + } + + if (!message.innerWindowID) { + return false; + } + + const { window } = targetActor; + const win = window?.WindowGlobalChild?.getByInnerWindowId( + message.innerWindowID + ); + return targetActor.browserId === win?.browsingContext?.browserId; + } + + /** + * 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; + } + + // For non-process targets, 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.timeStamp, + 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/index.js b/devtools/server/actors/resources/index.js new file mode 100644 index 0000000000..3d5adf98f5 --- /dev/null +++ b/devtools/server/actors/resources/index.js @@ -0,0 +1,301 @@ +/* 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("devtools/server/actors/targets/index"); + +const TYPES = { + CONSOLE_MESSAGE: "console-message", + CSS_CHANGE: "css-change", + CSS_MESSAGE: "css-message", + DOCUMENT_EVENT: "document-event", + ERROR_MESSAGE: "error-message", + LOCAL_STORAGE: "local-storage", + PLATFORM_MESSAGE: "platform-message", + NETWORK_EVENT: "network-event", + SESSION_STORAGE: "session-storage", + STYLESHEET: "stylesheet", + NETWORK_EVENT_STACKTRACE: "network-event-stacktrace", + SOURCE: "source", +}; +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 lists are specific for the parent process and each target type. +const FrameTargetResources = augmentResourceDictionary({ + [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_STACKTRACE]: { + path: "devtools/server/actors/resources/network-events-stacktraces", + }, + [TYPES.SOURCE]: { + path: "devtools/server/actors/resources/sources", + }, +}); +const ProcessTargetResources = augmentResourceDictionary({ + [TYPES.CONSOLE_MESSAGE]: { + path: "devtools/server/actors/resources/console-messages", + }, + [TYPES.CSS_MESSAGE]: { + path: "devtools/server/actors/resources/css-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", + }, +}); + +// We'll only support a few resource types in Workers (console-message, source, +// breakpoints, …) 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", + }, +}); + +const ParentProcessResources = augmentResourceDictionary({ + [TYPES.NETWORK_EVENT]: { + path: "devtools/server/actors/resources/network-events", + }, +}); + +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 watcherOrTargetActor + * Either a WatcherActor or a TargetActor which can be listening to a resource. + */ +function getResourceTypeDictionary(watcherOrTargetActor) { + const { typeName } = watcherOrTargetActor; + if (typeName == "watcher") { + return ParentProcessResources; + } + const { targetType } = watcherOrTargetActor; + 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 watcherOrTargetActor + * Either a WatcherActor or a TargetActor which can be listening to a resource. + * @param String resourceType + * The resource type to be observed. + */ +function getResourceTypeEntry(watcherOrTargetActor, resourceType) { + const dict = getResourceTypeDictionary(watcherOrTargetActor); + if (!(resourceType in dict)) { + throw new Error( + `Unsupported resource type '${resourceType}' for ${watcherOrTargetActor.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 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. + * This actor: + * - defines what context to observe (browsing context, process, worker, ...) + * Via browsingContextID, windows, docShells attributes for the target actor. + * Via browserId or browserElement attributes for the watcher actor. + * - exposes `notifyResourceAvailable` method to be notified about the available resources + * @param Array<String> resourceTypes + * List of all type of resource to listen to. + */ +async function watchResources(watcherOrTargetActor, 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 } = watcherOrTargetActor; + if (targetType) { + resourceTypes = getResourceTypesForTargetType(resourceTypes, targetType); + } + for (const resourceType of resourceTypes) { + const { watchers, WatcherClass } = getResourceTypeEntry( + watcherOrTargetActor, + resourceType + ); + + // Ignore resources we're already listening to + if (watchers.has(watcherOrTargetActor)) { + continue; + } + + const watcher = new WatcherClass(); + await watcher.watch(watcherOrTargetActor, { + onAvailable: watcherOrTargetActor.notifyResourceAvailable, + onDestroyed: watcherOrTargetActor.notifyResourceDestroyed, + onUpdated: watcherOrTargetActor.notifyResourceUpdated, + }); + watchers.set(watcherOrTargetActor, 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 watcherOrTargetActor + * The related actor, already passed to watchResources. + * @param Array<String> resourceTypes + * List of all type of resource to stop listening to. + */ +function unwatchResources(watcherOrTargetActor, resourceTypes) { + for (const resourceType of resourceTypes) { + // Pull all info about this resource type from `Resources` global object + const { watchers } = getResourceTypeEntry( + watcherOrTargetActor, + resourceType + ); + + const watcher = watchers.get(watcherOrTargetActor); + if (watcher) { + watcher.destroy(); + watchers.delete(watcherOrTargetActor); + } + } +} +exports.unwatchResources = unwatchResources; + +/** + * Stop watching for all watched resources on a given actor. + * + * @param Actor watcherOrTargetActor + * The related actor, already passed to watchResources. + */ +function unwatchAllTargetResources(watcherOrTargetActor) { + for (const { watchers } of Object.values( + getResourceTypeDictionary(watcherOrTargetActor) + )) { + const watcher = watchers.get(watcherOrTargetActor); + if (watcher) { + watcher.destroy(); + watchers.delete(watcherOrTargetActor); + } + } +} +exports.unwatchAllTargetResources = unwatchAllTargetResources; + +/** + * 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/moz.build b/devtools/server/actors/resources/moz.build new file mode 100644 index 0000000000..964456ee63 --- /dev/null +++ b/devtools/server/actors/resources/moz.build @@ -0,0 +1,28 @@ +# -*- 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", + "index.js", + "network-events-stacktraces.js", + "network-events.js", + "platform-messages.js", + "sources.js", + "storage-local-storage.js", + "storage-session-storage.js", + "stylesheets.js", +) + +with Files("*-messages.js"): + BUG_COMPONENT = ("DevTools", "Console") 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..4dbaa55ebd --- /dev/null +++ b/devtools/server/actors/resources/network-events-stacktraces.js @@ -0,0 +1,203 @@ +/* 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("devtools/server/actors/resources/index"); + +const { Ci, components } = require("chrome"); +const Services = require("Services"); + +loader.lazyRequireGetter( + this, + "ChannelEventSinkFactory", + "devtools/server/actors/network-monitor/channel-event-sink", + true +); + +loader.lazyRequireGetter( + this, + "matchRequest", + "devtools/server/actors/network-monitor/network-observer", + true +); + +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 }) { + 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); + + this.targetActor = targetActor; + this.onStackTraceAvailable = onAvailable; + + this.stacktraces = new Map(); + } + + /** + * 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) { + 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; + } + } + } + + // XXX: is window the best filter to use? + if (!matchRequest(channel, { window: this.targetActor.window })) { + 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, + targetFront: this.targetFront, + stacktraceAvailable: stacktrace && stacktrace.length > 0, + lastFrame: + stacktrace && stacktrace.length > 0 ? stacktrace[0] : undefined, + }, + ]); + } + + getStackTrace(id) { + let stacktrace = []; + if (this.stacktraces.has(id)) { + stacktrace = this.stacktraces.get(id); + this.stacktraces.delete(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..306af8c7da --- /dev/null +++ b/devtools/server/actors/resources/network-events.js @@ -0,0 +1,233 @@ +/* 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, + "NetworkObserver", + "devtools/server/actors/network-monitor/network-observer", + true +); + +loader.lazyRequireGetter( + this, + "NetworkEventActor", + "devtools/server/actors/network-monitor/network-event-actor", + true +); + +class NetworkEventWatcher { + /** + * Start watching for all network events related to a given Watcher Actor. + * + * @param WatcherActor watcherActor + * The watcher actor in the parent process from which we should + * observe network events. + * @param Object options + * Dictionary object with following attributes: + * - onAvailable: mandatory function + * This will be called for each resource. + * - onUpdated: optional function + * This would be called multiple times for each resource. + */ + async watch(watcherActor, { onAvailable, onUpdated }) { + this.networkEvents = new Map(); + + this.watcherActor = watcherActor; + this.onNetworkEventAvailable = onAvailable; + this.onNetworkEventUpdated = onUpdated; + + this.listener = new NetworkObserver( + { browserId: watcherActor.browserId }, + { onNetworkEvent: this.onNetworkEvent.bind(this) } + ); + + this.listener.init(); + } + + /** + * Gets the throttle settings + * + * @return {*} data + * + */ + getThrottleData() { + return this.listener.throttleData; + } + + /** + * Sets the throttle data + * + * @param {*} data + * + */ + setThrottleData(data) { + this.listener.throttleData = data; + } + + /** + * 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(); + } + + 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, + { + onNetworkEventUpdate: this.onNetworkEventUpdate.bind(this), + onNetworkEventDestroy: this.onNetworkEventDestroy.bind(this), + }, + event + ); + this.watcherActor.manage(actor); + + const resource = actor.asResource(); + + this.networkEvents.set(resource.resourceId, { + 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 { + 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") + ) { + return; + } + + this.onNetworkEventUpdated([ + { + resourceType, + resourceId, + resourceUpdates, + }, + ]); + } + + onNetworkEventDestroy(channelId) { + if (this.networkEvents.has(channelId)) { + this.networkEvents.delete(channelId); + } + } + + /** + * Stop watching for network event related to a given Watcher Actor. + * + * @param WatcherActor watcherActor + * The watcher actor from which we should stop observing network events + */ + destroy(watcherActor) { + if (this.listener) { + this.listener.destroy(); + } + } +} + +module.exports = NetworkEventWatcher; diff --git a/devtools/server/actors/resources/platform-messages.js b/devtools/server/actors/resources/platform-messages.js new file mode 100644 index 0000000000..9e2e4d1aa2 --- /dev/null +++ b/devtools/server/actors/resources/platform-messages.js @@ -0,0 +1,53 @@ +/* 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("devtools/server/actors/resources/utils/nsi-console-listener-watcher"); +const { Ci } = require("chrome"); + +const { + TYPES: { PLATFORM_MESSAGE }, +} = require("devtools/server/actors/resources/index"); + +const { createStringGrip } = require("devtools/server/actors/object/utils"); + +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; + } + + 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.timeStamp, + resourceType: PLATFORM_MESSAGE, + }; + } +} +module.exports = PlatformMessageWatcher; diff --git a/devtools/server/actors/resources/sources.js b/devtools/server/actors/resources/sources.js new file mode 100644 index 0000000000..04ee4a1cc7 --- /dev/null +++ b/devtools/server/actors/resources/sources.js @@ -0,0 +1,69 @@ +/* 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("devtools/server/actors/resources/index"); + +/** + * 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 }) { + // Force attaching the target in order to ensure it instantiates the ThreadActor + targetActor.attach(); + + const { threadActor } = targetActor; + this.threadActor = threadActor; + this.onAvailable = onAvailable; + + // Disable `ThreadActor.newSource` RDP event in order to avoid unnecessary traffic + threadActor.disableNewSourceEvents(); + + threadActor.sourcesManager.on("newSource", this.onNewSource); + + // 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() { + this.threadActor.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-local-storage.js b/devtools/server/actors/resources/storage-local-storage.js new file mode 100644 index 0000000000..0a582cba79 --- /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("devtools/server/actors/resources/index"); + +const ContentProcessStorage = require("devtools/server/actors/resources/utils/content-process-storage"); + +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..e6ad3eef88 --- /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("devtools/server/actors/resources/index"); + +const ContentProcessStorage = require("devtools/server/actors/resources/utils/content-process-storage"); + +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..40d3bf33f1 --- /dev/null +++ b/devtools/server/actors/resources/stylesheets.js @@ -0,0 +1,703 @@ +/* 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 { Ci } = require("chrome"); +const { fetch } = require("devtools/shared/DevToolsUtils"); +const InspectorUtils = require("InspectorUtils"); +const { + getSourcemapBaseURL, +} = require("devtools/server/actors/utils/source-map-utils"); + +const { + TYPES: { STYLESHEET }, +} = require("devtools/server/actors/resources/index"); + +loader.lazyRequireGetter( + this, + "CssLogic", + "devtools/shared/inspector/css-logic" +); + +loader.lazyRequireGetter( + this, + ["addPseudoClassLock", "removePseudoClassLock"], + "devtools/server/actors/highlighters/utils/markup", + true +); +loader.lazyRequireGetter( + this, + "loadSheet", + "devtools/shared/layout/utils", + true +); +loader.lazyRequireGetter( + this, + ["UPDATE_GENERAL", "UPDATE_PRESERVING_RULES"], + "devtools/server/actors/style-sheet", + true +); + +const TRANSITION_PSEUDO_CLASS = ":-moz-styleeditor-transitioning"; +const TRANSITION_DURATION_MS = 500; +const TRANSITION_BUFFER_MS = 1000; +const TRANSITION_RULE_SELECTOR = `:root${TRANSITION_PSEUDO_CLASS}, :root${TRANSITION_PSEUDO_CLASS} *`; +const TRANSITION_SHEET = + "data:text/css;charset=utf-8," + + encodeURIComponent(` + ${TRANSITION_RULE_SELECTOR} { + transition-duration: ${TRANSITION_DURATION_MS}ms !important; + transition-delay: 0ms !important; + transition-timing-function: ease-out !important; + transition-property: all !important; + } +`); + +// If the user edits a stylesheet, we stash a copy of the edited text +// here, keyed by the stylesheet. This way, if the tools are closed +// and then reopened, the edited text will be available. A weak map +// is used so that navigation by the user will eventually cause the +// edited text to be collected. +const modifiedStyleSheets = new WeakMap(); + +class StyleSheetWatcher { + constructor() { + this._resourceCount = 0; + // The _styleSheetMap maps resourceId and following value. + // { + // styleSheet: Raw StyleSheet object. + // } + this._styleSheetMap = new Map(); + // List of all watched media queries. Change listeners are being registered from _getMediaRules. + this._mqlList = []; + + this._onApplicableStateChanged = this._onApplicableStateChanged.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; + + // Listen for new stylesheet being added via StyleSheetApplicableStateChanged + this._targetActor.chromeEventHandler.addEventListener( + "StyleSheetApplicableStateChanged", + this._onApplicableStateChanged, + true + ); + + const styleSheets = []; + + for (const window of this._targetActor.windows) { + // We have to set this flag in order to get the + // StyleSheetApplicableStateChanged events. See Document.webidl. + window.document.styleSheetChangeEventsEnabled = true; + + styleSheets.push(...(await this._getStyleSheets(window))); + } + + await this._notifyResourcesAvailable(styleSheets); + } + + /** + * Create a new style sheet in the document with the given text. + * + * @param {Document} document + * Document that the new style sheet belong to. + * @param {string} text + * Content of style sheet. + * @param {string} fileName + * If the stylesheet adding is from file, `fileName` indicates the path. + */ + async addStyleSheet(document, text, fileName) { + const parent = document.documentElement; + const style = document.createElementNS( + "http://www.w3.org/1999/xhtml", + "style" + ); + style.setAttribute("type", "text/css"); + + if (text) { + style.appendChild(document.createTextNode(text)); + } + + // This triggers StyleSheetApplicableStateChanged event. + parent.appendChild(style); + + // This promise will be resolved when the resource for this stylesheet is available. + let resolve = null; + const promise = new Promise(r => { + resolve = r; + }); + + if (!this._stylesheetCreationData) { + this._stylesheetCreationData = new WeakMap(); + } + this._stylesheetCreationData.set(style.sheet, { + isCreatedByDevTools: true, + fileName, + resolve, + }); + + await promise; + + return style.sheet; + } + + async ensureResourceAvailable(styleSheet) { + if (this.getResourceId(styleSheet)) { + return; + } + + await this._notifyResourcesAvailable([styleSheet]); + } + + /** + * Return resourceId of the given style sheet. + */ + getResourceId(styleSheet) { + for (const [resourceId, value] of this._styleSheetMap.entries()) { + if (styleSheet === value.styleSheet) { + return resourceId; + } + } + + return null; + } + + /** + * Return owner node of the style sheet of the given resource id. + */ + getOwnerNode(resourceId) { + const { styleSheet } = this._styleSheetMap.get(resourceId); + return styleSheet.ownerNode; + } + + /** + * Return the style sheet of the given resource id. + */ + getStyleSheet(resourceId) { + const { styleSheet } = this._styleSheetMap.get(resourceId); + return styleSheet; + } + + /** + * Return the index of given stylesheet of the given resource id. + */ + getStyleSheetIndex(resourceId) { + const { styleSheet } = this._styleSheetMap.get(resourceId); + return this._getStyleSheetIndex(styleSheet); + } + + /** + * Protocol method to get the text of stylesheet of resourceId. + */ + async getText(resourceId) { + const { styleSheet } = this._styleSheetMap.get(resourceId); + + const modifiedText = modifiedStyleSheets.get(styleSheet); + + // modifiedText is the content of the stylesheet updated by update function. + // In case not updating, this is undefined. + if (modifiedText !== undefined) { + return modifiedText; + } + + if (!styleSheet.href) { + // this is an inline <style> sheet + return styleSheet.ownerNode.textContent; + } + + return this._fetchStylesheet(styleSheet); + } + + /** + * Toggle the disabled property of the stylesheet + * + * @return {Boolean} the disabled state after toggling. + */ + toggleDisabled(resourceId) { + const { styleSheet } = this._styleSheetMap.get(resourceId); + styleSheet.disabled = !styleSheet.disabled; + + this._notifyPropertyChanged(resourceId, "disabled", styleSheet.disabled); + + return styleSheet.disabled; + } + + /** + * Update the style sheet in place with new text. + * + * @param {String} resourceId + * @param {String} text + * New text. + * @param {Boolean} transition + * Whether to do CSS transition for change. + * @param {Number} kind + * Either UPDATE_PRESERVING_RULES or UPDATE_GENERAL + * @param {String} cause + * Indicates the cause of this update (e.g. "styleeditor") if this was called + * from the stylesheet to be edited by the user from the StyleEditor. + */ + async update( + resourceId, + text, + transition, + kind = UPDATE_GENERAL, + cause = "" + ) { + const { styleSheet } = this._styleSheetMap.get(resourceId); + InspectorUtils.parseStyleSheet(styleSheet, text); + modifiedStyleSheets.set(styleSheet, text); + + if (kind !== UPDATE_PRESERVING_RULES) { + this._notifyPropertyChanged( + resourceId, + "ruleCount", + styleSheet.cssRules.length + ); + } + + if (transition) { + this._startTransition(resourceId, kind, cause); + } else { + this._notifyResourceUpdated(resourceId, "style-applied", { + event: { kind, cause }, + }); + } + + // Remove event handler from all media query list we set to. + for (const mql of this._mqlList) { + mql.onchange = null; + } + + const mediaRules = await this._getMediaRules(resourceId, styleSheet); + this._notifyResourceUpdated(resourceId, "media-rules-changed", { + resourceUpdates: { mediaRules }, + }); + } + + _startTransition(resourceId, kind, cause) { + const { styleSheet } = this._styleSheetMap.get(resourceId); + const document = styleSheet.ownerNode.ownerDocument; + const window = styleSheet.ownerNode.ownerGlobal; + + if (!this._transitionSheetLoaded) { + this._transitionSheetLoaded = true; + // We don't remove this sheet. It uses an internal selector that + // we only apply via locks, so there's no need to load and unload + // it all the time. + loadSheet(window, TRANSITION_SHEET); + } + + addPseudoClassLock(document.documentElement, TRANSITION_PSEUDO_CLASS); + + // Set up clean up and commit after transition duration (+buffer) + // @see _onTransitionEnd + window.clearTimeout(this._transitionTimeout); + this._transitionTimeout = window.setTimeout( + this._onTransitionEnd.bind(this, resourceId, kind, cause), + TRANSITION_DURATION_MS + TRANSITION_BUFFER_MS + ); + } + + _onTransitionEnd(resourceId, kind, cause) { + const { styleSheet } = this._styleSheetMap.get(resourceId); + const document = styleSheet.ownerNode.ownerDocument; + + this._transitionTimeout = null; + removePseudoClassLock(document.documentElement, TRANSITION_PSEUDO_CLASS); + this._notifyResourceUpdated(resourceId, "style-applied", { + event: { kind, cause }, + }); + } + + async _fetchStylesheet(styleSheet) { + const href = styleSheet.href; + + const options = { + loadFromCache: true, + policy: Ci.nsIContentPolicy.TYPE_INTERNAL_STYLESHEET, + charset: this._getCSSCharset(styleSheet), + }; + + // Bug 1282660 - We use the system principal to load the default internal + // stylesheets instead of the content principal since such stylesheets + // require system principal to load. At meanwhile, we strip the loadGroup + // for preventing the assertion of the userContextId mismatching. + + // chrome|file|resource|moz-extension protocols rely on the system principal. + const excludedProtocolsRe = /^(chrome|file|resource|moz-extension):\/\//; + if (!excludedProtocolsRe.test(href)) { + // Stylesheets using other protocols should use the content principal. + if (styleSheet.ownerNode) { + // eslint-disable-next-line mozilla/use-ownerGlobal + options.window = styleSheet.ownerNode.ownerDocument.defaultView; + options.principal = styleSheet.ownerNode.ownerDocument.nodePrincipal; + } + } + + let result; + + try { + result = await fetch(href, options); + } catch (e) { + // The list of excluded protocols can be missing some protocols, try to use the + // system principal if the first fetch failed. + console.error( + `stylesheets: fetch failed for ${href},` + + ` using system principal instead.` + ); + options.window = undefined; + options.principal = undefined; + result = await fetch(href, options); + } + + return result.content; + } + + _getCSSCharset(styleSheet) { + if (styleSheet) { + // charset attribute of <link> or <style> element, if it exists + if (styleSheet.ownerNode?.getAttribute) { + const linkCharset = styleSheet.ownerNode.getAttribute("charset"); + if (linkCharset != null) { + return linkCharset; + } + } + + // charset of referring document. + if (styleSheet.ownerNode?.ownerDocument.characterSet) { + return styleSheet.ownerNode.ownerDocument.characterSet; + } + } + + return "UTF-8"; + } + + _getCSSRules(styleSheet) { + try { + return styleSheet.cssRules; + } catch (e) { + // sheet isn't loaded yet + } + + if (!styleSheet.ownerNode) { + return Promise.resolve([]); + } + + return new Promise(resolve => { + styleSheet.ownerNode.addEventListener( + "load", + () => resolve(styleSheet.cssRules), + { once: true } + ); + }); + } + + async _getImportedStyleSheets(document, styleSheet) { + const importedStyleSheets = []; + + for (const rule of await this._getCSSRules(styleSheet)) { + if (rule.type == CSSRule.IMPORT_RULE) { + // With the Gecko style system, the associated styleSheet may be null + // if it has already been seen because an import cycle for the same + // URL. With Stylo, the styleSheet will exist (which is correct per + // the latest CSSOM spec), so we also need to check ancestors for the + // same URL to avoid cycles. + if ( + !rule.styleSheet || + this._haveAncestorWithSameURL(rule.styleSheet) || + !this._shouldListSheet(rule.styleSheet) + ) { + continue; + } + + importedStyleSheets.push(rule.styleSheet); + + // recurse imports in this stylesheet as well + const children = await this._getImportedStyleSheets( + document, + rule.styleSheet + ); + importedStyleSheets.push(...children); + } else if (rule.type != CSSRule.CHARSET_RULE) { + // @import rules must precede all others except @charset + break; + } + } + + return importedStyleSheets; + } + + async _getMediaRules(resourceId, styleSheet) { + this._mqlList = []; + + const mediaRules = Array.from(await this._getCSSRules(styleSheet)).filter( + rule => rule.type === CSSRule.MEDIA_RULE + ); + + return mediaRules.map((rule, index) => { + let matches = false; + + try { + const window = styleSheet.ownerNode.ownerGlobal; + const mql = window.matchMedia(rule.media.mediaText); + matches = mql.matches; + mql.onchange = this._onMatchesChange.bind(this, resourceId, index); + this._mqlList.push(mql); + } catch (e) { + // Ignored + } + + return { + mediaText: rule.media.mediaText, + conditionText: rule.conditionText, + matches, + line: InspectorUtils.getRuleLine(rule), + column: InspectorUtils.getRuleColumn(rule), + }; + }); + } + + _onMatchesChange(resourceId, index, mql) { + this._notifyResourceUpdated(resourceId, "matches-change", { + nestedResourceUpdates: [ + { + path: ["mediaRules", index, "matches"], + value: mql.matches, + }, + ], + }); + } + + _getNodeHref(styleSheet) { + const { ownerNode } = styleSheet; + if (!ownerNode) { + return null; + } + + if (ownerNode.nodeType == ownerNode.DOCUMENT_NODE) { + return ownerNode.location.href; + } else if (ownerNode.ownerDocument?.location) { + return ownerNode.ownerDocument.location.href; + } + + return null; + } + + _getSourcemapBaseURL(styleSheet) { + // When the style is imported, `styleSheet.ownerNode` is null, + // so retrieve the topmost parent style sheet which has an ownerNode + let parentStyleSheet = styleSheet; + while (parentStyleSheet.parentStyleSheet) { + parentStyleSheet = parentStyleSheet.parentStyleSheet; + } + + // When the style is injected via nsIDOMWindowUtils.loadSheet, even + // the parent style sheet has no owner, so default back to target actor + // document + const ownerDocument = parentStyleSheet.ownerNode + ? parentStyleSheet.ownerNode.ownerDocument + : this._targetActor.window; + + return getSourcemapBaseURL( + // Technically resolveSourceURL should be used here alongside + // "this.rawSheet.sourceURL", but the style inspector does not support + // /*# sourceURL=*/ in CSS, so we're omitting it here (bug 880831). + styleSheet.href || this._getNodeHref(styleSheet), + ownerDocument + ); + } + + _getStyleSheetIndex(styleSheet) { + const styleSheets = InspectorUtils.getAllStyleSheets( + this._targetActor.window.document, + true + ); + return styleSheets.indexOf(styleSheet); + } + + async _getStyleSheets({ document }) { + const documentOnly = !document.nodePrincipal.isSystemPrincipal; + + const styleSheets = []; + + for (const styleSheet of InspectorUtils.getAllStyleSheets( + document, + documentOnly + )) { + if (!this._shouldListSheet(styleSheet)) { + continue; + } + + styleSheets.push(styleSheet); + + // Get all sheets, including imported ones + const importedStyleSheets = await this._getImportedStyleSheets( + document, + styleSheet + ); + styleSheets.push(...importedStyleSheets); + } + + return styleSheets; + } + + _haveAncestorWithSameURL(styleSheet) { + const href = styleSheet.href; + while (styleSheet.parentStyleSheet) { + if (styleSheet.parentStyleSheet.href == href) { + return true; + } + styleSheet = styleSheet.parentStyleSheet; + } + return false; + } + + _notifyPropertyChanged(resourceId, property, value) { + this._notifyResourceUpdated(resourceId, "property-change", { + resourceUpdates: { [property]: value }, + }); + } + + /** + * Event handler that is called when the state of applicable of style sheet is changed. + * + * For now, StyleSheetApplicableStateChanged event will be called at following timings. + * - Append <link> of stylesheet to document + * - Append <style> to document + * - Change disable attribute of stylesheet object + * - Change disable attribute of <link> to false + * When appending <link>, <style> or changing `disable` attribute to false, `applicable` + * is passed as true. The other hand, when changing `disable` to true, this will be + * false. + * NOTE: For now, StyleSheetApplicableStateChanged will not be called when removing the + * link and style element. + * + * @param {StyleSheetApplicableStateChanged} + * The triggering event. + */ + async _onApplicableStateChanged({ applicable, stylesheet: styleSheet }) { + for (const existing of this._styleSheetMap.values()) { + if (existing.styleSheet === styleSheet) { + return; + } + } + + if ( + // Have interest in applicable stylesheet only. + applicable && + // No ownerNode means that this stylesheet is *not* associated to a DOM Element. + styleSheet.ownerNode && + this._shouldListSheet(styleSheet) && + !this._haveAncestorWithSameURL(styleSheet) + ) { + await this._notifyResourcesAvailable([styleSheet]); + } + } + + _shouldListSheet(styleSheet) { + // Special case about:PreferenceStyleSheet, as it is generated on the + // fly and the URI is not registered with the about: handler. + // https://bugzilla.mozilla.org/show_bug.cgi?id=935803#c37 + if (styleSheet.href?.toLowerCase() === "about:preferencestylesheet") { + return false; + } + + return true; + } + + async _toResource( + styleSheet, + { isCreatedByDevTools = false, fileName = null } = {} + ) { + const resourceId = `stylesheet:${this._resourceCount++}`; + + const resource = { + resourceId, + resourceType: STYLESHEET, + disabled: styleSheet.disabled, + fileName, + href: styleSheet.href, + isNew: isCreatedByDevTools, + mediaRules: await this._getMediaRules(resourceId, styleSheet), + nodeHref: this._getNodeHref(styleSheet), + ruleCount: styleSheet.cssRules.length, + sourceMapBaseURL: this._getSourcemapBaseURL(styleSheet), + sourceMapURL: styleSheet.sourceMapURL, + styleSheetIndex: this._getStyleSheetIndex(styleSheet), + system: CssLogic.isAgentStylesheet(styleSheet), + title: styleSheet.title, + }; + + return resource; + } + + async _notifyResourcesAvailable(styleSheets) { + const resources = await Promise.all( + styleSheets.map(async styleSheet => { + const creationData = this._stylesheetCreationData?.get(styleSheet); + + const resource = await this._toResource(styleSheet, { + isCreatedByDevTools: creationData?.isCreatedByDevTools, + fileName: creationData?.fileName, + }); + + this._styleSheetMap.set(resource.resourceId, { styleSheet }); + return resource; + }) + ); + + await this._onAvailable(resources); + + for (const styleSheet of styleSheets) { + const creationData = this._stylesheetCreationData?.get(styleSheet); + creationData?.resolve(); + this._stylesheetCreationData?.delete(styleSheet); + } + } + + _notifyResourceUpdated( + resourceId, + updateType, + { resourceUpdates, nestedResourceUpdates, event } + ) { + this._onUpdated([ + { + resourceType: STYLESHEET, + resourceId, + updateType, + resourceUpdates, + nestedResourceUpdates, + event, + }, + ]); + } + + destroy() { + if (!this._targetActor.docShell) { + return; + } + + this._targetActor.chromeEventHandler.removeEventListener( + "StyleSheetApplicableStateChanged", + this._onApplicableStateChanged, + true + ); + } +} + +module.exports = StyleSheetWatcher; 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..cf07fe3e48 --- /dev/null +++ b/devtools/server/actors/resources/utils/content-process-storage.js @@ -0,0 +1,218 @@ +/* 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("devtools/server/actors/storage"); + +// ms of delay to throttle updates +const BATCH_DELAY = 200; + +class ContentProcessStorage { + constructor(storageKey, storageType) { + this.storageKey = storageKey; + this.storageType = storageType; + } + + async watch(targetActor, { onAvailable, onUpdated, onDestroyed }) { + const ActorConstructor = storageTypePool.get(this.storageKey); + this.actor = new ActorConstructor({ + get conn() { + return targetActor.conn; + }, + get windows() { + // about:blank pages that are included via an iframe, do not get their + // own process, and they will be present in targetActor.windows. + // We need to ignore them unless they are the top level page. + // Otherwise about:blank loads with the same principal as their parent document + // and would expose the same storage values as its parent. + const windows = targetActor.windows.filter(win => { + const isTopPage = win.parent === win; + return isTopPage || win.location.href !== "about:blank"; + }); + + return windows; + }, + get window() { + return targetActor.window; + }, + get document() { + return this.window.document; + }, + get originAttributes() { + return this.document.effectiveStoragePrincipal.originAttributes; + }, + + update(action, storeType, data) { + if (!this.boundUpdate) { + this.boundUpdate = {}; + } + + if (action === "cleared") { + const response = {}; + response[this.storageKey] = data; + + onDestroyed([ + { + // needs this so the resource gets passed as an actor + // ...storages[storageKey], + ...storage, + clearedHostsOrPaths: data, + }, + ]); + } + + 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, don't send the deleted or changed update + this._removeNamesFromUpdateList("deleted", storeType, data); + this._removeNamesFromUpdateList("changed", storeType, data); + } else if ( + action === "changed" && + 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 deleted, or a host got deleted, there's no point + // in sending added or changed upate, so we remove them. + this._removeNamesFromUpdateList("added", storeType, data); + this._removeNamesFromUpdateList("changed", storeType, data); + + for (const host in data) { + if ( + data[host].length === 0 && + this.boundUpdate?.added?.[storeType]?.[host] + ) { + delete this.boundUpdate.added[storeType][host]; + } + + if ( + data[host].length === 0 && + this.boundUpdate?.changed?.[storeType]?.[host] + ) { + delete this.boundUpdate.changed[storeType][host]; + } + } + } + + this.batchTimer = setTimeout(() => { + clearTimeout(this.batchTimer); + onUpdated([ + { + // needs this so the resource gets passed as an actor + // ...storages[storageKey], + ...storage, + added: this.boundUpdate.added, + changed: this.boundUpdate.changed, + deleted: this.boundUpdate.deleted, + }, + ]); + 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]?.[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; + }, + + on() { + targetActor.on.apply(this, arguments); + }, + off() { + targetActor.off.apply(this, arguments); + }, + once() { + targetActor.once.apply(this, arguments); + }, + }); + + // We have to manage the actor manually, because ResourceWatcher 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]); + } + + destroy() { + this.actor?.destroy(); + this.actor = null; + } +} + +module.exports = ContentProcessStorage; diff --git a/devtools/server/actors/resources/utils/moz.build b/devtools/server/actors/resources/utils/moz.build new file mode 100644 index 0000000000..b57360d218 --- /dev/null +++ b/devtools/server/actors/resources/utils/moz.build @@ -0,0 +1,13 @@ +# -*- 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", +) + +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..352d622c30 --- /dev/null +++ b/devtools/server/actors/resources/utils/nsi-console-listener-watcher.js @@ -0,0 +1,187 @@ +/* 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 { Ci, Cu } = require("chrome"); +const Services = require("Services"); +const ChromeUtils = require("ChromeUtils"); + +const { createStringGrip } = require("devtools/server/actors/object/utils"); + +const { + getActorIdForInternalSourceId, +} = require("devtools/server/actors/utils/dbg-source"); + +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; + } + + // The following code expects the ThreadActor to be instantiated (in prepareStackForRemote) + // The Thread Actor is instantiated via Target.attach, but we should probably review + // this and only instantiate the actor instead of attaching the target. + if (!targetActor.threadActor) { + targetActor.attach(); + } + + // Create the consoleListener. + const listener = { + QueryInterface: ChromeUtils.generateQI(["nsIConsoleListener"]), + observe: message => { + if (!this.shouldHandleMessage(targetActor, message)) { + return; + } + + onAvailable([this.buildResource(targetActor, message)]); + }, + }; + + // Retrieve the cached messages just before registering the listener, so we don't get + // duplicated messages. + const cachedMessages = Services.console.getMessageArray() || []; + 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)) { + 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; |