diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-19 00:47:55 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-19 00:47:55 +0000 |
commit | 26a029d407be480d791972afb5975cf62c9360a6 (patch) | |
tree | f435a8308119effd964b339f76abb83a57c29483 /devtools/server/actors/utils | |
parent | Initial commit. (diff) | |
download | firefox-upstream/124.0.1.tar.xz firefox-upstream/124.0.1.zip |
Adding upstream version 124.0.1.upstream/124.0.1
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'devtools/server/actors/utils')
25 files changed, 7016 insertions, 0 deletions
diff --git a/devtools/server/actors/utils/accessibility.js b/devtools/server/actors/utils/accessibility.js new file mode 100644 index 0000000000..ee8ee9ccd0 --- /dev/null +++ b/devtools/server/actors/utils/accessibility.js @@ -0,0 +1,103 @@ +/* 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, + ["loadSheet", "removeSheet"], + "resource://devtools/shared/layout/utils.js", + true +); + +// Highlighter style used for preventing transitions and applying transparency +// when calculating colour contrast. +const HIGHLIGHTER_STYLES_SHEET = `data:text/css;charset=utf-8, +* { + transition: initial !important; +} + +:-moz-devtools-highlighted { + color: transparent !important; + text-shadow: none !important; +}`; + +/** + * Helper function that determines if nsIAccessible object is in defunct state. + * + * @param {nsIAccessible} accessible + * object to be tested. + * @return {Boolean} + * True if accessible object is defunct, false otherwise. + */ +function isDefunct(accessible) { + // If accessibility is disabled, safely assume that the accessible object is + // now dead. + if (!Services.appinfo.accessibilityEnabled) { + return true; + } + + let defunct = false; + + try { + const extraState = {}; + accessible.getState({}, extraState); + // extraState.value is a bitmask. We are applying bitwise AND to mask out + // irrelevant states. + defunct = !!(extraState.value & Ci.nsIAccessibleStates.EXT_STATE_DEFUNCT); + } catch (e) { + defunct = true; + } + + return defunct; +} + +/** + * Load highlighter style sheet used for preventing transitions and + * applying transparency when calculating colour contrast. + * + * @param {Window} win + * Window where highlighting happens. + */ +function loadSheetForBackgroundCalculation(win) { + loadSheet(win, HIGHLIGHTER_STYLES_SHEET); +} + +/** + * Unload highlighter style sheet used for preventing transitions + * and applying transparency when calculating colour contrast. + * + * @param {Window} win + * Window where highlighting was happenning. + */ +function removeSheetForBackgroundCalculation(win) { + removeSheet(win, HIGHLIGHTER_STYLES_SHEET); +} + +/** + * Get role attribute for an accessible object if specified for its + * corresponding DOMNode. + * + * @param {nsIAccessible} accessible + * Accessible for which to determine its role attribute value. + * + * @returns {null|String} + * Role attribute value if specified. + */ +function getAriaRoles(accessible) { + try { + return accessible.attributes.getStringProperty("xml-roles"); + } catch (e) { + // No xml-roles. nsPersistentProperties throws if the attribute for a key + // is not found. + } + + return null; +} + +exports.getAriaRoles = getAriaRoles; +exports.isDefunct = isDefunct; +exports.loadSheetForBackgroundCalculation = loadSheetForBackgroundCalculation; +exports.removeSheetForBackgroundCalculation = + removeSheetForBackgroundCalculation; diff --git a/devtools/server/actors/utils/actor-registry.js b/devtools/server/actors/utils/actor-registry.js new file mode 100644 index 0000000000..ae88c38c6b --- /dev/null +++ b/devtools/server/actors/utils/actor-registry.js @@ -0,0 +1,418 @@ +/* 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"; + +var gRegisteredModules = Object.create(null); + +const ActorRegistry = { + // Map of global actor names to actor constructors. + globalActorFactories: {}, + // Map of target-scoped actor names to actor constructors. + targetScopedActorFactories: {}, + init(connections) { + this._connections = connections; + }, + + /** + * Register a CommonJS module with the devtools server. + * @param id string + * The ID of a CommonJS module. + * The actor is going to be registered immediately, but loaded only + * when a client starts sending packets to an actor with the same id. + * + * @param options object + * An object with 3 mandatory attributes: + * - prefix (string): + * The prefix of an actor is used to compute: + * - the `actorID` of each new actor instance (ex: prefix1). (See Pool.manage) + * - the actor name in the listTabs request. Sending a listTabs + * request to the root actor returns actor IDs. IDs are in + * dictionaries, with actor names as keys and actor IDs as values. + * The actor name is the prefix to which the "Actor" string is + * appended. So for an actor with the `console` prefix, the actor + * name will be `consoleActor`. + * - constructor (string): + * the name of the exported symbol to be used as the actor + * constructor. + * - type (a dictionary of booleans with following attribute names): + * - "global" + * registers a global actor instance, if true. + * A global actor has the root actor as its parent. + * - "target" + * registers a target-scoped actor instance, if true. + * A new actor will be created for each target, such as a tab. + */ + registerModule(id, options) { + if (id in gRegisteredModules) { + return; + } + + if (!options) { + throw new Error( + "ActorRegistry.registerModule requires an options argument" + ); + } + const { prefix, constructor, type } = options; + if (typeof prefix !== "string") { + throw new Error( + `Lazy actor definition for '${id}' requires a string ` + + `'prefix' option.` + ); + } + if (typeof constructor !== "string") { + throw new Error( + `Lazy actor definition for '${id}' requires a string ` + + `'constructor' option.` + ); + } + if (!("global" in type) && !("target" in type)) { + throw new Error( + `Lazy actor definition for '${id}' requires a dictionary ` + + `'type' option whose attributes can be 'global' or 'target'.` + ); + } + const name = prefix + "Actor"; + const mod = { + id, + prefix, + constructorName: constructor, + type, + globalActor: type.global, + targetScopedActor: type.target, + }; + gRegisteredModules[id] = mod; + if (mod.targetScopedActor) { + this.addTargetScopedActor(mod, name); + } + if (mod.globalActor) { + this.addGlobalActor(mod, name); + } + }, + + /** + * Unregister a previously-loaded CommonJS module from the devtools server. + */ + unregisterModule(id) { + const mod = gRegisteredModules[id]; + if (!mod) { + throw new Error( + "Tried to unregister a module that was not previously registered." + ); + } + + // Lazy actors + if (mod.targetScopedActor) { + this.removeTargetScopedActor(mod); + } + if (mod.globalActor) { + this.removeGlobalActor(mod); + } + + delete gRegisteredModules[id]; + }, + + /** + * Install Firefox-specific actors. + * + * /!\ Be careful when adding a new actor, especially global actors. + * Any new global actor will be exposed and returned by the root actor. + */ + addBrowserActors() { + this.registerModule("devtools/server/actors/preference", { + prefix: "preference", + constructor: "PreferenceActor", + type: { global: true }, + }); + this.registerModule("devtools/server/actors/addon/addons", { + prefix: "addons", + constructor: "AddonsActor", + type: { global: true }, + }); + this.registerModule("devtools/server/actors/device", { + prefix: "device", + constructor: "DeviceActor", + type: { global: true }, + }); + this.registerModule("devtools/server/actors/heap-snapshot-file", { + prefix: "heapSnapshotFile", + constructor: "HeapSnapshotFileActor", + type: { global: true }, + }); + // Always register this as a global module, even while there is a pref turning + // on and off the other performance actor. This actor shouldn't conflict with + // the other one. These are also lazily loaded so there shouldn't be a performance + // impact. + this.registerModule("devtools/server/actors/perf", { + prefix: "perf", + constructor: "PerfActor", + type: { global: true }, + }); + /** + * Always register parent accessibility actor as a global module. This + * actor is responsible for all top level accessibility actor functionality + * that relies on the parent process. + */ + this.registerModule( + "devtools/server/actors/accessibility/parent-accessibility", + { + prefix: "parentAccessibility", + constructor: "ParentAccessibilityActor", + type: { global: true }, + } + ); + + this.registerModule("devtools/server/actors/screenshot", { + prefix: "screenshot", + constructor: "ScreenshotActor", + type: { global: true }, + }); + }, + + /** + * Install target-scoped actors. + */ + addTargetScopedActors() { + this.registerModule("devtools/server/actors/webconsole", { + prefix: "console", + constructor: "WebConsoleActor", + type: { target: true }, + }); + this.registerModule("devtools/server/actors/inspector/inspector", { + prefix: "inspector", + constructor: "InspectorActor", + type: { target: true }, + }); + this.registerModule("devtools/server/actors/style-sheets", { + prefix: "styleSheets", + constructor: "StyleSheetsActor", + type: { target: true }, + }); + this.registerModule("devtools/server/actors/memory", { + prefix: "memory", + constructor: "MemoryActor", + type: { target: true }, + }); + this.registerModule("devtools/server/actors/reflow", { + prefix: "reflow", + constructor: "ReflowActor", + type: { target: true }, + }); + this.registerModule("devtools/server/actors/css-properties", { + prefix: "cssProperties", + constructor: "CssPropertiesActor", + type: { target: true }, + }); + this.registerModule("devtools/server/actors/animation", { + prefix: "animations", + constructor: "AnimationsActor", + type: { target: true }, + }); + this.registerModule("devtools/server/actors/emulation/responsive", { + prefix: "responsive", + constructor: "ResponsiveActor", + type: { target: true }, + }); + this.registerModule( + "devtools/server/actors/addon/webextension-inspected-window", + { + prefix: "webExtensionInspectedWindow", + constructor: "WebExtensionInspectedWindowActor", + type: { target: true }, + } + ); + this.registerModule("devtools/server/actors/accessibility/accessibility", { + prefix: "accessibility", + constructor: "AccessibilityActor", + type: { target: true }, + }); + this.registerModule("devtools/server/actors/changes", { + prefix: "changes", + constructor: "ChangesActor", + type: { target: true }, + }); + this.registerModule("devtools/server/actors/manifest", { + prefix: "manifest", + constructor: "ManifestActor", + type: { target: true }, + }); + this.registerModule( + "devtools/server/actors/network-monitor/network-content", + { + prefix: "networkContent", + constructor: "NetworkContentActor", + type: { target: true }, + } + ); + this.registerModule("devtools/server/actors/screenshot-content", { + prefix: "screenshotContent", + constructor: "ScreenshotContentActor", + type: { target: true }, + }); + this.registerModule("devtools/server/actors/tracer", { + prefix: "tracer", + constructor: "TracerActor", + type: { target: true }, + }); + this.registerModule("devtools/server/actors/objects-manager", { + prefix: "objectsManager", + constructor: "ObjectsManagerActor", + type: { target: true }, + }); + }, + + /** + * Registers handlers for new target-scoped request types defined dynamically. + * + * Note that the name of the request type is not allowed to clash with existing protocol + * packet properties, like 'title', 'url' or 'actor', since that would break the protocol. + * + * @param options object + * - constructorName: (required) + * name of actor constructor, which is also used when removing the actor. + * One of the following: + * - id: + * module ID that contains the actor + * - constructorFun: + * a function to construct the actor + * @param name string + * The name of the new request type. + */ + addTargetScopedActor(options, name) { + if (!name) { + throw Error("addTargetScopedActor requires the `name` argument"); + } + if (["title", "url", "actor"].includes(name)) { + throw Error(name + " is not allowed"); + } + if (this.targetScopedActorFactories.hasOwnProperty(name)) { + throw Error(name + " already exists"); + } + this.targetScopedActorFactories[name] = { options, name }; + }, + + /** + * Unregisters the handler for the specified target-scoped request type. + * + * When unregistering an existing target-scoped actor, we remove the actor factory as + * well as all existing instances of the actor. + * + * @param actor object, string + * In case of object: + * The `actor` object being given to related addTargetScopedActor call. + * In case of string: + * The `name` string being given to related addTargetScopedActor call. + */ + removeTargetScopedActor(actorOrName) { + let name; + if (typeof actorOrName == "string") { + name = actorOrName; + } else { + const actor = actorOrName; + for (const factoryName in this.targetScopedActorFactories) { + const handler = this.targetScopedActorFactories[factoryName]; + if ( + handler.options.constructorName == actor.name || + handler.options.id == actor.id + ) { + name = factoryName; + break; + } + } + } + if (!name) { + return; + } + delete this.targetScopedActorFactories[name]; + for (const connID of Object.getOwnPropertyNames(this._connections)) { + // DevToolsServerConnection in child process don't have rootActor + if (this._connections[connID].rootActor) { + this._connections[connID].rootActor.removeActorByName(name); + } + } + }, + + /** + * Registers handlers for new browser-scoped request types defined dynamically. + * + * Note that the name of the request type is not allowed to clash with existing protocol + * packet properties, like 'from', 'tabs' or 'selected', since that would break the protocol. + * + * @param options object + * - constructorName: (required) + * name of actor constructor, which is also used when removing the actor. + * One of the following: + * - id: + * module ID that contains the actor + * - constructorFun: + * a function to construct the actor + * @param name string + * The name of the new request type. + */ + addGlobalActor(options, name) { + if (!name) { + throw Error("addGlobalActor requires the `name` argument"); + } + if (["from", "tabs", "selected"].includes(name)) { + throw Error(name + " is not allowed"); + } + if (this.globalActorFactories.hasOwnProperty(name)) { + throw Error(name + " already exists"); + } + this.globalActorFactories[name] = { options, name }; + }, + + /** + * Unregisters the handler for the specified browser-scoped request type. + * + * When unregistering an existing global actor, we remove the actor factory as well as + * all existing instances of the actor. + * + * @param actor object, string + * In case of object: + * The `actor` object being given to related addGlobalActor call. + * In case of string: + * The `name` string being given to related addGlobalActor call. + */ + removeGlobalActor(actorOrName) { + let name; + if (typeof actorOrName == "string") { + name = actorOrName; + } else { + const actor = actorOrName; + for (const factoryName in this.globalActorFactories) { + const handler = this.globalActorFactories[factoryName]; + if ( + handler.options.constructorName == actor.name || + handler.options.id == actor.id + ) { + name = factoryName; + break; + } + } + } + if (!name) { + return; + } + delete this.globalActorFactories[name]; + for (const connID of Object.getOwnPropertyNames(this._connections)) { + // DevToolsServerConnection in child process don't have rootActor + if (this._connections[connID].rootActor) { + this._connections[connID].rootActor.removeActorByName(name); + } + } + }, + + destroy() { + for (const id of Object.getOwnPropertyNames(gRegisteredModules)) { + this.unregisterModule(id); + } + gRegisteredModules = Object.create(null); + + this.globalActorFactories = {}; + this.targetScopedActorFactories = {}; + }, +}; + +exports.ActorRegistry = ActorRegistry; diff --git a/devtools/server/actors/utils/breakpoint-actor-map.js b/devtools/server/actors/utils/breakpoint-actor-map.js new file mode 100644 index 0000000000..cce32b2833 --- /dev/null +++ b/devtools/server/actors/utils/breakpoint-actor-map.js @@ -0,0 +1,84 @@ +/* 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 { + BreakpointActor, +} = require("resource://devtools/server/actors/breakpoint.js"); + +/** + * A BreakpointActorMap is a map from locations to instances of BreakpointActor. + */ +class BreakpointActorMap { + constructor(threadActor) { + this._threadActor = threadActor; + this._actors = {}; + } + + // Get the key in the _actors table for a given breakpoint location. + // See also duplicate code in commands.js :( + _locationKey(location) { + const { sourceUrl, sourceId, line, column } = location; + return `${sourceUrl}:${sourceId}:${line}:${column}`; + } + + /** + * Return all BreakpointActors in this BreakpointActorMap. + */ + findActors() { + return Object.values(this._actors); + } + + listKeys() { + return Object.keys(this._actors); + } + + /** + * Return the BreakpointActor at the given location in this + * BreakpointActorMap. + * + * @param BreakpointLocation location + * The location for which the BreakpointActor should be returned. + * + * @returns BreakpointActor actor + * The BreakpointActor at the given location. + */ + getOrCreateBreakpointActor(location) { + const key = this._locationKey(location); + if (!this._actors[key]) { + this._actors[key] = new BreakpointActor(this._threadActor, location); + } + return this._actors[key]; + } + + get(location) { + const key = this._locationKey(location); + return this._actors[key]; + } + + /** + * Delete the BreakpointActor from the given location in this + * BreakpointActorMap. + * + * @param BreakpointLocation location + * The location from which the BreakpointActor should be deleted. + */ + deleteActor(location) { + const key = this._locationKey(location); + delete this._actors[key]; + } + + /** + * Unregister all currently active breakpoints. + */ + removeAllBreakpoints() { + for (const bpActor of Object.values(this._actors)) { + bpActor.removeScripts(); + } + this._actors = {}; + } +} + +exports.BreakpointActorMap = BreakpointActorMap; diff --git a/devtools/server/actors/utils/capture-screenshot.js b/devtools/server/actors/utils/capture-screenshot.js new file mode 100644 index 0000000000..e7b46620b2 --- /dev/null +++ b/devtools/server/actors/utils/capture-screenshot.js @@ -0,0 +1,200 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { LocalizationHelper } = require("resource://devtools/shared/l10n.js"); + +const CONTAINER_FLASHING_DURATION = 500; +const STRINGS_URI = "devtools/shared/locales/screenshot.properties"; +const L10N = new LocalizationHelper(STRINGS_URI); + +// These values are used to truncate the resulting image if the captured area is bigger. +// This is to avoid failing to produce a screenshot at all. +// It is recommended to keep these values in sync with the corresponding screenshots addon +// values in /browser/extensions/screenshots/selector/uicontrol.js +const MAX_IMAGE_WIDTH = 10000; +const MAX_IMAGE_HEIGHT = 10000; + +/** + * This function is called to simulate camera effects + * @param {BrowsingContext} browsingContext: The browsing context associated with the + * browser element we want to animate. + */ +function simulateCameraFlash(browsingContext) { + // If there's no topFrameElement (it can happen if the screenshot is taken from the + // browser toolbox), use the top chrome window document element. + const node = + browsingContext.topFrameElement || + browsingContext.topChromeWindow.document.documentElement; + + if (!node) { + console.error( + "Can't find an element to play the camera flash animation on for the following browsing context:", + browsingContext + ); + return; + } + + // Don't take a screenshot if the user prefers reduced motion. + if (node.ownerGlobal.matchMedia("(prefers-reduced-motion)").matches) { + return; + } + + node.animate([{ opacity: 0 }, { opacity: 1 }], { + duration: CONTAINER_FLASHING_DURATION, + }); +} + +/** + * Take a screenshot of a browser element given its browsingContext. + * + * @param {Object} args + * @param {Number} args.delay: Number of seconds to wait before taking the screenshot + * @param {Object|null} args.rect: Object with left, top, width and height properties + * representing the rect **inside the browser element** that should + * be rendered. If null, the current viewport of the element will be rendered. + * @param {Boolean} args.fullpage: Should the screenshot be the height of the whole page + * @param {String} args.filename: Expected filename for the screenshot + * @param {Number} args.snapshotScale: Scale that will be used by `drawSnapshot` to take the screenshot. + * ⚠️ Note that the scale might be decreased if the resulting image would + * be too big to draw safely. A warning message will be returned if that's + * the case. + * @param {Number} args.fileScale: Scale of the exported file. Defaults to args.snapshotScale. + * @param {Boolean} args.disableFlash: Set to true to disable the flash animation when the + * screenshot is taken. + * @param {BrowsingContext} browsingContext + * @returns {Object} object with the following properties: + * - data {String}: The dataURL representing the screenshot + * - height {Number}: Height of the resulting screenshot + * - width {Number}: Width of the resulting screenshot + * - filename {String}: Filename of the resulting screenshot + * - messages {Array<Object{text, level}>}: An array of object representing the + * different messages and their level that should be displayed to the user. + */ +async function captureScreenshot(args, browsingContext) { + const messages = []; + + let filename = getFilename(args.filename); + + if (args.fullpage) { + filename = filename.replace(".png", "-fullpage.png"); + } + + let { left, top, width, height } = args.rect || {}; + + // Truncate the width and height if necessary. + if (width && (width > MAX_IMAGE_WIDTH || height > MAX_IMAGE_HEIGHT)) { + width = Math.min(width, MAX_IMAGE_WIDTH); + height = Math.min(height, MAX_IMAGE_HEIGHT); + messages.push({ + level: "warn", + text: L10N.getFormatStr("screenshotTruncationWarning", width, height), + }); + } + + let rect = null; + if (args.rect) { + rect = new globalThis.DOMRect( + Math.round(left), + Math.round(top), + Math.round(width), + Math.round(height) + ); + } + + const document = browsingContext.topChromeWindow.document; + const canvas = document.createElementNS( + "http://www.w3.org/1999/xhtml", + "canvas" + ); + + const drawToCanvas = async actualRatio => { + // Even after decreasing width, height and ratio, there may still be cases where the + // hardware fails at creating the image. Let's catch this so we can at least show an + // error message to the user. + try { + const snapshot = await browsingContext.currentWindowGlobal.drawSnapshot( + rect, + actualRatio, + "rgb(255,255,255)", + args.fullpage + ); + + const fileScale = args.fileScale || actualRatio; + const renderingWidth = (snapshot.width / actualRatio) * fileScale; + const renderingHeight = (snapshot.height / actualRatio) * fileScale; + canvas.width = renderingWidth; + canvas.height = renderingHeight; + width = renderingWidth; + height = renderingHeight; + const ctx = canvas.getContext("2d"); + ctx.drawImage(snapshot, 0, 0, renderingWidth, renderingHeight); + + // Bug 1574935 - Huge dimensions can trigger an OOM because multiple copies + // of the bitmap will exist in memory. Force the removal of the snapshot + // because it is no longer needed. + snapshot.close(); + + return canvas.toDataURL("image/png", ""); + } catch (e) { + return null; + } + }; + + const ratio = args.snapshotScale; + let data = await drawToCanvas(ratio); + if (!data && ratio > 1.0) { + // If the user provided DPR or the window.devicePixelRatio was higher than 1, + // try again with a reduced ratio. + messages.push({ + level: "warn", + text: L10N.getStr("screenshotDPRDecreasedWarning"), + }); + data = await drawToCanvas(1.0); + } + if (!data) { + messages.push({ + level: "error", + text: L10N.getStr("screenshotRenderingError"), + }); + } + + if (data && args.disableFlash !== true) { + simulateCameraFlash(browsingContext); + } + + return { + data, + height, + width, + filename, + messages, + }; +} + +exports.captureScreenshot = captureScreenshot; + +/** + * We may have a filename specified in args, or we might have to generate + * one. + */ +function getFilename(defaultName) { + // Create a name for the file if not present + if (defaultName) { + return defaultName; + } + + const date = new Date(); + const monthString = (date.getMonth() + 1).toString().padStart(2, "0"); + const dayString = date.getDate().toString().padStart(2, "0"); + const dateString = `${date.getFullYear()}-${monthString}-${dayString}`; + + const timeString = date.toTimeString().replace(/:/g, ".").split(" ")[0]; + + return ( + L10N.getFormatStr("screenshotGeneratedFilename", dateString, timeString) + + ".png" + ); +} diff --git a/devtools/server/actors/utils/css-grid-utils.js b/devtools/server/actors/utils/css-grid-utils.js new file mode 100644 index 0000000000..9631dcd800 --- /dev/null +++ b/devtools/server/actors/utils/css-grid-utils.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"; + +/** + * Returns the grid fragment array with all the grid fragment data stringifiable. + * + * @param {Object} fragments + * Grid fragment object. + * @return {Array} representation with the grid fragment data stringifiable. + */ +function getStringifiableFragments(fragments = []) { + if (fragments[0] && Cu.isDeadWrapper(fragments[0])) { + return {}; + } + + return fragments.map(getStringifiableFragment); +} + +function getStringifiableFragment(fragment) { + return { + areas: getStringifiableAreas(fragment.areas), + cols: getStringifiableDimension(fragment.cols), + rows: getStringifiableDimension(fragment.rows), + }; +} + +function getStringifiableAreas(areas) { + return [...areas].map(getStringifiableArea); +} + +function getStringifiableDimension(dimension) { + return { + lines: [...dimension.lines].map(getStringifiableLine), + tracks: [...dimension.tracks].map(getStringifiableTrack), + }; +} + +function getStringifiableArea({ + columnEnd, + columnStart, + name, + rowEnd, + rowStart, + type, +}) { + return { columnEnd, columnStart, name, rowEnd, rowStart, type }; +} + +function getStringifiableLine({ breadth, names, number, start, type }) { + return { breadth, names, number, start, type }; +} + +function getStringifiableTrack({ breadth, start, state, type }) { + return { breadth, start, state, type }; +} + +exports.getStringifiableFragments = getStringifiableFragments; diff --git a/devtools/server/actors/utils/custom-formatters.js b/devtools/server/actors/utils/custom-formatters.js new file mode 100644 index 0000000000..e4ae20dad7 --- /dev/null +++ b/devtools/server/actors/utils/custom-formatters.js @@ -0,0 +1,499 @@ +/* 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, + "createValueGripForTarget", + "resource://devtools/server/actors/object/utils.js", + true +); + +loader.lazyRequireGetter( + this, + "ObjectUtils", + "resource://devtools/server/actors/object/utils.js" +); + +const _invalidCustomFormatterHooks = new WeakSet(); +function addInvalidCustomFormatterHooks(hook) { + if (!hook) { + return; + } + + try { + _invalidCustomFormatterHooks.add(hook); + } catch (e) { + console.error("Couldn't add hook to the WeakSet", hook); + } +} + +// Custom exception used between customFormatterHeader and processFormatterForHeader +class FormatterError extends Error { + constructor(message, script) { + super(message); + this.script = script; + } +} + +/** + * Handle a protocol request to get the custom formatter header for an object. + * This is typically returned into ObjectActor's form if custom formatters are enabled. + * + * @param {ObjectActor} objectActor + * + * @returns {Object} Data related to the custom formatter header: + * - {boolean} useCustomFormatter, indicating if a custom formatter is used. + * - {Array} header JsonML of the output header. + * - {boolean} hasBody True in case the custom formatter has a body. + * - {Object} formatter The devtoolsFormatters item that was being used to format + * the object. + */ +function customFormatterHeader(objectActor) { + const rawValue = objectActor.rawValue(); + const globalWrapper = Cu.getGlobalForObject(rawValue); + const global = globalWrapper?.wrappedJSObject; + + // We expect a `devtoolsFormatters` global attribute and it to be an array + if (!global || !Array.isArray(global.devtoolsFormatters)) { + return null; + } + + const customFormatterTooDeep = + (objectActor.hooks.customFormatterObjectTagDepth || 0) > 20; + if (customFormatterTooDeep) { + logCustomFormatterError( + globalWrapper, + `Too deep hierarchy of inlined custom previews` + ); + return null; + } + + const targetActor = objectActor.thread._parent; + + const { + customFormatterConfigDbgObj: configDbgObj, + customFormatterObjectTagDepth, + } = objectActor.hooks; + + const valueDbgObj = objectActor.obj; + + for (const [ + customFormatterIndex, + formatter, + ] of global.devtoolsFormatters.entries()) { + // If the message for the erroneous formatter already got logged, + // skip logging it again. + if (_invalidCustomFormatterHooks.has(formatter)) { + continue; + } + + // TODO: Any issues regarding the implementation will be covered in https://bugzil.la/1776611. + try { + const rv = processFormatterForHeader({ + configDbgObj, + customFormatterObjectTagDepth, + formatter, + targetActor, + valueDbgObj, + }); + // Return the first valid formatter value + if (rv) { + return rv; + } + } catch (e) { + logCustomFormatterError( + globalWrapper, + e instanceof FormatterError + ? `devtoolsFormatters[${customFormatterIndex}].${e.message}` + : `devtoolsFormatters[${customFormatterIndex}] couldn't be run: ${e.message}`, + // If the exception is FormatterError, this comes with a script attribute + e.script + ); + addInvalidCustomFormatterHooks(formatter); + } + } + + return null; +} +exports.customFormatterHeader = customFormatterHeader; + +/** + * Handle one precise custom formatter. + * i.e. one element of the window.customFormatters Array. + * + * @param {Object} options + * @param {Debugger.Object} options.configDbgObj + * The Debugger.Object of the config object. + * @param {Number} options.customFormatterObjectTagDepth + * See buildJsonMlFromCustomFormatterHookResult JSDoc. + * @param {Object} options.formatter + * The raw formatter object (coming from "customFormatter" array). + * @param {BrowsingContextTargetActor} options.targetActor + * See buildJsonMlFromCustomFormatterHookResult JSDoc. + * @param {Debugger.Object} options.valueDbgObj + * The Debugger.Object of rawValue. + * + * @returns {Object} See customFormatterHeader jsdoc, it returns the same object. + */ +function processFormatterForHeader({ + configDbgObj, + customFormatterObjectTagDepth, + formatter, + targetActor, + valueDbgObj, +}) { + const headerType = typeof formatter?.header; + if (headerType !== "function") { + throw new FormatterError(`header should be a function, got ${headerType}`); + } + + // Call the formatter's header attribute, which should be a function. + const formatterHeaderDbgValue = ObjectUtils.makeDebuggeeValueIfNeeded( + valueDbgObj, + formatter.header + ); + const header = formatterHeaderDbgValue.call( + formatterHeaderDbgValue.boundThis, + valueDbgObj, + configDbgObj + ); + + // If the header returns null, the custom formatter isn't used for that object + if (header?.return === null) { + return null; + } + + // The header has to be an Array, all other cases are errors + if (header?.return?.class !== "Array") { + let errorMsg = ""; + if (header == null) { + errorMsg = `header was not run because it has side effects`; + } else if ("return" in header) { + let type = typeof header.return; + if (type === "object") { + type = header.return?.class; + } + errorMsg = `header should return an array, got ${type}`; + } else if ("throw" in header) { + errorMsg = `header threw: ${header.throw.getProperty("message")?.return}`; + } + + throw new FormatterError(errorMsg, formatterHeaderDbgValue?.script); + } + + const rawHeader = header.return.unsafeDereference(); + if (rawHeader.length === 0) { + throw new FormatterError( + `header returned an empty array`, + formatterHeaderDbgValue?.script + ); + } + + const sanitizedHeader = buildJsonMlFromCustomFormatterHookResult( + header.return, + customFormatterObjectTagDepth, + targetActor + ); + + let hasBody = false; + const hasBodyType = typeof formatter?.hasBody; + if (hasBodyType === "function") { + const formatterHasBodyDbgValue = ObjectUtils.makeDebuggeeValueIfNeeded( + valueDbgObj, + formatter.hasBody + ); + hasBody = formatterHasBodyDbgValue.call( + formatterHasBodyDbgValue.boundThis, + valueDbgObj, + configDbgObj + ); + + if (hasBody == null) { + throw new FormatterError( + `hasBody was not run because it has side effects`, + formatterHasBodyDbgValue?.script + ); + } else if ("throw" in hasBody) { + throw new FormatterError( + `hasBody threw: ${hasBody.throw.getProperty("message")?.return}`, + formatterHasBodyDbgValue?.script + ); + } + } else if (hasBodyType !== "undefined") { + throw new FormatterError( + `hasBody should be a function, got ${hasBodyType}` + ); + } + + return { + useCustomFormatter: true, + header: sanitizedHeader, + hasBody: !!hasBody?.return, + formatter, + }; +} + +/** + * Handle a protocol request to get the custom formatter body for an object + * + * @param {ObjectActor} objectActor + * @param {Object} formatter: The global.devtoolsFormatters entry that was used in customFormatterHeader + * for this object. + * + * @returns {Object} Data related to the custom formatter body: + * - {*} customFormatterBody Data of the custom formatter body. + */ +async function customFormatterBody(objectActor, formatter) { + const rawValue = objectActor.rawValue(); + const globalWrapper = Cu.getGlobalForObject(rawValue); + const global = globalWrapper?.wrappedJSObject; + + const customFormatterIndex = global.devtoolsFormatters.indexOf(formatter); + + const targetActor = objectActor.thread._parent; + try { + const { customFormatterConfigDbgObj, customFormatterObjectTagDepth } = + objectActor.hooks; + + if (_invalidCustomFormatterHooks.has(formatter)) { + return { + customFormatterBody: null, + }; + } + + const bodyType = typeof formatter.body; + if (bodyType !== "function") { + logCustomFormatterError( + globalWrapper, + `devtoolsFormatters[${customFormatterIndex}].body should be a function, got ${bodyType}` + ); + addInvalidCustomFormatterHooks(formatter); + return { + customFormatterBody: null, + }; + } + + const formatterBodyDbgValue = ObjectUtils.makeDebuggeeValueIfNeeded( + objectActor.obj, + formatter.body + ); + const body = formatterBodyDbgValue.call( + formatterBodyDbgValue.boundThis, + objectActor.obj, + customFormatterConfigDbgObj + ); + if (body?.return?.class === "Array") { + const rawBody = body.return.unsafeDereference(); + if (rawBody.length === 0) { + logCustomFormatterError( + globalWrapper, + `devtoolsFormatters[${customFormatterIndex}].body returned an empty array`, + formatterBodyDbgValue?.script + ); + addInvalidCustomFormatterHooks(formatter); + return { + customFormatterBody: null, + }; + } + + const customFormatterBodyJsonMl = + buildJsonMlFromCustomFormatterHookResult( + body.return, + customFormatterObjectTagDepth, + targetActor + ); + + return { + customFormatterBody: customFormatterBodyJsonMl, + }; + } + + let errorMsg = ""; + if (body == null) { + errorMsg = `devtoolsFormatters[${customFormatterIndex}].body was not run because it has side effects`; + } else if ("return" in body) { + let type = body.return === null ? "null" : typeof body.return; + if (type === "object") { + type = body.return?.class; + } + errorMsg = `devtoolsFormatters[${customFormatterIndex}].body should return an array, got ${type}`; + } else if ("throw" in body) { + errorMsg = `devtoolsFormatters[${customFormatterIndex}].body threw: ${ + body.throw.getProperty("message")?.return + }`; + } + + logCustomFormatterError( + globalWrapper, + errorMsg, + formatterBodyDbgValue?.script + ); + addInvalidCustomFormatterHooks(formatter); + } catch (e) { + logCustomFormatterError( + globalWrapper, + `Custom formatter with index ${customFormatterIndex} couldn't be run: ${e.message}` + ); + } + + return {}; +} +exports.customFormatterBody = customFormatterBody; + +/** + * Log an error caused by a fault in a custom formatter to the web console. + * + * @param {Window} window The related global where we should log this message. + * This should be the xray wrapper in order to expose windowGlobalChild. + * The unwrapped, unpriviledged won't expose this attribute. + * @param {string} errorMsg Message to log to the console. + * @param {DebuggerObject} [script] The script causing the error. + */ +function logCustomFormatterError(window, errorMsg, script) { + const scriptErrorClass = Cc["@mozilla.org/scripterror;1"]; + const scriptError = scriptErrorClass.createInstance(Ci.nsIScriptError); + const { url, source, startLine, startColumn } = script ?? {}; + + scriptError.initWithWindowID( + `Custom formatter failed: ${errorMsg}`, + url, + source, + startLine, + startColumn, + Ci.nsIScriptError.errorFlag, + "devtoolsFormatter", + window.windowGlobalChild.innerWindowId + ); + Services.console.logMessage(scriptError); +} + +/** + * Return a ready to use JsonMl object, safe to be sent to the client. + * This will replace JsonMl items with object reference, e.g `[ "object", { config: ..., object: ... } ]` + * with objectActor grip or "regular" JsonMl items (e.g. `["span", {style: "color: red"}, "this is", "an object"]`) + * if the referenced object gets custom formatted as well. + * + * @param {DebuggerObject} jsonMlDbgObj: The debugger object representing a jsonMl object returned + * by a custom formatter hook. + * @param {Number} customFormatterObjectTagDepth: See `processObjectTag`. + * @param {BrowsingContextTargetActor} targetActor: The actor that will be managing any + * created ObjectActor. + * @returns {Array|null} Returns null if the passed object is a not DebuggerObject representing an Array + */ +function buildJsonMlFromCustomFormatterHookResult( + jsonMlDbgObj, + customFormatterObjectTagDepth, + targetActor +) { + const tagName = jsonMlDbgObj.getProperty(0)?.return; + if (typeof tagName !== "string") { + const tagNameType = + tagName?.class || (tagName === null ? "null" : typeof tagName); + throw new Error(`tagName should be a string, got ${tagNameType}`); + } + + // Fetch the other items of the jsonMl + const rest = []; + const dbgObjLength = jsonMlDbgObj.getProperty("length")?.return || 0; + for (let i = 1; i < dbgObjLength; i++) { + rest.push(jsonMlDbgObj.getProperty(i)?.return); + } + + // The second item of the array can either be an object holding the attributes + // for the element or the first child element. + const attributesDbgObj = + rest[0] && rest[0].class === "Object" ? rest[0] : null; + const childrenDbgObj = attributesDbgObj ? rest.slice(1) : rest; + + // If the tagName is "object", we need to replace the entry with the grip representing + // this object (that may or may not be custom formatted). + if (tagName == "object") { + if (!attributesDbgObj) { + throw new Error(`"object" tag should have attributes`); + } + + // TODO: We could emit a warning if `childrenDbgObj` isn't empty as we're going to + // ignore them here. + return processObjectTag( + attributesDbgObj, + customFormatterObjectTagDepth, + targetActor + ); + } + + const jsonMl = [tagName, {}]; + if (attributesDbgObj) { + // For non "object" tags, we only care about the style property + jsonMl[1].style = attributesDbgObj.getProperty("style")?.return; + } + + // Handle children, which could be simple primitives or JsonML objects + for (const childDbgObj of childrenDbgObj) { + const childDbgObjType = typeof childDbgObj; + if (childDbgObj?.class === "Array") { + // `childDbgObj` probably holds a JsonMl item, sanitize it. + jsonMl.push( + buildJsonMlFromCustomFormatterHookResult( + childDbgObj, + customFormatterObjectTagDepth, + targetActor + ) + ); + } else if (childDbgObjType == "object" && childDbgObj !== null) { + // If we don't have an array, match Chrome implementation. + jsonMl.push("[object Object]"); + } else { + // Here `childDbgObj` is a primitive. Create a grip so we can handle all the types + // we can stringify easily (e.g. `undefined`, `bigint`, …). + const grip = createValueGripForTarget(targetActor, childDbgObj); + if (grip !== null) { + jsonMl.push(grip); + } + } + } + return jsonMl; +} + +/** + * Return a ready to use JsonMl object, safe to be sent to the client. + * This will replace JsonMl items with object reference, e.g `[ "object", { config: ..., object: ... } ]` + * with objectActor grip or "regular" JsonMl items (e.g. `["span", {style: "color: red"}, "this is", "an object"]`) + * if the referenced object gets custom formatted as well. + * + * @param {DebuggerObject} attributesDbgObj: The debugger object representing the "attributes" + * of a jsonMl item (e.g. the second item in the array). + * @param {Number} customFormatterObjectTagDepth: As "object" tag can reference custom + * formatted data, we track the number of time we go through this function + * from the "root" object so we don't have an infinite loop. + * @param {BrowsingContextTargetActor} targetActor: The actor that will be managin any + * created ObjectActor. + * @returns {Object} Returns a grip representing the underlying object + */ +function processObjectTag( + attributesDbgObj, + customFormatterObjectTagDepth, + targetActor +) { + const objectDbgObj = attributesDbgObj.getProperty("object")?.return; + if (typeof objectDbgObj == "undefined") { + throw new Error( + `attribute of "object" tag should have an "object" property` + ); + } + + // We need to replace the "object" tag with the actual `attribute.object` object, + // which might be also custom formatted. + // We create the grip so the custom formatter hooks can be called on this object, or + // we'd get an object grip that we can consume to display an ObjectInspector on the client. + const configRv = attributesDbgObj.getProperty("config"); + const grip = createValueGripForTarget(targetActor, objectDbgObj, 0, { + // Store the config so we can pass it when calling custom formatter hooks for this object. + customFormatterConfigDbgObj: configRv?.return, + customFormatterObjectTagDepth: (customFormatterObjectTagDepth || 0) + 1, + }); + + return grip; +} diff --git a/devtools/server/actors/utils/dbg-source.js b/devtools/server/actors/utils/dbg-source.js new file mode 100644 index 0000000000..9c4111dfaa --- /dev/null +++ b/devtools/server/actors/utils/dbg-source.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"; + +/** + * Get the source text offset equivalent to a given line/column pair. + * + * @param {Debugger.Source} source + * @param {number} line The 1-based line number. + * @param {number} column The 0-based column number. + * @returns {number} The codepoint offset into the source's text. + */ +function findSourceOffset(source, line, column) { + const offsets = getSourceLineOffsets(source); + const offset = offsets[line - 1]; + + if (offset) { + // Make sure that columns that technically don't exist in the line text + // don't cause the offset to wrap to the next line. + return Math.min(offset.start + column, offset.textEnd); + } + + return line < 0 ? 0 : offsets[offsets.length - 1].end; +} +exports.findSourceOffset = findSourceOffset; + +const NEWLINE = /(\r?\n|\r|\u2028|\u2029)/g; +const SOURCE_OFFSETS = new WeakMap(); +/** + * Generate and cache line information for a given source to track what + * text offsets mark the start and end of lines. Each entry in the array + * represents a line in the source text. + * + * @param {Debugger.Source} source + * @returns {Array<{ start, textEnd, end }>} + * - start - The codepoint offset of the start of the line. + * - textEnd - The codepoint offset just after the last non-newline character. + * - end - The codepoint offset of the end of the line. This will be + * be the same as the 'start' value of the next offset object, + * and this includes the newlines for the line itself, where + * 'textEnd' excludes newline characters. + */ +function getSourceLineOffsets(source) { + const cached = SOURCE_OFFSETS.get(source); + if (cached) { + return cached; + } + + const { text } = source; + + const lines = text.split(NEWLINE); + + const offsets = []; + let offset = 0; + for (let i = 0; i < lines.length; i += 2) { + const line = lines[i]; + const start = offset; + + // Calculate the end codepoint offset. + let end = offset; + // eslint-disable-next-line no-unused-vars + for (const c of line) { + end++; + } + const textEnd = end; + + if (i + 1 < lines.length) { + end += lines[i + 1].length; + } + + offsets.push(Object.freeze({ start, textEnd, end })); + offset = end; + } + Object.freeze(offsets); + + SOURCE_OFFSETS.set(source, offsets); + return offsets; +} + +/** + * Given a target actor and a source platform internal ID, + * return the related SourceActor ID. + + * @param TargetActor targetActor + * The Target Actor from which this source originates. + * @param String id + * Platform Source ID + * @return String + * The SourceActor ID + */ +function getActorIdForInternalSourceId(targetActor, id) { + const actor = targetActor.sourcesManager.getSourceActorByInternalSourceId(id); + return actor ? actor.actorID : null; +} +exports.getActorIdForInternalSourceId = getActorIdForInternalSourceId; diff --git a/devtools/server/actors/utils/event-breakpoints.js b/devtools/server/actors/utils/event-breakpoints.js new file mode 100644 index 0000000000..a7752b8201 --- /dev/null +++ b/devtools/server/actors/utils/event-breakpoints.js @@ -0,0 +1,508 @@ +/* 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"; + +/** + * + * @param {String} groupID + * @param {String} eventType + * @param {Function} condition: Optional function that takes a Window as parameter. When + * passed, the event will only be included if the result of the function + * call is `true` (See `getAvailableEventBreakpoints`). + * @returns {Object} + */ +function generalEvent(groupID, eventType, condition) { + return { + id: `event.${groupID}.${eventType}`, + type: "event", + name: eventType, + message: `DOM '${eventType}' event`, + eventType, + filter: "general", + condition, + }; +} +function nodeEvent(groupID, eventType) { + return { + ...generalEvent(groupID, eventType), + filter: "node", + }; +} +function mediaNodeEvent(groupID, eventType) { + return { + ...generalEvent(groupID, eventType), + filter: "media", + }; +} +function globalEvent(groupID, eventType) { + return { + ...generalEvent(groupID, eventType), + message: `Global '${eventType}' event`, + filter: "global", + }; +} +function xhrEvent(groupID, eventType) { + return { + ...generalEvent(groupID, eventType), + message: `XHR '${eventType}' event`, + filter: "xhr", + }; +} + +function webSocketEvent(groupID, eventType) { + return { + ...generalEvent(groupID, eventType), + message: `WebSocket '${eventType}' event`, + filter: "websocket", + }; +} + +function workerEvent(eventType) { + return { + ...generalEvent("worker", eventType), + message: `Worker '${eventType}' event`, + filter: "worker", + }; +} + +function timerEvent(type, operation, name, notificationType) { + return { + id: `timer.${type}.${operation}`, + type: "simple", + name, + message: name, + notificationType, + }; +} + +function animationEvent(operation, name, notificationType) { + return { + id: `animationframe.${operation}`, + type: "simple", + name, + message: name, + notificationType, + }; +} + +const SCRIPT_FIRST_STATEMENT_BREAKPOINT = { + id: "script.source.firstStatement", + type: "script", + name: "Script First Statement", + message: "Script First Statement", +}; + +const AVAILABLE_BREAKPOINTS = [ + { + name: "Animation", + items: [ + animationEvent( + "request", + "Request Animation Frame", + "requestAnimationFrame" + ), + animationEvent( + "cancel", + "Cancel Animation Frame", + "cancelAnimationFrame" + ), + animationEvent( + "fire", + "Animation Frame fired", + "requestAnimationFrameCallback" + ), + ], + }, + { + name: "Clipboard", + items: [ + generalEvent("clipboard", "copy"), + generalEvent("clipboard", "cut"), + generalEvent("clipboard", "paste"), + generalEvent("clipboard", "beforecopy"), + generalEvent("clipboard", "beforecut"), + generalEvent("clipboard", "beforepaste"), + ], + }, + { + name: "Control", + items: [ + // The condition should be removed when "dom.element.popover.enabled" is removed + generalEvent("control", "beforetoggle", () => + Services.prefs.getBoolPref("dom.element.popover.enabled") + ), + generalEvent("control", "blur"), + generalEvent("control", "change"), + generalEvent("control", "focus"), + generalEvent("control", "focusin"), + generalEvent("control", "focusout"), + // The condition should be removed when "dom.element.invokers.enabled" is removed + generalEvent("control", "invoke", win => "InvokeEvent" in win), + generalEvent("control", "reset"), + generalEvent("control", "resize"), + generalEvent("control", "scroll"), + generalEvent("control", "scrollend"), + generalEvent("control", "select"), + generalEvent("control", "toggle"), + generalEvent("control", "submit"), + generalEvent("control", "zoom"), + ], + }, + { + name: "DOM Mutation", + items: [ + // Deprecated DOM events. + nodeEvent("dom-mutation", "DOMActivate"), + nodeEvent("dom-mutation", "DOMFocusIn"), + nodeEvent("dom-mutation", "DOMFocusOut"), + + // Standard DOM mutation events. + nodeEvent("dom-mutation", "DOMAttrModified"), + nodeEvent("dom-mutation", "DOMCharacterDataModified"), + nodeEvent("dom-mutation", "DOMNodeInserted"), + nodeEvent("dom-mutation", "DOMNodeInsertedIntoDocument"), + nodeEvent("dom-mutation", "DOMNodeRemoved"), + nodeEvent("dom-mutation", "DOMNodeRemovedIntoDocument"), + nodeEvent("dom-mutation", "DOMSubtreeModified"), + + // DOM load events. + nodeEvent("dom-mutation", "DOMContentLoaded"), + ], + }, + { + name: "Device", + items: [ + globalEvent("device", "deviceorientation"), + globalEvent("device", "devicemotion"), + ], + }, + { + name: "Drag and Drop", + items: [ + generalEvent("drag-and-drop", "drag"), + generalEvent("drag-and-drop", "dragstart"), + generalEvent("drag-and-drop", "dragend"), + generalEvent("drag-and-drop", "dragenter"), + generalEvent("drag-and-drop", "dragover"), + generalEvent("drag-and-drop", "dragleave"), + generalEvent("drag-and-drop", "drop"), + ], + }, + { + name: "Keyboard", + items: [ + generalEvent("keyboard", "beforeinput"), + generalEvent("keyboard", "input"), + generalEvent("keyboard", "keydown"), + generalEvent("keyboard", "keyup"), + generalEvent("keyboard", "keypress"), + generalEvent("keyboard", "compositionstart"), + generalEvent("keyboard", "compositionupdate"), + generalEvent("keyboard", "compositionend"), + ].filter(Boolean), + }, + { + name: "Load", + items: [ + globalEvent("load", "load"), + globalEvent("load", "beforeunload"), + globalEvent("load", "unload"), + globalEvent("load", "abort"), + globalEvent("load", "error"), + globalEvent("load", "hashchange"), + globalEvent("load", "popstate"), + ], + }, + { + name: "Media", + items: [ + mediaNodeEvent("media", "play"), + mediaNodeEvent("media", "pause"), + mediaNodeEvent("media", "playing"), + mediaNodeEvent("media", "canplay"), + mediaNodeEvent("media", "canplaythrough"), + mediaNodeEvent("media", "seeking"), + mediaNodeEvent("media", "seeked"), + mediaNodeEvent("media", "timeupdate"), + mediaNodeEvent("media", "ended"), + mediaNodeEvent("media", "ratechange"), + mediaNodeEvent("media", "durationchange"), + mediaNodeEvent("media", "volumechange"), + mediaNodeEvent("media", "loadstart"), + mediaNodeEvent("media", "progress"), + mediaNodeEvent("media", "suspend"), + mediaNodeEvent("media", "abort"), + mediaNodeEvent("media", "error"), + mediaNodeEvent("media", "emptied"), + mediaNodeEvent("media", "stalled"), + mediaNodeEvent("media", "loadedmetadata"), + mediaNodeEvent("media", "loadeddata"), + mediaNodeEvent("media", "waiting"), + ], + }, + { + name: "Mouse", + items: [ + generalEvent("mouse", "auxclick"), + generalEvent("mouse", "click"), + generalEvent("mouse", "dblclick"), + generalEvent("mouse", "mousedown"), + generalEvent("mouse", "mouseup"), + generalEvent("mouse", "mouseover"), + generalEvent("mouse", "mousemove"), + generalEvent("mouse", "mouseout"), + generalEvent("mouse", "mouseenter"), + generalEvent("mouse", "mouseleave"), + generalEvent("mouse", "mousewheel"), + generalEvent("mouse", "wheel"), + generalEvent("mouse", "contextmenu"), + ], + }, + { + name: "Pointer", + items: [ + generalEvent("pointer", "pointerover"), + generalEvent("pointer", "pointerout"), + generalEvent("pointer", "pointerenter"), + generalEvent("pointer", "pointerleave"), + generalEvent("pointer", "pointerdown"), + generalEvent("pointer", "pointerup"), + generalEvent("pointer", "pointermove"), + generalEvent("pointer", "pointercancel"), + generalEvent("pointer", "gotpointercapture"), + generalEvent("pointer", "lostpointercapture"), + ], + }, + { + name: "Script", + items: [SCRIPT_FIRST_STATEMENT_BREAKPOINT], + }, + { + name: "Timer", + items: [ + timerEvent("timeout", "set", "setTimeout", "setTimeout"), + timerEvent("timeout", "clear", "clearTimeout", "clearTimeout"), + timerEvent("timeout", "fire", "setTimeout fired", "setTimeoutCallback"), + timerEvent("interval", "set", "setInterval", "setInterval"), + timerEvent("interval", "clear", "clearInterval", "clearInterval"), + timerEvent( + "interval", + "fire", + "setInterval fired", + "setIntervalCallback" + ), + ], + }, + { + name: "Touch", + items: [ + generalEvent("touch", "touchstart"), + generalEvent("touch", "touchmove"), + generalEvent("touch", "touchend"), + generalEvent("touch", "touchcancel"), + ], + }, + { + name: "WebSocket", + items: [ + webSocketEvent("websocket", "open"), + webSocketEvent("websocket", "message"), + webSocketEvent("websocket", "error"), + webSocketEvent("websocket", "close"), + ], + }, + { + name: "Worker", + items: [ + workerEvent("message"), + workerEvent("messageerror"), + + // Service Worker events. + globalEvent("serviceworker", "fetch"), + ], + }, + { + name: "XHR", + items: [ + xhrEvent("xhr", "readystatechange"), + xhrEvent("xhr", "load"), + xhrEvent("xhr", "loadstart"), + xhrEvent("xhr", "loadend"), + xhrEvent("xhr", "abort"), + xhrEvent("xhr", "error"), + xhrEvent("xhr", "progress"), + xhrEvent("xhr", "timeout"), + ], + }, +]; + +const FLAT_EVENTS = []; +for (const category of AVAILABLE_BREAKPOINTS) { + for (const event of category.items) { + FLAT_EVENTS.push(event); + } +} +const EVENTS_BY_ID = {}; +for (const event of FLAT_EVENTS) { + if (EVENTS_BY_ID[event.id]) { + throw new Error("Duplicate event ID detected: " + event.id); + } + EVENTS_BY_ID[event.id] = event; +} + +const SIMPLE_EVENTS = {}; +const DOM_EVENTS = {}; +for (const eventBP of FLAT_EVENTS) { + if (eventBP.type === "simple") { + const { notificationType } = eventBP; + if (SIMPLE_EVENTS[notificationType]) { + throw new Error("Duplicate simple event"); + } + SIMPLE_EVENTS[notificationType] = eventBP.id; + } else if (eventBP.type === "event") { + const { eventType, filter } = eventBP; + + let targetTypes; + if (filter === "global") { + targetTypes = ["global"]; + } else if (filter === "xhr") { + targetTypes = ["xhr"]; + } else if (filter === "websocket") { + targetTypes = ["websocket"]; + } else if (filter === "worker") { + targetTypes = ["worker"]; + } else if (filter === "general") { + targetTypes = ["global", "node"]; + } else if (filter === "node" || filter === "media") { + targetTypes = ["node"]; + } else { + throw new Error("Unexpected filter type"); + } + + for (const targetType of targetTypes) { + let byEventType = DOM_EVENTS[targetType]; + if (!byEventType) { + byEventType = {}; + DOM_EVENTS[targetType] = byEventType; + } + + if (byEventType[eventType]) { + throw new Error("Duplicate dom event: " + eventType); + } + byEventType[eventType] = eventBP.id; + } + } else if (eventBP.type === "script") { + // Nothing to do. + } else { + throw new Error("Unknown type: " + eventBP.type); + } +} + +exports.eventBreakpointForNotification = eventBreakpointForNotification; +function eventBreakpointForNotification(dbg, notification) { + const notificationType = notification.type; + + if (notification.type === "domEvent") { + const domEventNotification = DOM_EVENTS[notification.targetType]; + if (!domEventNotification) { + return null; + } + + // The 'event' value is a cross-compartment wrapper for the DOM Event object. + // While we could use that directly in the main thread as an Xray wrapper, + // when debugging workers we can't, because it is an opaque wrapper. + // To make things work, we have to always interact with the Event object via + // the Debugger.Object interface. + const evt = dbg + .makeGlobalObjectReference(notification.global) + .makeDebuggeeValue(notification.event); + + const eventType = evt.getProperty("type").return; + const id = domEventNotification[eventType]; + if (!id) { + return null; + } + const eventBreakpoint = EVENTS_BY_ID[id]; + + if (eventBreakpoint.filter === "media") { + const currentTarget = evt.getProperty("currentTarget").return; + if (!currentTarget) { + return null; + } + + const nodeType = currentTarget.getProperty("nodeType").return; + const namespaceURI = currentTarget.getProperty("namespaceURI").return; + if ( + nodeType !== 1 /* ELEMENT_NODE */ || + namespaceURI !== "http://www.w3.org/1999/xhtml" + ) { + return null; + } + + const nodeName = currentTarget + .getProperty("nodeName") + .return.toLowerCase(); + if (nodeName !== "audio" && nodeName !== "video") { + return null; + } + } + + return id; + } + + return SIMPLE_EVENTS[notificationType] || null; +} + +exports.makeEventBreakpointMessage = makeEventBreakpointMessage; +function makeEventBreakpointMessage(id) { + return EVENTS_BY_ID[id].message; +} + +exports.firstStatementBreakpointId = firstStatementBreakpointId; +function firstStatementBreakpointId() { + return SCRIPT_FIRST_STATEMENT_BREAKPOINT.id; +} + +exports.eventsRequireNotifications = eventsRequireNotifications; +function eventsRequireNotifications(ids) { + for (const id of ids) { + const eventBreakpoint = EVENTS_BY_ID[id]; + + // Script events are implemented directly in the server and do not require + // notifications from Gecko, so there is no need to watch for them. + if (eventBreakpoint && eventBreakpoint.type !== "script") { + return true; + } + } + return false; +} + +exports.getAvailableEventBreakpoints = getAvailableEventBreakpoints; +/** + * Get all available event breakpoints + * + * @param {Window} window + * @returns {Array<Object>} An array containing object with 2 properties, an id and a name, + * representing the event. + */ +function getAvailableEventBreakpoints(window) { + const available = []; + for (const { name, items } of AVAILABLE_BREAKPOINTS) { + available.push({ + name, + events: items + .filter(item => !item.condition || item.condition(window)) + .map(item => ({ + id: item.id, + name: item.name, + })), + }); + } + return available; +} +exports.validateEventBreakpoint = validateEventBreakpoint; +function validateEventBreakpoint(id) { + return !!EVENTS_BY_ID[id]; +} diff --git a/devtools/server/actors/utils/event-loop.js b/devtools/server/actors/utils/event-loop.js new file mode 100644 index 0000000000..519d97ba7e --- /dev/null +++ b/devtools/server/actors/utils/event-loop.js @@ -0,0 +1,221 @@ +/* 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 xpcInspector = require("xpcInspector"); + +/** + * An object that represents a nested event loop. It is used as the nest + * requestor with nsIJSInspector instances. + * + * @param ThreadActor thread + * The thread actor that is creating this nested event loop. + */ +class EventLoop { + constructor({ thread }) { + this._thread = thread; + + // A flag which is true in between the two calls to enter() and exit(). + this._entered = false; + // Another flag which is true only after having called exit(). + // Note that this EventLoop may still be paused and its enter() method + // still be on hold, if another EventLoop paused about this one. + this._resolved = false; + } + + /** + * This is meant for other thread actors, and is used by other thread actor's + * EventLoop's isTheLastPausedThreadActor() + */ + get thread() { + return this._thread; + } + /** + * Similarly, it will be used by another thread actor's EventLoop's enter() method + */ + get resolved() { + return this._resolved; + } + + /** + * Tells if the last thread actor to have paused (i.e. last EventLoop on the stack) + * is the current one. + * + * We avoid trying to exit this event loop, + * if another thread actor pile up a more recent one. + * All the event loops will be effectively exited when + * the thread actor which piled up the most recent nested event loop resumes. + * + * For convenience for the callsite, this will return true if nothing paused. + */ + isTheLastPausedThreadActor() { + if (xpcInspector.eventLoopNestLevel > 0) { + return xpcInspector.lastNestRequestor.thread === this._thread; + } + return true; + } + + /** + * Enter a new nested event loop. + */ + enter() { + if (this._entered) { + throw new Error( + "Can't enter an event loop that has already been entered!" + ); + } + + const preEnterData = this.preEnter(); + + this._entered = true; + // Note: next line will synchronously block the execution until exit() is being called. + // + // This enterNestedEventLoop is a bit magical and will break run-to-completion rule of JS. + // JS will become multi-threaded. Some other task may start running on change state + // while we are blocked on this enterNestedEventLoop function call. + // You may find valuable information about Tasks and Event Loops on: + // https://docs.google.com/document/d/1jTMd-H_BwH9_QNUDxPse80vq884_hMvd234lvE5gqY8/edit?usp=sharing + // + // Note #2: this will update xpcInspector.lastNestRequestor to this + xpcInspector.enterNestedEventLoop(this); + + // If this code runs, it means that we just exited this event loop and lastNestRequestor is no longer equal to this. + // + // We will now "recursively" exit all the resolved EventLoops which are blocked on `enterNestedEventLoop`: + // - if the new lastNestRequestor is resolved, request to exit it as well + // - this lastNestRequestor is another EventLoop instance + // - exiting this EventLoop unblocks its "enter" method and moves lastNestRequestor to the next requestor (if any) + // - we go back to the first step, and attempt to exit the new lastNestRequestor if it is resolved, etc... + if (xpcInspector.eventLoopNestLevel > 0) { + const { resolved } = xpcInspector.lastNestRequestor; + if (resolved) { + xpcInspector.exitNestedEventLoop(); + } + } + + this.postExit(preEnterData); + } + + /** + * Exit this nested event loop. + * + * @returns boolean + * True if we exited this nested event loop because it was on top of + * the stack, false if there is another nested event loop above this + * one that hasn't exited yet. + */ + exit() { + if (!this._entered) { + throw new Error("Can't exit an event loop before it has been entered!"); + } + this._entered = false; + this._resolved = true; + + // If another ThreadActor paused and spawn a new nested event loop after this one, + // let it resume the thread and ignore this call. + // The code calling exitNestedEventLoop from EventLoop.enter will resume execution, + // by seeing that resolved attribute that we just toggled is true. + // + // Note that ThreadActor.resume method avoids calling exit thanks to `isTheLastPausedThreadActor` + // So for all use requests to resume, the ThreadActor won't call exit until it is the last + // thread actor to have entered a nested EventLoop. + if (this === xpcInspector.lastNestRequestor) { + xpcInspector.exitNestedEventLoop(); + return true; + } + return false; + } + + /** + * Retrieve the list of all DOM Windows debugged by the current thread actor. + */ + getAllWindowDebuggees() { + return this._thread.dbg + .getDebuggees() + .filter(debuggee => { + // Select only debuggee that relates to windows + // e.g. ignore sandboxes, jsm and such + return debuggee.class == "Window"; + }) + .map(debuggee => { + // Retrieve the JS reference for these windows + return debuggee.unsafeDereference(); + }) + + .filter(window => { + // Ignore document which have already been nuked, + // so navigated to another location and removed from memory completely. + if (Cu.isDeadWrapper(window)) { + return false; + } + // Also ignore document which are closed, as trying to access window.parent or top would throw NS_ERROR_NOT_INITIALIZED + if (window.closed) { + return false; + } + // Ignore remote iframes, which will be debugged by another thread actor, + // running in the remote process + if (Cu.isRemoteProxy(window)) { + return false; + } + // Accept "top remote iframe document": + // document of iframe whose immediate parent is in another process. + if (Cu.isRemoteProxy(window.parent) && !Cu.isRemoteProxy(window)) { + return true; + } + + // If EFT is enabled, accept any same process document (top-level or iframe). + if (this.thread.getParent().ignoreSubFrames) { + return true; + } + + try { + // Ignore iframes running in the same process as their parent document, + // as they will be paused automatically when pausing their owner top level document + return window.top === window; + } catch (e) { + // Warn if this is throwing for an unknown reason, but suppress the + // exception regardless so that we can enter the nested event loop. + if (!/not initialized/.test(e)) { + console.warn(`Exception in getAllWindowDebuggees: ${e}`); + } + return false; + } + }); + } + + /** + * Prepare to enter a nested event loop by disabling debuggee events. + */ + preEnter() { + const docShells = []; + // Disable events in all open windows. + for (const window of this.getAllWindowDebuggees()) { + const { windowUtils } = window; + windowUtils.suppressEventHandling(true); + windowUtils.suspendTimeouts(); + docShells.push(window.docShell); + } + return docShells; + } + + /** + * Prepare to exit a nested event loop by enabling debuggee events. + */ + postExit(pausedDocShells) { + // Enable events in all window paused in preEnter + for (const docShell of pausedDocShells) { + // Do not try to resume documents which are in destruction + // as resume methods would throw + if (docShell.isBeingDestroyed()) { + continue; + } + const { windowUtils } = docShell.domWindow; + windowUtils.resumeTimeouts(); + windowUtils.suppressEventHandling(false); + } + } +} + +exports.EventLoop = EventLoop; diff --git a/devtools/server/actors/utils/gecko-profile-collector.js b/devtools/server/actors/utils/gecko-profile-collector.js new file mode 100644 index 0000000000..1cdb6d7e56 --- /dev/null +++ b/devtools/server/actors/utils/gecko-profile-collector.js @@ -0,0 +1,285 @@ +/* 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"; + +// The fallback color for unexpected cases +const DEFAULT_COLOR = "grey"; + +// The default category for unexpected cases +const DEFAULT_CATEGORIES = [ + { + name: "Mixed", + color: DEFAULT_COLOR, + subcategories: ["Other"], + }, +]; + +// Color for each type of category/frame's implementation +const PREDEFINED_COLORS = { + interpreter: "yellow", + baseline: "orange", + ion: "blue", + wasm: "purple", +}; + +/** + * Utility class that collects the JS tracer data and converts it to a Gecko + * profile object. + */ +class GeckoProfileCollector { + #thread = null; + #stackMap = new Map(); + #frameMap = new Map(); + #categories = DEFAULT_CATEGORIES; + #currentStack = []; + #time = 0; + + /** + * Initialize the profiler and be ready to receive samples. + */ + start() { + this.#reset(); + this.#thread = this.#getEmptyThread(); + } + + /** + * Stop the record and return the gecko profiler data. + * + * @return {Object} + * The Gecko profile object. + */ + stop() { + // Create the profile to return. + const profile = this.#getEmptyProfile(); + profile.meta.categories = this.#categories; + profile.threads.push(this.#thread); + + // Cleanup. + this.#reset(); + + return profile; + } + + /** + * Clear all the internal state of this class. + */ + #reset() { + this.#thread = null; + this.#stackMap = new Map(); + this.#frameMap = new Map(); + this.#categories = DEFAULT_CATEGORIES; + this.#currentStack = []; + this.#time = 0; + } + + /** + * Initialize an empty Gecko profile object. + * + * @return {Object} + * Gecko profile object. + */ + #getEmptyProfile() { + const httpHandler = Cc[ + "@mozilla.org/network/protocol;1?name=http" + ].getService(Ci.nsIHttpProtocolHandler); + return { + meta: { + // Currently interval is 1, but we could change it to a lower number + // when we have durations coming from js tracer. + interval: 1, + startTime: 0, + product: Services.appinfo.name, + importedFrom: "JS Tracer", + version: 28, + presymbolicated: true, + abi: Services.appinfo.XPCOMABI, + misc: httpHandler.misc, + oscpu: httpHandler.oscpu, + platform: httpHandler.platform, + processType: Services.appinfo.processType, + categories: [], + stackwalk: 0, + toolkit: Services.appinfo.widgetToolkit, + appBuildID: Services.appinfo.appBuildID, + sourceURL: Services.appinfo.sourceURL, + physicalCPUs: 0, + logicalCPUs: 0, + CPUName: "", + markerSchema: [], + }, + libs: [], + pages: [], + threads: [], + processes: [], + }; + } + + /** + * Generate a thread object to be stored in the Gecko profile object. + */ + #getEmptyThread() { + return { + processType: "default", + processStartupTime: 0, + processShutdownTime: null, + registerTime: 0, + unregisterTime: null, + pausedRanges: [], + name: "GeckoMain", + "eTLD+1": "JS Tracer", + isMainThread: true, + pid: Services.appinfo.processID, + tid: 0, + samples: { + schema: { + stack: 0, + time: 1, + eventDelay: 2, + }, + data: [], + }, + markers: { + schema: { + name: 0, + startTime: 1, + endTime: 2, + phase: 3, + category: 4, + data: 5, + }, + data: [], + }, + stackTable: { + schema: { + prefix: 0, + frame: 1, + }, + data: [], + }, + frameTable: { + schema: { + location: 0, + relevantForJS: 1, + innerWindowID: 2, + implementation: 3, + line: 4, + column: 5, + category: 6, + subcategory: 7, + }, + data: [], + }, + stringTable: [], + }; + } + + /** + * Record a new sample to be stored in the Gecko profile object. + * + * @param {Object} frame + * Object describing a frame with following attributes: + * - {String} name + * Human readable name for this frame. + * - {String} url + * URL of the running script. + * - {Number} lineNumber + * Line currently executing for this script. + * - {Number} columnNumber + * Column currently executing for this script. + * - {String} category + * Which JS implementation is being used for this frame: interpreter, baseline, ion or wasm. + * See Debugger.frame.implementation. + */ + addSample(frame, depth) { + const currentDepth = this.#currentStack.length; + if (currentDepth == depth) { + // We are in the same depth and executing another frame. Replace the + // current frame with the new one. + this.#currentStack[currentDepth] = frame; + } else if (currentDepth < depth) { + // We are going deeper in the stack. Push the new frame. + this.#currentStack.push(frame); + } else { + // We are going back in the stack. Pop frames until we reach the right depth. + this.#currentStack.length = depth; + this.#currentStack[depth] = frame; + } + + const stack = this.#currentStack.reduce((prefix, stackFrame) => { + const frameIndex = this.#getOrCreateFrame(stackFrame); + return this.#getOrCreateStack(frameIndex, prefix); + }, null); + this.#thread.samples.data.push([ + stack, + // We put simply 1 sample (1ms) for each frame. We can change it in the + // future if we can get the duration of the frame. + this.#time++, + 0, // eventDelay + ]); + } + + #getOrCreateFrame(frame) { + const { frameTable, stringTable } = this.#thread; + const frameString = `${frame.name}:${frame.url}:${frame.lineNumber}:${frame.columnNumber}:${frame.category}`; + let frameIndex = this.#frameMap.get(frameString); + + if (frameIndex === undefined) { + frameIndex = frameTable.data.length; + const location = stringTable.length; + // Profiler frontend except a particular string to match the source URL: + // `functionName (http://script.url/:1234:1234)` + // https://github.com/firefox-devtools/profiler/blob/dab645b2db7e1b21185b286f96dd03b77f68f5c3/src/profile-logic/process-profile.js#L518 + stringTable.push( + `${frame.name} (${frame.url}:${frame.lineNumber}:${frame.columnNumber})` + ); + + const category = this.#getOrCreateCategory(frame.category); + + frameTable.data.push([ + location, + true, // relevantForJS + 0, // innerWindowID + null, // implementation + frame.lineNumber, // line + frame.columnNumber, // column + category, + 0, // subcategory + ]); + this.#frameMap.set(frameString, frameIndex); + } + + return frameIndex; + } + + #getOrCreateStack(frameIndex, prefix) { + const { stackTable } = this.#thread; + const key = prefix === null ? `${frameIndex}` : `${frameIndex},${prefix}`; + let stack = this.#stackMap.get(key); + + if (stack === undefined) { + stack = stackTable.data.length; + stackTable.data.push([prefix, frameIndex]); + this.#stackMap.set(key, stack); + } + return stack; + } + + #getOrCreateCategory(category) { + const categories = this.#categories; + let categoryIndex = categories.findIndex(c => c.name === category); + + if (categoryIndex === -1) { + categoryIndex = categories.length; + categories.push({ + name: category, + color: PREDEFINED_COLORS[category] ?? DEFAULT_COLOR, + subcategories: ["Other"], + }); + } + return categoryIndex; + } +} + +exports.GeckoProfileCollector = GeckoProfileCollector; diff --git a/devtools/server/actors/utils/inactive-property-helper.js b/devtools/server/actors/utils/inactive-property-helper.js new file mode 100644 index 0000000000..759c2e6215 --- /dev/null +++ b/devtools/server/actors/utils/inactive-property-helper.js @@ -0,0 +1,1443 @@ +/* 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, + "CssLogic", + "resource://devtools/server/actors/inspector/css-logic.js", + true +); + +const INACTIVE_CSS_ENABLED = Services.prefs.getBoolPref( + "devtools.inspector.inactive.css.enabled", + false +); + +const TEXT_WRAP_BALANCE_LIMIT = Services.prefs.getIntPref( + "layout.css.text-wrap-balance.limit", + 10 +); + +const VISITED_MDN_LINK = "https://developer.mozilla.org/docs/Web/CSS/:visited"; +const VISITED_INVALID_PROPERTIES = allCssPropertiesExcept([ + "all", + "color", + "background", + "background-color", + "border", + "border-color", + "border-bottom-color", + "border-left-color", + "border-right-color", + "border-top-color", + "border-block", + "border-block-color", + "border-block-start-color", + "border-block-end-color", + "border-inline", + "border-inline-color", + "border-inline-start-color", + "border-inline-end-color", + "column-rule", + "column-rule-color", + "outline", + "outline-color", + "text-decoration-color", + "text-emphasis-color", +]); + +// Set of node names which are always treated as replaced elements: +const REPLACED_ELEMENTS_NAMES = new Set([ + "audio", + "br", + "button", + "canvas", + "embed", + "hr", + "iframe", + // Inputs are generally replaced elements. E.g. checkboxes and radios are replaced + // unless they have `appearance: none`. However unconditionally treating them + // as replaced is enough for our purpose here, and avoids extra complexity that + // will likely not be necessary in most cases. + "input", + "math", + "object", + "picture", + // Select is a replaced element if it has `size<=1` or no size specified, but + // unconditionally treating it as replaced is enough for our purpose here, and + // avoids extra complexity that will likely not be necessary in most cases. + "select", + "svg", + "textarea", + "video", +]); + +const CUE_PSEUDO_ELEMENT_STYLING_SPEC_URL = + "https://developer.mozilla.org/docs/Web/CSS/::cue"; + +const HIGHLIGHT_PSEUDO_ELEMENTS_STYLING_SPEC_URL = + "https://www.w3.org/TR/css-pseudo-4/#highlight-styling"; +const HIGHLIGHT_PSEUDO_ELEMENTS = [ + "::highlight", + "::selection", + // Below are properties not yet implemented in Firefox (Bug 1694053) + "::grammar-error", + "::spelling-error", + "::target-text", +]; +const REGEXP_HIGHLIGHT_PSEUDO_ELEMENTS = new RegExp( + `${HIGHLIGHT_PSEUDO_ELEMENTS.join("|")}` +); + +const FIRST_LINE_PSEUDO_ELEMENT_STYLING_SPEC_URL = + "https://www.w3.org/TR/css-pseudo-4/#first-line-styling"; + +const FIRST_LETTER_PSEUDO_ELEMENT_STYLING_SPEC_URL = + "https://www.w3.org/TR/css-pseudo-4/#first-letter-styling"; + +const PLACEHOLDER_PSEUDO_ELEMENT_STYLING_SPEC_URL = + "https://www.w3.org/TR/css-pseudo-4/#placeholder-pseudo"; + +class InactivePropertyHelper { + /** + * A list of rules for when CSS properties have no effect. + * + * In certain situations, CSS properties do not have any effect. A common + * example is trying to set a width on an inline element like a <span>. + * + * There are so many properties in CSS that it's difficult to remember which + * ones do and don't apply in certain situations. Some are straight-forward + * like `flex-wrap` only applying to an element that has `display:flex`. + * Others are less trivial like setting something other than a color on a + * `:visited` pseudo-class. + * + * This file contains "rules" in the form of objects with the following + * properties: + * { + * invalidProperties: + * Set of CSS property names that are inactive if the rule matches. + * when: + * The rule itself, a JS function used to identify the conditions + * indicating whether a property is valid or not. + * fixId: + * A Fluent id containing a suggested solution to the problem that is + * causing a property to be inactive. + * msgId: + * A Fluent id containing an error message explaining why a property is + * inactive in this situation. + * } + * + * If you add a new rule, also add a test for it in: + * server/tests/chrome/test_inspector-inactive-property-helper.html + * + * The main export is `isPropertyUsed()`, which can be used to check if a + * property is used or not, and why. + * + * NOTE: We should generally *not* add rules here for any CSS properties that + * inherit by default, because it's hard for us to know whether such + * properties are truly "inactive". Web developers might legitimately set + * such a property on any arbitrary element, in order to concisely establish + * the default property-value throughout that element's subtree. For example, + * consider the "list-style-*" properties, which inherit by default and which + * only have a rendering effect on elements with "display:list-item" + * (e.g. <li>). It might superficially seem like we could add a rule here to + * warn about usages of these properties on non-"list-item" elements, but we + * shouldn't actually warn about that. A web developer may legitimately + * prefer to set these properties on an arbitrary container element (e.g. an + * <ol> element, or even the <html> element) in order to concisely adjust the + * rendering of a whole list (or all the lists in a document). + */ + get INVALID_PROPERTIES_VALIDATORS() { + return [ + // Flex container property used on non-flex container. + { + invalidProperties: ["flex-direction", "flex-flow", "flex-wrap"], + when: () => !this.flexContainer, + fixId: "inactive-css-not-flex-container-fix", + msgId: "inactive-css-not-flex-container", + }, + // Flex item property used on non-flex item. + { + invalidProperties: ["flex", "flex-basis", "flex-grow", "flex-shrink"], + when: () => !this.flexItem, + fixId: "inactive-css-not-flex-item-fix-2", + msgId: "inactive-css-not-flex-item", + }, + // Grid container property used on non-grid container. + { + invalidProperties: [ + "grid-auto-columns", + "grid-auto-flow", + "grid-auto-rows", + "grid-template", + "grid-template-areas", + "grid-template-columns", + "grid-template-rows", + "justify-items", + ], + when: () => !this.gridContainer, + fixId: "inactive-css-not-grid-container-fix", + msgId: "inactive-css-not-grid-container", + }, + // Grid item property used on non-grid item. + { + invalidProperties: [ + "grid-area", + "grid-column", + "grid-column-end", + "grid-column-start", + "grid-row", + "grid-row-end", + "grid-row-start", + "justify-self", + ], + when: () => !this.gridItem && !this.isAbsPosGridElement(), + fixId: "inactive-css-not-grid-item-fix-2", + msgId: "inactive-css-not-grid-item", + }, + // Grid and flex item properties used on non-grid or non-flex item. + { + invalidProperties: ["align-self", "place-self", "order"], + when: () => + !this.gridItem && !this.flexItem && !this.isAbsPosGridElement(), + fixId: "inactive-css-not-grid-or-flex-item-fix-3", + msgId: "inactive-css-not-grid-or-flex-item", + }, + // Grid and flex container properties used on non-grid or non-flex container. + { + invalidProperties: [ + "align-items", + "justify-content", + "place-content", + "place-items", + "row-gap", + // grid-*-gap are supported legacy shorthands for the corresponding *-gap properties. + // See https://drafts.csswg.org/css-align-3/#gap-legacy for more information. + "grid-row-gap", + ], + when: () => !this.gridContainer && !this.flexContainer, + fixId: "inactive-css-not-grid-or-flex-container-fix", + msgId: "inactive-css-not-grid-or-flex-container", + }, + // align-content is special as align-content:baseline does have an effect on all + // grid items, flex items and table cells, regardless of what type of box they are. + // See https://bugzilla.mozilla.org/show_bug.cgi?id=1598730 + { + invalidProperties: ["align-content"], + when: () => + !this.style["align-content"].includes("baseline") && + !this.gridContainer && + !this.flexContainer, + fixId: "inactive-css-not-grid-or-flex-container-fix", + msgId: "inactive-css-not-grid-or-flex-container", + }, + // column-gap and shorthands used on non-grid or non-flex or non-multi-col container. + { + invalidProperties: [ + "column-gap", + "gap", + "grid-gap", + // grid-*-gap are supported legacy shorthands for the corresponding *-gap properties. + // See https://drafts.csswg.org/css-align-3/#gap-legacy for more information. + "grid-column-gap", + ], + when: () => + !this.gridContainer && !this.flexContainer && !this.multiColContainer, + fixId: + "inactive-css-not-grid-or-flex-container-or-multicol-container-fix", + msgId: "inactive-css-not-grid-or-flex-container-or-multicol-container", + }, + // Multi-column related properties used on non-multi-column container. + { + invalidProperties: [ + "column-fill", + "column-rule", + "column-rule-color", + "column-rule-style", + "column-rule-width", + ], + when: () => !this.multiColContainer, + fixId: "inactive-css-not-multicol-container-fix", + msgId: "inactive-css-not-multicol-container", + }, + // Inline properties used on non-inline-level elements. + { + invalidProperties: ["vertical-align"], + when: () => + !this.isInlineLevel() && !this.isFirstLetter && !this.isFirstLine, + fixId: "inactive-css-not-inline-or-tablecell-fix", + msgId: "inactive-css-not-inline-or-tablecell", + }, + // Writing mode properties used on ::first-line pseudo-element. + { + invalidProperties: ["direction", "text-orientation", "writing-mode"], + when: () => this.isFirstLine, + fixId: "learn-more", + msgId: "inactive-css-first-line-pseudo-element-not-supported", + learnMoreURL: FIRST_LINE_PSEUDO_ELEMENT_STYLING_SPEC_URL, + }, + // Content modifying properties used on ::first-letter pseudo-element. + { + invalidProperties: ["content"], + when: () => this.isFirstLetter, + fixId: "learn-more", + msgId: "inactive-css-first-letter-pseudo-element-not-supported", + learnMoreURL: FIRST_LETTER_PSEUDO_ELEMENT_STYLING_SPEC_URL, + }, + // Writing mode or inline properties used on ::placeholder pseudo-element. + { + invalidProperties: [ + "baseline-source", + "direction", + "dominant-baseline", + "line-height", + "text-orientation", + "vertical-align", + "writing-mode", + // Below are properties not yet implemented in Firefox (Bug 1312611) + "alignment-baseline", + "baseline-shift", + "initial-letter", + "text-box-trim", + ], + when: () => { + const { selectorText } = this.cssRule; + return selectorText && selectorText.includes("::placeholder"); + }, + fixId: "learn-more", + msgId: "inactive-css-placeholder-pseudo-element-not-supported", + learnMoreURL: PLACEHOLDER_PSEUDO_ELEMENT_STYLING_SPEC_URL, + }, + // (max-|min-)width used on inline elements, table rows, or row groups. + { + invalidProperties: ["max-width", "min-width", "width"], + when: () => + this.nonReplacedInlineBox || + this.horizontalTableTrack || + this.horizontalTableTrackGroup, + fixId: "inactive-css-non-replaced-inline-or-table-row-or-row-group-fix", + msgId: "inactive-css-property-because-of-display", + }, + // (max-|min-)height used on inline elements, table columns, or column groups. + { + invalidProperties: ["max-height", "min-height", "height"], + when: () => + this.nonReplacedInlineBox || + this.verticalTableTrack || + this.verticalTableTrackGroup, + fixId: + "inactive-css-non-replaced-inline-or-table-column-or-column-group-fix", + msgId: "inactive-css-property-because-of-display", + }, + { + invalidProperties: ["display"], + when: () => + this.isFloated && + this.checkResolvedStyle("display", [ + "inline", + "inline-block", + "inline-table", + "inline-flex", + "inline-grid", + "table-cell", + "table-row", + "table-row-group", + "table-header-group", + "table-footer-group", + "table-column", + "table-column-group", + "table-caption", + ]), + fixId: "inactive-css-not-display-block-on-floated-fix", + msgId: "inactive-css-not-display-block-on-floated", + }, + // The property is impossible to override due to :visited restriction. + { + invalidProperties: VISITED_INVALID_PROPERTIES, + when: () => this.isVisitedRule(), + fixId: "learn-more", + msgId: "inactive-css-property-is-impossible-to-override-in-visited", + learnMoreURL: VISITED_MDN_LINK, + }, + // top, right, bottom, left properties used on non positioned boxes. + { + invalidProperties: ["top", "right", "bottom", "left"], + when: () => !this.isPositioned, + fixId: "inactive-css-position-property-on-unpositioned-box-fix", + msgId: "inactive-css-position-property-on-unpositioned-box", + }, + // z-index property used on non positioned boxes that are not grid/flex items. + { + invalidProperties: ["z-index"], + when: () => !this.isPositioned && !this.gridItem && !this.flexItem, + fixId: "inactive-css-position-property-on-unpositioned-box-fix", + msgId: "inactive-css-position-property-on-unpositioned-box", + }, + // text-overflow property used on elements for which 'overflow' is set to 'visible' + // (the initial value) in the inline axis. Note that this validator only checks if + // 'overflow-inline' computes to 'visible' on the element. + // In theory, we should also be checking if the element is a block as this doesn't + // normally work on inline element. However there are many edge cases that made it + // impossible for the JS code to determine whether the type of box would support + // text-overflow. So, rather than risking to show invalid warnings, we decided to + // only warn when 'overflow-inline: visible' was set. There is more information + // about this in this discussion https://phabricator.services.mozilla.com/D62407 and + // on the bug https://bugzilla.mozilla.org/show_bug.cgi?id=1551578 + { + invalidProperties: ["text-overflow"], + when: () => this.checkComputedStyle("overflow-inline", ["visible"]), + fixId: "inactive-text-overflow-when-no-overflow-fix", + msgId: "inactive-text-overflow-when-no-overflow", + }, + // margin properties used on table internal elements. + { + invalidProperties: [ + "margin", + "margin-block", + "margin-block-end", + "margin-block-start", + "margin-bottom", + "margin-inline", + "margin-inline-end", + "margin-inline-start", + "margin-left", + "margin-right", + "margin-top", + ], + when: () => this.internalTableElement, + fixId: "inactive-css-not-for-internal-table-elements-fix", + msgId: "inactive-css-not-for-internal-table-elements", + }, + // padding properties used on table internal elements except table cells. + { + invalidProperties: [ + "padding", + "padding-block", + "padding-block-end", + "padding-block-start", + "padding-bottom", + "padding-inline", + "padding-inline-end", + "padding-inline-start", + "padding-left", + "padding-right", + "padding-top", + ], + when: () => + this.internalTableElement && + !this.checkComputedStyle("display", ["table-cell"]), + fixId: + "inactive-css-not-for-internal-table-elements-except-table-cells-fix", + msgId: + "inactive-css-not-for-internal-table-elements-except-table-cells", + }, + // table-layout used on non-table elements. + { + invalidProperties: ["table-layout"], + when: () => + !this.checkComputedStyle("display", ["table", "inline-table"]), + fixId: "inactive-css-not-table-fix", + msgId: "inactive-css-not-table", + }, + // empty-cells property used on non-table-cell elements. + { + invalidProperties: ["empty-cells"], + when: () => !this.checkComputedStyle("display", ["table-cell"]), + fixId: "inactive-css-not-table-cell-fix", + msgId: "inactive-css-not-table-cell", + }, + // scroll-padding-* properties used on non-scrollable elements. + { + invalidProperties: [ + "scroll-padding", + "scroll-padding-top", + "scroll-padding-right", + "scroll-padding-bottom", + "scroll-padding-left", + "scroll-padding-block", + "scroll-padding-block-end", + "scroll-padding-block-start", + "scroll-padding-inline", + "scroll-padding-inline-end", + "scroll-padding-inline-start", + ], + when: () => !this.isScrollContainer, + fixId: "inactive-scroll-padding-when-not-scroll-container-fix", + msgId: "inactive-scroll-padding-when-not-scroll-container", + }, + // border-image properties used on internal table with border collapse. + { + invalidProperties: [ + "border-image", + "border-image-outset", + "border-image-repeat", + "border-image-slice", + "border-image-source", + "border-image-width", + ], + when: () => + this.internalTableElement && + this.checkTableParentHasBorderCollapsed(), + fixId: "inactive-css-border-image-fix", + msgId: "inactive-css-border-image", + }, + // width & height properties used on ruby elements. + { + invalidProperties: [ + "height", + "min-height", + "max-height", + "width", + "min-width", + "max-width", + ], + when: () => this.checkComputedStyle("display", ["ruby", "ruby-text"]), + fixId: "inactive-css-ruby-element-fix", + msgId: "inactive-css-ruby-element", + }, + // text-wrap: balance; used on elements exceeding the threshold line number + { + invalidProperties: ["text-wrap"], + when: () => { + if (!this.checkComputedStyle("text-wrap", ["balance"])) { + return false; + } + const blockLineCounts = InspectorUtils.getBlockLineCounts(this.node); + // We only check the number of lines within the first block + // because the text-wrap: balance; property only applies to + // the first block. And fragmented elements (with multiple + // blocks) are excluded from line balancing for the time being. + return ( + blockLineCounts && blockLineCounts[0] > TEXT_WRAP_BALANCE_LIMIT + ); + }, + fixId: "inactive-css-text-wrap-balance-lines-exceeded-fix", + msgId: "inactive-css-text-wrap-balance-lines-exceeded", + lineCount: TEXT_WRAP_BALANCE_LIMIT, + }, + // text-wrap: balance; used on fragmented elements + { + invalidProperties: ["text-wrap"], + when: () => { + if (!this.checkComputedStyle("text-wrap", ["balance"])) { + return false; + } + const blockLineCounts = InspectorUtils.getBlockLineCounts(this.node); + const isFragmented = blockLineCounts && blockLineCounts.length > 1; + return isFragmented; + }, + fixId: "inactive-css-text-wrap-balance-fragmented-fix", + msgId: "inactive-css-text-wrap-balance-fragmented", + }, + ]; + } + + /** + * A list of rules for when CSS properties have no effect, + * based on an allow list of properties. + * We're setting this as a different array than INVALID_PROPERTIES_VALIDATORS as we + * need to check every properties, which we don't do for invalid properties ( see check + * on this.invalidProperties). + * + * This file contains "rules" in the form of objects with the following + * properties: + * { + * acceptedProperties: + * Array of CSS property names that are the only one accepted if the rule matches. + * when: + * The rule itself, a JS function used to identify the conditions + * indicating whether a property is valid or not. + * fixId: + * A Fluent id containing a suggested solution to the problem that is + * causing a property to be inactive. + * msgId: + * A Fluent id containing an error message explaining why a property is + * inactive in this situation. + * } + * + * If you add a new rule, also add a test for it in: + * server/tests/chrome/test_inspector-inactive-property-helper.html + * + * The main export is `isPropertyUsed()`, which can be used to check if a + * property is used or not, and why. + */ + ACCEPTED_PROPERTIES_VALIDATORS = [ + // Constrained set of properties on highlight pseudo-elements + { + acceptedProperties: new Set([ + // At the moment, for shorthand we don't look into each properties it covers, + // and so, although `background` might hold inactive values (e.g. background-image) + // we don't want to mark it as inactive if it sets a background-color (e.g. background: red). + "background", + "background-color", + "color", + "text-decoration", + "text-decoration-color", + "text-decoration-line", + "text-decoration-style", + "text-decoration-thickness", + "text-shadow", + "text-underline-offset", + "text-underline-position", + "-webkit-text-fill-color", + "-webkit-text-stroke-color", + "-webkit-text-stroke-width", + "-webkit-text-stroke", + ]), + when: () => { + const { selectorText } = this.cssRule; + return ( + selectorText && REGEXP_HIGHLIGHT_PSEUDO_ELEMENTS.test(selectorText) + ); + }, + msgId: "inactive-css-highlight-pseudo-elements-not-supported", + fixId: "learn-more", + learnMoreURL: HIGHLIGHT_PSEUDO_ELEMENTS_STYLING_SPEC_URL, + }, + // Constrained set of properties on ::cue pseudo-element + // + // Note that Gecko doesn't yet support the ::cue() pseudo-element + // taking a selector as argument. The properties accecpted by that + // partly differ from the ones accepted by the ::cue pseudo-element. + // See https://w3c.github.io/webvtt/#ref-for-selectordef-cue-selector⑧. + // See https://bugzilla.mozilla.org/show_bug.cgi?id=865395 and its + // dependencies for the implementation status. + { + acceptedProperties: new Set([ + "background", + "background-attachment", + // The WebVTT spec. currently only allows all properties covered by + // the `background` shorthand and `background-blend-mode` is not + // part of that, though Gecko does support it, anyway. + // Therefore, there's also an issue pending to add it (and others) + // to the spec. See https://github.com/w3c/webvtt/issues/518. + "background-blend-mode", + "background-clip", + "background-color", + "background-image", + "background-origin", + "background-position", + "background-position-x", + "background-position-y", + "background-repeat", + "background-size", + "color", + "font", + "font-family", + "font-size", + "font-stretch", + "font-style", + "font-variant", + "font-variant-alternates", + "font-variant-caps", + "font-variant-east-asian", + "font-variant-ligatures", + "font-variant-numeric", + "font-variant-position", + "font-weight", + "line-height", + "opacity", + "outline", + "outline-color", + "outline-offset", + "outline-style", + "outline-width", + "ruby-position", + "text-combine-upright", + "text-decoration", + "text-decoration-color", + "text-decoration-line", + "text-decoration-style", + "text-decoration-thickness", + "text-shadow", + "visibility", + "white-space", + ]), + when: () => { + const { selectorText } = this.cssRule; + return selectorText && selectorText.includes("::cue"); + }, + msgId: "inactive-css-cue-pseudo-element-not-supported", + fixId: "learn-more", + learnMoreURL: CUE_PSEUDO_ELEMENT_STYLING_SPEC_URL, + }, + ]; + + /** + * Get a list of unique CSS property names for which there are checks + * for used/unused state. + * + * @return {Set} + * List of CSS properties + */ + get invalidProperties() { + if (!this._invalidProperties) { + const allProps = this.INVALID_PROPERTIES_VALIDATORS.map( + v => v.invalidProperties + ).flat(); + this._invalidProperties = new Set(allProps); + } + + return this._invalidProperties; + } + + /** + * Is this CSS property having any effect on this element? + * + * @param {DOMNode} el + * The DOM element. + * @param {Style} elStyle + * The computed style for this DOMNode. + * @param {DOMRule} cssRule + * The CSS rule the property is defined in. + * @param {String} property + * The CSS property name. + * + * @return {Object} object + * @return {String} object.display + * The element computed display value. + * @return {String} object.fixId + * A Fluent id containing a suggested solution to the problem that is + * causing a property to be inactive. + * @return {String} object.msgId + * A Fluent id containing an error message explaining why a property + * is inactive in this situation. + * @return {String} object.property + * The inactive property name. + * @return {String} object.learnMoreURL + * An optional link if we need to open an other link than + * the default MDN property one. + * @return {Boolean} object.used + * true if the property is used. + */ + isPropertyUsed(el, elStyle, cssRule, property) { + // Assume the property is used when the Inactive CSS pref is not enabled + if (!INACTIVE_CSS_ENABLED) { + return { used: true }; + } + + let fixId = ""; + let msgId = ""; + let learnMoreURL = null; + let lineCount = null; + let used = true; + + const someFn = validator => { + // First check if this rule cares about this property. + let isRuleConcerned = false; + + if (validator.invalidProperties) { + isRuleConcerned = validator.invalidProperties.includes(property); + } else if (validator.acceptedProperties) { + isRuleConcerned = !validator.acceptedProperties.has(property); + } + + if (!isRuleConcerned) { + return false; + } + + this.select(el, elStyle, cssRule, property); + + // And then run the validator, gathering the error message if the + // validator passes. + if (validator.when()) { + fixId = validator.fixId; + msgId = validator.msgId; + learnMoreURL = validator.learnMoreURL; + lineCount = validator.lineCount; + used = false; + + // We can bail out as soon as a validator reported an issue. + return true; + } + + return false; + }; + + // First run the accepted properties validators + const isNotAccepted = this.ACCEPTED_PROPERTIES_VALIDATORS.some(someFn); + + // If the property is not in the list of properties to check and there was no issues + // in the accepted properties validators, assume the property is used. + if (!isNotAccepted && !this.invalidProperties.has(property)) { + this.unselect(); + return { used: true }; + } + + // Otherwise, if there was no issue from the accepted properties validators, + // run the invalid properties validators. + if (!isNotAccepted) { + this.INVALID_PROPERTIES_VALIDATORS.some(someFn); + } + + this.unselect(); + + // Accessing elStyle might throws, we wrap it in a try/catch block to avoid test + // failures. + let display; + try { + display = elStyle ? elStyle.display : null; + } catch (e) {} + + return { + display, + fixId, + msgId, + property, + learnMoreURL, + lineCount, + used, + }; + } + + /** + * Focus on a node. + * + * @param {DOMNode} node + * Node to focus on. + */ + select(node, style, cssRule, property) { + this._node = node; + this._cssRule = cssRule; + this._property = property; + this._style = style; + } + + /** + * Clear references to avoid leaks. + */ + unselect() { + this._node = null; + this._cssRule = null; + this._property = null; + this._style = null; + } + + /** + * Provide a public reference to node. + */ + get node() { + return this._node; + } + + /** + * Cache and provide node's computed style. + */ + get style() { + return this._style; + } + + /** + * Provide a public reference to the css rule. + */ + get cssRule() { + return this._cssRule; + } + + /** + * Check if the current node's propName is set to one of the values passed in + * the values array. + * + * @param {String} propName + * Property name to check. + * @param {Array} values + * Values to compare against. + */ + checkComputedStyle(propName, values) { + if (!this.style) { + return false; + } + return values.some(value => this.style[propName] === value); + } + + /** + * Check if a rule's propName is set to one of the values passed in the values + * array. + * + * @param {String} propName + * Property name to check. + * @param {Array} values + * Values to compare against. + */ + checkResolvedStyle(propName, values) { + if (!(this.cssRule && this.cssRule.style)) { + return false; + } + const { style } = this.cssRule; + + return values.some(value => style[propName] === value); + } + + /** + * Check if the current node is an inline-level box. + */ + isInlineLevel() { + return this.checkComputedStyle("display", [ + "inline", + "inline-block", + "inline-table", + "inline-flex", + "inline-grid", + "table-cell", + "table-row", + "table-row-group", + "table-header-group", + "table-footer-group", + ]); + } + + /** + * Check if the current node is a flex container i.e. a node that has a style + * of `display:flex` or `display:inline-flex`. + */ + get flexContainer() { + return this.checkComputedStyle("display", ["flex", "inline-flex"]); + } + + /** + * Check if the current node is a flex item. + */ + get flexItem() { + return this.isFlexItem(this.node); + } + + /** + * Check if the current node is a grid container i.e. a node that has a style + * of `display:grid` or `display:inline-grid`. + */ + get gridContainer() { + return this.checkComputedStyle("display", ["grid", "inline-grid"]); + } + + /** + * Check if the current node is a grid item. + */ + get gridItem() { + return this.isGridItem(this.node); + } + + /** + * Check if the current node is a multi-column container, i.e. a node element whose + * `column-width` or `column-count` property is not `auto`. + */ + get multiColContainer() { + const autoColumnWidth = this.checkComputedStyle("column-width", ["auto"]); + const autoColumnCount = this.checkComputedStyle("column-count", ["auto"]); + + return !autoColumnWidth || !autoColumnCount; + } + + /** + * Check if the current node is a table row. + */ + get tableRow() { + return this.style && this.style.display === "table-row"; + } + + /** + * Check if the current node is a table column. + */ + get tableColumn() { + return this.style && this.style.display === "table-column"; + } + + /** + * Check if the current node is an internal table element. + */ + get internalTableElement() { + return this.checkComputedStyle("display", [ + "table-cell", + "table-row", + "table-row-group", + "table-header-group", + "table-footer-group", + "table-column", + "table-column-group", + ]); + } + + /** + * Check if the current node is a horizontal table track. That is: either a table row + * displayed in horizontal writing mode, or a table column displayed in vertical writing + * mode. + */ + get horizontalTableTrack() { + if (!this.tableRow && !this.tableColumn) { + return false; + } + + const tableTrackParent = this.getTableTrackParent(); + + return this.hasVerticalWritingMode(tableTrackParent) + ? this.tableColumn + : this.tableRow; + } + + /** + * Check if the current node is a vertical table track. That is: either a table row + * displayed in vertical writing mode, or a table column displayed in horizontal writing + * mode. + */ + get verticalTableTrack() { + if (!this.tableRow && !this.tableColumn) { + return false; + } + + const tableTrackParent = this.getTableTrackParent(); + + return this.hasVerticalWritingMode(tableTrackParent) + ? this.tableRow + : this.tableColumn; + } + + /** + * Check if the current node is a row group. + */ + get rowGroup() { + return this.isRowGroup(this.node); + } + + /** + * Check if the current node is a table column group. + */ + get columnGroup() { + return this.isColumnGroup(this.node); + } + + /** + * Check if the current node is a horizontal table track group. That is: either a table + * row group displayed in horizontal writing mode, or a table column group displayed in + * vertical writing mode. + */ + get horizontalTableTrackGroup() { + if (!this.rowGroup && !this.columnGroup) { + return false; + } + + const tableTrackParent = this.getTableTrackParent(true); + const isVertical = this.hasVerticalWritingMode(tableTrackParent); + + const isHorizontalRowGroup = this.rowGroup && !isVertical; + const isHorizontalColumnGroup = this.columnGroup && isVertical; + + return isHorizontalRowGroup || isHorizontalColumnGroup; + } + + /** + * Check if the current node is a vertical table track group. That is: either a table row + * group displayed in vertical writing mode, or a table column group displayed in + * horizontal writing mode. + */ + get verticalTableTrackGroup() { + if (!this.rowGroup && !this.columnGroup) { + return false; + } + + const tableTrackParent = this.getTableTrackParent(true); + const isVertical = this.hasVerticalWritingMode(tableTrackParent); + + const isVerticalRowGroup = this.rowGroup && isVertical; + const isVerticalColumnGroup = this.columnGroup && !isVertical; + + return isVerticalRowGroup || isVerticalColumnGroup; + } + + /** + * Returns whether this element uses CSS layout. + */ + get hasCssLayout() { + return !this.isSvg && !this.isMathMl; + } + + /** + * Check if the current node is a non-replaced CSS inline box. + */ + get nonReplacedInlineBox() { + return ( + this.hasCssLayout && + this.nonReplaced && + this.style && + this.style.display === "inline" + ); + } + + /** + * Check if the current selector refers to a ::first-letter pseudo-element + */ + get isFirstLetter() { + const { selectorText } = this.cssRule; + return selectorText && selectorText.includes("::first-letter"); + } + + /** + * Check if the current selector refers to a ::first-line pseudo-element + */ + get isFirstLine() { + const { selectorText } = this.cssRule; + return selectorText && selectorText.includes("::first-line"); + } + + /** + * Check if the current node is a non-replaced element. See `replaced()` for + * a description of what a replaced element is. + */ + get nonReplaced() { + return !this.replaced; + } + + /** + * Check if the current node is an absolutely-positioned element. + */ + get isAbsolutelyPositioned() { + return this.checkComputedStyle("position", ["absolute", "fixed"]); + } + + /** + * Check if the current node is positioned (i.e. its position property has a value other + * than static). + */ + get isPositioned() { + return this.checkComputedStyle("position", [ + "relative", + "absolute", + "fixed", + "sticky", + ]); + } + + /** + * Check if the current node is floated + */ + get isFloated() { + return this.style && this.style.cssFloat !== "none"; + } + + /** + * Check if the current node is scrollable + */ + get isScrollContainer() { + // If `overflow` doesn't contain the values `visible` or `clip`, it is a scroll container. + // While `hidden` doesn't allow scrolling via a user interaction, the element can + // still be scrolled programmatically. + // See https://www.w3.org/TR/css-overflow-3/#overflow-properties. + const overflow = computedStyle(this.node).overflow; + // `overflow` is a shorthand for `overflow-x` and `overflow-y` + // (and with that also for `overflow-inline` and `overflow-block`), + // so may hold two values. + const overflowValues = overflow.split(" "); + return !( + overflowValues.includes("visible") || overflowValues.includes("clip") + ); + } + + /** + * Check if the current node is a replaced element i.e. an element with + * content that will be replaced e.g. <img>, <audio>, <video> or <object> + * elements. + */ + get replaced() { + if (REPLACED_ELEMENTS_NAMES.has(this.localName)) { + return true; + } + + // img tags are replaced elements only when the image has finished loading. + if (this.localName === "img" && this.node.complete) { + return true; + } + + return false; + } + + /** + * Return the current node's localName. + * + * @returns {String} + */ + get localName() { + return this.node.localName; + } + + /** + * Return whether the node is a MathML element. + */ + get isMathMl() { + return this.node.namespaceURI === "http://www.w3.org/1998/Math/MathML"; + } + + /** + * Return whether the node is an SVG element. + */ + get isSvg() { + return this.node.namespaceURI === "http://www.w3.org/2000/svg"; + } + + /** + * Check if the current node is an absolutely-positioned grid element. + * See: https://drafts.csswg.org/css-grid/#abspos-items + * + * @return {Boolean} whether or not the current node is absolutely-positioned by a + * grid container. + */ + isAbsPosGridElement() { + if (!this.isAbsolutelyPositioned) { + return false; + } + + const containingBlock = this.getContainingBlock(); + + return containingBlock !== null && this.isGridContainer(containingBlock); + } + + /** + * Check if a node is a flex item. + * + * @param {DOMNode} node + * The node to check. + */ + isFlexItem(node) { + return !!node.parentFlexElement; + } + + /** + * Check if a node is a flex container. + * + * @param {DOMNode} node + * The node to check. + */ + isFlexContainer(node) { + return !!node.getAsFlexContainer(); + } + + /** + * Check if a node is a grid container. + * + * @param {DOMNode} node + * The node to check. + */ + isGridContainer(node) { + return node.hasGridFragments(); + } + + /** + * Check if a node is a grid item. + * + * @param {DOMNode} node + * The node to check. + */ + isGridItem(node) { + return !!this.getParentGridElement(this.node); + } + + isVisitedRule() { + if (!CssLogic.hasVisitedState(this.node)) { + return false; + } + + const selectors = CssLogic.getSelectors(this.cssRule); + if (!selectors.some(s => s.endsWith(":visited"))) { + return false; + } + + const { bindingElement, pseudo } = CssLogic.getBindingElementAndPseudo( + this.node + ); + + for (let i = 0; i < selectors.length; i++) { + if ( + !selectors[i].endsWith(":visited") && + this.cssRule.selectorMatchesElement(i, bindingElement, pseudo, true) + ) { + // Match non :visited selector. + return false; + } + } + + return true; + } + + /** + * Return the current node's ancestor that generates its containing block. + */ + getContainingBlock() { + return this.node ? InspectorUtils.containingBlockOf(this.node) : null; + } + + getParentGridElement(node) { + // The documentElement can't be a grid item, only a container, so bail out. + if (node.flattenedTreeParentNode === node.ownerDocument) { + return null; + } + + if (node.nodeType === node.ELEMENT_NODE) { + const display = this.style ? this.style.display : null; + + if (!display || display === "none" || display === "contents") { + // Doesn't generate a box, not a grid item. + return null; + } + if (this.isAbsolutelyPositioned) { + // Out of flow, not a grid item. + return null; + } + } else if (node.nodeType !== node.TEXT_NODE) { + return null; + } + + for ( + let p = node.flattenedTreeParentNode; + p; + p = p.flattenedTreeParentNode + ) { + if (this.isGridContainer(p)) { + // It's a grid item! + return p; + } + + const style = computedStyle(p, node.ownerGlobal); + const display = style.display; + + if (display !== "contents") { + return null; // Not a grid item, for sure. + } + // display: contents, walk to the parent + } + return null; + } + + isRowGroup(node) { + const style = node === this.node ? this.style : computedStyle(node); + + return ( + style && + (style.display === "table-row-group" || + style.display === "table-header-group" || + style.display === "table-footer-group") + ); + } + + isColumnGroup(node) { + const style = node === this.node ? this.style : computedStyle(node); + + return style && style.display === "table-column-group"; + } + + /** + * Check if the given node's writing mode is vertical + */ + hasVerticalWritingMode(node) { + // Only 'horizontal-tb' has a horizontal writing mode. + // See https://drafts.csswg.org/css-writing-modes-4/#propdef-writing-mode + return computedStyle(node).writingMode !== "horizontal-tb"; + } + + /** + * Assuming the current element is a table track (row or column) or table track group, + * get the parent table. + * This is either going to be the table element if there is one, or the parent element. + * If the current element is not a table track, this returns the current element. + * + * @param {Boolean} isGroup + * Whether the element is a table track group, instead of a table track. + * @return {DOMNode} + * The parent table, the parent element, or the element itself. + */ + getTableTrackParent(isGroup) { + let current = this.node.parentNode; + + // Skip over unrendered elements. + while (computedStyle(current).display === "contents") { + current = current.parentNode; + } + + // Skip over groups if the initial element wasn't already one. + if (!isGroup && (this.isRowGroup(current) || this.isColumnGroup(current))) { + current = current.parentNode; + } + + // Once more over unrendered elements above the group. + while (computedStyle(current).display === "contents") { + current = current.parentNode; + } + + return current; + } + + /** + * Get the parent table element of the current element. + * + * @return {DOMNode|null} + * The closest table element or null if there are none. + */ + getTableParent() { + let current = this.node.parentNode; + + // Find the table parent + while (current && computedStyle(current).display !== "table") { + current = current.parentNode; + + // If we reached the document element, stop. + if (current == this.node.ownerDocument.documentElement) { + return null; + } + } + + return current; + } + + /** + * Assuming the current element is an internal table element, + * check wether its parent table element has `border-collapse` set to `collapse`. + * + * @returns {Boolean} + */ + checkTableParentHasBorderCollapsed() { + const parent = this.getTableParent(); + if (!parent) { + return false; + } + return computedStyle(parent).borderCollapse === "collapse"; + } +} + +/** + * Returns all CSS property names except given properties. + * + * @param {Array} - propertiesToIgnore + * Array of property ignored. + * @return {Array} + * Array of all CSS property name except propertiesToIgnore. + */ +function allCssPropertiesExcept(propertiesToIgnore) { + const properties = new Set( + InspectorUtils.getCSSPropertyNames({ includeAliases: true }) + ); + + for (const name of propertiesToIgnore) { + properties.delete(name); + } + + return [...properties]; +} + +/** + * Helper for getting an element's computed styles. + * + * @param {DOMNode} node + * The node to get the styles for. + * @param {Window} window + * Optional window object. If omitted, will get the node's window. + * @return {Object} + */ +function computedStyle(node, window = node.ownerGlobal) { + return window.getComputedStyle(node); +} + +const inactivePropertyHelper = new InactivePropertyHelper(); + +// The only public method from this module is `isPropertyUsed`. +exports.isPropertyUsed = inactivePropertyHelper.isPropertyUsed.bind( + inactivePropertyHelper +); diff --git a/devtools/server/actors/utils/logEvent.js b/devtools/server/actors/utils/logEvent.js new file mode 100644 index 0000000000..88b166619e --- /dev/null +++ b/devtools/server/actors/utils/logEvent.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 { + formatDisplayName, +} = require("resource://devtools/server/actors/frame.js"); +const { + TYPES, + getResourceWatcher, +} = require("resource://devtools/server/actors/resources/index.js"); + +// Get a string message to display when a frame evaluation throws. +function getThrownMessage(completion) { + try { + if (completion.throw.getOwnPropertyDescriptor) { + return completion.throw.getOwnPropertyDescriptor("message").value; + } else if (completion.toString) { + return completion.toString(); + } + } catch (ex) { + // ignore + } + return "Unknown exception"; +} +module.exports.getThrownMessage = getThrownMessage; + +function logEvent({ threadActor, frame, level, expression, bindings }) { + const { sourceActor, line, column } = + threadActor.sourcesManager.getFrameLocation(frame); + const displayName = formatDisplayName(frame); + + // TODO remove this branch when (#1592584) lands (#1609540) + if (isWorker) { + threadActor._parent._consoleActor.evaluateJS({ + text: `console.log(...${expression})`, + bindings: { displayName, ...bindings }, + url: sourceActor.url, + lineNumber: line, + disableBreaks: true, + }); + + return undefined; + } + + let completion; + // Ensure disabling all types of breakpoints for all sources while evaluating the log points + threadActor.insideClientEvaluation = { disableBreaks: true }; + try { + completion = frame.evalWithBindings( + expression, + { + displayName, + ...bindings, + }, + { hideFromDebugger: true } + ); + } finally { + threadActor.insideClientEvaluation = null; + } + + let value; + if (!completion) { + // The evaluation was killed (possibly by the slow script dialog). + value = ["Evaluation failed"]; + } else if ("return" in completion) { + value = completion.return; + } else { + value = [getThrownMessage(completion)]; + level = `${level}Error`; + } + + if (value && typeof value.unsafeDereference === "function") { + value = value.unsafeDereference(); + } + + const targetActor = threadActor._parent; + const message = { + filename: sourceActor.url, + lineNumber: line, + columnNumber: column, + arguments: value, + level, + timeStamp: ChromeUtils.dateNow(), + chromeContext: + targetActor.actorID && + /conn\d+\.parentProcessTarget\d+/.test(targetActor.actorID), + // The 'prepareConsoleMessageForRemote' method in webconsoleActor expects internal source ID, + // thus we can't set sourceId directly to sourceActorID. + sourceId: sourceActor.internalSourceId, + }; + + // Note that only WindowGlobalTarget actor support resource watcher + // This is still missing for worker and content processes + const consoleMessageWatcher = getResourceWatcher( + targetActor, + TYPES.CONSOLE_MESSAGE + ); + if (consoleMessageWatcher) { + consoleMessageWatcher.emitMessages([message]); + } else { + // Bug 1642296: Once we enable ConsoleMessage resource on the server, we should remove onConsoleAPICall + // from the WebConsoleActor, and only support the ConsoleMessageWatcher codepath. + targetActor._consoleActor.onConsoleAPICall(message); + } + + return undefined; +} + +module.exports.logEvent = logEvent; diff --git a/devtools/server/actors/utils/make-debugger.js b/devtools/server/actors/utils/make-debugger.js new file mode 100644 index 0000000000..40c28f01b2 --- /dev/null +++ b/devtools/server/actors/utils/make-debugger.js @@ -0,0 +1,122 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const EventEmitter = require("resource://devtools/shared/event-emitter.js"); +const Debugger = require("Debugger"); + +const { + reportException, +} = require("resource://devtools/shared/DevToolsUtils.js"); + +/** + * Multiple actors that use a |Debugger| instance come in a few versions, each + * with a different set of debuggees. One version for content tabs (globals + * within a tab), one version for chrome debugging (all globals), and sometimes + * a third version for addon debugging (chrome globals the addon is loaded in + * and content globals the addon injects scripts into). The |makeDebugger| + * function helps us avoid repeating the logic for finding and maintaining the + * correct set of globals for a given |Debugger| instance across each version of + * all of our actors. + * + * The |makeDebugger| function expects a single object parameter with the + * following properties: + * + * @param Function findDebuggees + * Called with one argument: a |Debugger| instance. This function should + * return an iterable of globals to be added to the |Debugger| + * instance. The globals are the actual global objects and aren't wrapped + * in in a |Debugger.Object|. + * + * @param Function shouldAddNewGlobalAsDebuggee + * Called with one argument: a |Debugger.Object| wrapping a global + * object. This function must return |true| if the global object should + * be added as debuggee, and |false| otherwise. + * + * @returns Debugger + * Returns a |Debugger| instance that can manage its set of debuggee + * globals itself and is decorated with the |EventEmitter| class. + * + * Existing |Debugger| properties set on the returned |Debugger| + * instance: + * + * - onNewGlobalObject: The |Debugger| will automatically add new + * globals as debuggees if calling |shouldAddNewGlobalAsDebuggee| + * with the global returns true. + * + * - uncaughtExceptionHook: The |Debugger| already has an error + * reporter attached to |uncaughtExceptionHook|, so if any + * |Debugger| hooks fail, the error will be reported. + * + * New properties set on the returned |Debugger| instance: + * + * - addDebuggees: A function which takes no arguments. It adds all + * current globals that should be debuggees (as determined by + * |findDebuggees|) to the |Debugger| instance. + */ +module.exports = function makeDebugger({ + findDebuggees, + shouldAddNewGlobalAsDebuggee, +} = {}) { + const dbg = new Debugger(); + EventEmitter.decorate(dbg); + + // By default, we disable asm.js and WASM debugging because of performance reason. + // Enabling asm.js debugging (allowUnobservedAsmJS=false) will make asm.js fallback to JS compiler + // and be debugging as a regular JS script. + dbg.allowUnobservedAsmJS = true; + // Enabling WASM debugging (allowUnobservedWasm=false) will make the engine compile WASM scripts + // into different machine code with debugging instructions. This significantly increase the memory usage of it. + dbg.allowUnobservedWasm = true; + + dbg.uncaughtExceptionHook = reportDebuggerHookException; + + const onNewGlobalObject = function (global) { + if (shouldAddNewGlobalAsDebuggee(global)) { + safeAddDebuggee(this, global); + } + }; + + dbg.onNewGlobalObject = onNewGlobalObject; + dbg.addDebuggees = function () { + for (const global of findDebuggees(this)) { + safeAddDebuggee(this, global); + } + }; + + dbg.disable = function () { + dbg.removeAllDebuggees(); + dbg.onNewGlobalObject = undefined; + }; + + dbg.enable = function () { + dbg.addDebuggees(); + dbg.onNewGlobalObject = onNewGlobalObject; + }; + dbg.findDebuggees = function () { + return findDebuggees(dbg); + }; + + return dbg; +}; + +const reportDebuggerHookException = e => reportException("DBG-SERVER", e); + +/** + * Add |global| as a debuggee to |dbg|, handling error cases. + */ +function safeAddDebuggee(dbg, global) { + let globalDO; + try { + globalDO = dbg.addDebuggee(global); + } catch (e) { + // Ignoring attempt to add the debugger's compartment as a debuggee. + return; + } + + if (dbg.onNewDebuggee) { + dbg.onNewDebuggee(globalDO); + } +} diff --git a/devtools/server/actors/utils/moz.build b/devtools/server/actors/utils/moz.build new file mode 100644 index 0000000000..405b25cb4b --- /dev/null +++ b/devtools/server/actors/utils/moz.build @@ -0,0 +1,32 @@ +# -*- 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( + "accessibility.js", + "actor-registry.js", + "breakpoint-actor-map.js", + "capture-screenshot.js", + "css-grid-utils.js", + "custom-formatters.js", + "dbg-source.js", + "event-breakpoints.js", + "event-loop.js", + "gecko-profile-collector.js", + "inactive-property-helper.js", + "logEvent.js", + "make-debugger.js", + "shapes-utils.js", + "source-map-utils.js", + "source-url.js", + "sources-manager.js", + "stack.js", + "style-utils.js", + "stylesheet-utils.js", + "stylesheets-manager.js", + "track-change-emitter.js", + "walker-search.js", + "watchpoint-map.js", +) diff --git a/devtools/server/actors/utils/shapes-utils.js b/devtools/server/actors/utils/shapes-utils.js new file mode 100644 index 0000000000..aab50bf952 --- /dev/null +++ b/devtools/server/actors/utils/shapes-utils.js @@ -0,0 +1,149 @@ +/* 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"; + +/** + * Get the distance between two points on a plane. + * @param {Number} x1 the x coord of the first point + * @param {Number} y1 the y coord of the first point + * @param {Number} x2 the x coord of the second point + * @param {Number} y2 the y coord of the second point + * @returns {Number} the distance between the two points + */ +const getDistance = (x1, y1, x2, y2) => { + return Math.round(Math.hypot(x2 - x1, y2 - y1)); +}; + +/** + * Determine if the given x/y coords are along the edge of the given ellipse. + * We allow for a small area around the edge that still counts as being on the edge. + * @param {Number} x the x coordinate of the click + * @param {Number} y the y coordinate of the click + * @param {Number} cx the x coordinate of the center of the ellipse + * @param {Number} cy the y coordinate of the center of the ellipse + * @param {Number} rx the x radius of the ellipse + * @param {Number} ry the y radius of the ellipse + * @param {Number} clickWidthX the width of the area that counts as being on the edge + * along the x radius. + * @param {Number} clickWidthY the width of the area that counts as being on the edge + * along the y radius. + * @returns {Boolean} whether the click counts as being on the edge of the ellipse. + */ +const clickedOnEllipseEdge = ( + x, + y, + cx, + cy, + rx, + ry, + clickWidthX, + clickWidthY +) => { + // The formula to determine if something is inside or on the edge of an ellipse is: + // (x - cx)^2/rx^2 + (y - cy)^2/ry^2 <= 1. If > 1, it's outside. + // We make two ellipses, adjusting rx and ry with clickWidthX and clickWidthY + // to allow for an area around the edge of the ellipse that can be clicked on. + // If the click was outside the inner ellipse and inside the outer ellipse, return true. + const inner = + (x - cx) ** 2 / (rx - clickWidthX) ** 2 + + (y - cy) ** 2 / (ry - clickWidthY) ** 2; + const outer = + (x - cx) ** 2 / (rx + clickWidthX) ** 2 + + (y - cy) ** 2 / (ry + clickWidthY) ** 2; + return inner >= 1 && outer <= 1; +}; + +/** + * Get the distance between a point and a line defined by two other points. + * @param {Number} x1 the x coordinate of the first point in the line + * @param {Number} y1 the y coordinate of the first point in the line + * @param {Number} x2 the x coordinate of the second point in the line + * @param {Number} y2 the y coordinate of the second point in the line + * @param {Number} x3 the x coordinate of the point for which the distance is found + * @param {Number} y3 the y coordinate of the point for which the distance is found + * @returns {Number} the distance between (x3,y3) and the line defined by + * (x1,y1) and (y1,y2) + */ +const distanceToLine = (x1, y1, x2, y2, x3, y3) => { + // https://en.wikipedia.org/wiki/Distance_from_a_point_to_a_line#Line_defined_by_two_points + const num = Math.abs((y2 - y1) * x3 - (x2 - x1) * y3 + x2 * y1 - y2 * x1); + const denom = getDistance(x1, y1, x2, y2); + return num / denom; +}; + +/** + * Get the point on the line defined by points a,b that is closest to point c + * @param {Number} ax the x coordinate of point a + * @param {Number} ay the y coordinate of point a + * @param {Number} bx the x coordinate of point b + * @param {Number} by the y coordinate of point b + * @param {Number} cx the x coordinate of point c + * @param {Number} cy the y coordinate of point c + * @returns {Array} a 2 element array that contains the x/y coords of the projected point + */ +const projection = (ax, ay, bx, by, cx, cy) => { + // https://en.wikipedia.org/wiki/Vector_projection#Vector_projection_2 + const ab = [bx - ax, by - ay]; + const ac = [cx - ax, cy - ay]; + const scalar = dotProduct(ab, ac) / dotProduct(ab, ab); + return [ax + scalar * ab[0], ay + scalar * ab[1]]; +}; + +/** + * Get the dot product of two vectors, represented by arrays of numbers. + * @param {Array} a the first vector + * @param {Array} b the second vector + * @returns {Number} the dot product of a and b + */ +const dotProduct = (a, b) => { + return a.reduce((prev, curr, i) => { + return prev + curr * b[i]; + }, 0); +}; + +/** + * Determine if the given x/y coords are above the given point. + * @param {Number} x the x coordinate of the click + * @param {Number} y the y coordinate of the click + * @param {Number} pointX the x coordinate of the center of the point + * @param {Number} pointY the y coordinate of the center of the point + * @param {Number} radiusX the x radius of the point + * @param {Number} radiusY the y radius of the point + * @returns {Boolean} whether the click was on the point + */ +const clickedOnPoint = (x, y, pointX, pointY, radiusX, radiusY) => { + return ( + x >= pointX - radiusX && + x <= pointX + radiusX && + y >= pointY - radiusY && + y <= pointY + radiusY + ); +}; + +const roundTo = (value, exp) => { + // If the exp is undefined or zero... + if (typeof exp === "undefined" || +exp === 0) { + return Math.round(value); + } + value = +value; + exp = +exp; + // If the value is not a number or the exp is not an integer... + if (isNaN(value) || !(typeof exp === "number" && exp % 1 === 0)) { + return NaN; + } + // Shift + value = value.toString().split("e"); + value = Math.round(+(value[0] + "e" + (value[1] ? +value[1] - exp : -exp))); + // Shift back + value = value.toString().split("e"); + return +(value[0] + "e" + (value[1] ? +value[1] + exp : exp)); +}; + +exports.getDistance = getDistance; +exports.clickedOnEllipseEdge = clickedOnEllipseEdge; +exports.distanceToLine = distanceToLine; +exports.projection = projection; +exports.clickedOnPoint = clickedOnPoint; +exports.roundTo = roundTo; diff --git a/devtools/server/actors/utils/source-map-utils.js b/devtools/server/actors/utils/source-map-utils.js new file mode 100644 index 0000000000..fccd0d67bf --- /dev/null +++ b/devtools/server/actors/utils/source-map-utils.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"; + +exports.getSourcemapBaseURL = getSourcemapBaseURL; +function getSourcemapBaseURL(url, global) { + let sourceMapBaseURL = null; + if (url) { + // Sources that have explicit URLs can be used directly as the base. + sourceMapBaseURL = url; + } else if (global?.location?.href) { + // If there is no URL for the source, the map comment is relative to the + // page being viewed, so we use the document href. + sourceMapBaseURL = global?.location?.href; + } else { + // If there is no valid base, the sourcemap URL will need to be an absolute + // URL of some kind. + return null; + } + + // A data URL is large and will never be a valid base, so we can just treat + // it as if there is no base at all to avoid a sending it to the client + // for no reason. + if (sourceMapBaseURL.startsWith("data:")) { + return null; + } + + // If the base URL is a blob, we want to resolve relative to the origin + // that created the blob URL, if there is one. + if (sourceMapBaseURL.startsWith("blob:")) { + try { + const parsedBaseURL = new URL(sourceMapBaseURL); + return parsedBaseURL.origin === "null" ? null : parsedBaseURL.origin; + } catch (err) { + return null; + } + } + + return sourceMapBaseURL; +} diff --git a/devtools/server/actors/utils/source-url.js b/devtools/server/actors/utils/source-url.js new file mode 100644 index 0000000000..be80025e46 --- /dev/null +++ b/devtools/server/actors/utils/source-url.js @@ -0,0 +1,44 @@ +/* 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"; + +/** + * Debugger.Source objects have a `url` property that exposes the value + * that was passed to SpiderMonkey, but unfortunately often SpiderMonkey + * sets a URL even in cases where it doesn't make sense, so we have to + * explicitly ignore the URL value in these contexts to keep things a bit + * more consistent. + * + * @param {Debugger.Source} source + * + * @return {string | null} + */ +function getDebuggerSourceURL(source) { + const introType = source.introductionType; + + // These are all the sources that are eval or eval-like, but may still have + // a URL set on the source, so we explicitly ignore the source URL for these. + if ( + introType === "injectedScript" || + introType === "eval" || + introType === "debugger eval" || + introType === "Function" || + introType === "javascriptURL" || + introType === "eventHandler" || + introType === "domTimer" + ) { + return null; + } + // When using <iframe srcdoc="<script> js source </script>"/>, we can't easily fetch the srcdoc + // full html text content. So, consider each inline script as independant source with + // their own URL. Thus the ID appended to each URL. + if (source.url == "about:srcdoc") { + return source.url + "#" + source.id; + } + + return source.url; +} + +exports.getDebuggerSourceURL = getDebuggerSourceURL; diff --git a/devtools/server/actors/utils/sources-manager.js b/devtools/server/actors/utils/sources-manager.js new file mode 100644 index 0000000000..b80da69bfa --- /dev/null +++ b/devtools/server/actors/utils/sources-manager.js @@ -0,0 +1,515 @@ +/* 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 DevToolsUtils = require("resource://devtools/shared/DevToolsUtils.js"); +const { assert, fetch } = DevToolsUtils; +const EventEmitter = require("resource://devtools/shared/event-emitter.js"); +const { + SourceLocation, +} = require("resource://devtools/server/actors/common.js"); + +loader.lazyRequireGetter( + this, + "SourceActor", + "resource://devtools/server/actors/source.js", + true +); + +/** + * Matches strings of the form "foo.min.js" or "foo-min.js", etc. If the regular + * expression matches, we can be fairly sure that the source is minified, and + * treat it as such. + */ +const MINIFIED_SOURCE_REGEXP = /\bmin\.js$/; + +/** + * Manages the sources for a thread. Handles URL contents, locations in + * the sources, etc for ThreadActors. + */ +class SourcesManager extends EventEmitter { + constructor(threadActor) { + super(); + this._thread = threadActor; + + this.blackBoxedSources = new Map(); + + // Debugger.Source -> SourceActor + this._sourceActors = new Map(); + + // URL -> content + // + // Any possibly incomplete content that has been loaded for each URL. + this._urlContents = new Map(); + + // URL -> Promise[] + // + // Any promises waiting on a URL to be completely loaded. + this._urlWaiters = new Map(); + + // Debugger.Source.id -> Debugger.Source + // + // The IDs associated with ScriptSources and available via DebuggerSource.id + // are internal to this process and should not be exposed to the client. This + // map associates these IDs with the corresponding source, provided the source + // has not been GC'ed and the actor has been created. This is lazily populated + // the first time it is needed. + this._sourcesByInternalSourceId = null; + + if (!isWorker) { + Services.obs.addObserver(this, "devtools-html-content"); + } + } + + destroy() { + if (!isWorker) { + Services.obs.removeObserver(this, "devtools-html-content"); + } + } + + /** + * Clear existing sources so they are recreated on the next access. + */ + reset() { + this._sourceActors = new Map(); + this._urlContents = new Map(); + this._urlWaiters = new Map(); + this._sourcesByInternalSourceId = null; + } + + /** + * Create a source actor representing this source. + * + * @param Debugger.Source source + * The source to make an actor for. + * @returns a SourceActor representing the source. + */ + createSourceActor(source) { + assert(source, "SourcesManager.prototype.source needs a source"); + + if (this._sourceActors.has(source)) { + return this._sourceActors.get(source); + } + + const actor = new SourceActor({ + thread: this._thread, + source, + }); + + this._thread.threadLifetimePool.manage(actor); + + this._sourceActors.set(source, actor); + if (this._sourcesByInternalSourceId && source.id) { + this._sourcesByInternalSourceId.set(source.id, source); + } + + this.emit("newSource", actor); + return actor; + } + + _getSourceActor(source) { + if (this._sourceActors.has(source)) { + return this._sourceActors.get(source); + } + + return null; + } + + hasSourceActor(source) { + return !!this._getSourceActor(source); + } + + getSourceActor(source) { + const sourceActor = this._getSourceActor(source); + + if (!sourceActor) { + throw new Error( + "getSource: could not find source actor for " + (source.url || "source") + ); + } + + return sourceActor; + } + + getOrCreateSourceActor(source) { + // Tolerate the source coming from a different Debugger than the one + // associated with the thread. + try { + source = this._thread.dbg.adoptSource(source); + } catch (e) { + // We can't create actors for sources in the same compartment as the + // thread's Debugger. + if (/is in the same compartment as this debugger/.test(e)) { + return null; + } + throw e; + } + + if (this.hasSourceActor(source)) { + return this.getSourceActor(source); + } + return this.createSourceActor(source); + } + + getSourceActorByInternalSourceId(id) { + if (!this._sourcesByInternalSourceId) { + this._sourcesByInternalSourceId = new Map(); + for (const source of this._thread.dbg.findSources()) { + if (source.id) { + this._sourcesByInternalSourceId.set(source.id, source); + } + } + } + const source = this._sourcesByInternalSourceId.get(id); + if (source) { + return this.getOrCreateSourceActor(source); + } + return null; + } + + getSourceActorsByURL(url) { + const rv = []; + if (url) { + for (const [, actor] of this._sourceActors) { + if (actor.url === url) { + rv.push(actor); + } + } + } + return rv; + } + + getSourceActorById(actorId) { + for (const [, actor] of this._sourceActors) { + if (actor.actorID == actorId) { + return actor; + } + } + return null; + } + + /** + * Returns true if the URL likely points to a minified resource, false + * otherwise. + * + * @param String uri + * The url to test. + * @returns Boolean + */ + _isMinifiedURL(uri) { + if (!uri) { + return false; + } + + try { + const url = new URL(uri); + const pathname = url.pathname; + return MINIFIED_SOURCE_REGEXP.test( + pathname.slice(pathname.lastIndexOf("/") + 1) + ); + } catch (e) { + // Not a valid URL so don't try to parse out the filename, just test the + // whole thing with the minified source regexp. + return MINIFIED_SOURCE_REGEXP.test(uri); + } + } + + /** + * Return the non-source-mapped location of an offset in a script. + * + * @param Debugger.Script script + * The script associated with the offset. + * @param Number offset + * Offset within the script of the location. + * @returns Object + * Returns an object of the form { source, line, column } + */ + getScriptOffsetLocation(script, offset) { + const { lineNumber, columnNumber } = script.getOffsetMetadata(offset); + // NOTE: Debugger.Source.prototype.startColumn is 1-based. + // Convert to 0-based, while keeping the wasm's column (1) as is. + // (bug 1863878) + const columnBase = script.format === "wasm" ? 0 : 1; + return new SourceLocation( + this.createSourceActor(script.source), + lineNumber, + columnNumber - columnBase + ); + } + + /** + * Return the non-source-mapped location of the given Debugger.Frame. If the + * frame does not have a script, the location's properties are all null. + * + * @param Debugger.Frame frame + * The frame whose location we are getting. + * @returns Object + * Returns an object of the form { source, line, column } + */ + getFrameLocation(frame) { + if (!frame || !frame.script) { + return new SourceLocation(); + } + return this.getScriptOffsetLocation(frame.script, frame.offset); + } + + /** + * Returns true if URL for the given source is black boxed. + * + * * @param url String + * The URL of the source which we are checking whether it is black + * boxed or not. + */ + isBlackBoxed(url, line, column) { + if (!this.blackBoxedSources.has(url)) { + return false; + } + + const ranges = this.blackBoxedSources.get(url); + + // If we have an entry in the map, but it is falsy, the source is fully blackboxed. + if (!ranges) { + return true; + } + + const range = ranges.find(r => isLocationInRange({ line, column }, r)); + return !!range; + } + + isFrameBlackBoxed(frame) { + const { url, line, column } = this.getFrameLocation(frame); + return this.isBlackBoxed(url, line, column); + } + + clearAllBlackBoxing() { + this.blackBoxedSources.clear(); + } + + /** + * Add the given source URL to the set of sources that are black boxed. + * + * @param url String + * The URL of the source which we are black boxing. + */ + blackBox(url, range) { + if (!range) { + // blackbox the whole source + return this.blackBoxedSources.set(url, null); + } + + const ranges = this.blackBoxedSources.get(url) || []; + // ranges are sorted in ascening order + const index = ranges.findIndex( + r => r.end.line <= range.start.line && r.end.column <= range.start.column + ); + + ranges.splice(index + 1, 0, range); + this.blackBoxedSources.set(url, ranges); + return true; + } + + /** + * Remove the given source URL to the set of sources that are black boxed. + * + * @param url String + * The URL of the source which we are no longer black boxing. + */ + unblackBox(url, range) { + if (!range) { + return this.blackBoxedSources.delete(url); + } + + const ranges = this.blackBoxedSources.get(url); + const index = ranges.findIndex( + r => + r.start.line === range.start.line && + r.start.column === range.start.column && + r.end.line === range.end.line && + r.end.column === range.end.column + ); + + if (index !== -1) { + ranges.splice(index, 1); + } + + if (ranges.length === 0) { + return this.blackBoxedSources.delete(url); + } + + return this.blackBoxedSources.set(url, ranges); + } + + iter() { + return [...this._sourceActors.values()]; + } + + /** + * Listener for new HTML content. + */ + observe(subject, topic, data) { + if (topic == "devtools-html-content") { + const { parserID, uri, contents, complete } = JSON.parse(data); + if (this._urlContents.has(uri)) { + // We received many devtools-html-content events, if we already received one, + // aggregate the data with the one we already received. + const existing = this._urlContents.get(uri); + if (existing.parserID == parserID) { + assert(!existing.complete); + existing.content = existing.content + contents; + existing.complete = complete; + + // After the HTML has finished loading, resolve any promises + // waiting for the complete file contents. Waits will only + // occur when the URL was ever partially loaded. + if (complete) { + const waiters = this._urlWaiters.get(uri); + if (waiters) { + for (const waiter of waiters) { + waiter(); + } + this._urlWaiters.delete(uri); + } + } + } + } else if (contents) { + // Ensure that `contents` is non-empty. We may miss all the devtools-html-content events except the last + // one which has a empty `contents` and complete set to true. + // This reproduces when opening a same-process iframe. In this particular scenario, we instantiate the target and thread actor + // on `DOMDocElementInserted` and the HTML document is already parsed, but we still receive this one very last notification. + this._urlContents.set(uri, { + content: contents, + complete, + contentType: "text/html", + parserID, + }); + } + } + } + + /** + * Get the contents of a URL, fetching it if necessary. If partial is set and + * any content for the URL has been received, that partial content is returned + * synchronously. + */ + urlContents(url, partial, canUseCache) { + if (this._urlContents.has(url)) { + const data = this._urlContents.get(url); + if (!partial && !data.complete) { + return new Promise(resolve => { + if (!this._urlWaiters.has(url)) { + this._urlWaiters.set(url, []); + } + this._urlWaiters.get(url).push(resolve); + }).then(() => { + assert(data.complete); + return { + content: data.content, + contentType: data.contentType, + }; + }); + } + return { + content: data.content, + contentType: data.contentType, + }; + } + if (partial) { + return { + content: "", + contentType: "", + }; + } + return this._fetchURLContents(url, partial, canUseCache); + } + + async _fetchURLContents(url, partial, canUseCache) { + // Only try the cache if it is currently enabled for the document. + // Without this check, the cache may return stale data that doesn't match + // the document shown in the browser. + let loadFromCache = canUseCache; + if (canUseCache && this._thread._parent.browsingContext) { + loadFromCache = !( + this._thread._parent.browsingContext.defaultLoadFlags === + Ci.nsIRequest.LOAD_BYPASS_CACHE + ); + } + + // Fetch the sources with the same principal as the original document + const win = this._thread._parent.window; + let principal, cacheKey; + // On xpcshell, we don't have a window but a Sandbox + if (!isWorker && win instanceof Ci.nsIDOMWindow) { + const docShell = win.docShell; + const channel = docShell.currentDocumentChannel; + principal = channel.loadInfo.loadingPrincipal; + + // Retrieve the cacheKey in order to load POST requests from cache + // Note that chrome:// URLs don't support this interface. + if ( + loadFromCache && + docShell.currentDocumentChannel instanceof Ci.nsICacheInfoChannel + ) { + cacheKey = docShell.currentDocumentChannel.cacheKey; + } + } + + let result; + try { + result = await fetch(url, { + principal, + cacheKey, + loadFromCache, + }); + } catch (error) { + this._reportLoadSourceError(error); + throw error; + } + + // When we fetch the contents, there is a risk that the contents we get + // do not match up with the actual text of the sources these contents will + // be associated with. We want to always show contents that include that + // actual text (otherwise it will be very confusing or unusable for users), + // so replace the contents with the actual text if there is a mismatch. + const actors = [...this._sourceActors.values()].filter( + actor => actor.url == url + ); + if (!actors.every(actor => actor.contentMatches(result))) { + if (actors.length > 1) { + // When there are multiple actors we won't be able to show the source + // for all of them. Ask the user to reload so that we don't have to do + // any fetching. + result.content = "Error: Incorrect contents fetched, please reload."; + } else { + result.content = actors[0].actualText(); + } + } + + this._urlContents.set(url, { ...result, complete: true }); + + return result; + } + + _reportLoadSourceError(error) { + try { + DevToolsUtils.reportException("SourceActor", error); + + const lines = JSON.stringify(this.form(), null, 4).split(/\n/g); + lines.forEach(line => console.error("\t", line)); + } catch (e) { + // ignore + } + } +} + +function isLocationInRange({ line, column }, range) { + return ( + (range.start.line <= line || + (range.start.line == line && range.start.column <= column)) && + (range.end.line >= line || + (range.end.line == line && range.end.column >= column)) + ); +} + +exports.SourcesManager = SourcesManager; diff --git a/devtools/server/actors/utils/stack.js b/devtools/server/actors/utils/stack.js new file mode 100644 index 0000000000..6a216b252c --- /dev/null +++ b/devtools/server/actors/utils/stack.js @@ -0,0 +1,183 @@ +/* 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"; + +/** + * A helper class that stores stack frame objects. Each frame is + * assigned an index, and if a frame is added more than once, the same + * index is used. Users of the class can get an array of all frames + * that have been added. + */ +class StackFrameCache { + /** + * Initialize this object. + */ + constructor() { + this._framesToIndices = null; + this._framesToForms = null; + this._lastEventSize = 0; + } + + /** + * Prepare to accept frames. + */ + initFrames() { + if (this._framesToIndices) { + // The maps are already initialized. + return; + } + + this._framesToIndices = new Map(); + this._framesToForms = new Map(); + this._lastEventSize = 0; + } + + /** + * Forget all stored frames and reset to the initialized state. + */ + clearFrames() { + this._framesToIndices.clear(); + this._framesToIndices = null; + this._framesToForms.clear(); + this._framesToForms = null; + this._lastEventSize = 0; + } + + /** + * Add a frame to this stack frame cache, and return the index of + * the frame. + */ + addFrame(frame) { + this._assignFrameIndices(frame); + this._createFrameForms(frame); + return this._framesToIndices.get(frame); + } + + /** + * A helper method for the memory actor. This populates the packet + * object with "frames" property. Each of these + * properties will be an array indexed by frame ID. "frames" will + * contain frame objects (see makeEvent). + * + * @param packet + * The packet to update. + * + * @returns packet + */ + updateFramePacket(packet) { + // Now that we are guaranteed to have a form for every frame, we know the + // size the "frames" property's array must be. We use that information to + // create dense arrays even though we populate them out of order. + const size = this._framesToForms.size; + packet.frames = Array(size).fill(null); + + // Populate the "frames" properties. + for (const [stack, index] of this._framesToIndices) { + packet.frames[index] = this._framesToForms.get(stack); + } + + return packet; + } + + /** + * If any new stack frames have been added to this cache since the + * last call to makeEvent (clearing the cache also resets the "last + * call"), then return a new array describing the new frames. If no + * new frames are available, return null. + * + * The frame cache assumes that the user of the cache keeps track of + * all previously-returned arrays and, in theory, concatenates them + * all to form a single array holding all frames added to the cache + * since the last reset. This concatenated array can be indexed by + * the frame ID. The array returned by this function, though, is + * dense and starts at 0. + * + * Each element in the array is an object of the form: + * { + * line: <line number for this frame>, + * column: <column number for this frame>, + * source: <filename string for this frame>, + * functionDisplayName: <this frame's inferred function name function or null>, + * parent: <frame ID -- an index into the concatenated array mentioned above> + * asyncCause: the async cause, or null + * asyncParent: <frame ID -- an index into the concatenated array mentioned above> + * } + * + * The intent of this approach is to make it simpler to efficiently + * send frame information over the debugging protocol, by only + * sending new frames. + * + * @returns array or null + */ + makeEvent() { + const size = this._framesToForms.size; + if (!size || size <= this._lastEventSize) { + return null; + } + + const packet = Array(size - this._lastEventSize).fill(null); + for (const [stack, index] of this._framesToIndices) { + if (index >= this._lastEventSize) { + packet[index - this._lastEventSize] = this._framesToForms.get(stack); + } + } + + this._lastEventSize = size; + + return packet; + } + + /** + * Assigns an index to the given frame and its parents, if an index is not + * already assigned. + * + * @param SavedFrame frame + * A frame to assign an index to. + */ + _assignFrameIndices(frame) { + if (this._framesToIndices.has(frame)) { + return; + } + + if (frame) { + this._assignFrameIndices(frame.parent); + this._assignFrameIndices(frame.asyncParent); + } + + const index = this._framesToIndices.size; + this._framesToIndices.set(frame, index); + } + + /** + * Create the form for the given frame, if one doesn't already exist. + * + * @param SavedFrame frame + * A frame to create a form for. + */ + _createFrameForms(frame) { + if (this._framesToForms.has(frame)) { + return; + } + + let form = null; + if (frame) { + form = { + line: frame.line, + column: frame.column, + source: frame.source, + functionDisplayName: frame.functionDisplayName, + parent: this._framesToIndices.get(frame.parent), + asyncParent: this._framesToIndices.get(frame.asyncParent), + asyncCause: frame.asyncCause, + }; + this._createFrameForms(frame.parent); + this._createFrameForms(frame.asyncParent); + } + + this._framesToForms.set(frame, form); + } +} + +exports.StackFrameCache = StackFrameCache; diff --git a/devtools/server/actors/utils/style-utils.js b/devtools/server/actors/utils/style-utils.js new file mode 100644 index 0000000000..5f2e912002 --- /dev/null +++ b/devtools/server/actors/utils/style-utils.js @@ -0,0 +1,211 @@ +/* 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 { getCSSLexer } = require("resource://devtools/shared/css/lexer.js"); + +const XHTML_NS = "http://www.w3.org/1999/xhtml"; +const FONT_PREVIEW_TEXT = "Abc"; +const FONT_PREVIEW_FONT_SIZE = 40; +const FONT_PREVIEW_FILLSTYLE = "black"; +// Offset (in px) to avoid cutting off text edges of italic fonts. +const FONT_PREVIEW_OFFSET = 4; +// Factor used to resize the canvas in order to get better text quality. +const FONT_PREVIEW_OVERSAMPLING_FACTOR = 2; + +/** + * Helper function for getting an image preview of the given font. + * + * @param font {string} + * Name of font to preview + * @param doc {Document} + * Document to use to render font + * @param options {object} + * Object with options 'previewText' and 'previewFontSize' + * + * @return dataUrl + * The data URI of the font preview image + */ +function getFontPreviewData(font, doc, options) { + options = options || {}; + const previewText = options.previewText || FONT_PREVIEW_TEXT; + const previewTextLines = previewText.split("\n"); + const previewFontSize = options.previewFontSize || FONT_PREVIEW_FONT_SIZE; + const fillStyle = options.fillStyle || FONT_PREVIEW_FILLSTYLE; + const fontStyle = options.fontStyle || ""; + + const canvas = doc.createElementNS(XHTML_NS, "canvas"); + const ctx = canvas.getContext("2d"); + const fontValue = + fontStyle + " " + previewFontSize + "px " + font + ", serif"; + + // Get the correct preview text measurements and set the canvas dimensions + ctx.font = fontValue; + ctx.fillStyle = fillStyle; + const previewTextLinesWidths = previewTextLines.map( + previewTextLine => ctx.measureText(previewTextLine).width + ); + const textWidth = Math.round(Math.max(...previewTextLinesWidths)); + + // The canvas width is calculated as the width of the longest line plus + // an offset at the left and right of it. + // The canvas height is calculated as the font size multiplied by the + // number of lines plus an offset at the top and bottom. + // + // In order to get better text quality, we oversample the canvas. + // That means, after the width and height are calculated, we increase + // both sizes by some factor. + const simpleCanvasWidth = textWidth + FONT_PREVIEW_OFFSET * 2; + canvas.width = simpleCanvasWidth * FONT_PREVIEW_OVERSAMPLING_FACTOR; + canvas.height = + (previewFontSize * previewTextLines.length + FONT_PREVIEW_OFFSET * 2) * + FONT_PREVIEW_OVERSAMPLING_FACTOR; + + // we have to reset these after changing the canvas size + ctx.font = fontValue; + ctx.fillStyle = fillStyle; + + // Oversample the canvas for better text quality + ctx.scale(FONT_PREVIEW_OVERSAMPLING_FACTOR, FONT_PREVIEW_OVERSAMPLING_FACTOR); + + ctx.textBaseline = "top"; + ctx.textAlign = "center"; + const horizontalTextPosition = simpleCanvasWidth / 2; + let verticalTextPosition = FONT_PREVIEW_OFFSET; + for (let i = 0; i < previewTextLines.length; i++) { + ctx.fillText( + previewTextLines[i], + horizontalTextPosition, + verticalTextPosition + ); + + // Move vertical text position one line down + verticalTextPosition += previewFontSize; + } + + const dataURL = canvas.toDataURL("image/png"); + + return { + dataURL, + size: textWidth + FONT_PREVIEW_OFFSET * 2, + }; +} + +exports.getFontPreviewData = getFontPreviewData; + +/** + * Get the text content of a rule given some CSS text, a line and a column + * Consider the following example: + * body { + * color: red; + * } + * p { + * line-height: 2em; + * color: blue; + * } + * Calling the function with the whole text above and line=4 and column=1 would + * return "line-height: 2em; color: blue;" + * @param {String} initialText + * @param {Number} line (1-indexed) + * @param {Number} column (1-indexed) + * @return {object} An object of the form {offset: number, text: string} + * The offset is the index into the input string where + * the rule text started. The text is the content of + * the rule. + */ +function getRuleText(initialText, line, column) { + if (typeof line === "undefined" || typeof column === "undefined") { + throw new Error("Location information is missing"); + } + + const { offset: textOffset, text } = getTextAtLineColumn( + initialText, + line, + column + ); + const lexer = getCSSLexer(text); + + // Search forward for the opening brace. + while (true) { + const token = lexer.nextToken(); + if (!token) { + throw new Error("couldn't find start of the rule"); + } + if (token.tokenType === "symbol" && token.text === "{") { + break; + } + } + + // Now collect text until we see the matching close brace. + let braceDepth = 1; + let startOffset, endOffset; + while (true) { + const token = lexer.nextToken(); + if (!token) { + break; + } + if (startOffset === undefined) { + startOffset = token.startOffset; + } + if (token.tokenType === "symbol") { + if (token.text === "{") { + ++braceDepth; + } else if (token.text === "}") { + --braceDepth; + if (braceDepth == 0) { + break; + } + } + } + endOffset = token.endOffset; + } + + // If the rule was of the form "selector {" with no closing brace + // and no properties, just return an empty string. + if (startOffset === undefined) { + return { offset: 0, text: "" }; + } + // If the input didn't have any tokens between the braces (e.g., + // "div {}"), then the endOffset won't have been set yet; so account + // for that here. + if (endOffset === undefined) { + endOffset = startOffset; + } + + // Note that this approach will preserve comments, despite the fact + // that cssTokenizer skips them. + return { + offset: textOffset + startOffset, + text: text.substring(startOffset, endOffset), + }; +} + +exports.getRuleText = getRuleText; + +/** + * Return the offset and substring of |text| that starts at the given + * line and column. + * @param {String} text + * @param {Number} line (1-indexed) + * @param {Number} column (1-indexed) + * @return {object} An object of the form {offset: number, text: string}, + * where the offset is the offset into the input string + * where the text starts, and where text is the text. + */ +function getTextAtLineColumn(text, line, column) { + let offset; + if (line > 1) { + const rx = new RegExp( + "(?:[^\\r\\n\\f]*(?:\\r\\n|\\n|\\r|\\f)){" + (line - 1) + "}" + ); + offset = rx.exec(text)[0].length; + } else { + offset = 0; + } + offset += column - 1; + return { offset, text: text.substr(offset) }; +} + +exports.getTextAtLineColumn = getTextAtLineColumn; diff --git a/devtools/server/actors/utils/stylesheet-utils.js b/devtools/server/actors/utils/stylesheet-utils.js new file mode 100644 index 0000000000..682a752c3d --- /dev/null +++ b/devtools/server/actors/utils/stylesheet-utils.js @@ -0,0 +1,155 @@ +/* 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 { fetch } = require("resource://devtools/shared/DevToolsUtils.js"); + +/** + * For imported stylesheets, `ownerNode` is null. + * + * To resolve the ownerNode for an imported stylesheet, loop on `parentStylesheet` + * until we reach the topmost stylesheet, which should have a valid ownerNode. + * + * Constructable stylesheets do not have an owner node and this method will + * return null. + * + * @param {StyleSheet} + * The stylesheet for which we want to retrieve the ownerNode. + * @return {DOMNode|null} The ownerNode or null for constructable stylesheets. + */ +function getStyleSheetOwnerNode(sheet) { + // If this is not an imported stylesheet and we have an ownerNode available + // bail out immediately. + if (sheet.ownerNode) { + return sheet.ownerNode; + } + + let parentStyleSheet = sheet; + while ( + parentStyleSheet.parentStyleSheet && + parentStyleSheet !== parentStyleSheet.parentStyleSheet + ) { + parentStyleSheet = parentStyleSheet.parentStyleSheet; + } + + return parentStyleSheet.ownerNode; +} + +exports.getStyleSheetOwnerNode = getStyleSheetOwnerNode; + +/** + * Get the text of a stylesheet. + * + * TODO: A call site in window-global.js expects this method to return a promise + * so it is mandatory to keep it as an async function even if we are not using + * await explicitly. Bug 1810572. + * + * @param {StyleSheet} + * The stylesheet for which we want to retrieve the text. + * @returns {Promise} + */ +async function getStyleSheetText(styleSheet) { + if (!styleSheet.href) { + if (styleSheet.ownerNode) { + // this is an inline <style> sheet + return styleSheet.ownerNode.textContent; + } + // Constructed stylesheet. + // TODO(bug 1769933, bug 1809108): Maybe preserve authored text? + return ""; + } + + return fetchStyleSheetText(styleSheet); +} + +exports.getStyleSheetText = getStyleSheetText; + +/** + * Retrieve the content of a given stylesheet + * + * @param {StyleSheet} styleSheet + * @returns {String} + */ +async function fetchStyleSheetText(styleSheet) { + const href = styleSheet.href; + + const options = { + loadFromCache: true, + policy: Ci.nsIContentPolicy.TYPE_INTERNAL_STYLESHEET, + charset: getCSSCharset(styleSheet), + headers: { + // https://searchfox.org/mozilla-central/rev/68b1b0041a78abd06f19202558ccc4922e5ba759/netwerk/protocol/http/nsHttpHandler.cpp#124 + accept: "text/css,*/*;q=0.1", + }, + }; + + // 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. + const ownerNode = getStyleSheetOwnerNode(styleSheet); + if (ownerNode) { + // eslint-disable-next-line mozilla/use-ownerGlobal + options.window = ownerNode.ownerDocument.defaultView; + options.principal = ownerNode.ownerDocument.nodePrincipal; + } + } + + let result; + + try { + result = await fetch(href, options); + if (result.contentType !== "text/css") { + console.warn( + `stylesheets: fetch from cache returned non-css content-type ` + + `${result.contentType} for ${href}, trying without cache.` + ); + options.loadFromCache = false; + 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; +} + +/** + * Get charset of a given stylesheet + * + * @param {StyleSheet} styleSheet + * @returns {String} + */ +function 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"; +} diff --git a/devtools/server/actors/utils/stylesheets-manager.js b/devtools/server/actors/utils/stylesheets-manager.js new file mode 100644 index 0000000000..838e5be602 --- /dev/null +++ b/devtools/server/actors/utils/stylesheets-manager.js @@ -0,0 +1,1031 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const EventEmitter = require("resource://devtools/shared/event-emitter.js"); +const { + getSourcemapBaseURL, +} = require("resource://devtools/server/actors/utils/source-map-utils.js"); + +loader.lazyRequireGetter( + this, + ["addPseudoClassLock", "removePseudoClassLock"], + "resource://devtools/server/actors/highlighters/utils/markup.js", + true +); +loader.lazyRequireGetter( + this, + "loadSheet", + "resource://devtools/shared/layout/utils.js", + true +); +loader.lazyRequireGetter( + this, + ["getStyleSheetOwnerNode", "getStyleSheetText"], + "resource://devtools/server/actors/utils/stylesheet-utils.js", + 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} *:not(:-moz-native-anonymous)`; +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; + } +`); + +// The possible kinds of style-applied events. +// UPDATE_PRESERVING_RULES means that the update is guaranteed to +// preserve the number and order of rules on the style sheet. +// UPDATE_GENERAL covers any other kind of change to the style sheet. +const UPDATE_PRESERVING_RULES = 0; +const UPDATE_GENERAL = 1; + +// 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(); + +/** + * Manage stylesheets related to a given Target Actor. + * @emits stylesheet-updated: emitted when there was changes in a stylesheet + * First arg is an object with the following properties: + * - resourceId {String}: The id that was assigned to the stylesheet + * - updateKind {String}: Which kind of update it is ("style-applied", + * "at-rules-changed", "matches-change", "property-change") + * - updates {Object}: The update data + */ +class StyleSheetsManager extends EventEmitter { + #abortController; + // Map<resourceId, AbortController> + #mqlChangeAbortControllerMap = new Map(); + #styleSheetCount = 0; + #styleSheetMap = new Map(); + #styleSheetCreationData; + #targetActor; + #transitionSheetLoaded; + #transitionTimeout; + #watchListeners = { + onAvailable: [], + onUpdated: [], + onDestroyed: [], + }; + + /** + * @param TargetActor targetActor + * The target actor from which we should observe stylesheet changes. + */ + constructor(targetActor) { + super(); + + this.#targetActor = targetActor; + } + + #setEventListenersIfNeeded() { + if (this.#abortController) { + return; + } + + this.#abortController = new AbortController(); + const { signal } = this.#abortController; + + // Listen for new stylesheet being added via StyleSheetApplicableStateChanged + this.#targetActor.chromeEventHandler.addEventListener( + "StyleSheetApplicableStateChanged", + this.#onApplicableStateChanged, + { capture: true, signal } + ); + this.#targetActor.chromeEventHandler.addEventListener( + "StyleSheetRemoved", + this.#onStylesheetRemoved, + { capture: true, signal } + ); + + this.#watchStyleSheetChangeEvents(); + this.#targetActor.on("window-ready", this.#onTargetActorWindowReady, { + signal, + }); + } + + /** + * Calling this function will make the StyleSheetsManager start the event listeners needed + * to watch for stylesheet additions and modifications. + * This resolves once it notified about existing stylesheets. + * @param {Object} options + * @param {Function} onAvailable: Function that will be called when a stylesheet is + * registered, but also with already registered stylesheets + * if ignoreExisting is not set to true. + * This is called with a single object parameter with the following properties: + * - {String} resourceId: The id that was assigned to the stylesheet + * - {StyleSheet} styleSheet: The actual stylesheet object + * - {Object} creationData: An object with: + * - {Boolean} isCreatedByDevTools: Was the stylesheet created + * by DevTools (e.g. by the user clicking the new stylesheet + * button in the styleeditor) + * - {String} fileName + * @param {Function} onUpdated: Function that will be called when a stylesheet is updated + * This is called with a single object parameter with the following properties: + * - {String} resourceId: The id that was assigned to the stylesheet + * - {String} updateKind: Which kind of update it is ("style-applied", + * "at-rules-changed", "matches-change", "property-change") + * - {Object} updates : The update data + * @param {Function} onDestroyed: Function that will be called when a stylesheet is removed + * This is called with a single object parameter with the following properties: + * - {String} resourceId: The id that was assigned to the stylesheet + * @param {Boolean} ignoreExisting: Pass to true to avoid onAvailable to be called with + * already registered stylesheets. + */ + async watch({ onAvailable, onUpdated, onDestroyed, ignoreExisting = false }) { + if (!onAvailable && !onUpdated && !onDestroyed) { + throw new Error("Expect onAvailable, onUpdated or onDestroyed"); + } + + if (onAvailable) { + if (typeof onAvailable !== "function") { + throw new Error("onAvailable should be a function"); + } + + // Don't register the listener yet if we're ignoring existing stylesheets, we'll do + // that at the end of the function, after we processed existing stylesheets. + } + + if (onUpdated) { + if (typeof onUpdated !== "function") { + throw new Error("onUpdated should be a function"); + } + this.#watchListeners.onUpdated.push(onUpdated); + } + + if (onDestroyed) { + if (typeof onDestroyed !== "function") { + throw new Error("onDestroyed should be a function"); + } + this.#watchListeners.onDestroyed.push(onDestroyed); + } + + // Process existing stylesheets + const promises = []; + for (const window of this.#targetActor.windows) { + promises.push(this.#getStyleSheetsForWindow(window)); + } + + this.#setEventListenersIfNeeded(); + + // Finally, notify about existing stylesheets + const styleSheets = await Promise.all(promises); + const styleSheetsData = styleSheets.flat().map(styleSheet => ({ + styleSheet, + resourceId: this.#registerStyleSheet(styleSheet), + })); + + let registeredStyleSheetsPromises; + if (onAvailable && ignoreExisting !== true) { + registeredStyleSheetsPromises = styleSheetsData.map( + ({ resourceId, styleSheet }) => onAvailable({ resourceId, styleSheet }) + ); + } + + // Only register the listener after we went over the list of existing stylesheets + // so the listener is not triggered by possible calls to #registerStyleSheet earlier. + if (onAvailable) { + this.#watchListeners.onAvailable.push(onAvailable); + } + + if (registeredStyleSheetsPromises) { + await Promise.all(registeredStyleSheetsPromises); + } + } + + /** + * Remove the passed listeners + * + * @param {Object} options: See this.watch + */ + unwatch({ onAvailable, onUpdated, onDestroyed }) { + if (!this.#watchListeners) { + return; + } + + if (onAvailable) { + const index = this.#watchListeners.onAvailable.indexOf(onAvailable); + if (index !== -1) { + this.#watchListeners.onAvailable.splice(index, 1); + } + } + + if (onUpdated) { + const index = this.#watchListeners.onUpdated.indexOf(onUpdated); + if (index !== -1) { + this.#watchListeners.onUpdated.splice(index, 1); + } + } + + if (onDestroyed) { + const index = this.#watchListeners.onDestroyed.indexOf(onDestroyed); + if (index !== -1) { + this.#watchListeners.onDestroyed.splice(index, 1); + } + } + } + + #watchStyleSheetChangeEvents() { + for (const window of this.#targetActor.windows) { + this.#watchStyleSheetChangeEventsForWindow(window); + } + } + + #onTargetActorWindowReady = ({ window }) => { + this.#watchStyleSheetChangeEventsForWindow(window); + }; + + #watchStyleSheetChangeEventsForWindow(window) { + // We have to set this flag in order to get the + // StyleSheetApplicableStateChanged and StyleSheetRemoved events. See Document.webidl. + window.document.styleSheetChangeEventsEnabled = true; + } + + #unwatchStyleSheetChangeEvents() { + for (const window of this.#targetActor.windows) { + window.document.styleSheetChangeEventsEnabled = false; + } + } + + /** + * 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"); + style.setDevtoolsAsTriggeringPrincipal(); + + 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; + } + + /** + * Return resourceId of the given style sheet or create one if the stylesheet wasn't + * registered yet. + * + * @params {StyleSheet} styleSheet + * @returns {String} resourceId + */ + getStyleSheetResourceId(styleSheet) { + const existingResourceId = this.#findStyleSheetResourceId(styleSheet); + if (existingResourceId) { + return existingResourceId; + } + + // If we couldn't find an associated resourceId, that means the stylesheet isn't + // registered yet. Calling #registerStyleSheet will register it and return the + // associated resourceId it computed for it. + return this.#registerStyleSheet(styleSheet); + } + + /** + * Return the associated resourceId of the given registered style sheet, or null if the + * stylesheet wasn't registered yet. + * + * @params {StyleSheet} styleSheet + * @returns {String} resourceId + */ + #findStyleSheetResourceId(styleSheet) { + for (const [ + resourceId, + existingStyleSheet, + ] of this.#styleSheetMap.entries()) { + if (styleSheet === existingStyleSheet) { + return resourceId; + } + } + + return null; + } + + /** + * Return owner node of the style sheet of the given resource id. + * + * @params {String} resourceId + * The id associated with the stylesheet + * @returns {Element|null} + */ + getOwnerNode(resourceId) { + const styleSheet = this.#styleSheetMap.get(resourceId); + return styleSheet.ownerNode; + } + + /** + * Return the index of given stylesheet of the given resource id. + * + * @params {String} resourceId + * The id associated with the stylesheet + * @returns {Number} + */ + getStyleSheetIndex(resourceId) { + const styleSheet = this.#styleSheetMap.get(resourceId); + + const styleSheets = InspectorUtils.getAllStyleSheets( + this.#targetActor.window.document, + true + ); + let i = 0; + for (const sheet of styleSheets) { + if (!this.#shouldListSheet(sheet)) { + continue; + } + if (sheet == styleSheet) { + return i; + } + i++; + } + return -1; + } + + /** + * Get the text of a stylesheet given its resourceId. + * + * @params {String} resourceId + * The id associated with the stylesheet + * @returns {String} + */ + 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; + } + + return getStyleSheetText(styleSheet); + } + + /** + * Toggle the disabled property of the stylesheet + * + * @params {String} resourceId + * The id associated with 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 {Object} options + * @param {Boolean} options.transition + * Whether to do CSS transition for change. Defaults to false. + * @param {Number} options.kind + * Either UPDATE_PRESERVING_RULES or UPDATE_GENERAL. Defaults to UPDATE_GENERAL. + * @param {String} options.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 setStyleSheetText( + resourceId, + text, + { transition = false, kind = UPDATE_GENERAL, cause = "" } = {} + ) { + const styleSheet = this.#styleSheetMap.get(resourceId); + InspectorUtils.parseStyleSheet(styleSheet, text); + modifiedStyleSheets.set(styleSheet, text); + + const { atRules, ruleCount } = + this.getStyleSheetRuleCountAndAtRules(styleSheet); + + if (kind !== UPDATE_PRESERVING_RULES) { + this.#notifyPropertyChanged(resourceId, "ruleCount", ruleCount); + } + + if (transition) { + this.#startTransition(resourceId, kind, cause); + } else { + this.#onStyleSheetUpdated({ + resourceId, + updateKind: "style-applied", + updates: { + event: { kind, cause }, + }, + }); + } + + this.#onStyleSheetUpdated({ + resourceId, + updateKind: "at-rules-changed", + updates: { + resourceUpdates: { atRules }, + }, + }); + } + + /** + * Applies a transition to the stylesheet document so any change made by the user in the + * client will be animated so it's more visible. + * + * @param {String} resourceId + * The id associated with the stylesheet + * @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. + */ + #startTransition(resourceId, kind, cause) { + const styleSheet = this.#styleSheetMap.get(resourceId); + const document = styleSheet.associatedDocument; + const window = document.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 + ); + } + + /** + * @param {String} resourceId + * The id associated with the stylesheet + * @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. + */ + #onTransitionEnd(resourceId, kind, cause) { + const styleSheet = this.#styleSheetMap.get(resourceId); + const document = styleSheet.associatedDocument; + + this.#transitionTimeout = null; + removePseudoClassLock(document.documentElement, TRANSITION_PSEUDO_CLASS); + + this.#onStyleSheetUpdated({ + resourceId, + updateKind: "style-applied", + updates: { + event: { kind, cause }, + }, + }); + } + + /** + * Retrieve the CSSRuleList of a given stylesheet + * + * @param {StyleSheet} styleSheet + * @returns {CSSRuleList} + */ + #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 } + ); + }); + } + + /** + * Get the stylesheets imported by a given stylesheet (via @import) + * + * @param {Document} document + * @param {StyleSheet} styleSheet + * @returns Array<StyleSheet> + */ + async #getImportedStyleSheets(document, styleSheet) { + const importedStyleSheets = []; + + for (const rule of await this.#getCSSRules(styleSheet)) { + const ruleClassName = ChromeUtils.getClassName(rule); + if (ruleClassName == "CSSImportRule") { + // 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 (ruleClassName != "CSSCharsetRule") { + // @import rules must precede all others except @charset + break; + } + } + + return importedStyleSheets; + } + + /** + * Retrieve the total number of rules (including nested ones) and + * all the at-rules of a given stylesheet. + * + * @param {StyleSheet} styleSheet + * @returns {Object} An object of the following shape: + * - {Integer} ruleCount: The total number of rules in the stylesheet + * - {Array<Object>} atRules: An array of object of the following shape: + * - type {String} + * - conditionText {String} + * - matches {Boolean}: true if the media rule matches the current state of the document + * - layerName {String} + * - line {Number} + * - column {Number} + */ + getStyleSheetRuleCountAndAtRules(styleSheet) { + const resourceId = this.#findStyleSheetResourceId(styleSheet); + if (!resourceId) { + return []; + } + + if (this.#mqlChangeAbortControllerMap.has(resourceId)) { + this.#mqlChangeAbortControllerMap.get(resourceId).abort(); + this.#mqlChangeAbortControllerMap.delete(resourceId); + } + + // Accessing the stylesheet associated window might be slow due to cross compartment + // wrappers, so only retrieve it if it's needed. + let win; + const getStyleSheetAssociatedWindow = () => { + if (!win) { + win = styleSheet.associatedDocument?.ownerGlobal; + } + return win; + }; + + const styleSheetRules = + InspectorUtils.getAllStyleSheetCSSStyleRules(styleSheet); + const ruleCount = styleSheetRules.length; + // We need to go through nested rules to extract all the rules we're interested in + const atRules = []; + for (const rule of styleSheetRules) { + const className = ChromeUtils.getClassName(rule); + if (className === "CSSMediaRule") { + let matches = false; + + try { + const associatedWin = getStyleSheetAssociatedWindow(); + const mql = associatedWin.matchMedia(rule.media.mediaText); + matches = mql.matches; + + let ac = this.#mqlChangeAbortControllerMap.get(resourceId); + if (!ac) { + ac = new associatedWin.AbortController(); + this.#mqlChangeAbortControllerMap.set(resourceId, ac); + } + + const index = atRules.length; + mql.addEventListener( + "change", + () => this.#onMatchesChange(resourceId, index, mql), + { + signal: ac.signal, + } + ); + } catch (e) { + // Ignored + } + + atRules.push({ + type: "media", + conditionText: rule.conditionText, + matches, + line: InspectorUtils.getRelativeRuleLine(rule), + column: InspectorUtils.getRuleColumn(rule), + }); + } else if (className === "CSSContainerRule") { + atRules.push({ + type: "container", + conditionText: rule.conditionText, + line: InspectorUtils.getRelativeRuleLine(rule), + column: InspectorUtils.getRuleColumn(rule), + }); + } else if (className === "CSSSupportsRule") { + atRules.push({ + type: "support", + conditionText: rule.conditionText, + line: InspectorUtils.getRelativeRuleLine(rule), + column: InspectorUtils.getRuleColumn(rule), + }); + } else if (className === "CSSLayerBlockRule") { + atRules.push({ + type: "layer", + layerName: rule.name, + line: InspectorUtils.getRelativeRuleLine(rule), + column: InspectorUtils.getRuleColumn(rule), + }); + } + } + return { ruleCount, atRules }; + } + + /** + * Called when the status of a media query support changes (i.e. it now matches, or it + * was matching but isn't anymore) + * + * @param {String} resourceId + * The id associated with the stylesheet + * @param {Number} index + * The index of the media rule relatively to all the other at-rules of the stylesheet + * @param {MediaQueryList} mql + * The result of matchMedia for the given media rule + */ + #onMatchesChange(resourceId, index, mql) { + this.#onStyleSheetUpdated({ + resourceId, + updateKind: "matches-change", + updates: { + nestedResourceUpdates: [ + { + path: ["atRules", index, "matches"], + value: mql.matches, + }, + ], + }, + }); + } + + /** + * Get the node href of a given stylesheet + * + * @param {StyleSheet} styleSheet + * @returns {String} + */ + getNodeHref(styleSheet) { + const { ownerNode } = styleSheet; + if (!ownerNode) { + return null; + } + + if (ownerNode.nodeType == ownerNode.DOCUMENT_NODE) { + return ownerNode.location.href; + } + + if (ownerNode.ownerDocument?.location) { + return ownerNode.ownerDocument.location.href; + } + + return null; + } + + /** + * Get the sourcemap base url of a given stylesheet + * + * @param {StyleSheet} styleSheet + * @returns {String} + */ + getSourcemapBaseURL(styleSheet) { + // When the style is injected via nsIDOMWindowUtils.loadSheet, even + // the parent style sheet has no owner, so default back to target actor + // document + const ownerNode = getStyleSheetOwnerNode(styleSheet); + const ownerDocument = ownerNode + ? 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 + ); + } + + /** + * Get all the stylesheets for a given window + * + * @param {Window} window + * @returns {Array<StyleSheet>} + */ + async #getStyleSheetsForWindow(window) { + const { document } = window; + 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; + } + + /** + * Returns true if a given stylesheet has an ancestor with the same url it has + * + * @param {StyleSheet} styleSheet + * @returns {Boolean} + */ + #haveAncestorWithSameURL(styleSheet) { + const href = styleSheet.href; + while (styleSheet.parentStyleSheet) { + if (styleSheet.parentStyleSheet.href == href) { + return true; + } + styleSheet = styleSheet.parentStyleSheet; + } + return false; + } + + /** + * Helper function called when a property changed in a given stylesheet + * + * @param {String} resourceId + * The id of the stylesheet the change occured in + * @param {String} property + * The property that was changed + * @param {String} value + * The value of the property + */ + #notifyPropertyChanged(resourceId, property, value) { + this.#onStyleSheetUpdated({ + resourceId, + updateKind: "property-change", + updates: { 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 + * - Stylesheet is constructed. + * When appending <link>, <style> or changing `disabled` attribute to false, + * `applicable` is passed as true. The other hand, when changing `disabled` + * to true, this will be false. + * + * NOTE: StyleSheetApplicableStateChanged is _not_ called when removing the <link>/<style>, + * but a StyleSheetRemovedEvent is emitted in such case (see #onStyleSheetRemoved) + * + * @param {StyleSheetApplicableStateChangedEvent} + * The triggering event. + */ + #onApplicableStateChanged = ({ applicable, stylesheet: styleSheet }) => { + if ( + // Have interest in applicable stylesheet only. + applicable && + styleSheet.associatedDocument && + (!this.#targetActor.ignoreSubFrames || + styleSheet.associatedDocument.ownerGlobal === + this.#targetActor.window) && + this.#shouldListSheet(styleSheet) && + !this.#haveAncestorWithSameURL(styleSheet) + ) { + this.#registerStyleSheet(styleSheet); + } + }; + + /** + * Event handler that is called when a style sheet is removed. + * + * @param {StyleSheetRemovedEvent} + * The triggering event. + */ + #onStylesheetRemoved = event => { + this.#unregisterStyleSheet(event.stylesheet); + }; + + /** + * If the stylesheet isn't registered yet, this function will generate an associated + * resourceId and call registered `onAvailable` listeners. + * + * @param {StyleSheet} styleSheet + * @returns {String} the associated resourceId + */ + #registerStyleSheet(styleSheet) { + const existingResourceId = this.#findStyleSheetResourceId(styleSheet); + // If the stylesheet is already registered, there's no need to notify about it again. + if (existingResourceId) { + return existingResourceId; + } + + // It's important to prefix the resourceId with the target actorID so we can't have + // duplicated resource ids when the client connects to multiple targets. + const resourceId = `${this.#targetActor.actorID}:stylesheet:${this + .#styleSheetCount++}`; + this.#styleSheetMap.set(resourceId, styleSheet); + + const creationData = this.#styleSheetCreationData?.get(styleSheet); + this.#styleSheetCreationData?.delete(styleSheet); + + const onAvailablePromises = []; + for (const onAvailable of this.#watchListeners.onAvailable) { + onAvailablePromises.push( + onAvailable({ + resourceId, + styleSheet, + creationData, + }) + ); + } + + // creationData exists if this stylesheet was created via `addStyleSheet`. + if (creationData) { + // We resolve the promise once the watcher sent the resources to the client, + // so `addStyleSheet` calls can be fullfilled. + Promise.all(onAvailablePromises).then(() => creationData?.resolve()); + } + return resourceId; + } + + /** + * If the stylesheet is registered, this function will call registered `onDestroyed` + * listeners with the stylesheet resourceId. + * + * @param {StyleSheet} styleSheet + */ + #unregisterStyleSheet(styleSheet) { + const existingResourceId = this.#findStyleSheetResourceId(styleSheet); + if (!existingResourceId) { + return; + } + + this.#styleSheetMap.delete(existingResourceId); + this.#styleSheetCreationData?.delete(styleSheet); + if (this.#mqlChangeAbortControllerMap.has(existingResourceId)) { + this.#mqlChangeAbortControllerMap.get(existingResourceId).abort(); + this.#mqlChangeAbortControllerMap.delete(existingResourceId); + } + + for (const onDestroyed of this.#watchListeners.onDestroyed) { + onDestroyed({ + resourceId: existingResourceId, + }); + } + } + + #onStyleSheetUpdated(data) { + this.emit("stylesheet-updated", data); + + for (const onUpdated of this.#watchListeners.onUpdated) { + onUpdated(data); + } + } + + /** + * Returns true if the passed styleSheet should be handled. + * + * @param {StyleSheet} styleSheet + * @returns {Boolean} + */ + #shouldListSheet(styleSheet) { + const href = styleSheet.href?.toLowerCase(); + // FIXME(bug 1826538): Make accessiblecaret.css and similar UA-widget + // sheets system sheets, then remove this special-case. + if ( + href === "resource://content-accessible/accessiblecaret.css" || + (href === "resource://devtools-highlighter-styles/highlighters.css" && + this.#targetActor.sessionContext.type !== "all") + ) { + return false; + } + return true; + } + + /** + * The StyleSheetsManager instance is managed by the target, so this will be called when + * the target gets destroyed. + */ + destroy() { + // Cleanup + if (this.#abortController) { + this.#abortController.abort(); + } + if (this.#mqlChangeAbortControllerMap) { + for (const ac of this.#mqlChangeAbortControllerMap.values()) { + ac.abort(); + } + } + + try { + this.#unwatchStyleSheetChangeEvents(); + } catch (e) { + console.error( + "Error when destroying StyleSheet manager for", + this.#targetActor, + ": ", + e + ); + } + + this.#styleSheetMap.clear(); + this.#abortController = null; + this.#mqlChangeAbortControllerMap = null; + this.#styleSheetCreationData = null; + this.#styleSheetMap = null; + this.#targetActor = null; + this.#watchListeners = null; + } +} + +module.exports = { + StyleSheetsManager, + UPDATE_GENERAL, + UPDATE_PRESERVING_RULES, +}; diff --git a/devtools/server/actors/utils/track-change-emitter.js b/devtools/server/actors/utils/track-change-emitter.js new file mode 100644 index 0000000000..19de2b92fb --- /dev/null +++ b/devtools/server/actors/utils/track-change-emitter.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 EventEmitter = require("resource://devtools/shared/event-emitter.js"); + +/** + * A helper class that is listened to by the ChangesActor, and can be + * used to send changes to the ChangesActor. + */ +class TrackChangeEmitter extends EventEmitter { + trackChange(change) { + this.emit("track-change", change); + } +} + +module.exports = new TrackChangeEmitter(); diff --git a/devtools/server/actors/utils/walker-search.js b/devtools/server/actors/utils/walker-search.js new file mode 100644 index 0000000000..a5ffb48fad --- /dev/null +++ b/devtools/server/actors/utils/walker-search.js @@ -0,0 +1,320 @@ +/* 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, + "isWhitespaceTextNode", + "resource://devtools/server/actors/inspector/utils.js", + true +); + +/** + * The walker-search module provides a simple API to index and search strings + * and elements inside a given document. + * It indexes tag names, attribute names and values, and text contents. + * It provides a simple search function that returns a list of nodes that + * matched. + */ + +class WalkerIndex { + /** + * The WalkerIndex class indexes the document (and all subdocs) from + * a given walker. + * + * It is only indexed the first time the data is accessed and will be + * re-indexed if a mutation happens between requests. + * + * @param {Walker} walker The walker to be indexed + */ + constructor(walker) { + this.walker = walker; + this.clearIndex = this.clearIndex.bind(this); + + // Kill the index when mutations occur, the next data get will re-index. + this.walker.on("any-mutation", this.clearIndex); + } + + /** + * Destroy this instance, releasing all data and references + */ + destroy() { + this.walker.off("any-mutation", this.clearIndex); + } + + clearIndex() { + if (!this.currentlyIndexing) { + this._data = null; + } + } + + get doc() { + return this.walker.rootDoc; + } + + /** + * Get the indexed data + * This getter also indexes if it hasn't been done yet or if the state is + * dirty + * + * @returns Map<String, Array<{type:String, node:DOMNode}>> + * A Map keyed on the searchable value, containing an array with + * objects containing the 'type' (one of ALL_RESULTS_TYPES), and + * the DOM Node. + */ + get data() { + if (!this._data) { + this._data = new Map(); + this.index(); + } + + return this._data; + } + + _addToIndex(type, node, value) { + // Add an entry for this value if there isn't one + const entry = this._data.get(value); + if (!entry) { + this._data.set(value, []); + } + + // Add the type/node to the list + this._data.get(value).push({ + type, + node, + }); + } + + index() { + // Handle case where iterating nextNode() with the deepTreeWalker triggers + // a mutation (Bug 1222558) + this.currentlyIndexing = true; + + const documentWalker = this.walker.getDocumentWalker(this.doc); + while (documentWalker.nextNode()) { + const node = documentWalker.currentNode; + + if ( + this.walker.targetActor.ignoreSubFrames && + node.ownerDocument !== this.doc + ) { + continue; + } + + if (node.nodeType === 1) { + // For each element node, we get the tagname and all attributes names + // and values + const localName = node.localName; + if (localName === "_moz_generated_content_marker") { + this._addToIndex("tag", node, "::marker"); + this._addToIndex("text", node, node.textContent.trim()); + } else if (localName === "_moz_generated_content_before") { + this._addToIndex("tag", node, "::before"); + this._addToIndex("text", node, node.textContent.trim()); + } else if (localName === "_moz_generated_content_after") { + this._addToIndex("tag", node, "::after"); + this._addToIndex("text", node, node.textContent.trim()); + } else { + this._addToIndex("tag", node, node.localName); + } + + for (const { name, value } of node.attributes) { + this._addToIndex("attributeName", node, name); + this._addToIndex("attributeValue", node, value); + } + } else if (node.textContent && node.textContent.trim().length) { + // For comments and text nodes, we get the text + this._addToIndex("text", node, node.textContent.trim()); + } + } + + this.currentlyIndexing = false; + } +} + +exports.WalkerIndex = WalkerIndex; + +class WalkerSearch { + /** + * The WalkerSearch class provides a way to search an indexed document as well + * as find elements that match a given css selector. + * + * Usage example: + * let s = new WalkerSearch(doc); + * let res = s.search("lang", index); + * for (let {matched, results} of res) { + * for (let {node, type} of results) { + * console.log("The query matched a node's " + type); + * console.log("Node that matched", node); + * } + * } + * s.destroy(); + * + * @param {Walker} the walker to be searched + */ + constructor(walker) { + this.walker = walker; + this.index = new WalkerIndex(this.walker); + } + + destroy() { + this.index.destroy(); + this.walker = null; + } + + _addResult(node, type, results) { + if (!results.has(node)) { + results.set(node, []); + } + + const matches = results.get(node); + + // Do not add if the exact same result is already in the list + let isKnown = false; + for (const match of matches) { + if (match.type === type) { + isKnown = true; + break; + } + } + + if (!isKnown) { + matches.push({ type }); + } + } + + _searchIndex(query, options, results) { + for (const [matched, res] of this.index.data) { + if (!options.searchMethod(query, matched)) { + continue; + } + + // Add any relevant results (skipping non-requested options). + res + .filter(entry => { + return options.types.includes(entry.type); + }) + .forEach(({ node, type }) => { + this._addResult(node, type, results); + }); + } + } + + _searchSelectors(query, options, results) { + // If the query is just one "word", no need to search because _searchIndex + // will lead the same results since it has access to tagnames anyway + const isSelector = query && query.match(/[ >~.#\[\]]/); + if (!options.types.includes("selector") || !isSelector) { + return; + } + + const nodes = this.walker._multiFrameQuerySelectorAll(query); + for (const node of nodes) { + this._addResult(node, "selector", results); + } + } + + _searchXPath(query, options, results) { + if (!options.types.includes("xpath")) { + return; + } + + const nodes = this.walker._multiFrameXPath(query); + for (const node of nodes) { + // Exclude text nodes that only contain whitespace + // because they are not displayed in the Inspector. + if (!isWhitespaceTextNode(node)) { + this._addResult(node, "xpath", results); + } + } + } + + /** + * Search the document + * @param {String} query What to search for + * @param {Object} options The following options are accepted: + * - searchMethod {String} one of WalkerSearch.SEARCH_METHOD_* + * defaults to WalkerSearch.SEARCH_METHOD_CONTAINS (does not apply to + * selector and XPath search types) + * - types {Array} a list of things to search for (tag, text, attributes, etc) + * defaults to WalkerSearch.ALL_RESULTS_TYPES + * @return {Array} An array is returned with each item being an object like: + * { + * node: <the dom node that matched>, + * type: <the type of match: one of WalkerSearch.ALL_RESULTS_TYPES> + * } + */ + search(query, options = {}) { + options.searchMethod = + options.searchMethod || WalkerSearch.SEARCH_METHOD_CONTAINS; + options.types = options.types || WalkerSearch.ALL_RESULTS_TYPES; + + // Empty strings will return no results, as will non-string input + if (typeof query !== "string") { + query = ""; + } + + // Store results in a map indexed by nodes to avoid duplicate results + const results = new Map(); + + // Search through the indexed data + this._searchIndex(query, options, results); + + // Search with querySelectorAll + this._searchSelectors(query, options, results); + + // Search with XPath + this._searchXPath(query, options, results); + + // Concatenate all results into an Array to return + const resultList = []; + for (const [node, matches] of results) { + for (const { type } of matches) { + resultList.push({ + node, + type, + }); + + // For now, just do one result per node since the frontend + // doesn't have a way to highlight each result individually + // yet. + break; + } + } + + const documents = this.walker.targetActor.windows.map(win => win.document); + + // Sort the resulting nodes by order of appearance in the DOM + resultList.sort((a, b) => { + // Disconnected nodes won't get good results from compareDocumentPosition + // so check the order of their document instead. + if (a.node.ownerDocument != b.node.ownerDocument) { + const indA = documents.indexOf(a.node.ownerDocument); + const indB = documents.indexOf(b.node.ownerDocument); + return indA - indB; + } + // If the same document, then sort on DOCUMENT_POSITION_FOLLOWING (4) + // which means B is after A. + return a.node.compareDocumentPosition(b.node) & 4 ? -1 : 1; + }); + + return resultList; + } +} + +WalkerSearch.SEARCH_METHOD_CONTAINS = (query, candidate) => { + return query && candidate.toLowerCase().includes(query.toLowerCase()); +}; + +WalkerSearch.ALL_RESULTS_TYPES = [ + "tag", + "text", + "attributeName", + "attributeValue", + "selector", + "xpath", +]; + +exports.WalkerSearch = WalkerSearch; diff --git a/devtools/server/actors/utils/watchpoint-map.js b/devtools/server/actors/utils/watchpoint-map.js new file mode 100644 index 0000000000..7e4e3ee54c --- /dev/null +++ b/devtools/server/actors/utils/watchpoint-map.js @@ -0,0 +1,163 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +class WatchpointMap { + constructor(threadActor) { + this.threadActor = threadActor; + this._watchpoints = new Map(); + } + + _setWatchpoint(objActor, data) { + const { property, label, watchpointType } = data; + const obj = objActor.rawValue(); + + const desc = objActor.obj.getOwnPropertyDescriptor(property); + + if (this.has(obj, property) || desc.set || desc.get || !desc.configurable) { + return null; + } + + function getValue() { + return typeof desc.value === "object" && desc.value + ? desc.value.unsafeDereference() + : desc.value; + } + + function setValue(v) { + desc.value = objActor.obj.makeDebuggeeValue(v); + } + + const maybeHandlePause = type => { + const frame = this.threadActor.dbg.getNewestFrame(); + + if ( + this.threadActor.shouldSkipAnyBreakpoint || + !this.threadActor.hasMoved(frame, type) || + this.threadActor.sourcesManager.isFrameBlackBoxed(frame) + ) { + return; + } + + this.threadActor._pauseAndRespond(frame, { + type, + message: label, + }); + }; + + if (watchpointType === "get") { + objActor.obj.defineProperty(property, { + configurable: desc.configurable, + enumerable: desc.enumerable, + set: objActor.obj.makeDebuggeeValue(v => { + setValue(v); + }), + get: objActor.obj.makeDebuggeeValue(() => { + maybeHandlePause("getWatchpoint"); + return getValue(); + }), + }); + } + + if (watchpointType === "set") { + objActor.obj.defineProperty(property, { + configurable: desc.configurable, + enumerable: desc.enumerable, + set: objActor.obj.makeDebuggeeValue(v => { + maybeHandlePause("setWatchpoint"); + setValue(v); + }), + get: objActor.obj.makeDebuggeeValue(() => { + return getValue(); + }), + }); + } + + if (watchpointType === "getorset") { + objActor.obj.defineProperty(property, { + configurable: desc.configurable, + enumerable: desc.enumerable, + set: objActor.obj.makeDebuggeeValue(v => { + maybeHandlePause("setWatchpoint"); + setValue(v); + }), + get: objActor.obj.makeDebuggeeValue(() => { + maybeHandlePause("getWatchpoint"); + return getValue(); + }), + }); + } + + return desc; + } + + add(objActor, data) { + // Get the object's description before calling setWatchpoint, + // otherwise we'll get the modified property descriptor instead + const desc = this._setWatchpoint(objActor, data); + if (!desc) { + return; + } + + const objWatchpoints = + this._watchpoints.get(objActor.rawValue()) || new Map(); + + objWatchpoints.set(data.property, { ...data, desc }); + this._watchpoints.set(objActor.rawValue(), objWatchpoints); + } + + has(obj, property) { + const objWatchpoints = this._watchpoints.get(obj); + return objWatchpoints && objWatchpoints.has(property); + } + + get(obj, property) { + const objWatchpoints = this._watchpoints.get(obj); + return objWatchpoints && objWatchpoints.get(property); + } + + remove(objActor, property) { + const obj = objActor.rawValue(); + + // This should remove watchpoints on all of the object's properties if + // a property isn't passed in as an argument + if (!property) { + for (const objProperty in obj) { + this.remove(objActor, objProperty); + } + } + + if (!this.has(obj, property)) { + return; + } + + const objWatchpoints = this._watchpoints.get(obj); + const { desc } = objWatchpoints.get(property); + + objWatchpoints.delete(property); + this._watchpoints.set(obj, objWatchpoints); + + // We should stop keeping track of an object if it no longer + // has a watchpoint + if (objWatchpoints.size == 0) { + this._watchpoints.delete(obj); + } + + objActor.obj.defineProperty(property, desc); + } + + removeAll(objActor) { + const objWatchpoints = this._watchpoints.get(objActor.rawValue()); + if (!objWatchpoints) { + return; + } + + for (const objProperty in objWatchpoints) { + this.remove(objActor, objProperty); + } + } +} + +exports.WatchpointMap = WatchpointMap; |