diff options
author | Matt A. Tobin <mattatobin@localhost.localdomain> | 2018-02-02 04:16:08 -0500 |
---|---|---|
committer | Matt A. Tobin <mattatobin@localhost.localdomain> | 2018-02-02 04:16:08 -0500 |
commit | ad18d877ddd2a44d98fa12ccd3dbbcf4d0ac4299 (patch) | |
tree | 10027f336435511475e392454359edea8e25895d /devtools/server/actors | |
parent | 15477ed9af4859dacb069040b5d4de600803d3bc (diff) | |
download | uxp-ad18d877ddd2a44d98fa12ccd3dbbcf4d0ac4299.tar.gz |
Add m-esr52 at 52.6.0
Diffstat (limited to 'devtools/server/actors')
83 files changed, 40802 insertions, 0 deletions
diff --git a/devtools/server/actors/actor-registry.js b/devtools/server/actors/actor-registry.js new file mode 100644 index 0000000000..6a083ba6f9 --- /dev/null +++ b/devtools/server/actors/actor-registry.js @@ -0,0 +1,54 @@ +/* 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 protocol = require("devtools/shared/protocol"); +const { method, custom, Arg, Option, RetVal } = protocol; + +const { Cu, CC, components } = require("chrome"); +const Services = require("Services"); +const { DebuggerServer } = require("devtools/server/main"); +const { registerActor, unregisterActor } = require("devtools/server/actors/utils/actor-registry-utils"); +const { actorActorSpec, actorRegistrySpec } = require("devtools/shared/specs/actor-registry"); + +/** + * The ActorActor gives you a handle to an actor you've dynamically + * registered and allows you to unregister it. + */ +const ActorActor = protocol.ActorClassWithSpec(actorActorSpec, { + initialize: function (conn, options) { + protocol.Actor.prototype.initialize.call(this, conn); + + this.options = options; + }, + + unregister: function () { + unregisterActor(this.options); + } +}); + +/* + * The ActorRegistryActor allows clients to define new actors on the + * server. This is particularly useful for addons. + */ +const ActorRegistryActor = protocol.ActorClassWithSpec(actorRegistrySpec, { + initialize: function (conn) { + protocol.Actor.prototype.initialize.call(this, conn); + }, + + registerActor: function (sourceText, fileName, options) { + return registerActor(sourceText, fileName, options).then(() => { + let { constructor, type } = options; + + return ActorActor(this.conn, { + name: constructor, + tab: type.tab, + global: type.global + }); + }); + } +}); + +exports.ActorRegistryActor = ActorRegistryActor; diff --git a/devtools/server/actors/addon.js b/devtools/server/actors/addon.js new file mode 100644 index 0000000000..7f152e9845 --- /dev/null +++ b/devtools/server/actors/addon.js @@ -0,0 +1,352 @@ +/* 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 { Ci, Cu } = require("chrome"); +var Services = require("Services"); +var { ActorPool } = require("devtools/server/actors/common"); +var { TabSources } = require("./utils/TabSources"); +var makeDebugger = require("./utils/make-debugger"); +var { ConsoleAPIListener } = require("devtools/server/actors/utils/webconsole-utils"); +var DevToolsUtils = require("devtools/shared/DevToolsUtils"); +var { assert, update } = DevToolsUtils; + +loader.lazyRequireGetter(this, "AddonThreadActor", "devtools/server/actors/script", true); +loader.lazyRequireGetter(this, "unwrapDebuggerObjectGlobal", "devtools/server/actors/script", true); +loader.lazyRequireGetter(this, "mapURIToAddonID", "devtools/server/actors/utils/map-uri-to-addon-id"); +loader.lazyRequireGetter(this, "WebConsoleActor", "devtools/server/actors/webconsole", true); + +loader.lazyImporter(this, "AddonManager", "resource://gre/modules/AddonManager.jsm"); + +function BrowserAddonActor(aConnection, aAddon) { + this.conn = aConnection; + this._addon = aAddon; + this._contextPool = new ActorPool(this.conn); + this.conn.addActorPool(this._contextPool); + this.threadActor = null; + this._global = null; + + this._shouldAddNewGlobalAsDebuggee = this._shouldAddNewGlobalAsDebuggee.bind(this); + + this.makeDebugger = makeDebugger.bind(null, { + findDebuggees: this._findDebuggees.bind(this), + shouldAddNewGlobalAsDebuggee: this._shouldAddNewGlobalAsDebuggee + }); + + AddonManager.addAddonListener(this); +} +exports.BrowserAddonActor = BrowserAddonActor; + +BrowserAddonActor.prototype = { + actorPrefix: "addon", + + get exited() { + return !this._addon; + }, + + get id() { + return this._addon.id; + }, + + get url() { + return this._addon.sourceURI ? this._addon.sourceURI.spec : undefined; + }, + + get attached() { + return this.threadActor; + }, + + get global() { + return this._global; + }, + + get sources() { + if (!this._sources) { + assert(this.threadActor, "threadActor should exist when creating sources."); + this._sources = new TabSources(this.threadActor, this._allowSource); + } + return this._sources; + }, + + + form: function BAA_form() { + assert(this.actorID, "addon should have an actorID."); + if (!this._consoleActor) { + this._consoleActor = new AddonConsoleActor(this._addon, this.conn, this); + this._contextPool.addActor(this._consoleActor); + } + + return { + actor: this.actorID, + id: this.id, + name: this._addon.name, + url: this.url, + iconURL: this._addon.iconURL, + debuggable: this._addon.isDebuggable, + temporarilyInstalled: this._addon.temporarilyInstalled, + consoleActor: this._consoleActor.actorID, + + traits: { + highlightable: false, + networkMonitor: false, + }, + }; + }, + + disconnect: function BAA_disconnect() { + this.conn.removeActorPool(this._contextPool); + this._contextPool = null; + this._consoleActor = null; + this._addon = null; + this._global = null; + AddonManager.removeAddonListener(this); + }, + + setOptions: function BAA_setOptions(aOptions) { + if ("global" in aOptions) { + this._global = aOptions.global; + } + }, + + onInstalled: function BAA_updateAddonWrapper(aAddon) { + if (aAddon.id != this._addon.id) { + return; + } + + // Update the AddonManager's addon object on reload/update. + this._addon = aAddon; + }, + + onDisabled: function BAA_onDisabled(aAddon) { + if (aAddon != this._addon) { + return; + } + + this._global = null; + }, + + onUninstalled: function BAA_onUninstalled(aAddon) { + if (aAddon != this._addon) { + return; + } + + if (this.attached) { + this.onDetach(); + + // The BrowserAddonActor is not a TabActor and it has to send + // "tabDetached" directly to close the devtools toolbox window. + this.conn.send({ from: this.actorID, type: "tabDetached" }); + } + + this.disconnect(); + }, + + onAttach: function BAA_onAttach() { + if (this.exited) { + return { type: "exited" }; + } + + if (!this.attached) { + this.threadActor = new AddonThreadActor(this.conn, this); + this._contextPool.addActor(this.threadActor); + } + + return { type: "tabAttached", threadActor: this.threadActor.actorID }; + }, + + onDetach: function BAA_onDetach() { + if (!this.attached) { + return { error: "wrongState" }; + } + + this._contextPool.removeActor(this.threadActor); + + this.threadActor = null; + this._sources = null; + + return { type: "detached" }; + }, + + onReload: function BAA_onReload() { + return this._addon.reload() + .then(() => { + return {}; // send an empty response + }); + }, + + preNest: function () { + let e = Services.wm.getEnumerator(null); + while (e.hasMoreElements()) { + let win = e.getNext(); + let windowUtils = win.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDOMWindowUtils); + windowUtils.suppressEventHandling(true); + windowUtils.suspendTimeouts(); + } + }, + + postNest: function () { + let e = Services.wm.getEnumerator(null); + while (e.hasMoreElements()) { + let win = e.getNext(); + let windowUtils = win.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDOMWindowUtils); + windowUtils.resumeTimeouts(); + windowUtils.suppressEventHandling(false); + } + }, + + /** + * Return true if the given global is associated with this addon and should be + * added as a debuggee, false otherwise. + */ + _shouldAddNewGlobalAsDebuggee: function (aGlobal) { + const global = unwrapDebuggerObjectGlobal(aGlobal); + try { + // This will fail for non-Sandbox objects, hence the try-catch block. + let metadata = Cu.getSandboxMetadata(global); + if (metadata) { + return metadata.addonID === this.id; + } + } catch (e) {} + + if (global instanceof Ci.nsIDOMWindow) { + return mapURIToAddonID(global.document.documentURIObject) == this.id; + } + + // Check the global for a __URI__ property and then try to map that to an + // add-on + let uridescriptor = aGlobal.getOwnPropertyDescriptor("__URI__"); + if (uridescriptor && "value" in uridescriptor && uridescriptor.value) { + let uri; + try { + uri = Services.io.newURI(uridescriptor.value, null, null); + } + catch (e) { + DevToolsUtils.reportException( + "BrowserAddonActor.prototype._shouldAddNewGlobalAsDebuggee", + new Error("Invalid URI: " + uridescriptor.value) + ); + return false; + } + + if (mapURIToAddonID(uri) == this.id) { + return true; + } + } + + return false; + }, + + /** + * Override the eligibility check for scripts and sources to make + * sure every script and source with a URL is stored when debugging + * add-ons. + */ + _allowSource: function (aSource) { + // XPIProvider.jsm evals some code in every add-on's bootstrap.js. Hide it. + if (aSource.url === "resource://gre/modules/addons/XPIProvider.jsm") { + return false; + } + + return true; + }, + + /** + * Yield the current set of globals associated with this addon that should be + * added as debuggees. + */ + _findDebuggees: function (dbg) { + return dbg.findAllGlobals().filter(this._shouldAddNewGlobalAsDebuggee); + } +}; + +BrowserAddonActor.prototype.requestTypes = { + "attach": BrowserAddonActor.prototype.onAttach, + "detach": BrowserAddonActor.prototype.onDetach, + "reload": BrowserAddonActor.prototype.onReload +}; + +/** + * The AddonConsoleActor implements capabilities needed for the add-on web + * console feature. + * + * @constructor + * @param object aAddon + * The add-on that this console watches. + * @param object aConnection + * The connection to the client, DebuggerServerConnection. + * @param object aParentActor + * The parent BrowserAddonActor actor. + */ +function AddonConsoleActor(aAddon, aConnection, aParentActor) +{ + this.addon = aAddon; + WebConsoleActor.call(this, aConnection, aParentActor); +} + +AddonConsoleActor.prototype = Object.create(WebConsoleActor.prototype); + +update(AddonConsoleActor.prototype, { + constructor: AddonConsoleActor, + + actorPrefix: "addonConsole", + + /** + * The add-on that this console watches. + */ + addon: null, + + /** + * The main add-on JS global + */ + get window() { + return this.parentActor.global; + }, + + /** + * Destroy the current AddonConsoleActor instance. + */ + disconnect: function ACA_disconnect() + { + WebConsoleActor.prototype.disconnect.call(this); + this.addon = null; + }, + + /** + * Handler for the "startListeners" request. + * + * @param object aRequest + * The JSON request object received from the Web Console client. + * @return object + * The response object which holds the startedListeners array. + */ + onStartListeners: function ACA_onStartListeners(aRequest) + { + let startedListeners = []; + + while (aRequest.listeners.length > 0) { + let listener = aRequest.listeners.shift(); + switch (listener) { + case "ConsoleAPI": + if (!this.consoleAPIListener) { + this.consoleAPIListener = + new ConsoleAPIListener(null, this, { addonId: this.addon.id }); + this.consoleAPIListener.init(); + } + startedListeners.push(listener); + break; + } + } + return { + startedListeners: startedListeners, + nativeConsoleAPI: true, + traits: this.traits, + }; + }, +}); + +AddonConsoleActor.prototype.requestTypes = Object.create(WebConsoleActor.prototype.requestTypes); +AddonConsoleActor.prototype.requestTypes.startListeners = AddonConsoleActor.prototype.onStartListeners; diff --git a/devtools/server/actors/addons.js b/devtools/server/actors/addons.js new file mode 100644 index 0000000000..297a3a4380 --- /dev/null +++ b/devtools/server/actors/addons.js @@ -0,0 +1,41 @@ +/* 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 {AddonManager} = require("resource://gre/modules/AddonManager.jsm"); +const protocol = require("devtools/shared/protocol"); +const {FileUtils} = require("resource://gre/modules/FileUtils.jsm"); +const {Task} = require("devtools/shared/task"); +const {addonsSpec} = require("devtools/shared/specs/addons"); + +const AddonsActor = protocol.ActorClassWithSpec(addonsSpec, { + + initialize: function (conn) { + protocol.Actor.prototype.initialize.call(this, conn); + }, + + installTemporaryAddon: Task.async(function* (addonPath) { + let addonFile; + let addon; + try { + addonFile = new FileUtils.File(addonPath); + addon = yield AddonManager.installTemporaryAddon(addonFile); + } catch (error) { + throw new Error(`Could not install add-on at '${addonPath}': ${error}`); + } + + // TODO: once the add-on actor has been refactored to use + // protocol.js, we could return it directly. + // return new BrowserAddonActor(this.conn, addon); + + // Return a pseudo add-on object that a calling client can work + // with. Provide a flag that the client can use to detect when it + // gets upgraded to a real actor object. + return { id: addon.id, actor: false }; + + }), +}); + +exports.AddonsActor = AddonsActor; diff --git a/devtools/server/actors/animation.js b/devtools/server/actors/animation.js new file mode 100644 index 0000000000..642c4bcafb --- /dev/null +++ b/devtools/server/actors/animation.js @@ -0,0 +1,751 @@ +/* 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"; + +/** + * Set of actors that expose the Web Animations API to devtools protocol + * clients. + * + * The |Animations| actor is the main entry point. It is used to discover + * animation players on given nodes. + * There should only be one instance per debugger server. + * + * The |AnimationPlayer| actor provides attributes and methods to inspect an + * animation as well as pause/resume/seek it. + * + * The Web Animation spec implementation is ongoing in Gecko, and so this set + * of actors should evolve when the implementation progresses. + * + * References: + * - WebAnimation spec: + * http://w3c.github.io/web-animations/ + * - WebAnimation WebIDL files: + * /dom/webidl/Animation*.webidl + */ + +const {Cu} = require("chrome"); +const promise = require("promise"); +const {Task} = require("devtools/shared/task"); +const protocol = require("devtools/shared/protocol"); +const {Actor, ActorClassWithSpec} = protocol; +const {animationPlayerSpec, animationsSpec} = require("devtools/shared/specs/animation"); +const events = require("sdk/event/core"); + +// Types of animations. +const ANIMATION_TYPES = { + CSS_ANIMATION: "cssanimation", + CSS_TRANSITION: "csstransition", + SCRIPT_ANIMATION: "scriptanimation", + UNKNOWN: "unknown" +}; +exports.ANIMATION_TYPES = ANIMATION_TYPES; + +/** + * The AnimationPlayerActor provides information about a given animation: its + * startTime, currentTime, current state, etc. + * + * Since the state of a player changes as the animation progresses it is often + * useful to call getCurrentState at regular intervals to get the current state. + * + * This actor also allows playing, pausing and seeking the animation. + */ +var AnimationPlayerActor = protocol.ActorClassWithSpec(animationPlayerSpec, { + /** + * @param {AnimationsActor} The main AnimationsActor instance + * @param {AnimationPlayer} The player object returned by getAnimationPlayers + */ + initialize: function (animationsActor, player) { + Actor.prototype.initialize.call(this, animationsActor.conn); + + this.onAnimationMutation = this.onAnimationMutation.bind(this); + + this.walker = animationsActor.walker; + this.player = player; + + // Listen to animation mutations on the node to alert the front when the + // current animation changes. + // If the node is a pseudo-element, then we listen on its parent with + // subtree:true (there's no risk of getting too many notifications in + // onAnimationMutation since we filter out events that aren't for the + // current animation). + this.observer = new this.window.MutationObserver(this.onAnimationMutation); + if (this.isPseudoElement) { + this.observer.observe(this.node.parentElement, + {animations: true, subtree: true}); + } else { + this.observer.observe(this.node, {animations: true}); + } + }, + + destroy: function () { + // Only try to disconnect the observer if it's not already dead (i.e. if the + // container view hasn't navigated since). + if (this.observer && !Cu.isDeadWrapper(this.observer)) { + this.observer.disconnect(); + } + this.player = this.observer = this.walker = null; + + Actor.prototype.destroy.call(this); + }, + + get isPseudoElement() { + return !this.player.effect.target.ownerDocument; + }, + + get node() { + if (this._node) { + return this._node; + } + + let node = this.player.effect.target; + + if (this.isPseudoElement) { + // The target is a CSSPseudoElement object which just has a property that + // points to its parent element and a string type (::before or ::after). + let treeWalker = this.walker.getDocumentWalker(node.parentElement); + while (treeWalker.nextNode()) { + let currentNode = treeWalker.currentNode; + if ((currentNode.nodeName === "_moz_generated_content_before" && + node.type === "::before") || + (currentNode.nodeName === "_moz_generated_content_after" && + node.type === "::after")) { + this._node = currentNode; + } + } + } else { + // The target is a DOM node. + this._node = node; + } + + return this._node; + }, + + get window() { + return this.node.ownerDocument.defaultView; + }, + + /** + * Release the actor, when it isn't needed anymore. + * Protocol.js uses this release method to call the destroy method. + */ + release: function () {}, + + form: function (detail) { + if (detail === "actorid") { + return this.actorID; + } + + let data = this.getCurrentState(); + data.actor = this.actorID; + + // If we know the WalkerActor, and if the animated node is known by it, then + // return its corresponding NodeActor ID too. + if (this.walker && this.walker.hasNode(this.node)) { + data.animationTargetNodeActorID = this.walker.getNode(this.node).actorID; + } + + return data; + }, + + isCssAnimation: function (player = this.player) { + return player instanceof this.window.CSSAnimation; + }, + + isCssTransition: function (player = this.player) { + return player instanceof this.window.CSSTransition; + }, + + isScriptAnimation: function (player = this.player) { + return player instanceof this.window.Animation && !( + player instanceof this.window.CSSAnimation || + player instanceof this.window.CSSTransition + ); + }, + + getType: function () { + if (this.isCssAnimation()) { + return ANIMATION_TYPES.CSS_ANIMATION; + } else if (this.isCssTransition()) { + return ANIMATION_TYPES.CSS_TRANSITION; + } else if (this.isScriptAnimation()) { + return ANIMATION_TYPES.SCRIPT_ANIMATION; + } + + return ANIMATION_TYPES.UNKNOWN; + }, + + /** + * Get the name of this animation. This can be either the animation.id + * property if it was set, or the keyframe rule name or the transition + * property. + * @return {String} + */ + getName: function () { + if (this.player.id) { + return this.player.id; + } else if (this.isCssAnimation()) { + return this.player.animationName; + } else if (this.isCssTransition()) { + return this.player.transitionProperty; + } + + return ""; + }, + + /** + * Get the animation duration from this player, in milliseconds. + * @return {Number} + */ + getDuration: function () { + return this.player.effect.getComputedTiming().duration; + }, + + /** + * Get the animation delay from this player, in milliseconds. + * @return {Number} + */ + getDelay: function () { + return this.player.effect.getComputedTiming().delay; + }, + + /** + * Get the animation endDelay from this player, in milliseconds. + * @return {Number} + */ + getEndDelay: function () { + return this.player.effect.getComputedTiming().endDelay; + }, + + /** + * Get the animation iteration count for this player. That is, how many times + * is the animation scheduled to run. + * @return {Number} The number of iterations, or null if the animation repeats + * infinitely. + */ + getIterationCount: function () { + let iterations = this.player.effect.getComputedTiming().iterations; + return iterations === "Infinity" ? null : iterations; + }, + + /** + * Get the animation iterationStart from this player, in ratio. + * That is offset of starting position of the animation. + * @return {Number} + */ + getIterationStart: function () { + return this.player.effect.getComputedTiming().iterationStart; + }, + + /** + * Get the animation easing from this player. + * @return {String} + */ + getEasing: function () { + return this.player.effect.timing.easing; + }, + + /** + * Get the animation fill mode from this player. + * @return {String} + */ + getFill: function () { + return this.player.effect.getComputedTiming().fill; + }, + + /** + * Get the animation direction from this player. + * @return {String} + */ + getDirection: function () { + return this.player.effect.getComputedTiming().direction; + }, + + getPropertiesCompositorStatus: function () { + let properties = this.player.effect.getProperties(); + return properties.map(prop => { + return { + property: prop.property, + runningOnCompositor: prop.runningOnCompositor, + warning: prop.warning + }; + }); + }, + + /** + * Return the current start of the Animation. + * @return {Object} + */ + getState: function () { + // Remember the startTime each time getState is called, it may be useful + // when animations get paused. As in, when an animation gets paused, its + // startTime goes back to null, but the front-end might still be interested + // in knowing what the previous startTime was. So everytime it is set, + // remember it and send it along with the newState. + if (this.player.startTime) { + this.previousStartTime = this.player.startTime; + } + + // Note that if you add a new property to the state object, make sure you + // add the corresponding property in the AnimationPlayerFront' initialState + // getter. + return { + type: this.getType(), + // startTime is null whenever the animation is paused or waiting to start. + startTime: this.player.startTime, + previousStartTime: this.previousStartTime, + currentTime: this.player.currentTime, + playState: this.player.playState, + playbackRate: this.player.playbackRate, + name: this.getName(), + duration: this.getDuration(), + delay: this.getDelay(), + endDelay: this.getEndDelay(), + iterationCount: this.getIterationCount(), + iterationStart: this.getIterationStart(), + fill: this.getFill(), + easing: this.getEasing(), + direction: this.getDirection(), + // animation is hitting the fast path or not. Returns false whenever the + // animation is paused as it is taken off the compositor then. + isRunningOnCompositor: + this.getPropertiesCompositorStatus() + .some(propState => propState.runningOnCompositor), + propertyState: this.getPropertiesCompositorStatus(), + // The document timeline's currentTime is being sent along too. This is + // not strictly related to the node's animationPlayer, but is useful to + // know the current time of the animation with respect to the document's. + documentCurrentTime: this.node.ownerDocument.timeline.currentTime + }; + }, + + /** + * Get the current state of the AnimationPlayer (currentTime, playState, ...). + * Note that the initial state is returned as the form of this actor when it + * is initialized. + * This protocol method only returns a trimed down version of this state in + * case some properties haven't changed since last time (since the front can + * reconstruct those). If you want the full state, use the getState method. + * @return {Object} + */ + getCurrentState: function () { + let newState = this.getState(); + + // If we've saved a state before, compare and only send what has changed. + // It's expected of the front to also save old states to re-construct the + // full state when an incomplete one is received. + // This is to minimize protocol traffic. + let sentState = {}; + if (this.currentState) { + for (let key in newState) { + if (typeof this.currentState[key] === "undefined" || + this.currentState[key] !== newState[key]) { + sentState[key] = newState[key]; + } + } + } else { + sentState = newState; + } + this.currentState = newState; + + return sentState; + }, + + /** + * Executed when the current animation changes, used to emit the new state + * the the front. + */ + onAnimationMutation: function (mutations) { + let isCurrentAnimation = animation => animation === this.player; + let hasCurrentAnimation = animations => animations.some(isCurrentAnimation); + let hasChanged = false; + + for (let {removedAnimations, changedAnimations} of mutations) { + if (hasCurrentAnimation(removedAnimations)) { + // Reset the local copy of the state on removal, since the animation can + // be kept on the client and re-added, its state needs to be sent in + // full. + this.currentState = null; + } + + if (hasCurrentAnimation(changedAnimations)) { + // Only consider the state has having changed if any of delay, duration, + // iterationcount or iterationStart has changed (for now at least). + let newState = this.getState(); + let oldState = this.currentState; + hasChanged = newState.delay !== oldState.delay || + newState.iterationCount !== oldState.iterationCount || + newState.iterationStart !== oldState.iterationStart || + newState.duration !== oldState.duration || + newState.endDelay !== oldState.endDelay; + break; + } + } + + if (hasChanged) { + events.emit(this, "changed", this.getCurrentState()); + } + }, + + /** + * Pause the player. + */ + pause: function () { + this.player.pause(); + return this.player.ready; + }, + + /** + * Play the player. + * This method only returns when the animation has left its pending state. + */ + play: function () { + this.player.play(); + return this.player.ready; + }, + + /** + * Simply exposes the player ready promise. + * + * When an animation is created/paused then played, there's a short time + * during which its playState is pending, before being set to running. + * + * If you either created a new animation using the Web Animations API or + * paused/played an existing one, and then want to access the playState, you + * might be interested to call this method. + * This is especially important for tests. + */ + ready: function () { + return this.player.ready; + }, + + /** + * Set the current time of the animation player. + */ + setCurrentTime: function (currentTime) { + // The spec is that the progress of animation is changed + // if the time of setCurrentTime is during the endDelay. + // We should prevent the time + // to make the same animation behavior as the original. + // Likewise, in case the time is less than 0. + const timing = this.player.effect.getComputedTiming(); + if (timing.delay < 0) { + currentTime += timing.delay; + } + if (currentTime < 0) { + currentTime = 0; + } else if (currentTime * this.player.playbackRate > timing.endTime) { + currentTime = timing.endTime; + } + this.player.currentTime = currentTime * this.player.playbackRate; + }, + + /** + * Set the playback rate of the animation player. + */ + setPlaybackRate: function (playbackRate) { + this.player.playbackRate = playbackRate; + }, + + /** + * Get data about the keyframes of this animation player. + * @return {Object} Returns a list of frames, each frame containing the list + * animated properties as well as the frame's offset. + */ + getFrames: function () { + return this.player.effect.getKeyframes(); + }, + + /** + * Get data about the animated properties of this animation player. + * @return {Array} Returns a list of animated properties. + * Each property contains a list of values and their offsets + */ + getProperties: function () { + return this.player.effect.getProperties().map(property => { + return {name: property.property, values: property.values}; + }); + } +}); + +exports.AnimationPlayerActor = AnimationPlayerActor; + +/** + * The Animations actor lists animation players for a given node. + */ +var AnimationsActor = exports.AnimationsActor = protocol.ActorClassWithSpec(animationsSpec, { + initialize: function(conn, tabActor) { + Actor.prototype.initialize.call(this, conn); + this.tabActor = tabActor; + + this.onWillNavigate = this.onWillNavigate.bind(this); + this.onNavigate = this.onNavigate.bind(this); + this.onAnimationMutation = this.onAnimationMutation.bind(this); + + this.allAnimationsPaused = false; + events.on(this.tabActor, "will-navigate", this.onWillNavigate); + events.on(this.tabActor, "navigate", this.onNavigate); + }, + + destroy: function () { + Actor.prototype.destroy.call(this); + events.off(this.tabActor, "will-navigate", this.onWillNavigate); + events.off(this.tabActor, "navigate", this.onNavigate); + + this.stopAnimationPlayerUpdates(); + this.tabActor = this.observer = this.actors = this.walker = null; + }, + + /** + * Since AnimationsActor doesn't have a protocol.js parent actor that takes + * care of its lifetime, implementing disconnect is required to cleanup. + */ + disconnect: function () { + this.destroy(); + }, + + /** + * Clients can optionally call this with a reference to their WalkerActor. + * If they do, then AnimationPlayerActor's forms are going to also include + * NodeActor IDs when the corresponding NodeActors do exist. + * This, in turns, is helpful for clients to avoid having to go back once more + * to the server to get a NodeActor for a particular animation. + * @param {WalkerActor} walker + */ + setWalkerActor: function (walker) { + this.walker = walker; + }, + + /** + * Retrieve the list of AnimationPlayerActor actors for currently running + * animations on a node and its descendants. + * Note that calling this method a second time will destroy all previously + * retrieved AnimationPlayerActors. Indeed, the lifecycle of these actors + * is managed here on the server and tied to getAnimationPlayersForNode + * being called. + * @param {NodeActor} nodeActor The NodeActor as defined in + * /devtools/server/actors/inspector + */ + getAnimationPlayersForNode: function (nodeActor) { + let animations = nodeActor.rawNode.getAnimations({subtree: true}); + + // Destroy previously stored actors + if (this.actors) { + this.actors.forEach(actor => actor.destroy()); + } + this.actors = []; + + for (let i = 0; i < animations.length; i++) { + let actor = AnimationPlayerActor(this, animations[i]); + this.actors.push(actor); + } + + // When a front requests the list of players for a node, start listening + // for animation mutations on this node to send updates to the front, until + // either getAnimationPlayersForNode is called again or + // stopAnimationPlayerUpdates is called. + this.stopAnimationPlayerUpdates(); + let win = nodeActor.rawNode.ownerDocument.defaultView; + this.observer = new win.MutationObserver(this.onAnimationMutation); + this.observer.observe(nodeActor.rawNode, { + animations: true, + subtree: true + }); + + return this.actors; + }, + + onAnimationMutation: function (mutations) { + let eventData = []; + let readyPromises = []; + + for (let {addedAnimations, removedAnimations} of mutations) { + for (let player of removedAnimations) { + // Note that animations are reported as removed either when they are + // actually removed from the node (e.g. css class removed) or when they + // are finished and don't have forwards animation-fill-mode. + // In the latter case, we don't send an event, because the corresponding + // animation can still be seeked/resumed, so we want the client to keep + // its reference to the AnimationPlayerActor. + if (player.playState !== "idle") { + continue; + } + + let index = this.actors.findIndex(a => a.player === player); + if (index !== -1) { + eventData.push({ + type: "removed", + player: this.actors[index] + }); + this.actors.splice(index, 1); + } + } + + for (let player of addedAnimations) { + // If the added player already exists, it means we previously filtered + // it out when it was reported as removed. So filter it out here too. + if (this.actors.find(a => a.player === player)) { + continue; + } + + // If the added player has the same name and target node as a player we + // already have, it means it's a transition that's re-starting. So send + // a "removed" event for the one we already have. + let index = this.actors.findIndex(a => { + let isSameType = a.player.constructor === player.constructor; + let isSameName = (a.isCssAnimation() && + a.player.animationName === player.animationName) || + (a.isCssTransition() && + a.player.transitionProperty === player.transitionProperty); + let isSameNode = a.player.effect.target === player.effect.target; + + return isSameType && isSameNode && isSameName; + }); + if (index !== -1) { + eventData.push({ + type: "removed", + player: this.actors[index] + }); + this.actors.splice(index, 1); + } + + let actor = AnimationPlayerActor(this, player); + this.actors.push(actor); + eventData.push({ + type: "added", + player: actor + }); + readyPromises.push(player.ready); + } + } + + if (eventData.length) { + // Let's wait for all added animations to be ready before telling the + // front-end. + Promise.all(readyPromises).then(() => { + events.emit(this, "mutations", eventData); + }); + } + }, + + /** + * After the client has called getAnimationPlayersForNode for a given DOM + * node, the actor starts sending animation mutations for this node. If the + * client doesn't want this to happen anymore, it should call this method. + */ + stopAnimationPlayerUpdates: function () { + if (this.observer && !Cu.isDeadWrapper(this.observer)) { + this.observer.disconnect(); + } + }, + + /** + * Iterates through all nodes below a given rootNode (optionally also in + * nested frames) and finds all existing animation players. + * @param {DOMNode} rootNode The root node to start iterating at. Animation + * players will *not* be reported for this node. + * @param {Boolean} traverseFrames Whether we should iterate through nested + * frames too. + * @return {Array} An array of AnimationPlayer objects. + */ + getAllAnimations: function (rootNode, traverseFrames) { + if (!traverseFrames) { + return rootNode.getAnimations({subtree: true}); + } + + let animations = []; + for (let {document} of this.tabActor.windows) { + animations = [...animations, ...document.getAnimations({subtree: true})]; + } + return animations; + }, + + onWillNavigate: function ({isTopLevel}) { + if (isTopLevel) { + this.stopAnimationPlayerUpdates(); + } + }, + + onNavigate: function ({isTopLevel}) { + if (isTopLevel) { + this.allAnimationsPaused = false; + } + }, + + /** + * Pause all animations in the current tabActor's frames. + */ + pauseAll: function () { + let readyPromises = []; + // Until the WebAnimations API provides a way to play/pause via the document + // timeline, we have to iterate through the whole DOM to find all players. + for (let player of + this.getAllAnimations(this.tabActor.window.document, true)) { + player.pause(); + readyPromises.push(player.ready); + } + this.allAnimationsPaused = true; + return promise.all(readyPromises); + }, + + /** + * Play all animations in the current tabActor's frames. + * This method only returns when animations have left their pending states. + */ + playAll: function () { + let readyPromises = []; + // Until the WebAnimations API provides a way to play/pause via the document + // timeline, we have to iterate through the whole DOM to find all players. + for (let player of + this.getAllAnimations(this.tabActor.window.document, true)) { + player.play(); + readyPromises.push(player.ready); + } + this.allAnimationsPaused = false; + return promise.all(readyPromises); + }, + + toggleAll: function () { + if (this.allAnimationsPaused) { + return this.playAll(); + } + return this.pauseAll(); + }, + + /** + * Toggle (play/pause) several animations at the same time. + * @param {Array} players A list of AnimationPlayerActor objects. + * @param {Boolean} shouldPause If set to true, the players will be paused, + * otherwise they will be played. + */ + toggleSeveral: function (players, shouldPause) { + return promise.all(players.map(player => { + return shouldPause ? player.pause() : player.play(); + })); + }, + + /** + * Set the current time of several animations at the same time. + * @param {Array} players A list of AnimationPlayerActor. + * @param {Number} time The new currentTime. + * @param {Boolean} shouldPause Should the players be paused too. + */ + setCurrentTimes: function (players, time, shouldPause) { + return promise.all(players.map(player => { + let pause = shouldPause ? player.pause() : promise.resolve(); + return pause.then(() => player.setCurrentTime(time)); + })); + }, + + /** + * Set the playback rate of several animations at the same time. + * @param {Array} players A list of AnimationPlayerActor. + * @param {Number} rate The new rate. + */ + setPlaybackRates: function (players, rate) { + for (let player of players) { + player.setPlaybackRate(rate); + } + } +}); diff --git a/devtools/server/actors/breakpoint.js b/devtools/server/actors/breakpoint.js new file mode 100644 index 0000000000..547dcd0f14 --- /dev/null +++ b/devtools/server/actors/breakpoint.js @@ -0,0 +1,189 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2; js-indent-level: 2 -*- */ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* 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 { ActorClassWithSpec } = require("devtools/shared/protocol"); +const { breakpointSpec } = require("devtools/shared/specs/breakpoint"); + +/** + * Set breakpoints on all the given entry points with the given + * BreakpointActor as the handler. + * + * @param BreakpointActor actor + * The actor handling the breakpoint hits. + * @param Array entryPoints + * An array of objects of the form `{ script, offsets }`. + */ +function setBreakpointAtEntryPoints(actor, entryPoints) { + for (let { script, offsets } of entryPoints) { + actor.addScript(script); + for (let offset of offsets) { + script.setBreakpoint(offset, actor); + } + } +} + +exports.setBreakpointAtEntryPoints = setBreakpointAtEntryPoints; + +/** + * BreakpointActors exist for the lifetime of their containing thread and are + * responsible for deleting breakpoints, handling breakpoint hits and + * associating breakpoints with scripts. + */ +let BreakpointActor = ActorClassWithSpec(breakpointSpec, { + /** + * Create a Breakpoint actor. + * + * @param ThreadActor threadActor + * The parent thread actor that contains this breakpoint. + * @param OriginalLocation originalLocation + * The original location of the breakpoint. + */ + initialize: function (threadActor, originalLocation) { + // The set of Debugger.Script instances that this breakpoint has been set + // upon. + this.scripts = new Set(); + + this.threadActor = threadActor; + this.originalLocation = originalLocation; + this.condition = null; + this.isPending = true; + }, + + disconnect: function () { + this.removeScripts(); + }, + + hasScript: function (script) { + return this.scripts.has(script); + }, + + /** + * Called when this same breakpoint is added to another Debugger.Script + * instance. + * + * @param script Debugger.Script + * The new source script on which the breakpoint has been set. + */ + addScript: function (script) { + this.scripts.add(script); + this.isPending = false; + }, + + /** + * Remove the breakpoints from associated scripts and clear the script cache. + */ + removeScripts: function () { + for (let script of this.scripts) { + script.clearBreakpoint(this); + } + this.scripts.clear(); + }, + + /** + * Check if this breakpoint has a condition that doesn't error and + * evaluates to true in frame. + * + * @param frame Debugger.Frame + * The frame to evaluate the condition in + * @returns Object + * - result: boolean|undefined + * True when the conditional breakpoint should trigger a pause, + * false otherwise. If the condition evaluation failed/killed, + * `result` will be `undefined`. + * - message: string + * If the condition throws, this is the thrown message. + */ + checkCondition: function (frame) { + let completion = frame.eval(this.condition); + if (completion) { + if (completion.throw) { + // The evaluation failed and threw + let message = "Unknown exception"; + try { + if (completion.throw.getOwnPropertyDescriptor) { + message = completion.throw.getOwnPropertyDescriptor("message") + .value; + } else if (completion.toString) { + message = completion.toString(); + } + } catch (ex) {} + return { + result: true, + message: message + }; + } else if (completion.yield) { + assert(false, "Shouldn't ever get yield completions from an eval"); + } else { + return { result: completion.return ? true : false }; + } + } else { + // The evaluation was killed (possibly by the slow script dialog) + return { result: undefined }; + } + }, + + /** + * A function that the engine calls when a breakpoint has been hit. + * + * @param frame Debugger.Frame + * The stack frame that contained the breakpoint. + */ + hit: function (frame) { + // Don't pause if we are currently stepping (in or over) or the frame is + // black-boxed. + let generatedLocation = this.threadActor.sources.getFrameLocation(frame); + let { originalSourceActor } = this.threadActor.unsafeSynchronize( + this.threadActor.sources.getOriginalLocation(generatedLocation)); + let url = originalSourceActor.url; + + if (this.threadActor.sources.isBlackBoxed(url) + || frame.onStep) { + return undefined; + } + + let reason = {}; + + if (this.threadActor._hiddenBreakpoints.has(this.actorID)) { + reason.type = "pauseOnDOMEvents"; + } else if (!this.condition) { + reason.type = "breakpoint"; + // TODO: add the rest of the breakpoints on that line (bug 676602). + reason.actors = [ this.actorID ]; + } else { + let { result, message } = this.checkCondition(frame); + + if (result) { + if (!message) { + reason.type = "breakpoint"; + } else { + reason.type = "breakpointConditionThrown"; + reason.message = message; + } + reason.actors = [ this.actorID ]; + } else { + return undefined; + } + } + return this.threadActor._pauseAndRespond(frame, reason); + }, + + /** + * Handle a protocol request to remove this breakpoint. + */ + delete: function () { + // Remove from the breakpoint store. + if (this.originalLocation) { + this.threadActor.breakpointActorMap.deleteActor(this.originalLocation); + } + this.threadActor.threadLifetimePool.removeActor(this); + // Remove the actual breakpoint from the associated scripts. + this.removeScripts(); + } +}); + +exports.BreakpointActor = BreakpointActor; diff --git a/devtools/server/actors/call-watcher.js b/devtools/server/actors/call-watcher.js new file mode 100644 index 0000000000..5729f95089 --- /dev/null +++ b/devtools/server/actors/call-watcher.js @@ -0,0 +1,634 @@ +/* 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 {Cc, Ci, Cu, Cr} = require("chrome"); +const events = require("sdk/event/core"); +const protocol = require("devtools/shared/protocol"); +const {serializeStack, parseStack} = require("toolkit/loader"); + +const {on, once, off, emit} = events; +const {method, Arg, Option, RetVal} = protocol; + +const { functionCallSpec, callWatcherSpec } = require("devtools/shared/specs/call-watcher"); +const { CallWatcherFront } = require("devtools/shared/fronts/call-watcher"); + +/** + * This actor contains information about a function call, like the function + * type, name, stack, arguments, returned value etc. + */ +var FunctionCallActor = protocol.ActorClassWithSpec(functionCallSpec, { + /** + * Creates the function call actor. + * + * @param DebuggerServerConnection conn + * The server connection. + * @param DOMWindow window + * The content window. + * @param string global + * The name of the global object owning this function, like + * "CanvasRenderingContext2D" or "WebGLRenderingContext". + * @param object caller + * The object owning the function when it was called. + * For example, in `foo.bar()`, the caller is `foo`. + * @param number type + * Either METHOD_FUNCTION, METHOD_GETTER or METHOD_SETTER. + * @param string name + * The called function's name. + * @param array stack + * The called function's stack, as a list of { name, file, line } objects. + * @param number timestamp + * The performance.now() timestamp when the function was called. + * @param array args + * The called function's arguments. + * @param any result + * The value returned by the function call. + * @param boolean holdWeak + * Determines whether or not FunctionCallActor stores a weak reference + * to the underlying objects. + */ + initialize: function (conn, [window, global, caller, type, name, stack, timestamp, args, result], holdWeak) { + protocol.Actor.prototype.initialize.call(this, conn); + + this.details = { + global: global, + type: type, + name: name, + stack: stack, + timestamp: timestamp + }; + + // Store a weak reference to all objects so we don't + // prevent natural GC if `holdWeak` was passed into + // setup as truthy. + if (holdWeak) { + let weakRefs = { + window: Cu.getWeakReference(window), + caller: Cu.getWeakReference(caller), + args: Cu.getWeakReference(args), + result: Cu.getWeakReference(result), + }; + + Object.defineProperties(this.details, { + window: { get: () => weakRefs.window.get() }, + caller: { get: () => weakRefs.caller.get() }, + args: { get: () => weakRefs.args.get() }, + result: { get: () => weakRefs.result.get() }, + }); + } + // Otherwise, hold strong references to the objects. + else { + this.details.window = window; + this.details.caller = caller; + this.details.args = args; + this.details.result = result; + } + + // The caller, args and results are string names for now. It would + // certainly be nicer if they were Object actors. Make this smarter, so + // that the frontend can inspect each argument, be it object or primitive. + // Bug 978960. + this.details.previews = { + caller: this._generateStringPreview(caller), + args: this._generateArgsPreview(args), + result: this._generateStringPreview(result) + }; + }, + + /** + * Customize the marshalling of this actor to provide some generic information + * directly on the Front instance. + */ + form: function () { + return { + actor: this.actorID, + type: this.details.type, + name: this.details.name, + file: this.details.stack[0].file, + line: this.details.stack[0].line, + timestamp: this.details.timestamp, + callerPreview: this.details.previews.caller, + argsPreview: this.details.previews.args, + resultPreview: this.details.previews.result + }; + }, + + /** + * Gets more information about this function call, which is not necessarily + * available on the Front instance. + */ + getDetails: function () { + let { type, name, stack, timestamp } = this.details; + + // Since not all calls on the stack have corresponding owner files (e.g. + // callbacks of a requestAnimationFrame etc.), there's no benefit in + // returning them, as the user can't jump to the Debugger from them. + for (let i = stack.length - 1; ;) { + if (stack[i].file) { + break; + } + stack.pop(); + i--; + } + + // XXX: Use grips for objects and serialize them properly, in order + // to add the function's caller, arguments and return value. Bug 978957. + return { + type: type, + name: name, + stack: stack, + timestamp: timestamp + }; + }, + + /** + * Serializes the arguments so that they can be easily be transferred + * as a string, but still be useful when displayed in a potential UI. + * + * @param array args + * The source arguments. + * @return string + * The arguments as a string. + */ + _generateArgsPreview: function (args) { + let { global, name, caller } = this.details; + + // Get method signature to determine if there are any enums + // used in this method. + let methodSignatureEnums; + + let knownGlobal = CallWatcherFront.KNOWN_METHODS[global]; + if (knownGlobal) { + let knownMethod = knownGlobal[name]; + if (knownMethod) { + let isOverloaded = typeof knownMethod.enums === "function"; + if (isOverloaded) { + methodSignatureEnums = methodSignatureEnums(args); + } else { + methodSignatureEnums = knownMethod.enums; + } + } + } + + let serializeArgs = () => args.map((arg, i) => { + // XXX: Bug 978960. + if (arg === undefined) { + return "undefined"; + } + if (arg === null) { + return "null"; + } + if (typeof arg == "function") { + return "Function"; + } + if (typeof arg == "object") { + return "Object"; + } + // If this argument matches the method's signature + // and is an enum, change it to its constant name. + if (methodSignatureEnums && methodSignatureEnums.has(i)) { + return getBitToEnumValue(global, caller, arg); + } + return arg + ""; + }); + + return serializeArgs().join(", "); + }, + + /** + * Serializes the data so that it can be easily be transferred + * as a string, but still be useful when displayed in a potential UI. + * + * @param object data + * The source data. + * @return string + * The arguments as a string. + */ + _generateStringPreview: function (data) { + // XXX: Bug 978960. + if (data === undefined) { + return "undefined"; + } + if (data === null) { + return "null"; + } + if (typeof data == "function") { + return "Function"; + } + if (typeof data == "object") { + return "Object"; + } + return data + ""; + } +}); + +/** + * This actor observes function calls on certain objects or globals. + */ +var CallWatcherActor = exports.CallWatcherActor = protocol.ActorClassWithSpec(callWatcherSpec, { + initialize: function (conn, tabActor) { + protocol.Actor.prototype.initialize.call(this, conn); + this.tabActor = tabActor; + this._onGlobalCreated = this._onGlobalCreated.bind(this); + this._onGlobalDestroyed = this._onGlobalDestroyed.bind(this); + this._onContentFunctionCall = this._onContentFunctionCall.bind(this); + on(this.tabActor, "window-ready", this._onGlobalCreated); + on(this.tabActor, "window-destroyed", this._onGlobalDestroyed); + }, + destroy: function (conn) { + protocol.Actor.prototype.destroy.call(this, conn); + off(this.tabActor, "window-ready", this._onGlobalCreated); + off(this.tabActor, "window-destroyed", this._onGlobalDestroyed); + this.finalize(); + }, + + /** + * Lightweight listener invoked whenever an instrumented function is called + * while recording. We're doing this to avoid the event emitter overhead, + * since this is expected to be a very hot function. + */ + onCall: null, + + /** + * Starts waiting for the current tab actor's document global to be + * created, in order to instrument the specified objects and become + * aware of everything the content does with them. + */ + setup: function ({ tracedGlobals, tracedFunctions, startRecording, performReload, holdWeak, storeCalls }) { + if (this._initialized) { + return; + } + this._initialized = true; + this._timestampEpoch = 0; + + this._functionCalls = []; + this._tracedGlobals = tracedGlobals || []; + this._tracedFunctions = tracedFunctions || []; + this._holdWeak = !!holdWeak; + this._storeCalls = !!storeCalls; + + if (startRecording) { + this.resumeRecording(); + } + if (performReload) { + this.tabActor.window.location.reload(); + } + }, + + /** + * Stops listening for document global changes and puts this actor + * to hibernation. This method is called automatically just before the + * actor is destroyed. + */ + finalize: function () { + if (!this._initialized) { + return; + } + this._initialized = false; + this._finalized = true; + + this._tracedGlobals = null; + this._tracedFunctions = null; + }, + + /** + * Returns whether the instrumented function calls are currently recorded. + */ + isRecording: function () { + return this._recording; + }, + + /** + * Initialize the timestamp epoch used to offset function call timestamps. + */ + initTimestampEpoch: function () { + this._timestampEpoch = this.tabActor.window.performance.now(); + }, + + /** + * Starts recording function calls. + */ + resumeRecording: function () { + this._recording = true; + }, + + /** + * Stops recording function calls. + */ + pauseRecording: function () { + this._recording = false; + return this._functionCalls; + }, + + /** + * Erases all the recorded function calls. + * Calling `resumeRecording` or `pauseRecording` does not erase history. + */ + eraseRecording: function () { + this._functionCalls = []; + }, + + /** + * Invoked whenever the current tab actor's document global is created. + */ + _onGlobalCreated: function ({window, id, isTopLevel}) { + if (!this._initialized) { + return; + } + + // TODO: bug 981748, support more than just the top-level documents. + if (!isTopLevel) { + return; + } + + let self = this; + this._tracedWindowId = id; + + let unwrappedWindow = XPCNativeWrapper.unwrap(window); + let callback = this._onContentFunctionCall; + + for (let global of this._tracedGlobals) { + let prototype = unwrappedWindow[global].prototype; + let properties = Object.keys(prototype); + properties.forEach(name => overrideSymbol(global, prototype, name, callback)); + } + + for (let name of this._tracedFunctions) { + overrideSymbol("window", unwrappedWindow, name, callback); + } + + /** + * Instruments a method, getter or setter on the specified target object to + * invoke a callback whenever it is called. + */ + function overrideSymbol(global, target, name, callback) { + let propertyDescriptor = Object.getOwnPropertyDescriptor(target, name); + + if (propertyDescriptor.get || propertyDescriptor.set) { + overrideAccessor(global, target, name, propertyDescriptor, callback); + return; + } + if (propertyDescriptor.writable && typeof propertyDescriptor.value == "function") { + overrideFunction(global, target, name, propertyDescriptor, callback); + return; + } + } + + /** + * Instruments a function on the specified target object. + */ + function overrideFunction(global, target, name, descriptor, callback) { + // Invoking .apply on an unxrayed content function doesn't work, because + // the arguments array is inaccessible to it. Get Xrays back. + let originalFunc = Cu.unwaiveXrays(target[name]); + + Cu.exportFunction(function (...args) { + let result; + try { + result = Cu.waiveXrays(originalFunc.apply(this, args)); + } catch (e) { + throw createContentError(e, unwrappedWindow); + } + + if (self._recording) { + let type = CallWatcherFront.METHOD_FUNCTION; + let stack = getStack(name); + let timestamp = self.tabActor.window.performance.now() - self._timestampEpoch; + callback(unwrappedWindow, global, this, type, name, stack, timestamp, args, result); + } + return result; + }, target, { defineAs: name }); + + Object.defineProperty(target, name, { + configurable: descriptor.configurable, + enumerable: descriptor.enumerable, + writable: true + }); + } + + /** + * Instruments a getter or setter on the specified target object. + */ + function overrideAccessor(global, target, name, descriptor, callback) { + // Invoking .apply on an unxrayed content function doesn't work, because + // the arguments array is inaccessible to it. Get Xrays back. + let originalGetter = Cu.unwaiveXrays(target.__lookupGetter__(name)); + let originalSetter = Cu.unwaiveXrays(target.__lookupSetter__(name)); + + Object.defineProperty(target, name, { + get: function (...args) { + if (!originalGetter) return undefined; + let result = Cu.waiveXrays(originalGetter.apply(this, args)); + + if (self._recording) { + let type = CallWatcherFront.GETTER_FUNCTION; + let stack = getStack(name); + let timestamp = self.tabActor.window.performance.now() - self._timestampEpoch; + callback(unwrappedWindow, global, this, type, name, stack, timestamp, args, result); + } + return result; + }, + set: function (...args) { + if (!originalSetter) return; + originalSetter.apply(this, args); + + if (self._recording) { + let type = CallWatcherFront.SETTER_FUNCTION; + let stack = getStack(name); + let timestamp = self.tabActor.window.performance.now() - self._timestampEpoch; + callback(unwrappedWindow, global, this, type, name, stack, timestamp, args, undefined); + } + }, + configurable: descriptor.configurable, + enumerable: descriptor.enumerable + }); + } + + /** + * Stores the relevant information about calls on the stack when + * a function is called. + */ + function getStack(caller) { + try { + // Using Components.stack wouldn't be a better idea, since it's + // much slower because it attempts to retrieve the C++ stack as well. + throw new Error(); + } catch (e) { + var stack = e.stack; + } + + // Of course, using a simple regex like /(.*?)@(.*):(\d*):\d*/ would be + // much prettier, but this is a very hot function, so let's sqeeze + // every drop of performance out of it. + let calls = []; + let callIndex = 0; + let currNewLinePivot = stack.indexOf("\n") + 1; + let nextNewLinePivot = stack.indexOf("\n", currNewLinePivot); + + while (nextNewLinePivot > 0) { + let nameDelimiterIndex = stack.indexOf("@", currNewLinePivot); + let columnDelimiterIndex = stack.lastIndexOf(":", nextNewLinePivot - 1); + let lineDelimiterIndex = stack.lastIndexOf(":", columnDelimiterIndex - 1); + + if (!calls[callIndex]) { + calls[callIndex] = { name: "", file: "", line: 0 }; + } + if (!calls[callIndex + 1]) { + calls[callIndex + 1] = { name: "", file: "", line: 0 }; + } + + if (callIndex > 0) { + let file = stack.substring(nameDelimiterIndex + 1, lineDelimiterIndex); + let line = stack.substring(lineDelimiterIndex + 1, columnDelimiterIndex); + let name = stack.substring(currNewLinePivot, nameDelimiterIndex); + calls[callIndex].name = name; + calls[callIndex - 1].file = file; + calls[callIndex - 1].line = line; + } else { + // Since the topmost stack frame is actually our overwritten function, + // it will not have the expected name. + calls[0].name = caller; + } + + currNewLinePivot = nextNewLinePivot + 1; + nextNewLinePivot = stack.indexOf("\n", currNewLinePivot); + callIndex++; + } + + return calls; + } + }, + + /** + * Invoked whenever the current tab actor's inner window is destroyed. + */ + _onGlobalDestroyed: function ({window, id, isTopLevel}) { + if (this._tracedWindowId == id) { + this.pauseRecording(); + this.eraseRecording(); + this._timestampEpoch = 0; + } + }, + + /** + * Invoked whenever an instrumented function is called. + */ + _onContentFunctionCall: function (...details) { + // If the consuming tool has finalized call-watcher, ignore the + // still-instrumented calls. + if (this._finalized) { + return; + } + + let functionCall = new FunctionCallActor(this.conn, details, this._holdWeak); + + if (this._storeCalls) { + this._functionCalls.push(functionCall); + } + + if (this.onCall) { + this.onCall(functionCall); + } else { + emit(this, "call", functionCall); + } + } +}); + +/** + * A lookup table for cross-referencing flags or properties with their name + * assuming they look LIKE_THIS most of the time. + * + * For example, when gl.clear(gl.COLOR_BUFFER_BIT) is called, the actual passed + * argument's value is 16384, which we want identified as "COLOR_BUFFER_BIT". + */ +var gEnumRegex = /^[A-Z][A-Z0-9_]+$/; +var gEnumsLookupTable = {}; + +// These values are returned from errors, or empty values, +// and need to be ignored when checking arguments due to the bitwise math. +var INVALID_ENUMS = [ + "INVALID_ENUM", "NO_ERROR", "INVALID_VALUE", "OUT_OF_MEMORY", "NONE" +]; + +function getBitToEnumValue(type, object, arg) { + let table = gEnumsLookupTable[type]; + + // If mapping not yet created, do it on the first run. + if (!table) { + table = gEnumsLookupTable[type] = {}; + + for (let key in object) { + if (key.match(gEnumRegex)) { + // Maps `16384` to `"COLOR_BUFFER_BIT"`, etc. + table[object[key]] = key; + } + } + } + + // If a single bit value, just return it. + if (table[arg]) { + return table[arg]; + } + + // Otherwise, attempt to reduce it to the original bit flags: + // `16640` -> "COLOR_BUFFER_BIT | DEPTH_BUFFER_BIT" + let flags = []; + for (let flag in table) { + if (INVALID_ENUMS.indexOf(table[flag]) !== -1) { + continue; + } + + // Cast to integer as all values are stored as strings + // in `table` + flag = flag | 0; + if (flag && (arg & flag) === flag) { + flags.push(table[flag]); + } + } + + // Cache the combined bitmask value + return table[arg] = flags.join(" | ") || arg; +} + +/** + * Creates a new error from an error that originated from content but was called + * from a wrapped overridden method. This is so we can make our own error + * that does not look like it originated from the call watcher. + * + * We use toolkit/loader's parseStack and serializeStack rather than the + * parsing done in the local `getStack` function, because it does not expose + * column number, would have to change the protocol models `call-stack-items` and `call-details` + * which hurts backwards compatibility, and the local `getStack` is an optimized, hot function. + */ +function createContentError(e, win) { + let { message, name, stack } = e; + let parsedStack = parseStack(stack); + let { fileName, lineNumber, columnNumber } = parsedStack[parsedStack.length - 1]; + let error; + + let isDOMException = e instanceof Ci.nsIDOMDOMException; + let constructor = isDOMException ? win.DOMException : (win[e.name] || win.Error); + + if (isDOMException) { + error = new constructor(message, name); + Object.defineProperties(error, { + code: { value: e.code }, + columnNumber: { value: 0 }, // columnNumber is always 0 for DOMExceptions? + filename: { value: fileName }, // note the lowercase `filename` + lineNumber: { value: lineNumber }, + result: { value: e.result }, + stack: { value: serializeStack(parsedStack) } + }); + } + else { + // Constructing an error here retains all the stack information, + // and we can add message, fileName and lineNumber via constructor, though + // need to manually add columnNumber. + error = new constructor(message, fileName, lineNumber); + Object.defineProperty(error, "columnNumber", { + value: columnNumber + }); + } + return error; +} diff --git a/devtools/server/actors/canvas.js b/devtools/server/actors/canvas.js new file mode 100644 index 0000000000..f6e1f57ecf --- /dev/null +++ b/devtools/server/actors/canvas.js @@ -0,0 +1,728 @@ +/* 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 {Cc, Ci, Cu, Cr} = require("chrome"); +const events = require("sdk/event/core"); +const promise = require("promise"); +const protocol = require("devtools/shared/protocol"); +const {CallWatcherActor} = require("devtools/server/actors/call-watcher"); +const {CallWatcherFront} = require("devtools/shared/fronts/call-watcher"); +const DevToolsUtils = require("devtools/shared/DevToolsUtils"); +const {WebGLPrimitiveCounter} = require("devtools/server/primitive"); +const { + frameSnapshotSpec, + canvasSpec, + CANVAS_CONTEXTS, + ANIMATION_GENERATORS, + LOOP_GENERATORS, + DRAW_CALLS, + INTERESTING_CALLS, +} = require("devtools/shared/specs/canvas"); +const {CanvasFront} = require("devtools/shared/fronts/canvas"); + +const {on, once, off, emit} = events; +const {method, custom, Arg, Option, RetVal} = protocol; + +/** + * This actor represents a recorded animation frame snapshot, along with + * all the corresponding canvas' context methods invoked in that frame, + * thumbnails for each draw call and a screenshot of the end result. + */ +var FrameSnapshotActor = protocol.ActorClassWithSpec(frameSnapshotSpec, { + /** + * Creates the frame snapshot call actor. + * + * @param DebuggerServerConnection conn + * The server connection. + * @param HTMLCanvasElement canvas + * A reference to the content canvas. + * @param array calls + * An array of "function-call" actor instances. + * @param object screenshot + * A single "snapshot-image" type instance. + */ + initialize: function (conn, { canvas, calls, screenshot, primitive }) { + protocol.Actor.prototype.initialize.call(this, conn); + this._contentCanvas = canvas; + this._functionCalls = calls; + this._animationFrameEndScreenshot = screenshot; + this._primitive = primitive; + }, + + /** + * Gets as much data about this snapshot without computing anything costly. + */ + getOverview: function () { + return { + calls: this._functionCalls, + thumbnails: this._functionCalls.map(e => e._thumbnail).filter(e => !!e), + screenshot: this._animationFrameEndScreenshot, + primitive: { + tris: this._primitive.tris, + vertices: this._primitive.vertices, + points: this._primitive.points, + lines: this._primitive.lines + } + }; + }, + + /** + * Gets a screenshot of the canvas's contents after the specified + * function was called. + */ + generateScreenshotFor: function (functionCall) { + let caller = functionCall.details.caller; + let global = functionCall.details.global; + + let canvas = this._contentCanvas; + let calls = this._functionCalls; + let index = calls.indexOf(functionCall); + + // To get a screenshot, replay all the steps necessary to render the frame, + // by invoking the context calls up to and including the specified one. + // This will be done in a custom framebuffer in case of a WebGL context. + let replayData = ContextUtils.replayAnimationFrame({ + contextType: global, + canvas: canvas, + calls: calls, + first: 0, + last: index + }); + + let { replayContext, replayContextScaling, lastDrawCallIndex, doCleanup } = replayData; + let [left, top, width, height] = replayData.replayViewport; + let screenshot; + + // Depending on the canvas' context, generating a screenshot is done + // in different ways. + if (global == "WebGLRenderingContext") { + screenshot = ContextUtils.getPixelsForWebGL(replayContext, left, top, width, height); + screenshot.flipped = true; + } else if (global == "CanvasRenderingContext2D") { + screenshot = ContextUtils.getPixelsFor2D(replayContext, left, top, width, height); + screenshot.flipped = false; + } + + // In case of the WebGL context, we also need to reset the framebuffer + // binding to the original value, after generating the screenshot. + doCleanup(); + + screenshot.scaling = replayContextScaling; + screenshot.index = lastDrawCallIndex; + return screenshot; + } +}); + +/** + * This Canvas Actor handles simple instrumentation of all the methods + * of a 2D or WebGL context, to provide information regarding all the calls + * made when drawing frame inside an animation loop. + */ +var CanvasActor = exports.CanvasActor = protocol.ActorClassWithSpec(canvasSpec, { + // Reset for each recording, boolean indicating whether or not + // any draw calls were called for a recording. + _animationContainsDrawCall: false, + + initialize: function (conn, tabActor) { + protocol.Actor.prototype.initialize.call(this, conn); + this.tabActor = tabActor; + this._webGLPrimitiveCounter = new WebGLPrimitiveCounter(tabActor); + this._onContentFunctionCall = this._onContentFunctionCall.bind(this); + }, + destroy: function (conn) { + protocol.Actor.prototype.destroy.call(this, conn); + this._webGLPrimitiveCounter.destroy(); + this.finalize(); + }, + + /** + * Starts listening for function calls. + */ + setup: function ({ reload }) { + if (this._initialized) { + if (reload) { + this.tabActor.window.location.reload(); + } + return; + } + this._initialized = true; + + this._callWatcher = new CallWatcherActor(this.conn, this.tabActor); + this._callWatcher.onCall = this._onContentFunctionCall; + this._callWatcher.setup({ + tracedGlobals: CANVAS_CONTEXTS, + tracedFunctions: [...ANIMATION_GENERATORS, ...LOOP_GENERATORS], + performReload: reload, + storeCalls: true + }); + }, + + /** + * Stops listening for function calls. + */ + finalize: function () { + if (!this._initialized) { + return; + } + this._initialized = false; + + this._callWatcher.finalize(); + this._callWatcher = null; + }, + + /** + * Returns whether this actor has been set up. + */ + isInitialized: function () { + return !!this._initialized; + }, + + /** + * Returns whether or not the CanvasActor is recording an animation. + * Used in tests. + */ + isRecording: function () { + return !!this._callWatcher.isRecording(); + }, + + /** + * Records a snapshot of all the calls made during the next animation frame. + * The animation should be implemented via the de-facto requestAnimationFrame + * utility, or inside recursive `setTimeout`s. `setInterval` at this time are not supported. + */ + recordAnimationFrame: function () { + if (this._callWatcher.isRecording()) { + return this._currentAnimationFrameSnapshot.promise; + } + + this._recordingContainsDrawCall = false; + this._callWatcher.eraseRecording(); + this._callWatcher.initTimestampEpoch(); + this._webGLPrimitiveCounter.resetCounts(); + this._callWatcher.resumeRecording(); + + let deferred = this._currentAnimationFrameSnapshot = promise.defer(); + return deferred.promise; + }, + + /** + * Cease attempts to record an animation frame. + */ + stopRecordingAnimationFrame: function () { + if (!this._callWatcher.isRecording()) { + return; + } + this._animationStarted = false; + this._callWatcher.pauseRecording(); + this._callWatcher.eraseRecording(); + this._currentAnimationFrameSnapshot.resolve(null); + this._currentAnimationFrameSnapshot = null; + }, + + /** + * Invoked whenever an instrumented function is called, be it on a + * 2d or WebGL context, or an animation generator like requestAnimationFrame. + */ + _onContentFunctionCall: function (functionCall) { + let { window, name, args } = functionCall.details; + + // The function call arguments are required to replay animation frames, + // in order to generate screenshots. However, simply storing references to + // every kind of object is a bad idea, since their properties may change. + // Consider transformation matrices for example, which are typically + // Float32Arrays whose values can easily change across context calls. + // They need to be cloned. + inplaceShallowCloneArrays(args, window); + + // Handle animations generated using requestAnimationFrame + if (CanvasFront.ANIMATION_GENERATORS.has(name)) { + this._handleAnimationFrame(functionCall); + return; + } + // Handle animations generated using setTimeout. While using + // those timers is considered extremely poor practice, they're still widely + // used on the web, especially for old demos; it's nice to support them as well. + if (CanvasFront.LOOP_GENERATORS.has(name)) { + this._handleAnimationFrame(functionCall); + return; + } + if (CanvasFront.DRAW_CALLS.has(name) && this._animationStarted) { + this._handleDrawCall(functionCall); + this._webGLPrimitiveCounter.handleDrawPrimitive(functionCall); + return; + } + }, + + /** + * Handle animations generated using requestAnimationFrame. + */ + _handleAnimationFrame: function (functionCall) { + if (!this._animationStarted) { + this._handleAnimationFrameBegin(); + } + // Check to see if draw calls occurred yet, as it could be future frames, + // like in the scenario where requestAnimationFrame is called to trigger an animation, + // and rAF is at the beginning of the animate loop. + else if (this._animationContainsDrawCall) { + this._handleAnimationFrameEnd(functionCall); + } + }, + + /** + * Called whenever an animation frame rendering begins. + */ + _handleAnimationFrameBegin: function () { + this._callWatcher.eraseRecording(); + this._animationStarted = true; + }, + + /** + * Called whenever an animation frame rendering ends. + */ + _handleAnimationFrameEnd: function () { + // Get a hold of all the function calls made during this animation frame. + // Since only one snapshot can be recorded at a time, erase all the + // previously recorded calls. + let functionCalls = this._callWatcher.pauseRecording(); + this._callWatcher.eraseRecording(); + this._animationContainsDrawCall = false; + + // Since the animation frame finished, get a hold of the (already retrieved) + // canvas pixels to conveniently create a screenshot of the final rendering. + let index = this._lastDrawCallIndex; + let width = this._lastContentCanvasWidth; + let height = this._lastContentCanvasHeight; + let flipped = !!this._lastThumbnailFlipped; // undefined -> false + let pixels = ContextUtils.getPixelStorage()["8bit"]; + let primitiveResult = this._webGLPrimitiveCounter.getCounts(); + let animationFrameEndScreenshot = { + index: index, + width: width, + height: height, + scaling: 1, + flipped: flipped, + pixels: pixels.subarray(0, width * height * 4) + }; + + // Wrap the function calls and screenshot in a FrameSnapshotActor instance, + // which will resolve the promise returned by `recordAnimationFrame`. + let frameSnapshot = new FrameSnapshotActor(this.conn, { + canvas: this._lastDrawCallCanvas, + calls: functionCalls, + screenshot: animationFrameEndScreenshot, + primitive: { + tris: primitiveResult.tris, + vertices: primitiveResult.vertices, + points: primitiveResult.points, + lines: primitiveResult.lines + } + }); + + this._currentAnimationFrameSnapshot.resolve(frameSnapshot); + this._currentAnimationFrameSnapshot = null; + this._animationStarted = false; + }, + + /** + * Invoked whenever a draw call is detected in the animation frame which is + * currently being recorded. + */ + _handleDrawCall: function (functionCall) { + let functionCalls = this._callWatcher.pauseRecording(); + let caller = functionCall.details.caller; + let global = functionCall.details.global; + + let contentCanvas = this._lastDrawCallCanvas = caller.canvas; + let index = this._lastDrawCallIndex = functionCalls.indexOf(functionCall); + let w = this._lastContentCanvasWidth = contentCanvas.width; + let h = this._lastContentCanvasHeight = contentCanvas.height; + + // To keep things fast, generate images of small and fixed dimensions. + let dimensions = CanvasFront.THUMBNAIL_SIZE; + let thumbnail; + + this._animationContainsDrawCall = true; + + // Create a thumbnail on every draw call on the canvas context, to augment + // the respective function call actor with this additional data. + if (global == "WebGLRenderingContext") { + // Check if drawing to a custom framebuffer (when rendering to texture). + // Don't create a thumbnail in this particular case. + let framebufferBinding = caller.getParameter(caller.FRAMEBUFFER_BINDING); + if (framebufferBinding == null) { + thumbnail = ContextUtils.getPixelsForWebGL(caller, 0, 0, w, h, dimensions); + thumbnail.flipped = this._lastThumbnailFlipped = true; + thumbnail.index = index; + } + } else if (global == "CanvasRenderingContext2D") { + thumbnail = ContextUtils.getPixelsFor2D(caller, 0, 0, w, h, dimensions); + thumbnail.flipped = this._lastThumbnailFlipped = false; + thumbnail.index = index; + } + + functionCall._thumbnail = thumbnail; + this._callWatcher.resumeRecording(); + } +}); + +/** + * A collection of methods for manipulating canvas contexts. + */ +var ContextUtils = { + /** + * WebGL contexts are sensitive to how they're queried. Use this function + * to make sure the right context is always retrieved, if available. + * + * @param HTMLCanvasElement canvas + * The canvas element for which to get a WebGL context. + * @param WebGLRenderingContext gl + * The queried WebGL context, or null if unavailable. + */ + getWebGLContext: function (canvas) { + return canvas.getContext("webgl") || + canvas.getContext("experimental-webgl"); + }, + + /** + * Gets a hold of the rendered pixels in the most efficient way possible for + * a canvas with a WebGL context. + * + * @param WebGLRenderingContext gl + * The WebGL context to get a screenshot from. + * @param number srcX [optional] + * The first left pixel that is read from the framebuffer. + * @param number srcY [optional] + * The first top pixel that is read from the framebuffer. + * @param number srcWidth [optional] + * The number of pixels to read on the X axis. + * @param number srcHeight [optional] + * The number of pixels to read on the Y axis. + * @param number dstHeight [optional] + * The desired generated screenshot height. + * @return object + * An objet containing the screenshot's width, height and pixel data, + * represented as an 8-bit array buffer of r, g, b, a values. + */ + getPixelsForWebGL: function (gl, + srcX = 0, srcY = 0, + srcWidth = gl.canvas.width, + srcHeight = gl.canvas.height, + dstHeight = srcHeight) + { + let contentPixels = ContextUtils.getPixelStorage(srcWidth, srcHeight); + let { "8bit": charView, "32bit": intView } = contentPixels; + gl.readPixels(srcX, srcY, srcWidth, srcHeight, gl.RGBA, gl.UNSIGNED_BYTE, charView); + return this.resizePixels(intView, srcWidth, srcHeight, dstHeight); + }, + + /** + * Gets a hold of the rendered pixels in the most efficient way possible for + * a canvas with a 2D context. + * + * @param CanvasRenderingContext2D ctx + * The 2D context to get a screenshot from. + * @param number srcX [optional] + * The first left pixel that is read from the canvas. + * @param number srcY [optional] + * The first top pixel that is read from the canvas. + * @param number srcWidth [optional] + * The number of pixels to read on the X axis. + * @param number srcHeight [optional] + * The number of pixels to read on the Y axis. + * @param number dstHeight [optional] + * The desired generated screenshot height. + * @return object + * An objet containing the screenshot's width, height and pixel data, + * represented as an 8-bit array buffer of r, g, b, a values. + */ + getPixelsFor2D: function (ctx, + srcX = 0, srcY = 0, + srcWidth = ctx.canvas.width, + srcHeight = ctx.canvas.height, + dstHeight = srcHeight) + { + let { data } = ctx.getImageData(srcX, srcY, srcWidth, srcHeight); + let { "32bit": intView } = ContextUtils.usePixelStorage(data.buffer); + return this.resizePixels(intView, srcWidth, srcHeight, dstHeight); + }, + + /** + * Resizes the provided pixels to fit inside a rectangle with the specified + * height and the same aspect ratio as the source. + * + * @param Uint32Array srcPixels + * The source pixel data, assuming 32bit/pixel and 4 color components. + * @param number srcWidth + * The source pixel data width. + * @param number srcHeight + * The source pixel data height. + * @param number dstHeight [optional] + * The desired resized pixel data height. + * @return object + * An objet containing the resized pixels width, height and data, + * represented as an 8-bit array buffer of r, g, b, a values. + */ + resizePixels: function (srcPixels, srcWidth, srcHeight, dstHeight) { + let screenshotRatio = dstHeight / srcHeight; + let dstWidth = (srcWidth * screenshotRatio) | 0; + let dstPixels = new Uint32Array(dstWidth * dstHeight); + + // If the resized image ends up being completely transparent, returning + // an empty array will skip some redundant serialization cycles. + let isTransparent = true; + + for (let dstX = 0; dstX < dstWidth; dstX++) { + for (let dstY = 0; dstY < dstHeight; dstY++) { + let srcX = (dstX / screenshotRatio) | 0; + let srcY = (dstY / screenshotRatio) | 0; + let cPos = srcX + srcWidth * srcY; + let dPos = dstX + dstWidth * dstY; + let color = dstPixels[dPos] = srcPixels[cPos]; + if (color) { + isTransparent = false; + } + } + } + + return { + width: dstWidth, + height: dstHeight, + pixels: isTransparent ? [] : new Uint8Array(dstPixels.buffer) + }; + }, + + /** + * Invokes a series of canvas context calls, to "replay" an animation frame + * and generate a screenshot. + * + * In case of a WebGL context, an offscreen framebuffer is created for + * the respective canvas, and the rendering will be performed into it. + * This is necessary because some state (like shaders, textures etc.) can't + * be shared between two different WebGL contexts. + * - Hopefully, once SharedResources are a thing this won't be necessary: + * http://www.khronos.org/webgl/wiki/SharedResouces + * - Alternatively, we could pursue the idea of using the same context + * for multiple canvases, instead of trying to share resources: + * https://www.khronos.org/webgl/public-mailing-list/archives/1210/msg00058.html + * + * In case of a 2D context, a new canvas is created, since there's no + * intrinsic state that can't be easily duplicated. + * + * @param number contexType + * The type of context to use. See the CallWatcherFront scope types. + * @param HTMLCanvasElement canvas + * The canvas element which is the source of all context calls. + * @param array calls + * An array of function call actors. + * @param number first + * The first function call to start from. + * @param number last + * The last (inclusive) function call to end at. + * @return object + * The context on which the specified calls were invoked, the + * last registered draw call's index and a cleanup function, which + * needs to be called whenever any potential followup work is finished. + */ + replayAnimationFrame: function ({ contextType, canvas, calls, first, last }) { + let w = canvas.width; + let h = canvas.height; + + let replayContext; + let replayContextScaling; + let customViewport; + let customFramebuffer; + let lastDrawCallIndex = -1; + let doCleanup = () => {}; + + // In case of WebGL contexts, rendering will be done offscreen, in a + // custom framebuffer, but using the same provided context. This is + // necessary because it's very memory-unfriendly to rebuild all the + // required GL state (like recompiling shaders, setting global flags, etc.) + // in an entirely new canvas. However, special care is needed to not + // permanently affect the existing GL state in the process. + if (contextType == "WebGLRenderingContext") { + // To keep things fast, replay the context calls on a framebuffer + // of smaller dimensions than the actual canvas (maximum 256x256 pixels). + let scaling = Math.min(CanvasFront.WEBGL_SCREENSHOT_MAX_HEIGHT, h) / h; + replayContextScaling = scaling; + w = (w * scaling) | 0; + h = (h * scaling) | 0; + + // Fetch the same WebGL context and bind a new framebuffer. + let gl = replayContext = this.getWebGLContext(canvas); + let { newFramebuffer, oldFramebuffer } = this.createBoundFramebuffer(gl, w, h); + customFramebuffer = newFramebuffer; + + // Set the viewport to match the new framebuffer's dimensions. + let { newViewport, oldViewport } = this.setCustomViewport(gl, w, h); + customViewport = newViewport; + + // Revert the framebuffer and viewport to the original values. + doCleanup = () => { + gl.bindFramebuffer(gl.FRAMEBUFFER, oldFramebuffer); + gl.viewport.apply(gl, oldViewport); + }; + } + // In case of 2D contexts, draw everything on a separate canvas context. + else if (contextType == "CanvasRenderingContext2D") { + let contentDocument = canvas.ownerDocument; + let replayCanvas = contentDocument.createElement("canvas"); + replayCanvas.width = w; + replayCanvas.height = h; + replayContext = replayCanvas.getContext("2d"); + replayContextScaling = 1; + customViewport = [0, 0, w, h]; + } + + // Replay all the context calls up to and including the specified one. + for (let i = first; i <= last; i++) { + let { type, name, args } = calls[i].details; + + // Prevent WebGL context calls that try to reset the framebuffer binding + // to the default value, since we want to perform the rendering offscreen. + if (name == "bindFramebuffer" && args[1] == null) { + replayContext.bindFramebuffer(replayContext.FRAMEBUFFER, customFramebuffer); + continue; + } + // Also prevent WebGL context calls that try to change the viewport + // while our custom framebuffer is bound. + if (name == "viewport") { + let framebufferBinding = replayContext.getParameter(replayContext.FRAMEBUFFER_BINDING); + if (framebufferBinding == customFramebuffer) { + replayContext.viewport.apply(replayContext, customViewport); + continue; + } + } + if (type == CallWatcherFront.METHOD_FUNCTION) { + replayContext[name].apply(replayContext, args); + } else if (type == CallWatcherFront.SETTER_FUNCTION) { + replayContext[name] = args; + } + if (CanvasFront.DRAW_CALLS.has(name)) { + lastDrawCallIndex = i; + } + } + + return { + replayContext: replayContext, + replayContextScaling: replayContextScaling, + replayViewport: customViewport, + lastDrawCallIndex: lastDrawCallIndex, + doCleanup: doCleanup + }; + }, + + /** + * Gets an object containing a buffer large enough to hold width * height + * pixels, assuming 32bit/pixel and 4 color components. + * + * This method avoids allocating memory and tries to reuse a common buffer + * as much as possible. + * + * @param number w + * The desired pixel array storage width. + * @param number h + * The desired pixel array storage height. + * @return object + * The requested pixel array buffer. + */ + getPixelStorage: function (w = 0, h = 0) { + let storage = this._currentPixelStorage; + if (storage && storage["32bit"].length >= w * h) { + return storage; + } + return this.usePixelStorage(new ArrayBuffer(w * h * 4)); + }, + + /** + * Creates and saves the array buffer views used by `getPixelStorage`. + * + * @param ArrayBuffer buffer + * The raw buffer used as storage for various array buffer views. + */ + usePixelStorage: function (buffer) { + let array8bit = new Uint8Array(buffer); + let array32bit = new Uint32Array(buffer); + return this._currentPixelStorage = { + "8bit": array8bit, + "32bit": array32bit + }; + }, + + /** + * Creates a framebuffer of the specified dimensions for a WebGL context, + * assuming a RGBA color buffer, a depth buffer and no stencil buffer. + * + * @param WebGLRenderingContext gl + * The WebGL context to create and bind a framebuffer for. + * @param number width + * The desired width of the renderbuffers. + * @param number height + * The desired height of the renderbuffers. + * @return WebGLFramebuffer + * The generated framebuffer object. + */ + createBoundFramebuffer: function (gl, width, height) { + let oldFramebuffer = gl.getParameter(gl.FRAMEBUFFER_BINDING); + let oldRenderbufferBinding = gl.getParameter(gl.RENDERBUFFER_BINDING); + let oldTextureBinding = gl.getParameter(gl.TEXTURE_BINDING_2D); + + let newFramebuffer = gl.createFramebuffer(); + gl.bindFramebuffer(gl.FRAMEBUFFER, newFramebuffer); + + // Use a texture as the color renderbuffer attachment, since consumers of + // this function will most likely want to read the rendered pixels back. + let colorBuffer = gl.createTexture(); + gl.bindTexture(gl.TEXTURE_2D, colorBuffer); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, width, height, 0, gl.RGBA, gl.UNSIGNED_BYTE, null); + + let depthBuffer = gl.createRenderbuffer(); + gl.bindRenderbuffer(gl.RENDERBUFFER, depthBuffer); + gl.renderbufferStorage(gl.RENDERBUFFER, gl.DEPTH_COMPONENT16, width, height); + + gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, colorBuffer, 0); + gl.framebufferRenderbuffer(gl.FRAMEBUFFER, gl.DEPTH_ATTACHMENT, gl.RENDERBUFFER, depthBuffer); + + gl.bindTexture(gl.TEXTURE_2D, oldTextureBinding); + gl.bindRenderbuffer(gl.RENDERBUFFER, oldRenderbufferBinding); + + return { oldFramebuffer, newFramebuffer }; + }, + + /** + * Sets the viewport of the drawing buffer for a WebGL context. + * @param WebGLRenderingContext gl + * @param number width + * @param number height + */ + setCustomViewport: function (gl, width, height) { + let oldViewport = XPCNativeWrapper.unwrap(gl.getParameter(gl.VIEWPORT)); + let newViewport = [0, 0, width, height]; + gl.viewport.apply(gl, newViewport); + + return { oldViewport, newViewport }; + } +}; + +/** + * Goes through all the arguments and creates a one-level shallow copy + * of all arrays and array buffers. + */ +function inplaceShallowCloneArrays(functionArguments, contentWindow) { + let { Object, Array, ArrayBuffer } = contentWindow; + + functionArguments.forEach((arg, index, store) => { + if (arg instanceof Array) { + store[index] = arg.slice(); + } + if (arg instanceof Object && arg.buffer instanceof ArrayBuffer) { + store[index] = new arg.constructor(arg); + } + }); +} diff --git a/devtools/server/actors/child-process.js b/devtools/server/actors/child-process.js new file mode 100644 index 0000000000..7b0e2eaf85 --- /dev/null +++ b/devtools/server/actors/child-process.js @@ -0,0 +1,146 @@ +/* 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 { Cc, Ci, Cu } = require("chrome"); + +const { ChromeDebuggerActor } = require("devtools/server/actors/script"); +const { WebConsoleActor } = require("devtools/server/actors/webconsole"); +const makeDebugger = require("devtools/server/actors/utils/make-debugger"); +const { ActorPool } = require("devtools/server/main"); +const Services = require("Services"); +const { assert } = require("devtools/shared/DevToolsUtils"); +const { TabSources } = require("./utils/TabSources"); + +loader.lazyRequireGetter(this, "WorkerActorList", "devtools/server/actors/worker", true); + +function ChildProcessActor(aConnection) { + this.conn = aConnection; + this._contextPool = new ActorPool(this.conn); + this.conn.addActorPool(this._contextPool); + this.threadActor = null; + + // Use a see-everything debugger + this.makeDebugger = makeDebugger.bind(null, { + findDebuggees: dbg => dbg.findAllGlobals(), + shouldAddNewGlobalAsDebuggee: global => true + }); + + // Scope into which the webconsole executes: + // An empty sandbox with chrome privileges + let systemPrincipal = Cc["@mozilla.org/systemprincipal;1"] + .createInstance(Ci.nsIPrincipal); + let sandbox = Cu.Sandbox(systemPrincipal); + this._consoleScope = sandbox; + + this._workerList = null; + this._workerActorPool = null; + this._onWorkerListChanged = this._onWorkerListChanged.bind(this); +} +exports.ChildProcessActor = ChildProcessActor; + +ChildProcessActor.prototype = { + actorPrefix: "process", + + get isRootActor() { + return true; + }, + + get exited() { + return !this._contextPool; + }, + + get url() { + return undefined; + }, + + get window() { + return this._consoleScope; + }, + + get sources() { + if (!this._sources) { + assert(this.threadActor, "threadActor should exist when creating sources."); + this._sources = new TabSources(this.threadActor); + } + return this._sources; + }, + + form: function () { + if (!this._consoleActor) { + this._consoleActor = new WebConsoleActor(this.conn, this); + this._contextPool.addActor(this._consoleActor); + } + + if (!this.threadActor) { + this.threadActor = new ChromeDebuggerActor(this.conn, this); + this._contextPool.addActor(this.threadActor); + } + + return { + actor: this.actorID, + name: "Content process", + + consoleActor: this._consoleActor.actorID, + chromeDebugger: this.threadActor.actorID, + + traits: { + highlightable: false, + networkMonitor: false, + }, + }; + }, + + onListWorkers: function () { + if (!this._workerList) { + this._workerList = new WorkerActorList(this.conn, {}); + } + return this._workerList.getList().then(actors => { + let pool = new ActorPool(this.conn); + for (let actor of actors) { + pool.addActor(actor); + } + + this.conn.removeActorPool(this._workerActorPool); + this._workerActorPool = pool; + this.conn.addActorPool(this._workerActorPool); + + this._workerList.onListChanged = this._onWorkerListChanged; + + return { + "from": this.actorID, + "workers": actors.map(actor => actor.form()) + }; + }); + }, + + _onWorkerListChanged: function () { + this.conn.send({ from: this.actorID, type: "workerListChanged" }); + this._workerList.onListChanged = null; + }, + + disconnect: function () { + this.conn.removeActorPool(this._contextPool); + this._contextPool = null; + + // Tell the live lists we aren't watching any more. + if (this._workerList) { + this._workerList.onListChanged = null; + } + }, + + preNest: function () { + // TODO: freeze windows + // window mediator doesn't work in child. + // it doesn't throw, but doesn't return any window + }, + + postNest: function () { + }, +}; + +ChildProcessActor.prototype.requestTypes = { + "listWorkers": ChildProcessActor.prototype.onListWorkers, +}; diff --git a/devtools/server/actors/childtab.js b/devtools/server/actors/childtab.js new file mode 100644 index 0000000000..96d82e2816 --- /dev/null +++ b/devtools/server/actors/childtab.js @@ -0,0 +1,82 @@ +/* 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 { Cr } = require("chrome"); +var { TabActor } = require("devtools/server/actors/webbrowser"); + +/** + * Tab actor for documents living in a child process. + * + * Depends on TabActor, defined in webbrowser.js. + */ + +/** + * Creates a tab actor for handling requests to the single tab, like + * attaching and detaching. ContentActor respects the actor factories + * registered with DebuggerServer.addTabActor. + * + * @param connection DebuggerServerConnection + * The conection to the client. + * @param chromeGlobal + * The content script global holding |content| and |docShell| properties for a tab. + * @param prefix + * the prefix used in protocol to create IDs for each actor. + * Used as ID identifying this particular TabActor from the parent process. + */ +function ContentActor(connection, chromeGlobal, prefix) +{ + this._chromeGlobal = chromeGlobal; + this._prefix = prefix; + TabActor.call(this, connection, chromeGlobal); + this.traits.reconfigure = false; + this._sendForm = this._sendForm.bind(this); + this._chromeGlobal.addMessageListener("debug:form", this._sendForm); + + Object.defineProperty(this, "docShell", { + value: this._chromeGlobal.docShell, + configurable: true + }); +} + +ContentActor.prototype = Object.create(TabActor.prototype); + +ContentActor.prototype.constructor = ContentActor; + +Object.defineProperty(ContentActor.prototype, "title", { + get: function () { + return this.window.document.title; + }, + enumerable: true, + configurable: true +}); + +ContentActor.prototype.exit = function () { + if (this._sendForm) { + try { + this._chromeGlobal.removeMessageListener("debug:form", this._sendForm); + } catch (e) { + if (e.result != Cr.NS_ERROR_NULL_POINTER) { + throw e; + } + // In some cases, especially when using messageManagers in non-e10s mode, we reach + // this point with a dead messageManager which only throws errors but does not + // seem to indicate in any other way that it is dead. + } + this._sendForm = null; + } + + TabActor.prototype.exit.call(this); + + this._chromeGlobal = null; +}; + +/** + * On navigation events, our URL and/or title may change, so we update our + * counterpart in the parent process that participates in the tab list. + */ +ContentActor.prototype._sendForm = function () { + this._chromeGlobal.sendAsyncMessage("debug:form", this.form()); +}; diff --git a/devtools/server/actors/chrome.js b/devtools/server/actors/chrome.js new file mode 100644 index 0000000000..07cd2ad995 --- /dev/null +++ b/devtools/server/actors/chrome.js @@ -0,0 +1,185 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { Ci } = require("chrome"); +const Services = require("Services"); +const { DebuggerServer } = require("../main"); +const { getChildDocShells, TabActor } = require("./webbrowser"); +const makeDebugger = require("./utils/make-debugger"); + +/** + * Creates a TabActor for debugging all the chrome content in the + * current process. Most of the implementation is inherited from TabActor. + * ChromeActor is a child of RootActor, it can be instanciated via + * RootActor.getProcess request. + * ChromeActor exposes all tab actors via its form() request, like TabActor. + * + * History lecture: + * All tab actors used to also be registered as global actors, + * so that the root actor was also exposing tab actors for the main process. + * Tab actors ended up having RootActor as parent actor, + * but more and more features of the tab actors were relying on TabActor. + * So we are now exposing a process actor that offers the same API as TabActor + * by inheriting its functionality. + * Global actors are now only the actors that are meant to be global, + * and are no longer related to any specific scope/document. + * + * @param aConnection DebuggerServerConnection + * The connection to the client. + */ +function ChromeActor(aConnection) { + TabActor.call(this, aConnection); + + // This creates a Debugger instance for chrome debugging all globals. + this.makeDebugger = makeDebugger.bind(null, { + findDebuggees: dbg => dbg.findAllGlobals(), + shouldAddNewGlobalAsDebuggee: () => true + }); + + // Ensure catching the creation of any new content docshell + this.listenForNewDocShells = true; + + // Defines the default docshell selected for the tab actor + let window = Services.wm.getMostRecentWindow(DebuggerServer.chromeWindowType); + + // Default to any available top level window if there is no expected window + // (for example when we open firefox with -webide argument) + if (!window) { + window = Services.wm.getMostRecentWindow(null); + } + // On xpcshell, there is no window/docshell + let docShell = window ? window.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDocShell) + : null; + Object.defineProperty(this, "docShell", { + value: docShell, + configurable: true + }); +} +exports.ChromeActor = ChromeActor; + +ChromeActor.prototype = Object.create(TabActor.prototype); + +ChromeActor.prototype.constructor = ChromeActor; + +ChromeActor.prototype.isRootActor = true; + +/** + * Getter for the list of all docshells in this tabActor + * @return {Array} + */ +Object.defineProperty(ChromeActor.prototype, "docShells", { + get: function () { + // Iterate over all top-level windows and all their docshells. + let docShells = []; + let e = Services.ww.getWindowEnumerator(); + while (e.hasMoreElements()) { + let window = e.getNext(); + let docShell = window.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIWebNavigation) + .QueryInterface(Ci.nsIDocShell); + docShells = docShells.concat(getChildDocShells(docShell)); + } + + return docShells; + } +}); + +ChromeActor.prototype.observe = function (aSubject, aTopic, aData) { + TabActor.prototype.observe.call(this, aSubject, aTopic, aData); + if (!this.attached) { + return; + } + if (aTopic == "chrome-webnavigation-create") { + aSubject.QueryInterface(Ci.nsIDocShell); + this._onDocShellCreated(aSubject); + } else if (aTopic == "chrome-webnavigation-destroy") { + this._onDocShellDestroy(aSubject); + } +}; + +ChromeActor.prototype._attach = function () { + if (this.attached) { + return false; + } + + TabActor.prototype._attach.call(this); + + // Listen for any new/destroyed chrome docshell + Services.obs.addObserver(this, "chrome-webnavigation-create", false); + Services.obs.addObserver(this, "chrome-webnavigation-destroy", false); + + // Iterate over all top-level windows. + let docShells = []; + let e = Services.ww.getWindowEnumerator(); + while (e.hasMoreElements()) { + let window = e.getNext(); + let docShell = window.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIWebNavigation) + .QueryInterface(Ci.nsIDocShell); + if (docShell == this.docShell) { + continue; + } + this._progressListener.watch(docShell); + } +}; + +ChromeActor.prototype._detach = function () { + if (!this.attached) { + return false; + } + + Services.obs.removeObserver(this, "chrome-webnavigation-create"); + Services.obs.removeObserver(this, "chrome-webnavigation-destroy"); + + // Iterate over all top-level windows. + let docShells = []; + let e = Services.ww.getWindowEnumerator(); + while (e.hasMoreElements()) { + let window = e.getNext(); + let docShell = window.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIWebNavigation) + .QueryInterface(Ci.nsIDocShell); + if (docShell == this.docShell) { + continue; + } + this._progressListener.unwatch(docShell); + } + + TabActor.prototype._detach.call(this); +}; + +/* ThreadActor hooks. */ + +/** + * Prepare to enter a nested event loop by disabling debuggee events. + */ +ChromeActor.prototype.preNest = function () { + // Disable events in all open windows. + let e = Services.wm.getEnumerator(null); + while (e.hasMoreElements()) { + let win = e.getNext(); + let windowUtils = win.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDOMWindowUtils); + windowUtils.suppressEventHandling(true); + windowUtils.suspendTimeouts(); + } +}; + +/** + * Prepare to exit a nested event loop by enabling debuggee events. + */ +ChromeActor.prototype.postNest = function (aNestData) { + // Enable events in all open windows. + let e = Services.wm.getEnumerator(null); + while (e.hasMoreElements()) { + let win = e.getNext(); + let windowUtils = win.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDOMWindowUtils); + windowUtils.resumeTimeouts(); + windowUtils.suppressEventHandling(false); + } +}; diff --git a/devtools/server/actors/common.js b/devtools/server/actors/common.js new file mode 100644 index 0000000000..0177c67491 --- /dev/null +++ b/devtools/server/actors/common.js @@ -0,0 +1,521 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* 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 promise = require("promise"); +const { method } = require("devtools/shared/protocol"); + +/** + * Creates "registered" actors factory meant for creating another kind of + * factories, ObservedActorFactory, during the call to listTabs. + * These factories live in DebuggerServer.{tab|global}ActorFactories. + * + * These actors only exposes: + * - `name` string attribute used to match actors by constructor name + * in DebuggerServer.remove{Global,Tab}Actor. + * - `createObservedActorFactory` function to create "observed" actors factory + * + * @param options object, function + * Either an object or a function. + * If given an object: + * + * If given a function (deprecated): + * Constructor function of an actor. + * The constructor function for this actor type. + * This expects to be called as a constructor (i.e. with 'new'), + * and passed two arguments: the DebuggerServerConnection, and + * the BrowserTabActor with which it will be associated. + * Only used for deprecated eagerly loaded actors. + * + */ +function RegisteredActorFactory(options, prefix) { + // By default the actor name will also be used for the actorID prefix. + this._prefix = prefix; + if (typeof (options) != "function") { + // actors definition registered by actorRegistryActor + if (options.constructorFun) { + this._getConstructor = () => options.constructorFun; + } else { + // Lazy actor definition, where options contains all the information + // required to load the actor lazily. + this._getConstructor = function () { + // Load the module + let mod; + try { + mod = require(options.id); + } catch (e) { + throw new Error("Unable to load actor module '" + options.id + "'.\n" + + e.message + "\n" + e.stack + "\n"); + } + // Fetch the actor constructor + let c = mod[options.constructorName]; + if (!c) { + throw new Error("Unable to find actor constructor named '" + + options.constructorName + "'. (Is it exported?)"); + } + return c; + }; + } + // Exposes `name` attribute in order to allow removeXXXActor to match + // the actor by its actor constructor name. + this.name = options.constructorName; + } else { + // Old actor case, where options is a function that is the actor constructor. + this._getConstructor = () => options; + // Exposes `name` attribute in order to allow removeXXXActor to match + // the actor by its actor constructor name. + this.name = options.name; + + // For old actors, we allow the use of a different prefix for actorID + // than for listTabs actor names, by fetching a prefix on the actor prototype. + // (Used by ChromeDebuggerActor) + if (options.prototype && options.prototype.actorPrefix) { + this._prefix = options.prototype.actorPrefix; + } + } +} +RegisteredActorFactory.prototype.createObservedActorFactory = function (conn, parentActor) { + return new ObservedActorFactory(this._getConstructor, this._prefix, conn, parentActor); +}; +exports.RegisteredActorFactory = RegisteredActorFactory; + +/** + * Creates "observed" actors factory meant for creating real actor instances. + * These factories lives in actor pools and fake various actor attributes. + * They will be replaced in actor pools by final actor instances during + * the first request for the same actorID from DebuggerServer._getOrCreateActor. + * + * ObservedActorFactory fakes the following actors attributes: + * actorPrefix (string) Used by ActorPool.addActor to compute the actor id + * actorID (string) Set by ActorPool.addActor just after being instantiated + * registeredPool (object) Set by ActorPool.addActor just after being + * instantiated + * And exposes the following method: + * createActor (function) Instantiate an actor that is going to replace + * this factory in the actor pool. + */ +function ObservedActorFactory(getConstructor, prefix, conn, parentActor) { + this._getConstructor = getConstructor; + this._conn = conn; + this._parentActor = parentActor; + + this.actorPrefix = prefix; + + this.actorID = null; + this.registeredPool = null; +} +ObservedActorFactory.prototype.createActor = function () { + // Fetch the actor constructor + let c = this._getConstructor(); + // Instantiate a new actor instance + let instance = new c(this._conn, this._parentActor); + instance.conn = this._conn; + instance.parentID = this._parentActor.actorID; + // We want the newly-constructed actor to completely replace the factory + // actor. Reusing the existing actor ID will make sure ActorPool.addActor + // does the right thing. + instance.actorID = this.actorID; + this.registeredPool.addActor(instance); + return instance; +}; +exports.ObservedActorFactory = ObservedActorFactory; + + +/** + * Methods shared between RootActor and BrowserTabActor. + */ + +/** + * Populate |this._extraActors| as specified by |aFactories|, reusing whatever + * actors are already there. Add all actors in the final extra actors table to + * |aPool|. + * + * The root actor and the tab actor use this to instantiate actors that other + * parts of the browser have specified with DebuggerServer.addTabActor and + * DebuggerServer.addGlobalActor. + * + * @param aFactories + * An object whose own property names are the names of properties to add to + * some reply packet (say, a tab actor grip or the "listTabs" response + * form), and whose own property values are actor constructor functions, as + * documented for addTabActor and addGlobalActor. + * + * @param this + * The BrowserRootActor or BrowserTabActor with which the new actors will + * be associated. It should support whatever API the |aFactories| + * constructor functions might be interested in, as it is passed to them. + * For the sake of CommonCreateExtraActors itself, it should have at least + * the following properties: + * + * - _extraActors + * An object whose own property names are factory table (and packet) + * property names, and whose values are no-argument actor constructors, + * of the sort that one can add to an ActorPool. + * + * - conn + * The DebuggerServerConnection in which the new actors will participate. + * + * - actorID + * The actor's name, for use as the new actors' parentID. + */ +exports.createExtraActors = function createExtraActors(aFactories, aPool) { + // Walk over global actors added by extensions. + for (let name in aFactories) { + let actor = this._extraActors[name]; + if (!actor) { + // Register another factory, but this time specific to this connection. + // It creates a fake actor that looks like an regular actor in the pool, + // but without actually instantiating the actor. + // It will only be instantiated on the first request made to the actor. + actor = aFactories[name].createObservedActorFactory(this.conn, this); + this._extraActors[name] = actor; + } + + // If the actor already exists in the pool, it may have been instantiated, + // so make sure not to overwrite it by a non-instantiated version. + if (!aPool.has(actor.actorID)) { + aPool.addActor(actor); + } + } +}; + +/** + * Append the extra actors in |this._extraActors|, constructed by a prior call + * to CommonCreateExtraActors, to |aObject|. + * + * @param aObject + * The object to which the extra actors should be added, under the + * property names given in the |aFactories| table passed to + * CommonCreateExtraActors. + * + * @param this + * The BrowserRootActor or BrowserTabActor whose |_extraActors| table we + * should use; see above. + */ +exports.appendExtraActors = function appendExtraActors(aObject) { + for (let name in this._extraActors) { + let actor = this._extraActors[name]; + aObject[name] = actor.actorID; + } +}; + +/** + * Construct an ActorPool. + * + * ActorPools are actorID -> actor mapping and storage. These are + * used to accumulate and quickly dispose of groups of actors that + * share a lifetime. + */ +function ActorPool(aConnection) +{ + this.conn = aConnection; + this._actors = {}; +} + +ActorPool.prototype = { + /** + * Destroy the pool. This will remove all actors from the pool. + */ + destroy: function AP_destroy() { + for (let id in this._actors) { + this.removeActor(this._actors[id]); + } + }, + + /** + * Add an actor to the pool. If the actor doesn't have an ID, allocate one + * from the connection. + * + * @param Object aActor + * The actor to be added to the pool. + */ + addActor: function AP_addActor(aActor) { + aActor.conn = this.conn; + if (!aActor.actorID) { + let prefix = aActor.actorPrefix; + if (!prefix && typeof aActor == "function") { + // typeName is a convention used with protocol.js-based actors + prefix = aActor.prototype.actorPrefix || aActor.prototype.typeName; + } + aActor.actorID = this.conn.allocID(prefix || undefined); + } + + // If the actor is already in a pool, remove it without destroying it. + if (aActor.registeredPool) { + delete aActor.registeredPool._actors[aActor.actorID]; + } + aActor.registeredPool = this; + + this._actors[aActor.actorID] = aActor; + }, + + /** + * Remove an actor from the pool. If the actor has a disconnect method, call + * it. + */ + removeActor: function AP_remove(aActor) { + delete this._actors[aActor.actorID]; + if (aActor.disconnect) { + aActor.disconnect(); + } + }, + + get: function AP_get(aActorID) { + return this._actors[aActorID] || undefined; + }, + + has: function AP_has(aActorID) { + return aActorID in this._actors; + }, + + /** + * Returns true if the pool is empty. + */ + isEmpty: function AP_isEmpty() { + return Object.keys(this._actors).length == 0; + }, + + /** + * Match the api expected by the protocol library. + */ + unmanage: function (aActor) { + return this.removeActor(aActor); + }, + + forEach: function (callback) { + for (let name in this._actors) { + callback(this._actors[name]); + } + }, +}; + +exports.ActorPool = ActorPool; + +/** + * An OriginalLocation represents a location in an original source. + * + * @param SourceActor actor + * A SourceActor representing an original source. + * @param Number line + * A line within the given source. + * @param Number column + * A column within the given line. + * @param String name + * The name of the symbol corresponding to this OriginalLocation. + */ +function OriginalLocation(actor, line, column, name) { + this._connection = actor ? actor.conn : null; + this._actorID = actor ? actor.actorID : undefined; + this._line = line; + this._column = column; + this._name = name; +} + +OriginalLocation.fromGeneratedLocation = function (generatedLocation) { + return new OriginalLocation( + generatedLocation.generatedSourceActor, + generatedLocation.generatedLine, + generatedLocation.generatedColumn + ); +}; + +OriginalLocation.prototype = { + get originalSourceActor() { + return this._connection ? this._connection.getActor(this._actorID) : null; + }, + + get originalUrl() { + let actor = this.originalSourceActor; + let source = actor.source; + return source ? source.url : actor._originalUrl; + }, + + get originalLine() { + return this._line; + }, + + get originalColumn() { + return this._column; + }, + + get originalName() { + return this._name; + }, + + get generatedSourceActor() { + throw new Error("Shouldn't access generatedSourceActor from an OriginalLocation"); + }, + + get generatedLine() { + throw new Error("Shouldn't access generatedLine from an OriginalLocation"); + }, + + get generatedColumn() { + throw new Error("Shouldn't access generatedColumn from an Originallocation"); + }, + + equals: function (other) { + return this.originalSourceActor.url == other.originalSourceActor.url && + this.originalLine === other.originalLine && + (this.originalColumn === undefined || + other.originalColumn === undefined || + this.originalColumn === other.originalColumn); + }, + + toJSON: function () { + return { + source: this.originalSourceActor.form(), + line: this.originalLine, + column: this.originalColumn + }; + } +}; + +exports.OriginalLocation = OriginalLocation; + +/** + * A GeneratedLocation represents a location in a generated source. + * + * @param SourceActor actor + * A SourceActor representing a generated source. + * @param Number line + * A line within the given source. + * @param Number column + * A column within the given line. + */ +function GeneratedLocation(actor, line, column, lastColumn) { + this._connection = actor ? actor.conn : null; + this._actorID = actor ? actor.actorID : undefined; + this._line = line; + this._column = column; + this._lastColumn = (lastColumn !== undefined) ? lastColumn : column + 1; +} + +GeneratedLocation.fromOriginalLocation = function (originalLocation) { + return new GeneratedLocation( + originalLocation.originalSourceActor, + originalLocation.originalLine, + originalLocation.originalColumn + ); +}; + +GeneratedLocation.prototype = { + get originalSourceActor() { + throw new Error(); + }, + + get originalUrl() { + throw new Error("Shouldn't access originalUrl from a GeneratedLocation"); + }, + + get originalLine() { + throw new Error("Shouldn't access originalLine from a GeneratedLocation"); + }, + + get originalColumn() { + throw new Error("Shouldn't access originalColumn from a GeneratedLocation"); + }, + + get originalName() { + throw new Error("Shouldn't access originalName from a GeneratedLocation"); + }, + + get generatedSourceActor() { + return this._connection ? this._connection.getActor(this._actorID) : null; + }, + + get generatedLine() { + return this._line; + }, + + get generatedColumn() { + return this._column; + }, + + get generatedLastColumn() { + return this._lastColumn; + }, + + equals: function (other) { + return this.generatedSourceActor.url == other.generatedSourceActor.url && + this.generatedLine === other.generatedLine && + (this.generatedColumn === undefined || + other.generatedColumn === undefined || + this.generatedColumn === other.generatedColumn); + }, + + toJSON: function () { + return { + source: this.generatedSourceActor.form(), + line: this.generatedLine, + column: this.generatedColumn, + lastColumn: this.generatedLastColumn + }; + } +}; + +exports.GeneratedLocation = GeneratedLocation; + +/** + * A method decorator that ensures the actor is in the expected state before + * proceeding. If the actor is not in the expected state, the decorated method + * returns a rejected promise. + * + * The actor's state must be at this.state property. + * + * @param String expectedState + * The expected state. + * @param String activity + * Additional info about what's going on. + * @param Function method + * The actor method to proceed with when the actor is in the expected + * state. + * + * @returns Function + * The decorated method. + */ +function expectState(expectedState, method, activity) { + return function (...args) { + if (this.state !== expectedState) { + const msg = `Wrong state while ${activity}:` + + `Expected '${expectedState}', ` + + `but current state is '${this.state}'.`; + return promise.reject(new Error(msg)); + } + + return method.apply(this, args); + }; +} + +exports.expectState = expectState; + +/** + * Proxies a call from an actor to an underlying module, stored + * as `bridge` on the actor. This allows a module to be defined in one + * place, usable by other modules/actors on the server, but a separate + * module defining the actor/RDP definition. + * + * @see Framerate implementation: devtools/server/performance/framerate.js + * @see Framerate actor definition: devtools/server/actors/framerate.js + */ +function actorBridge(methodName, definition = {}) { + return method(function () { + return this.bridge[methodName].apply(this.bridge, arguments); + }, definition); +} +exports.actorBridge = actorBridge; + +/** + * Like `actorBridge`, but without a spec definition, for when the actor is + * created with `ActorClassWithSpec` rather than vanilla `ActorClass`. + */ +function actorBridgeWithSpec (methodName) { + return method(function () { + return this.bridge[methodName].apply(this.bridge, arguments); + }); +} +exports.actorBridgeWithSpec = actorBridgeWithSpec; diff --git a/devtools/server/actors/css-properties.js b/devtools/server/actors/css-properties.js new file mode 100644 index 0000000000..d24c133d42 --- /dev/null +++ b/devtools/server/actors/css-properties.js @@ -0,0 +1,120 @@ +/* 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 { Cc, Ci } = require("chrome"); + +loader.lazyGetter(this, "DOMUtils", () => { + return Cc["@mozilla.org/inspector/dom-utils;1"].getService(Ci.inIDOMUtils); +}); + +const protocol = require("devtools/shared/protocol"); +const { ActorClassWithSpec, Actor } = protocol; +const { cssPropertiesSpec } = require("devtools/shared/specs/css-properties"); +const { CSS_PROPERTIES, CSS_TYPES } = require("devtools/shared/css/properties-db"); +const { cssColors } = require("devtools/shared/css/color-db"); + +exports.CssPropertiesActor = ActorClassWithSpec(cssPropertiesSpec, { + typeName: "cssProperties", + + initialize(conn, parent) { + Actor.prototype.initialize.call(this, conn); + this.parent = parent; + }, + + destroy() { + Actor.prototype.destroy.call(this); + }, + + getCSSDatabase() { + const properties = generateCssProperties(); + const pseudoElements = DOMUtils.getCSSPseudoElementNames(); + + return { properties, pseudoElements }; + } +}); + +/** + * Generate the CSS properties object. Every key is the property name, while + * the values are objects that contain information about that property. + * + * @return {Object} + */ +function generateCssProperties() { + const properties = {}; + const propertyNames = DOMUtils.getCSSPropertyNames(DOMUtils.INCLUDE_ALIASES); + const colors = Object.keys(cssColors); + + propertyNames.forEach(name => { + // Get the list of CSS types this property supports. + let supports = []; + for (let type in CSS_TYPES) { + if (safeCssPropertySupportsType(name, DOMUtils["TYPE_" + type])) { + supports.push(CSS_TYPES[type]); + } + } + + // Don't send colors over RDP, these will be re-attached by the front. + let values = DOMUtils.getCSSValuesForProperty(name); + if (values.includes("aliceblue")) { + values = values.filter(x => !colors.includes(x)); + values.unshift("COLOR"); + } + + let subproperties = DOMUtils.getSubpropertiesForCSSProperty(name); + + // In order to maintain any backwards compatible changes when debugging older + // clients, take the definition from the static CSS properties database, and fill it + // in with the most recent property definition from the server. + const clientDefinition = CSS_PROPERTIES[name] || {}; + const serverDefinition = { + isInherited: DOMUtils.isInheritedProperty(name), + values, + supports, + subproperties, + }; + properties[name] = Object.assign(clientDefinition, serverDefinition); + }); + + return properties; +} +exports.generateCssProperties = generateCssProperties; + +/** + * Test if a CSS is property is known using server-code. + * + * @param {string} name + * @return {Boolean} + */ +function isCssPropertyKnown(name) { + try { + // If the property name is unknown, the cssPropertyIsShorthand + // will throw an exception. But if it is known, no exception will + // be thrown; so we just ignore the return value. + DOMUtils.cssPropertyIsShorthand(name); + return true; + } catch (e) { + return false; + } +} + +exports.isCssPropertyKnown = isCssPropertyKnown; + +/** + * A wrapper for DOMUtils.cssPropertySupportsType that ignores invalid + * properties. + * + * @param {String} name The property name. + * @param {number} type The type tested for support. + * @return {Boolean} Whether the property supports the type. + * If the property is unknown, false is returned. + */ +function safeCssPropertySupportsType(name, type) { + try { + return DOMUtils.cssPropertySupportsType(name, type); + } catch (e) { + return false; + } +} diff --git a/devtools/server/actors/csscoverage.js b/devtools/server/actors/csscoverage.js new file mode 100644 index 0000000000..2f700656f7 --- /dev/null +++ b/devtools/server/actors/csscoverage.js @@ -0,0 +1,726 @@ +/* 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 { Cc, Ci } = require("chrome"); + +const Services = require("Services"); +const { XPCOMUtils } = require("resource://gre/modules/XPCOMUtils.jsm"); + +const events = require("sdk/event/core"); +const protocol = require("devtools/shared/protocol"); +const { cssUsageSpec } = require("devtools/shared/specs/csscoverage"); + +loader.lazyGetter(this, "DOMUtils", () => { + return Cc["@mozilla.org/inspector/dom-utils;1"].getService(Ci.inIDOMUtils); +}); +loader.lazyRequireGetter(this, "stylesheets", "devtools/server/actors/stylesheets"); +loader.lazyRequireGetter(this, "prettifyCSS", "devtools/shared/inspector/css-logic", true); + +const CSSRule = Ci.nsIDOMCSSRule; + +const MAX_UNUSED_RULES = 10000; + +/** + * Allow: let foo = l10n.lookup("csscoverageFoo"); + */ +const l10n = exports.l10n = { + _URI: "chrome://devtools-shared/locale/csscoverage.properties", + lookup: function (msg) { + if (this._stringBundle == null) { + this._stringBundle = Services.strings.createBundle(this._URI); + } + return this._stringBundle.GetStringFromName(msg); + } +}; + +/** + * CSSUsage manages the collection of CSS usage data. + * The core of a CSSUsage is a JSON-able data structure called _knownRules + * which looks like this: + * This records the CSSStyleRules and their usage. + * The format is: + * Map({ + * <CSS-URL>|<START-LINE>|<START-COLUMN>: { + * selectorText: <CSSStyleRule.selectorText>, + * test: <simplify(CSSStyleRule.selectorText)>, + * cssText: <CSSStyleRule.cssText>, + * isUsed: <TRUE|FALSE>, + * presentOn: Set([ <HTML-URL>, ... ]), + * preLoadOn: Set([ <HTML-URL>, ... ]), + * isError: <TRUE|FALSE>, + * } + * }) + * + * For example: + * this._knownRules = Map({ + * "http://eg.com/styles1.css|15|0": { + * selectorText: "p.quote:hover", + * test: "p.quote", + * cssText: "p.quote { color: red; }", + * isUsed: true, + * presentOn: Set([ "http://eg.com/page1.html", ... ]), + * preLoadOn: Set([ "http://eg.com/page1.html" ]), + * isError: false, + * }, ... + * }); + */ +var CSSUsageActor = protocol.ActorClassWithSpec(cssUsageSpec, { + initialize: function (conn, tabActor) { + protocol.Actor.prototype.initialize.call(this, conn); + + this._tabActor = tabActor; + this._running = false; + + this._onTabLoad = this._onTabLoad.bind(this); + this._onChange = this._onChange.bind(this); + + this._notifyOn = Ci.nsIWebProgress.NOTIFY_STATUS | + Ci.nsIWebProgress.NOTIFY_STATE_ALL; + }, + + destroy: function () { + this._tabActor = undefined; + + delete this._onTabLoad; + delete this._onChange; + + protocol.Actor.prototype.destroy.call(this); + }, + + /** + * Begin recording usage data + * @param noreload It's best if we start by reloading the current page + * because that starts the test at a known point, but there could be reasons + * why we don't want to do that (e.g. the page contains state that will be + * lost across a reload) + */ + start: function (noreload) { + if (this._running) { + throw new Error(l10n.lookup("csscoverageRunningError")); + } + + this._isOneShot = false; + this._visitedPages = new Set(); + this._knownRules = new Map(); + this._running = true; + this._tooManyUnused = false; + + this._progressListener = { + QueryInterface: XPCOMUtils.generateQI([ Ci.nsIWebProgressListener, + Ci.nsISupportsWeakReference ]), + + onStateChange: (progress, request, flags, status) => { + let isStop = flags & Ci.nsIWebProgressListener.STATE_STOP; + let isWindow = flags & Ci.nsIWebProgressListener.STATE_IS_WINDOW; + + if (isStop && isWindow) { + this._onTabLoad(progress.DOMWindow.document); + } + }, + + onLocationChange: () => {}, + onProgressChange: () => {}, + onSecurityChange: () => {}, + onStatusChange: () => {}, + destroy: () => {} + }; + + this._progress = this._tabActor.docShell.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIWebProgress); + this._progress.addProgressListener(this._progressListener, this._notifyOn); + + if (noreload) { + // If we're not starting by reloading the page, then pretend that onload + // has just happened. + this._onTabLoad(this._tabActor.window.document); + } else { + this._tabActor.window.location.reload(); + } + + events.emit(this, "state-change", { isRunning: true }); + }, + + /** + * Cease recording usage data + */ + stop: function () { + if (!this._running) { + throw new Error(l10n.lookup("csscoverageNotRunningError")); + } + + this._progress.removeProgressListener(this._progressListener, this._notifyOn); + this._progress = undefined; + + this._running = false; + events.emit(this, "state-change", { isRunning: false }); + }, + + /** + * Start/stop recording usage data depending on what we're currently doing. + */ + toggle: function () { + return this._running ? this.stop() : this.start(); + }, + + /** + * Running start() quickly followed by stop() does a bunch of unnecessary + * work, so this cuts all that out + */ + oneshot: function () { + if (this._running) { + throw new Error(l10n.lookup("csscoverageRunningError")); + } + + this._isOneShot = true; + this._visitedPages = new Set(); + this._knownRules = new Map(); + + this._populateKnownRules(this._tabActor.window.document); + this._updateUsage(this._tabActor.window.document, false); + }, + + /** + * Called by the ProgressListener to simulate a "load" event + */ + _onTabLoad: function (document) { + this._populateKnownRules(document); + this._updateUsage(document, true); + + this._observeMutations(document); + }, + + /** + * Setup a MutationObserver on the current document + */ + _observeMutations: function (document) { + let MutationObserver = document.defaultView.MutationObserver; + let observer = new MutationObserver(mutations => { + // It's possible that one of the mutations in this list adds a 'use' of + // a CSS rule, and another takes it away. See Bug 1010189 + this._onChange(document); + }); + + observer.observe(document, { + attributes: true, + childList: true, + characterData: false, + subtree: true + }); + }, + + /** + * Event handler for whenever we think the page has changed in a way that + * means the CSS usage might have changed. + */ + _onChange: function (document) { + // Ignore changes pre 'load' + if (!this._visitedPages.has(getURL(document))) { + return; + } + this._updateUsage(document, false); + }, + + /** + * Called whenever we think the list of stylesheets might have changed so + * we can update the list of rules that we should be checking + */ + _populateKnownRules: function (document) { + let url = getURL(document); + this._visitedPages.add(url); + // Go through all the rules in the current sheets adding them to knownRules + // if needed and adding the current url to the list of pages they're on + for (let rule of getAllSelectorRules(document)) { + let ruleId = ruleToId(rule); + let ruleData = this._knownRules.get(ruleId); + if (ruleData == null) { + ruleData = { + selectorText: rule.selectorText, + cssText: rule.cssText, + test: getTestSelector(rule.selectorText), + isUsed: false, + presentOn: new Set(), + preLoadOn: new Set(), + isError: false + }; + this._knownRules.set(ruleId, ruleData); + } + + ruleData.presentOn.add(url); + } + }, + + /** + * Update knownRules with usage information from the current page + */ + _updateUsage: function (document, isLoad) { + let qsaCount = 0; + + // Update this._data with matches to say 'used at load time' by sheet X + let url = getURL(document); + + for (let [ , ruleData ] of this._knownRules) { + // If it broke before, don't try again selectors don't change + if (ruleData.isError) { + continue; + } + + // If it's used somewhere already, don't bother checking again unless + // this is a load event in which case we need to add preLoadOn + if (!isLoad && ruleData.isUsed) { + continue; + } + + // Ignore rules that are not present on this page + if (!ruleData.presentOn.has(url)) { + continue; + } + + qsaCount++; + if (qsaCount > MAX_UNUSED_RULES) { + console.error("Too many unused rules on " + url + " "); + this._tooManyUnused = true; + continue; + } + + try { + let match = document.querySelector(ruleData.test); + if (match != null) { + ruleData.isUsed = true; + if (isLoad) { + ruleData.preLoadOn.add(url); + } + } + } catch (ex) { + ruleData.isError = true; + } + } + }, + + /** + * Returns a JSONable structure designed to help marking up the style editor, + * which describes the CSS selector usage. + * Example: + * [ + * { + * selectorText: "p#content", + * usage: "unused|used", + * start: { line: 3, column: 0 }, + * }, + * ... + * ] + */ + createEditorReport: function (url) { + if (this._knownRules == null) { + return { reports: [] }; + } + + let reports = []; + for (let [ruleId, ruleData] of this._knownRules) { + let { url: ruleUrl, line, column } = deconstructRuleId(ruleId); + if (ruleUrl !== url || ruleData.isUsed) { + continue; + } + + let ruleReport = { + selectorText: ruleData.selectorText, + start: { line: line, column: column } + }; + + if (ruleData.end) { + ruleReport.end = ruleData.end; + } + + reports.push(ruleReport); + } + + return { reports: reports }; + }, + + /** + * Compute the stylesheet URL and delegate the report creation to createEditorReport. + * See createEditorReport documentation. + * + * @param {StyleSheetActor} stylesheetActor + * the stylesheet actor for which the coverage report should be generated. + */ + createEditorReportForSheet: function (stylesheetActor) { + let url = sheetToUrl(stylesheetActor.rawSheet); + return this.createEditorReport(url); + }, + + /** + * Returns a JSONable structure designed for the page report which shows + * the recommended changes to a page. + * + * "preload" means that a rule is used before the load event happens, which + * means that the page could by optimized by placing it in a <style> element + * at the top of the page, moving the <link> elements to the bottom. + * + * Example: + * { + * preload: [ + * { + * url: "http://example.org/page1.html", + * shortUrl: "page1.html", + * rules: [ + * { + * url: "http://example.org/style1.css", + * shortUrl: "style1.css", + * start: { line: 3, column: 4 }, + * selectorText: "p#content", + * formattedCssText: "p#content {\n color: red;\n }\n" + * }, + * ... + * ] + * } + * ], + * unused: [ + * { + * url: "http://example.org/style1.css", + * shortUrl: "style1.css", + * rules: [ ... ] + * } + * ] + * } + */ + createPageReport: function () { + if (this._running) { + throw new Error(l10n.lookup("csscoverageRunningError")); + } + + if (this._visitedPages == null) { + throw new Error(l10n.lookup("csscoverageNotRunError")); + } + + if (this._isOneShot) { + throw new Error(l10n.lookup("csscoverageOneShotReportError")); + } + + // Helper function to create a JSONable data structure representing a rule + const ruleToRuleReport = function (rule, ruleData) { + return { + url: rule.url, + shortUrl: rule.url.split("/").slice(-1)[0], + start: { line: rule.line, column: rule.column }, + selectorText: ruleData.selectorText, + formattedCssText: prettifyCSS(ruleData.cssText) + }; + }; + + // A count of each type of rule for the bar chart + let summary = { used: 0, unused: 0, preload: 0 }; + + // Create the set of the unused rules + let unusedMap = new Map(); + for (let [ruleId, ruleData] of this._knownRules) { + let rule = deconstructRuleId(ruleId); + let rules = unusedMap.get(rule.url); + if (rules == null) { + rules = []; + unusedMap.set(rule.url, rules); + } + if (!ruleData.isUsed) { + let ruleReport = ruleToRuleReport(rule, ruleData); + rules.push(ruleReport); + } else { + summary.unused++; + } + } + let unused = []; + for (let [url, rules] of unusedMap) { + unused.push({ + url: url, + shortUrl: url.split("/").slice(-1), + rules: rules + }); + } + + // Create the set of rules that could be pre-loaded + let preload = []; + for (let url of this._visitedPages) { + let page = { + url: url, + shortUrl: url.split("/").slice(-1), + rules: [] + }; + + for (let [ruleId, ruleData] of this._knownRules) { + if (ruleData.preLoadOn.has(url)) { + let rule = deconstructRuleId(ruleId); + let ruleReport = ruleToRuleReport(rule, ruleData); + page.rules.push(ruleReport); + summary.preload++; + } else { + summary.used++; + } + } + + if (page.rules.length > 0) { + preload.push(page); + } + } + + return { + summary: summary, + preload: preload, + unused: unused + }; + }, + + /** + * For testing only. What pages did we visit. + */ + _testOnlyVisitedPages: function () { + return [...this._visitedPages]; + }, +}); + +exports.CSSUsageActor = CSSUsageActor; + +/** + * Generator that filters the CSSRules out of _getAllRules so it only + * iterates over the CSSStyleRules + */ +function* getAllSelectorRules(document) { + for (let rule of getAllRules(document)) { + if (rule.type === CSSRule.STYLE_RULE && rule.selectorText !== "") { + yield rule; + } + } +} + +/** + * Generator to iterate over the CSSRules in all the stylesheets the + * current document (i.e. it includes import rules, media rules, etc) + */ +function* getAllRules(document) { + // sheets is an array of the <link> and <style> element in this document + let sheets = getAllSheets(document); + for (let i = 0; i < sheets.length; i++) { + for (let j = 0; j < sheets[i].cssRules.length; j++) { + yield sheets[i].cssRules[j]; + } + } +} + +/** + * Get an array of all the stylesheets that affect this document. That means + * the <link> and <style> based sheets, and the @imported sheets (recursively) + * but not the sheets in nested frames. + */ +function getAllSheets(document) { + // sheets is an array of the <link> and <style> element in this document + let sheets = Array.slice(document.styleSheets); + // Add @imported sheets + for (let i = 0; i < sheets.length; i++) { + let subSheets = getImportedSheets(sheets[i]); + sheets = sheets.concat(...subSheets); + } + return sheets; +} + +/** + * Recursively find @import rules in the given stylesheet. + * We're relying on the browser giving rule.styleSheet == null to resolve + * @import loops + */ +function getImportedSheets(stylesheet) { + let sheets = []; + for (let i = 0; i < stylesheet.cssRules.length; i++) { + let rule = stylesheet.cssRules[i]; + // rule.styleSheet == null with duplicate @imports for the same URL. + if (rule.type === CSSRule.IMPORT_RULE && rule.styleSheet != null) { + sheets.push(rule.styleSheet); + let subSheets = getImportedSheets(rule.styleSheet); + sheets = sheets.concat(...subSheets); + } + } + return sheets; +} + +/** + * Get a unique identifier for a rule. This is currently the string + * <CSS-URL>|<START-LINE>|<START-COLUMN> + * @see deconstructRuleId(ruleId) + */ +function ruleToId(rule) { + let line = DOMUtils.getRelativeRuleLine(rule); + let column = DOMUtils.getRuleColumn(rule); + return sheetToUrl(rule.parentStyleSheet) + "|" + line + "|" + column; +} + +/** + * Convert a ruleId to an object with { url, line, column } properties + * @see ruleToId(rule) + */ +const deconstructRuleId = exports.deconstructRuleId = function (ruleId) { + let split = ruleId.split("|"); + if (split.length > 3) { + let replace = split.slice(0, split.length - 3 + 1).join("|"); + split.splice(0, split.length - 3 + 1, replace); + } + let [ url, line, column ] = split; + return { + url: url, + line: parseInt(line, 10), + column: parseInt(column, 10) + }; +}; + +/** + * We're only interested in the origin and pathname, because changes to the + * username, password, hash, or query string probably don't significantly + * change the CSS usage properties of a page. + * @param document + */ +const getURL = exports.getURL = function (document) { + let url = new document.defaultView.URL(document.documentURI); + return url == "about:blank" ? "" : "" + url.origin + url.pathname; +}; + +/** + * Pseudo class handling constants: + * We split pseudo-classes into a number of categories so we can decide how we + * should match them. See getTestSelector for how we use these constants. + * + * @see http://dev.w3.org/csswg/selectors4/#overview + * @see https://developer.mozilla.org/en-US/docs/tag/CSS%20Pseudo-class + * @see https://developer.mozilla.org/en-US/docs/Web/CSS/Pseudo-elements + */ + +/** + * Category 1: Pseudo-classes that depend on external browser/OS state + * This includes things like the time, locale, position of mouse/caret/window, + * contents of browser history, etc. These can be hard to mimic. + * Action: Remove from selectors + */ +const SEL_EXTERNAL = [ + "active", "active-drop", "current", "dir", "focus", "future", "hover", + "invalid-drop", "lang", "past", "placeholder-shown", "target", "valid-drop", + "visited" +]; + +/** + * Category 2: Pseudo-classes that depend on user-input state + * These are pseudo-classes that arguably *should* be covered by unit tests but + * which probably aren't and which are unlikely to be covered by manual tests. + * We're currently stripping them out, + * Action: Remove from selectors (but consider future command line flag to + * enable them in the future. e.g. 'csscoverage start --strict') + */ +const SEL_FORM = [ + "checked", "default", "disabled", "enabled", "fullscreen", "in-range", + "indeterminate", "invalid", "optional", "out-of-range", "required", "valid" +]; + +/** + * Category 3: Pseudo-elements + * querySelectorAll doesn't return matches with pseudo-elements because there + * is no element to match (they're pseudo) so we have to remove them all. + * (See http://codepen.io/joewalker/pen/sanDw for a demo) + * Action: Remove from selectors (including deprecated single colon versions) + */ +const SEL_ELEMENT = [ + "after", "before", "first-letter", "first-line", "selection" +]; + +/** + * Category 4: Structural pseudo-classes + * This is a category defined by the spec (also called tree-structural and + * grid-structural) for selection based on relative position in the document + * tree that cannot be represented by other simple selectors or combinators. + * Action: Require a page-match + */ +const SEL_STRUCTURAL = [ + "empty", "first-child", "first-of-type", "last-child", "last-of-type", + "nth-column", "nth-last-column", "nth-child", "nth-last-child", + "nth-last-of-type", "nth-of-type", "only-child", "only-of-type", "root" +]; + +/** + * Category 4a: Semi-structural pseudo-classes + * These are not structural according to the spec, but act nevertheless on + * information in the document tree. + * Action: Require a page-match + */ +const SEL_SEMI = [ "any-link", "link", "read-only", "read-write", "scope" ]; + +/** + * Category 5: Combining pseudo-classes + * has(), not() etc join selectors together in various ways. We take care when + * removing pseudo-classes to convert "not(:hover)" into "not(*)" and so on. + * With these changes the combining pseudo-classes should probably stand on + * their own. + * Action: Require a page-match + */ +const SEL_COMBINING = [ "not", "has", "matches" ]; + +/** + * Category 6: Media pseudo-classes + * Pseudo-classes that should be ignored because they're only relevant to + * media queries + * Action: Don't need removing from selectors as they appear in media queries + */ +const SEL_MEDIA = [ "blank", "first", "left", "right" ]; + +/** + * A test selector is a reduced form of a selector that we actually test + * against. This code strips out pseudo-elements and some pseudo-classes that + * we think should not have to match in order for the selector to be relevant. + */ +function getTestSelector(selector) { + let replacement = selector; + let replaceSelector = pseudo => { + replacement = replacement.replace(" :" + pseudo, " *") + .replace("(:" + pseudo, "(*") + .replace(":" + pseudo, ""); + }; + + SEL_EXTERNAL.forEach(replaceSelector); + SEL_FORM.forEach(replaceSelector); + SEL_ELEMENT.forEach(replaceSelector); + + // Pseudo elements work in : and :: forms + SEL_ELEMENT.forEach(pseudo => { + replacement = replacement.replace("::" + pseudo, ""); + }); + + return replacement; +} + +/** + * I've documented all known pseudo-classes above for 2 reasons: To allow + * checking logic and what might be missing, but also to allow a unit test + * that fetches the list of supported pseudo-classes and pseudo-elements from + * the platform and check that they were all represented here. + */ +exports.SEL_ALL = [ + SEL_EXTERNAL, SEL_FORM, SEL_ELEMENT, SEL_STRUCTURAL, SEL_SEMI, + SEL_COMBINING, SEL_MEDIA +].reduce(function (prev, curr) { + return prev.concat(curr); +}, []); + +/** + * Find a URL for a given stylesheet + * @param {StyleSheet} stylesheet raw stylesheet + */ +const sheetToUrl = function (stylesheet) { + // For <link> elements + if (stylesheet.href) { + return stylesheet.href; + } + + // For <style> elements + if (stylesheet.ownerNode) { + let document = stylesheet.ownerNode.ownerDocument; + let sheets = [...document.querySelectorAll("style")]; + let index = sheets.indexOf(stylesheet.ownerNode); + return getURL(document) + " → <style> index " + index; + } + + throw new Error("Unknown sheet source"); +}; diff --git a/devtools/server/actors/device.js b/devtools/server/actors/device.js new file mode 100644 index 0000000000..bf400913ab --- /dev/null +++ b/devtools/server/actors/device.js @@ -0,0 +1,70 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const {Ci} = require("chrome"); +const Services = require("Services"); +const protocol = require("devtools/shared/protocol"); +const promise = require("promise"); +const {LongStringActor} = require("devtools/server/actors/string"); +const {DebuggerServer} = require("devtools/server/main"); +const {getSystemInfo, getSetting} = require("devtools/shared/system"); +const {deviceSpec} = require("devtools/shared/specs/device"); +const FileReader = require("FileReader"); +const {PermissionsTable} = require("resource://gre/modules/PermissionsTable.jsm"); + +var DeviceActor = exports.DeviceActor = protocol.ActorClassWithSpec(deviceSpec, { + _desc: null, + + getDescription: function () { + return getSystemInfo(); + }, + + getWallpaper: function () { + let deferred = promise.defer(); + getSetting("wallpaper.image").then((blob) => { + let reader = new FileReader(); + let conn = this.conn; + reader.addEventListener("load", function () { + let str = new LongStringActor(conn, reader.result); + deferred.resolve(str); + }); + reader.addEventListener("error", function () { + deferred.reject(reader.error); + }); + reader.readAsDataURL(blob); + }); + return deferred.promise; + }, + + screenshotToDataURL: function () { + let window = Services.wm.getMostRecentWindow(DebuggerServer.chromeWindowType); + var devicePixelRatio = window.devicePixelRatio; + let canvas = window.document.createElementNS("http://www.w3.org/1999/xhtml", "canvas"); + let width = window.innerWidth; + let height = window.innerHeight; + canvas.setAttribute("width", Math.round(width * devicePixelRatio)); + canvas.setAttribute("height", Math.round(height * devicePixelRatio)); + let context = canvas.getContext("2d"); + let flags = + context.DRAWWINDOW_DRAW_CARET | + context.DRAWWINDOW_DRAW_VIEW | + context.DRAWWINDOW_USE_WIDGET_LAYERS; + context.scale(devicePixelRatio, devicePixelRatio); + context.drawWindow(window, 0, 0, width, height, "rgb(255,255,255)", flags); + let dataURL = canvas.toDataURL("image/png"); + return new LongStringActor(this.conn, dataURL); + }, + + getRawPermissionsTable: function () { + return { + rawPermissionsTable: PermissionsTable, + UNKNOWN_ACTION: Ci.nsIPermissionManager.UNKNOWN_ACTION, + ALLOW_ACTION: Ci.nsIPermissionManager.ALLOW_ACTION, + DENY_ACTION: Ci.nsIPermissionManager.DENY_ACTION, + PROMPT_ACTION: Ci.nsIPermissionManager.PROMPT_ACTION + }; + } +}); diff --git a/devtools/server/actors/director-manager.js b/devtools/server/actors/director-manager.js new file mode 100644 index 0000000000..027a456db3 --- /dev/null +++ b/devtools/server/actors/director-manager.js @@ -0,0 +1,615 @@ +/* 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 events = require("sdk/event/core"); +const protocol = require("devtools/shared/protocol"); + +const { Cu, Ci } = require("chrome"); + +const { on, once, off, emit } = events; +const { method, Arg, Option, RetVal, types } = protocol; + +const { sandbox, evaluate } = require("sdk/loader/sandbox"); +const { Class } = require("sdk/core/heritage"); + +const { PlainTextConsole } = require("sdk/console/plain-text"); + +const { DirectorRegistry } = require("./director-registry"); + +const { + messagePortSpec, + directorManagerSpec, + directorScriptSpec, +} = require("devtools/shared/specs/director-manager"); + +/** + * Error Messages + */ + +const ERR_MESSAGEPORT_FINALIZED = "message port finalized"; + +const ERR_DIRECTOR_UNKNOWN_SCRIPTID = "unkown director-script id"; +const ERR_DIRECTOR_UNINSTALLED_SCRIPTID = "uninstalled director-script id"; + +/** + * A MessagePort Actor allowing communication through messageport events + * over the remote debugging protocol. + */ +var MessagePortActor = exports.MessagePortActor = protocol.ActorClassWithSpec(messagePortSpec, { + /** + * Create a MessagePort actor. + * + * @param DebuggerServerConnection conn + * The server connection. + * @param MessagePort port + * The wrapped MessagePort. + */ + initialize: function (conn, port) { + protocol.Actor.prototype.initialize.call(this, conn); + + // NOTE: can't get a weak reference because we need to subscribe events + // using port.onmessage or addEventListener + this.port = port; + }, + + destroy: function (conn) { + protocol.Actor.prototype.destroy.call(this, conn); + this.finalize(); + }, + + /** + * Sends a message on the wrapped message port. + * + * @param Object msg + * The JSON serializable message event payload + */ + postMessage: function (msg) { + if (!this.port) { + console.error(ERR_MESSAGEPORT_FINALIZED); + return; + } + + this.port.postMessage(msg); + }, + + /** + * Starts to receive and send queued messages on this message port. + */ + start: function () { + if (!this.port) { + console.error(ERR_MESSAGEPORT_FINALIZED); + return; + } + + // NOTE: set port.onmessage to a function is an implicit start + // and starts to send queued messages. + // On the client side we should set MessagePortClient.onmessage + // to a setter which register an handler to the message event + // and call the actor start method to start receiving messages + // from the MessagePort's queue. + this.port.onmessage = (evt) => { + var ports; + + // TODO: test these wrapped ports + if (Array.isArray(evt.ports)) { + ports = evt.ports.map((port) => { + let actor = new MessagePortActor(this.conn, port); + this.manage(actor); + return actor; + }); + } + + emit(this, "message", { + isTrusted: evt.isTrusted, + data: evt.data, + origin: evt.origin, + lastEventId: evt.lastEventId, + source: this, + ports: ports + }); + }; + }, + + /** + * Starts to receive and send queued messages on this message port, or + * raise an exception if the port is null + */ + close: function () { + if (!this.port) { + console.error(ERR_MESSAGEPORT_FINALIZED); + return; + } + + try { + this.port.onmessage = null; + this.port.close(); + } catch (e) { + // The port might be a dead object + console.error(e); + } + }, + + finalize: function () { + this.close(); + this.port = null; + }, +}); + +/** + * The Director Script Actor manage javascript code running in a non-privileged sandbox with the same + * privileges of the target global (browser tab or a firefox os app). + * + * After retrieving an instance of this actor (from the tab director actor), you'll need to set it up + * by calling setup(). + * + * After the setup, this actor will automatically attach/detach the content script (and optionally a + * directly connect the debugger client and the content script using a MessageChannel) on tab + * navigation. + */ +var DirectorScriptActor = exports.DirectorScriptActor = protocol.ActorClassWithSpec(directorScriptSpec, { + /** + * Creates the director script actor + * + * @param DebuggerServerConnection conn + * The server connection. + * @param Actor tabActor + * The tab (or root) actor. + * @param String scriptId + * The director-script id. + * @param String scriptCode + * The director-script javascript source. + * @param Object scriptOptions + * The director-script options object. + */ + initialize: function (conn, tabActor, { scriptId, scriptCode, scriptOptions }) { + protocol.Actor.prototype.initialize.call(this, conn, tabActor); + + this.tabActor = tabActor; + + this._scriptId = scriptId; + this._scriptCode = scriptCode; + this._scriptOptions = scriptOptions; + this._setupCalled = false; + + this._onGlobalCreated = this._onGlobalCreated.bind(this); + this._onGlobalDestroyed = this._onGlobalDestroyed.bind(this); + }, + destroy: function (conn) { + protocol.Actor.prototype.destroy.call(this, conn); + + this.finalize(); + }, + + /** + * Starts listening to the tab global created, in order to create the director-script sandbox + * using the configured scriptCode, attached/detached automatically to the tab + * window on tab navigation. + * + * @param Boolean reload + * attach the page immediately or reload it first. + * @param Boolean skipAttach + * skip the attach + */ + setup: function ({ reload, skipAttach }) { + if (this._setupCalled) { + // do nothing + return; + } + + this._setupCalled = true; + + on(this.tabActor, "window-ready", this._onGlobalCreated); + on(this.tabActor, "window-destroyed", this._onGlobalDestroyed); + + // optional skip attach (needed by director-manager for director scripts bulk activation) + if (skipAttach) { + return; + } + + if (reload) { + this.window.location.reload(); + } else { + // fake a global created event to attach without reload + this._onGlobalCreated({ id: getWindowID(this.window), window: this.window, isTopLevel: true }); + } + }, + + /** + * Get the attached MessagePort actor if any + */ + getMessagePort: function () { + return this._messagePortActor; + }, + + /** + * Stop listening for document global changes, destroy the content worker and puts + * this actor to hibernation. + */ + finalize: function () { + if (!this._setupCalled) { + return; + } + + off(this.tabActor, "window-ready", this._onGlobalCreated); + off(this.tabActor, "window-destroyed", this._onGlobalDestroyed); + + this._onGlobalDestroyed({ id: this._lastAttachedWinId }); + + this._setupCalled = false; + }, + + // local helpers + get window() { + return this.tabActor.window; + }, + + /* event handlers */ + _onGlobalCreated: function ({ id, window, isTopLevel }) { + if (!isTopLevel) { + // filter iframes + return; + } + + try { + if (this._lastAttachedWinId) { + // if we have received a global created without a previous global destroyed, + // it's time to cleanup the previous state + this._onGlobalDestroyed(this._lastAttachedWinId); + } + + // TODO: check if we want to share a single sandbox per global + // for multiple debugger clients + + // create & attach the new sandbox + this._scriptSandbox = new DirectorScriptSandbox({ + scriptId: this._scriptId, + scriptCode: this._scriptCode, + scriptOptions: this._scriptOptions + }); + + // attach the global window + this._lastAttachedWinId = id; + var port = this._scriptSandbox.attach(window, id); + this._onDirectorScriptAttach(window, port); + } catch (e) { + this._onDirectorScriptError(e); + } + }, + _onGlobalDestroyed: function ({ id }) { + if (id !== this._lastAttachedWinId) { + // filter destroyed globals + return; + } + + // unmanage and cleanup the messageport actor + if (this._messagePortActor) { + this.unmanage(this._messagePortActor); + this._messagePortActor = null; + } + + // NOTE: destroy here the old worker + if (this._scriptSandbox) { + this._scriptSandbox.destroy(this._onDirectorScriptError.bind(this)); + + // send a detach event to the debugger client + emit(this, "detach", { + directorScriptId: this._scriptId, + innerId: this._lastAttachedWinId + }); + + this._lastAttachedWinId = null; + this._scriptSandbox = null; + } + }, + _onDirectorScriptError: function (error) { + // route the content script error to the debugger client + if (error) { + // prevents silent director-script-errors + console.error("director-script-error", error); + // route errors to debugger server clients + emit(this, "error", { + directorScriptId: this._scriptId, + message: error.toString(), + stack: error.stack, + fileName: error.fileName, + lineNumber: error.lineNumber, + columnNumber: error.columnNumber + }); + } + }, + _onDirectorScriptAttach: function (window, port) { + let portActor = new MessagePortActor(this.conn, port); + this.manage(portActor); + this._messagePortActor = portActor; + + emit(this, "attach", { + directorScriptId: this._scriptId, + url: (window && window.location) ? window.location.toString() : "", + innerId: this._lastAttachedWinId, + port: this._messagePortActor + }); + } +}); + +/** + * The DirectorManager Actor is a tab actor which manages enabling/disabling director scripts. + */ +const DirectorManagerActor = exports.DirectorManagerActor = protocol.ActorClassWithSpec(directorManagerSpec, { + /* init & destroy methods */ + initialize: function (conn, tabActor) { + protocol.Actor.prototype.initialize.call(this, conn); + this.tabActor = tabActor; + this._directorScriptActorsMap = new Map(); + }, + destroy: function (conn) { + protocol.Actor.prototype.destroy.call(this, conn); + this.finalize(); + }, + + /** + * Retrieves the list of installed director-scripts. + */ + list: function () { + let enabledScriptIds = [...this._directorScriptActorsMap.keys()]; + + return { + installed: DirectorRegistry.list(), + enabled: enabledScriptIds + }; + }, + + /** + * Bulk enabling director-scripts. + * + * @param Array[String] selectedIds + * The list of director-script ids to be enabled, + * ["*"] will activate all the installed director-scripts + * @param Boolean reload + * optionally reload the target window + */ + enableByScriptIds: function (selectedIds, { reload }) { + if (selectedIds && selectedIds.length === 0) { + // filtered all director scripts ids + return; + } + + for (let scriptId of DirectorRegistry.list()) { + // filter director script ids + if (selectedIds.indexOf("*") < 0 && + selectedIds.indexOf(scriptId) < 0) { + continue; + } + + let actor = this.getByScriptId(scriptId); + + // skip attach if reload is true (activated director scripts + // will be automatically attached on the final reload) + actor.setup({ reload: false, skipAttach: reload }); + } + + if (reload) { + this.tabActor.window.location.reload(); + } + }, + + /** + * Bulk disabling director-scripts. + * + * @param Array[String] selectedIds + * The list of director-script ids to be disable, + * ["*"] will de-activate all the enable director-scripts + * @param Boolean reload + * optionally reload the target window + */ + disableByScriptIds: function (selectedIds, { reload }) { + if (selectedIds && selectedIds.length === 0) { + // filtered all director scripts ids + return; + } + + for (let scriptId of this._directorScriptActorsMap.keys()) { + // filter director script ids + if (selectedIds.indexOf("*") < 0 && + selectedIds.indexOf(scriptId) < 0) { + continue; + } + + let actor = this._directorScriptActorsMap.get(scriptId); + this._directorScriptActorsMap.delete(scriptId); + + // finalize the actor (which will produce director-script-detach event) + actor.finalize(); + // unsubscribe event handlers on the disabled actor + off(actor); + + this.unmanage(actor); + } + + if (reload) { + this.tabActor.window.location.reload(); + } + }, + + /** + * Retrieves the actor instance of an installed director-script + * (and create the actor instance if it doesn't exists yet). + */ + getByScriptId: function (scriptId) { + var id = scriptId; + // raise an unknown director-script id exception + if (!DirectorRegistry.checkInstalled(id)) { + console.error(ERR_DIRECTOR_UNKNOWN_SCRIPTID, id); + throw Error(ERR_DIRECTOR_UNKNOWN_SCRIPTID); + } + + // get a previous created actor instance + let actor = this._directorScriptActorsMap.get(id); + + // create a new actor instance + if (!actor) { + let directorScriptDefinition = DirectorRegistry.get(id); + + // test lazy director-script (e.g. uninstalled in the parent process) + if (!directorScriptDefinition) { + + console.error(ERR_DIRECTOR_UNINSTALLED_SCRIPTID, id); + throw Error(ERR_DIRECTOR_UNINSTALLED_SCRIPTID); + } + + actor = new DirectorScriptActor(this.conn, this.tabActor, directorScriptDefinition); + this._directorScriptActorsMap.set(id, actor); + + on(actor, "error", emit.bind(null, this, "director-script-error")); + on(actor, "attach", emit.bind(null, this, "director-script-attach")); + on(actor, "detach", emit.bind(null, this, "director-script-detach")); + + this.manage(actor); + } + + return actor; + }, + + finalize: function () { + this.disableByScriptIds(["*"], false); + } +}); + +/* private helpers */ + +/** + * DirectorScriptSandbox is a private utility class, which attach a non-priviliged sandbox + * to a target window. + */ +const DirectorScriptSandbox = Class({ + initialize: function ({scriptId, scriptCode, scriptOptions}) { + this._scriptId = scriptId; + this._scriptCode = scriptCode; + this._scriptOptions = scriptOptions; + }, + + attach: function (window, innerId) { + this._innerId = innerId, + this._window = window; + this._proto = Cu.createObjectIn(this._window); + + var id = this._scriptId; + var uri = this._scriptCode; + + this._sandbox = sandbox(window, { + sandboxName: uri, + sandboxPrototype: this._proto, + sameZoneAs: window, + wantXrays: true, + wantComponents: false, + wantExportHelpers: false, + metadata: { + URI: uri, + addonID: id, + SDKDirectorScript: true, + "inner-window-id": innerId + } + }); + + // create a CommonJS module object which match the interface from addon-sdk + // (addon-sdk/sources/lib/toolkit/loader.js#L678-L686) + var module = Cu.cloneInto(Object.create(null, { + id: { enumerable: true, value: id }, + uri: { enumerable: true, value: uri }, + exports: { enumerable: true, value: Cu.createObjectIn(this._sandbox) } + }), this._sandbox); + + // create a console API object + let directorScriptConsole = new PlainTextConsole(null, this._innerId); + + // inject CommonJS module globals into the sandbox prototype + Object.defineProperties(this._proto, { + module: { enumerable: true, value: module }, + exports: { enumerable: true, value: module.exports }, + console: { + enumerable: true, + value: Cu.cloneInto(directorScriptConsole, this._sandbox, { cloneFunctions: true }) + } + }); + + Object.defineProperties(this._sandbox, { + require: { + enumerable: true, + value: Cu.cloneInto(function () { + throw Error("NOT IMPLEMENTED"); + }, this._sandbox, { cloneFunctions: true }) + } + }); + + // TODO: if the debugger target is local, the debugger client could pass + // to the director actor the resource url instead of the entire javascript source code. + + // evaluate the director script source in the sandbox + evaluate(this._sandbox, this._scriptCode, "javascript:" + this._scriptCode); + + // prepare the messageport connected to the debugger client + let { port1, port2 } = new this._window.MessageChannel(); + + // prepare the unload callbacks queue + var sandboxOnUnloadQueue = this._sandboxOnUnloadQueue = []; + + // create the attach options + var attachOptions = this._attachOptions = Cu.createObjectIn(this._sandbox); + Object.defineProperties(attachOptions, { + port: { enumerable: true, value: port1 }, + window: { enumerable: true, value: window }, + scriptOptions: { enumerable: true, value: Cu.cloneInto(this._scriptOptions, this._sandbox) }, + onUnload: { + enumerable: true, + value: Cu.cloneInto(function (cb) { + // collect unload callbacks + if (typeof cb == "function") { + sandboxOnUnloadQueue.push(cb); + } + }, this._sandbox, { cloneFunctions: true }) + } + }); + + // select the attach method + var exports = this._proto.module.exports; + if (this._scriptOptions && "attachMethod" in this._scriptOptions) { + this._sandboxOnAttach = exports[this._scriptOptions.attachMethod]; + } else { + this._sandboxOnAttach = exports; + } + + if (typeof this._sandboxOnAttach !== "function") { + throw Error("the configured attachMethod '" + + (this._scriptOptions.attachMethod || "module.exports") + + "' is not exported by the directorScript"); + } + + // call the attach method + this._sandboxOnAttach.call(this._sandbox, attachOptions); + + return port2; + }, + destroy: function (onError) { + // evaluate queue unload methods if any + while (this._sandboxOnUnloadQueue && this._sandboxOnUnloadQueue.length > 0) { + let cb = this._sandboxOnUnloadQueue.pop(); + + try { + cb(); + } catch (e) { + console.error("Exception on DirectorScript Sandbox destroy", e); + onError(e); + } + } + + Cu.nukeSandbox(this._sandbox); + } +}); + +function getWindowID(window) { + return window.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDOMWindowUtils) + .currentInnerWindowID; +} diff --git a/devtools/server/actors/director-registry.js b/devtools/server/actors/director-registry.js new file mode 100644 index 0000000000..54584bcded --- /dev/null +++ b/devtools/server/actors/director-registry.js @@ -0,0 +1,254 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2; js-indent-level: 2 -*- */ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* 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 protocol = require("devtools/shared/protocol"); + +const {DebuggerServer} = require("devtools/server/main"); + +const {directorRegistrySpec} = require("devtools/shared/specs/director-registry"); + +/** + * Error Messages + */ + +const ERR_DIRECTOR_INSTALL_TWICE = "Trying to install a director-script twice"; +const ERR_DIRECTOR_INSTALL_EMPTY = "Trying to install an empty director-script"; +const ERR_DIRECTOR_UNINSTALL_UNKNOWN = "Trying to uninstall an unkown director-script"; + +const ERR_DIRECTOR_PARENT_UNKNOWN_METHOD = "Unknown parent process method"; +const ERR_DIRECTOR_CHILD_NOTIMPLEMENTED_METHOD = "Unexpected call to notImplemented method"; +const ERR_DIRECTOR_CHILD_MULTIPLE_REPLIES = "Unexpected multiple replies to called parent method"; +const ERR_DIRECTOR_CHILD_NO_REPLY = "Unexpected no reply to called parent method"; + +/** + * Director Registry + */ + +// Map of director scripts ids to director script definitions +var gDirectorScripts = Object.create(null); + +const DirectorRegistry = exports.DirectorRegistry = { + /** + * Register a Director Script with the debugger server. + * @param id string + * The ID of a director script. + * @param directorScriptDef object + * The definition of a director script. + */ + install: function (id, scriptDef) { + if (id in gDirectorScripts) { + console.error(ERR_DIRECTOR_INSTALL_TWICE, id); + return false; + } + + if (!scriptDef) { + console.error(ERR_DIRECTOR_INSTALL_EMPTY, id); + return false; + } + + gDirectorScripts[id] = scriptDef; + + return true; + }, + + /** + * Unregister a Director Script with the debugger server. + * @param id string + * The ID of a director script. + */ + uninstall: function (id) { + if (id in gDirectorScripts) { + delete gDirectorScripts[id]; + + return true; + } + + console.error(ERR_DIRECTOR_UNINSTALL_UNKNOWN, id); + + return false; + }, + + /** + * Returns true if a director script id has been registered. + * @param id string + * The ID of a director script. + */ + checkInstalled: function (id) { + return (this.list().indexOf(id) >= 0); + }, + + /** + * Returns a registered director script definition by id. + * @param id string + * The ID of a director script. + */ + get: function (id) { + return gDirectorScripts[id]; + }, + + /** + * Returns an array of registered director script ids. + */ + list: function () { + return Object.keys(gDirectorScripts); + }, + + /** + * Removes all the registered director scripts. + */ + clear: function () { + gDirectorScripts = Object.create(null); + } +}; + +/** + * E10S parent/child setup helpers + */ + +exports.setupParentProcess = function setupParentProcess({ mm, prefix }) { + // listen for director-script requests from the child process + setMessageManager(mm); + + /* parent process helpers */ + + function handleChildRequest(msg) { + switch (msg.json.method) { + case "get": + return DirectorRegistry.get(msg.json.args[0]); + case "list": + return DirectorRegistry.list(); + default: + console.error(ERR_DIRECTOR_PARENT_UNKNOWN_METHOD, msg.json.method); + throw new Error(ERR_DIRECTOR_PARENT_UNKNOWN_METHOD); + } + } + + function setMessageManager(newMM) { + if (mm) { + mm.removeMessageListener("debug:director-registry-request", handleChildRequest); + } + mm = newMM; + if (mm) { + mm.addMessageListener("debug:director-registry-request", handleChildRequest); + } + } + + return { + onBrowserSwap: setMessageManager, + onDisconnected: () => setMessageManager(null), + }; +}; + +/** + * The DirectorRegistry Actor is a global actor which manages install/uninstall of + * director scripts definitions. + */ +const DirectorRegistryActor = exports.DirectorRegistryActor = protocol.ActorClassWithSpec(directorRegistrySpec, { + /* init & destroy methods */ + initialize: function (conn, parentActor) { + protocol.Actor.prototype.initialize.call(this, conn); + this.maybeSetupChildProcess(conn); + }, + destroy: function (conn) { + protocol.Actor.prototype.destroy.call(this, conn); + this.finalize(); + }, + + finalize: function () { + // nothing to cleanup + }, + + maybeSetupChildProcess(conn) { + // skip child setup if this actor module is not running in a child process + if (!DebuggerServer.isInChildProcess) { + return; + } + + const { sendSyncMessage } = conn.parentMessageManager; + + conn.setupInParent({ + module: "devtools/server/actors/director-registry", + setupParent: "setupParentProcess" + }); + + DirectorRegistry.install = notImplemented.bind(null, "install"); + DirectorRegistry.uninstall = notImplemented.bind(null, "uninstall"); + DirectorRegistry.clear = notImplemented.bind(null, "clear"); + + DirectorRegistry.get = callParentProcess.bind(null, "get"); + DirectorRegistry.list = callParentProcess.bind(null, "list"); + + /* child process helpers */ + + function notImplemented(method) { + console.error(ERR_DIRECTOR_CHILD_NOTIMPLEMENTED_METHOD, method); + throw Error(ERR_DIRECTOR_CHILD_NOTIMPLEMENTED_METHOD); + } + + function callParentProcess(method, ...args) { + var reply = sendSyncMessage("debug:director-registry-request", { + method: method, + args: args + }); + + if (reply.length === 0) { + console.error(ERR_DIRECTOR_CHILD_NO_REPLY); + throw Error(ERR_DIRECTOR_CHILD_NO_REPLY); + } else if (reply.length > 1) { + console.error(ERR_DIRECTOR_CHILD_MULTIPLE_REPLIES); + throw Error(ERR_DIRECTOR_CHILD_MULTIPLE_REPLIES); + } + + return reply[0]; + } + }, + + /** + * Install a new director-script definition. + * + * @param String id + * The director-script definition identifier. + * @param String scriptCode + * The director-script javascript source. + * @param Object scriptOptions + * The director-script option object. + */ + install: function (id, { scriptCode, scriptOptions }) { + // TODO: add more checks on id format? + if (!id || id.length === 0) { + throw Error("director-script id is mandatory"); + } + + if (!scriptCode) { + throw Error("director-script scriptCode is mandatory"); + } + + return DirectorRegistry.install(id, { + scriptId: id, + scriptCode: scriptCode, + scriptOptions: scriptOptions + }); + }, + + /** + * Uninstall a director-script definition. + * + * @param String id + * The identifier of the director-script definition to be removed + */ + uninstall: function (id) { + return DirectorRegistry.uninstall(id); + }, + + /** + * Retrieves the list of installed director-scripts. + */ + list: function () { + return DirectorRegistry.list(); + } +}); diff --git a/devtools/server/actors/emulation.js b/devtools/server/actors/emulation.js new file mode 100644 index 0000000000..b69183305c --- /dev/null +++ b/devtools/server/actors/emulation.js @@ -0,0 +1,241 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { Ci } = require("chrome"); +const protocol = require("devtools/shared/protocol"); +const { emulationSpec } = require("devtools/shared/specs/emulation"); +const { SimulatorCore } = require("devtools/shared/touch/simulator-core"); + +/** + * This actor overrides various browser features to simulate different environments to + * test how pages perform under various conditions. + * + * The design below, which saves the previous value of each property before setting, is + * needed because it's possible to have multiple copies of this actor for a single page. + * When some instance of this actor changes a property, we want it to be able to restore + * that property to the way it was found before the change. + * + * A subtle aspect of the code below is that all get* methods must return non-undefined + * values, so that the absence of a previous value can be distinguished from the value for + * "no override" for each of the properties. + */ +let EmulationActor = protocol.ActorClassWithSpec(emulationSpec, { + + initialize(conn, tabActor) { + protocol.Actor.prototype.initialize.call(this, conn); + this.tabActor = tabActor; + this.docShell = tabActor.docShell; + this.simulatorCore = new SimulatorCore(tabActor.chromeEventHandler); + }, + + disconnect() { + this.destroy(); + }, + + destroy() { + this.clearDPPXOverride(); + this.clearNetworkThrottling(); + this.clearTouchEventsOverride(); + this.clearUserAgentOverride(); + this.tabActor = null; + this.docShell = null; + this.simulatorCore = null; + protocol.Actor.prototype.destroy.call(this); + }, + + /** + * Retrieve the console actor for this tab. This allows us to expose network throttling + * as part of emulation settings, even though it's internally connected to the network + * monitor, which for historical reasons is part of the console actor. + */ + get _consoleActor() { + if (this.tabActor.exited) { + return null; + } + let form = this.tabActor.form(); + return this.conn._getOrCreateActor(form.consoleActor); + }, + + /* DPPX override */ + + _previousDPPXOverride: undefined, + + setDPPXOverride(dppx) { + if (this.getDPPXOverride() === dppx) { + return false; + } + + if (this._previousDPPXOverride === undefined) { + this._previousDPPXOverride = this.getDPPXOverride(); + } + + this.docShell.contentViewer.overrideDPPX = dppx; + + return true; + }, + + getDPPXOverride() { + return this.docShell.contentViewer.overrideDPPX; + }, + + clearDPPXOverride() { + if (this._previousDPPXOverride !== undefined) { + return this.setDPPXOverride(this._previousDPPXOverride); + } + + return false; + }, + + /* Network Throttling */ + + _previousNetworkThrottling: undefined, + + /** + * Transform the RDP format into the internal format and then set network throttling. + */ + setNetworkThrottling({ downloadThroughput, uploadThroughput, latency }) { + let throttleData = { + roundTripTimeMean: latency, + roundTripTimeMax: latency, + downloadBPSMean: downloadThroughput, + downloadBPSMax: downloadThroughput, + uploadBPSMean: uploadThroughput, + uploadBPSMax: uploadThroughput, + }; + return this._setNetworkThrottling(throttleData); + }, + + _setNetworkThrottling(throttleData) { + let current = this._getNetworkThrottling(); + // Check if they are both objects or both null + let match = throttleData == current; + // If both objects, check all entries + if (match && current && throttleData) { + match = Object.entries(current).every(([ k, v ]) => { + return throttleData[k] === v; + }); + } + if (match) { + return false; + } + + if (this._previousNetworkThrottling === undefined) { + this._previousNetworkThrottling = current; + } + + let consoleActor = this._consoleActor; + if (!consoleActor) { + return false; + } + consoleActor.onStartListeners({ + listeners: [ "NetworkActivity" ], + }); + consoleActor.onSetPreferences({ + preferences: { + "NetworkMonitor.throttleData": throttleData, + } + }); + return true; + }, + + /** + * Get network throttling and then transform the internal format into the RDP format. + */ + getNetworkThrottling() { + let throttleData = this._getNetworkThrottling(); + if (!throttleData) { + return null; + } + let { downloadBPSMax, uploadBPSMax, roundTripTimeMax } = throttleData; + return { + downloadThroughput: downloadBPSMax, + uploadThroughput: uploadBPSMax, + latency: roundTripTimeMax, + }; + }, + + _getNetworkThrottling() { + let consoleActor = this._consoleActor; + if (!consoleActor) { + return null; + } + let prefs = consoleActor.onGetPreferences({ + preferences: [ "NetworkMonitor.throttleData" ], + }); + return prefs.preferences["NetworkMonitor.throttleData"] || null; + }, + + clearNetworkThrottling() { + if (this._previousNetworkThrottling !== undefined) { + return this._setNetworkThrottling(this._previousNetworkThrottling); + } + + return false; + }, + + /* Touch events override */ + + _previousTouchEventsOverride: undefined, + + setTouchEventsOverride(flag) { + if (this.getTouchEventsOverride() == flag) { + return false; + } + if (this._previousTouchEventsOverride === undefined) { + this._previousTouchEventsOverride = this.getTouchEventsOverride(); + } + + // Start or stop the touch simulator depending on the override flag + if (flag == Ci.nsIDocShell.TOUCHEVENTS_OVERRIDE_ENABLED) { + this.simulatorCore.start(); + } else { + this.simulatorCore.stop(); + } + + this.docShell.touchEventsOverride = flag; + return true; + }, + + getTouchEventsOverride() { + return this.docShell.touchEventsOverride; + }, + + clearTouchEventsOverride() { + if (this._previousTouchEventsOverride !== undefined) { + return this.setTouchEventsOverride(this._previousTouchEventsOverride); + } + return false; + }, + + /* User agent override */ + + _previousUserAgentOverride: undefined, + + setUserAgentOverride(userAgent) { + if (this.getUserAgentOverride() == userAgent) { + return false; + } + if (this._previousUserAgentOverride === undefined) { + this._previousUserAgentOverride = this.getUserAgentOverride(); + } + this.docShell.customUserAgent = userAgent; + return true; + }, + + getUserAgentOverride() { + return this.docShell.customUserAgent; + }, + + clearUserAgentOverride() { + if (this._previousUserAgentOverride !== undefined) { + return this.setUserAgentOverride(this._previousUserAgentOverride); + } + return false; + }, + +}); + +exports.EmulationActor = EmulationActor; diff --git a/devtools/server/actors/environment.js b/devtools/server/actors/environment.js new file mode 100644 index 0000000000..bd03586eb1 --- /dev/null +++ b/devtools/server/actors/environment.js @@ -0,0 +1,199 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2; js-indent-level: 2 -*- */ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* 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 { ActorClassWithSpec } = require("devtools/shared/protocol"); +const { createValueGrip } = require("devtools/server/actors/object"); +const { environmentSpec } = require("devtools/shared/specs/environment"); + +/** + * Creates an EnvironmentActor. EnvironmentActors are responsible for listing + * the bindings introduced by a lexical environment and assigning new values to + * those identifier bindings. + * + * @param Debugger.Environment aEnvironment + * The lexical environment that will be used to create the actor. + * @param ThreadActor aThreadActor + * The parent thread actor that contains this environment. + */ +let EnvironmentActor = ActorClassWithSpec(environmentSpec, { + initialize: function (environment, threadActor) { + this.obj = environment; + this.threadActor = threadActor; + }, + + /** + * Return an environment form for use in a protocol message. + */ + form: function () { + let form = { actor: this.actorID }; + + // What is this environment's type? + if (this.obj.type == "declarative") { + form.type = this.obj.callee ? "function" : "block"; + } else { + form.type = this.obj.type; + } + + // Does this environment have a parent? + if (this.obj.parent) { + form.parent = (this.threadActor + .createEnvironmentActor(this.obj.parent, + this.registeredPool) + .form()); + } + + // Does this environment reflect the properties of an object as variables? + if (this.obj.type == "object" || this.obj.type == "with") { + form.object = createValueGrip(this.obj.object, + this.registeredPool, this.threadActor.objectGrip); + } + + // Is this the environment created for a function call? + if (this.obj.callee) { + form.function = createValueGrip(this.obj.callee, + this.registeredPool, this.threadActor.objectGrip); + } + + // Shall we list this environment's bindings? + if (this.obj.type == "declarative") { + form.bindings = this.bindings(); + } + + return form; + }, + + /** + * Handle a protocol request to change the value of a variable bound in this + * lexical environment. + * + * @param string name + * The name of the variable to be changed. + * @param any value + * The value to be assigned. + */ + assign: function (name, value) { + // TODO: enable the commented-out part when getVariableDescriptor lands + // (bug 725815). + /* let desc = this.obj.getVariableDescriptor(name); + + if (!desc.writable) { + return { error: "immutableBinding", + message: "Changing the value of an immutable binding is not " + + "allowed" }; + }*/ + + try { + this.obj.setVariable(name, value); + } catch (e) { + if (e instanceof Debugger.DebuggeeWouldRun) { + throw { + error: "threadWouldRun", + message: "Assigning a value would cause the debuggee to run" + }; + } else { + throw e; + } + } + return { from: this.actorID }; + }, + + /** + * Handle a protocol request to fully enumerate the bindings introduced by the + * lexical environment. + */ + bindings: function () { + let bindings = { arguments: [], variables: {} }; + + // TODO: this part should be removed in favor of the commented-out part + // below when getVariableDescriptor lands (bug 725815). + if (typeof this.obj.getVariable != "function") { + // if (typeof this.obj.getVariableDescriptor != "function") { + return bindings; + } + + let parameterNames; + if (this.obj.callee) { + parameterNames = this.obj.callee.parameterNames; + } else { + parameterNames = []; + } + for (let name of parameterNames) { + let arg = {}; + let value = this.obj.getVariable(name); + + // TODO: this part should be removed in favor of the commented-out part + // below when getVariableDescriptor lands (bug 725815). + let desc = { + value: value, + configurable: false, + writable: !(value && value.optimizedOut), + enumerable: true + }; + + // let desc = this.obj.getVariableDescriptor(name); + let descForm = { + enumerable: true, + configurable: desc.configurable + }; + if ("value" in desc) { + descForm.value = createValueGrip(desc.value, + this.registeredPool, this.threadActor.objectGrip); + descForm.writable = desc.writable; + } else { + descForm.get = createValueGrip(desc.get, this.registeredPool, + this.threadActor.objectGrip); + descForm.set = createValueGrip(desc.set, this.registeredPool, + this.threadActor.objectGrip); + } + arg[name] = descForm; + bindings.arguments.push(arg); + } + + for (let name of this.obj.names()) { + if (bindings.arguments.some(function exists(element) { + return !!element[name]; + })) { + continue; + } + + let value = this.obj.getVariable(name); + + // TODO: this part should be removed in favor of the commented-out part + // below when getVariableDescriptor lands. + let desc = { + value: value, + configurable: false, + writable: !(value && + (value.optimizedOut || + value.uninitialized || + value.missingArguments)), + enumerable: true + }; + + // let desc = this.obj.getVariableDescriptor(name); + let descForm = { + enumerable: true, + configurable: desc.configurable + }; + if ("value" in desc) { + descForm.value = createValueGrip(desc.value, + this.registeredPool, this.threadActor.objectGrip); + descForm.writable = desc.writable; + } else { + descForm.get = createValueGrip(desc.get || undefined, + this.registeredPool, this.threadActor.objectGrip); + descForm.set = createValueGrip(desc.set || undefined, + this.registeredPool, this.threadActor.objectGrip); + } + bindings.variables[name] = descForm; + } + + return bindings; + } +}); + +exports.EnvironmentActor = EnvironmentActor; diff --git a/devtools/server/actors/errordocs.js b/devtools/server/actors/errordocs.js new file mode 100644 index 0000000000..27f687dc77 --- /dev/null +++ b/devtools/server/actors/errordocs.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/. */ + +/** + * A mapping of error message names to external documentation. Any error message + * included here will be displayed alongside its link in the web console. + */ + +"use strict"; + +const baseURL = "https://developer.mozilla.org/docs/Web/JavaScript/Reference/Errors/"; +const params = "?utm_source=mozilla&utm_medium=firefox-console-errors&utm_campaign=default"; +const ErrorDocs = { + JSMSG_READ_ONLY: "Read-only", + JSMSG_BAD_ARRAY_LENGTH: "Invalid_array_length", + JSMSG_NEGATIVE_REPETITION_COUNT: "Negative_repetition_count", + JSMSG_RESULTING_STRING_TOO_LARGE: "Resulting_string_too_large", + JSMSG_BAD_RADIX: "Bad_radix", + JSMSG_PRECISION_RANGE: "Precision_range", + JSMSG_BAD_FORMAL: "Malformed_formal_parameter", + JSMSG_STMT_AFTER_RETURN: "Stmt_after_return", + JSMSG_NOT_A_CODEPOINT: "Not_a_codepoint", + JSMSG_BAD_SORT_ARG: "Array_sort_argument", + JSMSG_UNEXPECTED_TYPE: "Unexpected_type", + JSMSG_NOT_DEFINED: "Not_defined", + JSMSG_NOT_FUNCTION: "Not_a_function", + JSMSG_EQUAL_AS_ASSIGN: "Equal_as_assign", + JSMSG_UNDEFINED_PROP: "Undefined_prop", + JSMSG_DEPRECATED_PRAGMA: "Deprecated_source_map_pragma", + JSMSG_DEPRECATED_USAGE: "Deprecated_caller_or_arguments_usage", + JSMSG_CANT_DELETE: "Cant_delete", + JSMSG_VAR_HIDES_ARG: "Var_hides_argument", + JSMSG_JSON_BAD_PARSE: "JSON_bad_parse", + JSMSG_UNDECLARED_VAR: "Undeclared_var", + JSMSG_UNEXPECTED_TOKEN: "Unexpected_token", + JSMSG_BAD_OCTAL: "Bad_octal", + JSMSG_PROPERTY_ACCESS_DENIED: "Property_access_denied", + JSMSG_NO_PROPERTIES: "No_properties", + JSMSG_ALREADY_HAS_PRAGMA: "Already_has_pragma", + JSMSG_BAD_RETURN_OR_YIELD: "Bad_return_or_yield", + JSMSG_SEMI_BEFORE_STMNT: "Missing_semicolon_before_statement", + JSMSG_OVER_RECURSED: "Too_much_recursion", + JSMSG_BRACKET_AFTER_LIST: "Missing_bracket_after_list", + JSMSG_PAREN_AFTER_ARGS: "Missing_parenthesis_after_argument_list", + JSMSG_MORE_ARGS_NEEDED: "More_arguments_needed", + JSMSG_BAD_LEFTSIDE_OF_ASS: "Invalid_assignment_left-hand_side", + JSMSG_UNTERMINATED_STRING: "Unterminated_string_literal", + JSMSG_NOT_CONSTRUCTOR: "Not_a_constructor", + JSMSG_CURLY_AFTER_LIST: "Missing_curly_after_property_list", + JSMSG_DEPRECATED_FOR_EACH: "For-each-in_loops_are_deprecated", +}; + +const MIXED_CONTENT_LEARN_MORE = "https://developer.mozilla.org/docs/Web/Security/Mixed_content"; +const TRACKING_PROTECTION_LEARN_MORE = "https://developer.mozilla.org/Firefox/Privacy/Tracking_Protection"; +const INSECURE_PASSWORDS_LEARN_MORE = "https://developer.mozilla.org/docs/Web/Security/Insecure_passwords"; +const PUBLIC_KEY_PINS_LEARN_MORE = "https://developer.mozilla.org/docs/Web/Security/Public_Key_Pinning"; +const STRICT_TRANSPORT_SECURITY_LEARN_MORE = "https://developer.mozilla.org/docs/Web/Security/HTTP_strict_transport_security"; +const WEAK_SIGNATURE_ALGORITHM_LEARN_MORE = "https://developer.mozilla.org/docs/Web/Security/Weak_Signature_Algorithm"; +const ErrorCategories = { + "Insecure Password Field": INSECURE_PASSWORDS_LEARN_MORE, + "Mixed Content Message": MIXED_CONTENT_LEARN_MORE, + "Mixed Content Blocker": MIXED_CONTENT_LEARN_MORE, + "Invalid HPKP Headers": PUBLIC_KEY_PINS_LEARN_MORE, + "Invalid HSTS Headers": STRICT_TRANSPORT_SECURITY_LEARN_MORE, + "SHA-1 Signature": WEAK_SIGNATURE_ALGORITHM_LEARN_MORE, + "Tracking Protection": TRACKING_PROTECTION_LEARN_MORE, +}; + +exports.GetURL = (error) => { + if (!error) { + return; + } + + let doc = ErrorDocs[error.errorMessageName]; + if (doc) { + return baseURL + doc + params; + } + + let categoryURL = ErrorCategories[error.category]; + if (categoryURL) { + return categoryURL + params; + } +}; diff --git a/devtools/server/actors/eventlooplag.js b/devtools/server/actors/eventlooplag.js new file mode 100644 index 0000000000..6e0a101c9a --- /dev/null +++ b/devtools/server/actors/eventlooplag.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"; + +/** + * The eventLoopLag actor emits "event-loop-lag" events when the event + * loop gets unresponsive. The event comes with a "time" property (the + * duration of the lag in milliseconds). + */ + +const {Ci} = require("chrome"); +const Services = require("Services"); +const {XPCOMUtils} = require("resource://gre/modules/XPCOMUtils.jsm"); +const {Actor, ActorClassWithSpec} = require("devtools/shared/protocol"); +const events = require("sdk/event/core"); +const {eventLoopLagSpec} = require("devtools/shared/specs/eventlooplag"); + +var EventLoopLagActor = exports.EventLoopLagActor = ActorClassWithSpec(eventLoopLagSpec, { + _observerAdded: false, + + /** + * Start tracking the event loop lags. + */ + start: function () { + if (!this._observerAdded) { + Services.obs.addObserver(this, "event-loop-lag", false); + this._observerAdded = true; + } + return Services.appShell.startEventLoopLagTracking(); + }, + + /** + * Stop tracking the event loop lags. + */ + stop: function () { + if (this._observerAdded) { + Services.obs.removeObserver(this, "event-loop-lag"); + this._observerAdded = false; + } + Services.appShell.stopEventLoopLagTracking(); + }, + + destroy: function () { + this.stop(); + Actor.prototype.destroy.call(this); + }, + + // nsIObserver + + observe: function (subject, topic, data) { + if (topic == "event-loop-lag") { + // Forward event loop lag event + events.emit(this, "event-loop-lag", data); + } + }, + + QueryInterface: XPCOMUtils.generateQI([Ci.nsIObserver]), +}); diff --git a/devtools/server/actors/frame.js b/devtools/server/actors/frame.js new file mode 100644 index 0000000000..fefcad1b03 --- /dev/null +++ b/devtools/server/actors/frame.js @@ -0,0 +1,100 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2; js-indent-level: 2 -*- */ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* 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 { ActorPool } = require("devtools/server/actors/common"); +const { createValueGrip } = require("devtools/server/actors/object"); +const { ActorClassWithSpec } = require("devtools/shared/protocol"); +const { frameSpec } = require("devtools/shared/specs/frame"); + +/** + * An actor for a specified stack frame. + */ +let FrameActor = ActorClassWithSpec(frameSpec, { + /** + * Creates the Frame actor. + * + * @param frame Debugger.Frame + * The debuggee frame. + * @param threadActor ThreadActor + * The parent thread actor for this frame. + */ + initialize: function (frame, threadActor) { + this.frame = frame; + this.threadActor = threadActor; + }, + + /** + * A pool that contains frame-lifetime objects, like the environment. + */ + _frameLifetimePool: null, + get frameLifetimePool() { + if (!this._frameLifetimePool) { + this._frameLifetimePool = new ActorPool(this.conn); + this.conn.addActorPool(this._frameLifetimePool); + } + return this._frameLifetimePool; + }, + + /** + * Finalization handler that is called when the actor is being evicted from + * the pool. + */ + disconnect: function () { + this.conn.removeActorPool(this._frameLifetimePool); + this._frameLifetimePool = null; + }, + + /** + * Returns a frame form for use in a protocol message. + */ + form: function () { + let threadActor = this.threadActor; + let form = { actor: this.actorID, + type: this.frame.type }; + if (this.frame.type === "call") { + form.callee = createValueGrip(this.frame.callee, threadActor._pausePool, + threadActor.objectGrip); + } + + if (this.frame.environment) { + let envActor = threadActor.createEnvironmentActor( + this.frame.environment, + this.frameLifetimePool + ); + form.environment = envActor.form(); + } + form.this = createValueGrip(this.frame.this, threadActor._pausePool, + threadActor.objectGrip); + form.arguments = this._args(); + if (this.frame.script) { + var generatedLocation = this.threadActor.sources.getFrameLocation(this.frame); + form.where = { + source: generatedLocation.generatedSourceActor.form(), + line: generatedLocation.generatedLine, + column: generatedLocation.generatedColumn + }; + } + + if (!this.frame.older) { + form.oldest = true; + } + + return form; + }, + + _args: function () { + if (!this.frame.arguments) { + return []; + } + + return this.frame.arguments.map(arg => createValueGrip(arg, + this.threadActor._pausePool, this.threadActor.objectGrip)); + } +}); + +exports.FrameActor = FrameActor; diff --git a/devtools/server/actors/framerate.js b/devtools/server/actors/framerate.js new file mode 100644 index 0000000000..872cd73606 --- /dev/null +++ b/devtools/server/actors/framerate.js @@ -0,0 +1,33 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +const { Actor, ActorClassWithSpec } = require("devtools/shared/protocol"); +const { actorBridgeWithSpec } = require("devtools/server/actors/common"); +const { on, once, off, emit } = require("sdk/event/core"); +const { Framerate } = require("devtools/server/performance/framerate"); +const { framerateSpec } = require("devtools/shared/specs/framerate"); + +/** + * An actor wrapper around Framerate. Uses exposed + * methods via bridge and provides RDP definitions. + * + * @see devtools/server/performance/framerate.js for documentation. + */ +var FramerateActor = exports.FramerateActor = ActorClassWithSpec(framerateSpec, { + initialize: function (conn, tabActor) { + Actor.prototype.initialize.call(this, conn); + this.bridge = new Framerate(tabActor); + }, + destroy: function (conn) { + Actor.prototype.destroy.call(this, conn); + this.bridge.destroy(); + }, + + startRecording: actorBridgeWithSpec("startRecording"), + stopRecording: actorBridgeWithSpec("stopRecording"), + cancelRecording: actorBridgeWithSpec("cancelRecording"), + isRecording: actorBridgeWithSpec("isRecording"), + getPendingTicks: actorBridgeWithSpec("getPendingTicks"), +}); diff --git a/devtools/server/actors/gcli.js b/devtools/server/actors/gcli.js new file mode 100644 index 0000000000..651825a28d --- /dev/null +++ b/devtools/server/actors/gcli.js @@ -0,0 +1,233 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { Task } = require("devtools/shared/task"); +const { + method, Arg, Option, RetVal, Actor, ActorClassWithSpec +} = require("devtools/shared/protocol"); +const { gcliSpec } = require("devtools/shared/specs/gcli"); +const events = require("sdk/event/core"); +const { createSystem } = require("gcli/system"); + +/** + * Manage remote connections that want to talk to GCLI + */ +const GcliActor = ActorClassWithSpec(gcliSpec, { + initialize: function (conn, tabActor) { + Actor.prototype.initialize.call(this, conn); + + this._commandsChanged = this._commandsChanged.bind(this); + + this._tabActor = tabActor; + this._requisitionPromise = undefined; // see _getRequisition() + }, + + disconnect: function () { + return this.destroy(); + }, + + destroy: function () { + Actor.prototype.destroy.call(this); + + // If _getRequisition has not been called, just bail quickly + if (this._requisitionPromise == null) { + this._commandsChanged = undefined; + this._tabActor = undefined; + return Promise.resolve(); + } + + return this._getRequisition().then(requisition => { + requisition.destroy(); + + this._system.commands.onCommandsChange.remove(this._commandsChanged); + this._system.destroy(); + this._system = undefined; + + this._requisitionPromise = undefined; + this._tabActor = undefined; + + this._commandsChanged = undefined; + }); + }, + + /** + * Load a module into the requisition + */ + _testOnlyAddItemsByModule: function (names) { + return this._getRequisition().then(requisition => { + return requisition.system.addItemsByModule(names); + }); + }, + + /** + * Unload a module from the requisition + */ + _testOnlyRemoveItemsByModule: function (names) { + return this._getRequisition().then(requisition => { + return requisition.system.removeItemsByModule(names); + }); + }, + + /** + * Retrieve a list of the remotely executable commands + * @param customProps Array of strings containing additional properties which, + * if specified in the command spec, will be included in the JSON. Normally we + * transfer only the properties required for GCLI to function. + */ + specs: function (customProps) { + return this._getRequisition().then(requisition => { + return requisition.system.commands.getCommandSpecs(customProps); + }); + }, + + /** + * Execute a GCLI command + * @return a promise of an object with the following properties: + * - data: The output of the command + * - type: The type of the data to allow selection of a converter + * - error: True if the output was considered an error + */ + execute: function (typed) { + return this._getRequisition().then(requisition => { + return requisition.updateExec(typed).then(output => output.toJson()); + }); + }, + + /** + * Get the state of an input string. i.e. requisition.getStateData() + */ + state: function (typed, start, rank) { + return this._getRequisition().then(requisition => { + return requisition.update(typed).then(() => { + return requisition.getStateData(start, rank); + }); + }); + }, + + /** + * Call type.parse to check validity. Used by the remote type + * @return a promise of an object with the following properties: + * - status: Of of the following strings: VALID|INCOMPLETE|ERROR + * - message: The message to display to the user + * - predictions: An array of suggested values for the given parameter + */ + parseType: function (typed, paramName) { + return this._getRequisition().then(requisition => { + return requisition.update(typed).then(() => { + let assignment = requisition.getAssignment(paramName); + return Promise.resolve(assignment.predictions).then(predictions => { + return { + status: assignment.getStatus().toString(), + message: assignment.message, + predictions: predictions + }; + }); + }); + }); + }, + + /** + * Get the incremented/decremented value of some type + * @return a promise of a string containing the new argument text + */ + nudgeType: function (typed, by, paramName) { + return this.requisition.update(typed).then(() => { + const assignment = this.requisition.getAssignment(paramName); + return this.requisition.nudge(assignment, by).then(() => { + return assignment.arg == null ? undefined : assignment.arg.text; + }); + }); + }, + + /** + * Perform a lookup on a selection type to get the allowed values + */ + getSelectionLookup: function (commandName, paramName) { + return this._getRequisition().then(requisition => { + const command = requisition.system.commands.get(commandName); + if (command == null) { + throw new Error("No command called '" + commandName + "'"); + } + + let type; + command.params.forEach(param => { + if (param.name === paramName) { + type = param.type; + } + }); + + if (type == null) { + throw new Error("No parameter called '" + paramName + "' in '" + + commandName + "'"); + } + + const reply = type.getLookup(requisition.executionContext); + return Promise.resolve(reply).then(lookup => { + // lookup returns an array of objects with name/value properties and + // the values might not be JSONable, so remove them + return lookup.map(info => ({ name: info.name })); + }); + }); + }, + + /** + * Lazy init for a Requisition + */ + _getRequisition: function () { + if (this._tabActor == null) { + throw new Error("GcliActor used post-destroy"); + } + + if (this._requisitionPromise != null) { + return this._requisitionPromise; + } + + const Requisition = require("gcli/cli").Requisition; + const tabActor = this._tabActor; + + this._system = createSystem({ location: "server" }); + this._system.commands.onCommandsChange.add(this._commandsChanged); + + const gcliInit = require("devtools/shared/gcli/commands/index"); + gcliInit.addAllItemsByModule(this._system); + + // this._requisitionPromise should be created synchronously with the call + // to _getRequisition so that destroy can tell whether there is an async + // init in progress + this._requisitionPromise = this._system.load().then(() => { + const environment = { + get chromeWindow() { + throw new Error("environment.chromeWindow is not available in runAt:server commands"); + }, + + get chromeDocument() { + throw new Error("environment.chromeDocument is not available in runAt:server commands"); + }, + + get window() { + return tabActor.window; + }, + + get document() { + return tabActor.window && tabActor.window.document; + } + }; + + return new Requisition(this._system, { environment: environment }); + }); + + return this._requisitionPromise; + }, + + /** + * Pass events from requisition.system.commands.onCommandsChange upwards + */ + _commandsChanged: function () { + events.emit(this, "commands-changed"); + }, +}); + +exports.GcliActor = GcliActor; diff --git a/devtools/server/actors/heap-snapshot-file.js b/devtools/server/actors/heap-snapshot-file.js new file mode 100644 index 0000000000..545f2265d3 --- /dev/null +++ b/devtools/server/actors/heap-snapshot-file.js @@ -0,0 +1,68 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const protocol = require("devtools/shared/protocol"); +const { method, Arg } = protocol; +const Services = require("Services"); +const { Task } = require("devtools/shared/task"); + +const { heapSnapshotFileSpec } = require("devtools/shared/specs/heap-snapshot-file"); + +loader.lazyRequireGetter(this, "DevToolsUtils", + "devtools/shared/DevToolsUtils"); +loader.lazyRequireGetter(this, "OS", "resource://gre/modules/osfile.jsm", true); +loader.lazyRequireGetter(this, "HeapSnapshotFileUtils", + "devtools/shared/heapsnapshot/HeapSnapshotFileUtils"); + +/** + * The HeapSnapshotFileActor handles transferring heap snapshot files from the + * server to the client. This has to be a global actor in the parent process + * because child processes are sandboxed and do not have access to the file + * system. + */ +exports.HeapSnapshotFileActor = protocol.ActorClassWithSpec(heapSnapshotFileSpec, { + initialize: function (conn, parent) { + if (Services.appinfo.processType !== Services.appinfo.PROCESS_TYPE_DEFAULT) { + const err = new Error("Attempt to create a HeapSnapshotFileActor in a " + + "child process! The HeapSnapshotFileActor *MUST* " + + "be in the parent process!"); + DevToolsUtils.reportException( + "HeapSnapshotFileActor.prototype.initialize", err); + return; + } + + protocol.Actor.prototype.initialize.call(this, conn, parent); + }, + + /** + * @see MemoryFront.prototype.transferHeapSnapshot + */ + transferHeapSnapshot: Task.async(function* (snapshotId) { + const snapshotFilePath = + HeapSnapshotFileUtils.getHeapSnapshotTempFilePath(snapshotId); + if (!snapshotFilePath) { + throw new Error(`No heap snapshot with id: ${snapshotId}`); + } + + const streamPromise = DevToolsUtils.openFileStream(snapshotFilePath); + + const { size } = yield OS.File.stat(snapshotFilePath); + const bulkPromise = this.conn.startBulkSend({ + actor: this.actorID, + type: "heap-snapshot", + length: size + }); + + const [bulk, stream] = yield Promise.all([bulkPromise, streamPromise]); + + try { + yield bulk.copyFrom(stream); + } finally { + stream.close(); + } + }), + +}); diff --git a/devtools/server/actors/highlighters.css b/devtools/server/actors/highlighters.css new file mode 100644 index 0000000000..87375ea36a --- /dev/null +++ b/devtools/server/actors/highlighters.css @@ -0,0 +1,536 @@ +/* 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/. */ + +/* + The :-moz-native-anonymous selector prefix prevents the styles defined here + from impacting web content. Indeed, this pseudo-class is only available to chrome code. + This stylesheet is loaded as a ua stylesheet via the addon sdk, so having this + pseudo-class is important. + Having bug 1086532 fixed would make it possible to load this stylesheet in a + <style scoped> node instead, directly in the native anonymous container + element. + + A specific selector should still be specified to avoid impacting non-devtools + chrome content. +*/ + +:-moz-native-anonymous .highlighter-container { + /* + Content CSS applying to the html element impact the highlighters. + To avoid that, possible cases have been set to initial. + */ + text-transform: initial; + text-indent: initial; + letter-spacing: initial; + word-spacing: initial; + color: initial; +} + +:-moz-native-anonymous .highlighter-container { + --highlighter-guide-color: #08c; + --highlighter-content-color: #87ceeb; + --highlighter-bubble-text-color: hsl(216, 33%, 97%); + --highlighter-bubble-background-color: hsl(214, 13%, 24%); + --highlighter-bubble-border-color: rgba(255, 255, 255, 0.2); +} + +:-moz-native-anonymous .highlighter-container { + position: fixed; + width: 100%; + height: 100%; + /* The container for all highlighters doesn't react to pointer-events by + default. This is because most highlighters cover the whole viewport but + don't contain UIs that need to be accessed. + If your highlighter has UI that needs to be interacted with, add + 'pointer-events:auto;' on its container element. */ + pointer-events: none; +} + +:-moz-native-anonymous .highlighter-container.box-model { + /* Make the box-model container have a z-index other than auto so it always sits above + other highlighters. */ + z-index: 1; +} + +:-moz-native-anonymous .highlighter-container [hidden] { + display: none; +} + +:-moz-native-anonymous .highlighter-container [dragging] { + cursor: grabbing; +} + +/* Box Model Highlighter */ + +:-moz-native-anonymous .box-model-regions { + opacity: 0.6; +} + +/* Box model regions can be faded (see the onlyRegionArea option in + highlighters.js) in order to only display certain regions. */ +:-moz-native-anonymous .box-model-regions [faded] { + display: none; +} + +:-moz-native-anonymous .box-model-content { + fill: var(--highlighter-content-color); +} + +:-moz-native-anonymous .box-model-padding { + fill: #6a5acd; +} + +:-moz-native-anonymous .box-model-border { + fill: #444444; +} + +:-moz-native-anonymous .box-model-margin { + fill: #edff64; +} + +:-moz-native-anonymous .box-model-content, +:-moz-native-anonymous .box-model-padding, +:-moz-native-anonymous .box-model-border, +:-moz-native-anonymous .box-model-margin { + stroke: none; +} + +:-moz-native-anonymous .box-model-guide-top, +:-moz-native-anonymous .box-model-guide-right, +:-moz-native-anonymous .box-model-guide-bottom, +:-moz-native-anonymous .box-model-guide-left { + stroke: var(--highlighter-guide-color); + stroke-dasharray: 5 3; + shape-rendering: crispEdges; +} + +/* Highlighter - Infobar */ + +:-moz-native-anonymous [class$=infobar-container] { + position: absolute; + max-width: 95%; + + font: message-box; + font-size: 11px; +} + +:-moz-native-anonymous [class$=infobar] { + position: relative; + + /* Centering the infobar in the container */ + left: -50%; + + padding: 5px; + min-width: 75px; + + border-radius: 3px; + background: var(--highlighter-bubble-background-color) no-repeat padding-box; + + color: var(--highlighter-bubble-text-color); + text-shadow: none; + + border: 1px solid var(--highlighter-bubble-border-color); +} + +:-moz-native-anonymous [class$=infobar-container][hide-arrow] > [class$=infobar] { + margin: 7px 0; +} + +/* Arrows */ + +:-moz-native-anonymous [class$=infobar-container] > [class$=infobar]:before { + left: calc(50% - 8px); + border: 8px solid var(--highlighter-bubble-border-color); +} + +:-moz-native-anonymous [class$=infobar-container] > [class$=infobar]:after { + left: calc(50% - 7px); + border: 7px solid var(--highlighter-bubble-background-color); +} + +:-moz-native-anonymous [class$=infobar-container] > [class$=infobar]:before, +:-moz-native-anonymous [class$=infobar-container] > [class$=infobar]:after { + content: ""; + display: none; + position: absolute; + height: 0; + width: 0; + border-left-color: transparent; + border-right-color: transparent; +} + +:-moz-native-anonymous [class$=infobar-container][position="top"]:not([hide-arrow]) > [class$=infobar]:before, +:-moz-native-anonymous [class$=infobar-container][position="top"]:not([hide-arrow]) > [class$=infobar]:after { + border-bottom: 0; + top: 100%; + display: block; +} + +:-moz-native-anonymous [class$=infobar-container][position="bottom"]:not([hide-arrow]) > [class$=infobar]:before, +:-moz-native-anonymous [class$=infobar-container][position="bottom"]:not([hide-arrow]) > [class$=infobar]:after { + border-top: 0; + bottom: 100%; + display: block; +} + +/* Text Container */ + +:-moz-native-anonymous [class$=infobar-text] { + overflow: hidden; + white-space: nowrap; + direction: ltr; + padding-bottom: 1px; + display: flex; +} + +:-moz-native-anonymous .box-model-infobar-tagname { + color: hsl(285,100%, 75%); +} + +:-moz-native-anonymous .box-model-infobar-id { + color: hsl(103, 46%, 54%); + overflow: hidden; + text-overflow: ellipsis; +} + +:-moz-native-anonymous .box-model-infobar-classes, +:-moz-native-anonymous .box-model-infobar-pseudo-classes { + color: hsl(200, 74%, 57%); + overflow: hidden; + text-overflow: ellipsis; +} + +:-moz-native-anonymous [class$=infobar-dimensions] { + color: hsl(210, 30%, 85%); + border-inline-start: 1px solid #5a6169; + margin-inline-start: 6px; + padding-inline-start: 6px; +} + +/* CSS Grid Highlighter */ + +:-moz-native-anonymous .css-grid-canvas { + position: absolute; + pointer-events: none; + top: 0; + left: 0; + image-rendering: -moz-crisp-edges; +} + +:-moz-native-anonymous .css-grid-regions { + opacity: 0.6; +} + +:-moz-native-anonymous .css-grid-areas { + fill: #CEC0ED; + stroke: none; +} + +:-moz-native-anonymous .css-grid-infobar-areaname { + color: hsl(285,100%, 75%); +} + +/* CSS Transform Highlighter */ + +:-moz-native-anonymous .css-transform-transformed { + fill: var(--highlighter-content-color); + opacity: 0.8; +} + +:-moz-native-anonymous .css-transform-untransformed { + fill: #66cc52; + opacity: 0.8; +} + +:-moz-native-anonymous .css-transform-transformed, +:-moz-native-anonymous .css-transform-untransformed, +:-moz-native-anonymous .css-transform-line { + stroke: var(--highlighter-guide-color); + stroke-dasharray: 5 3; + stroke-width: 2; +} + +/* Rect Highlighter */ + +:-moz-native-anonymous .highlighted-rect { + position: absolute; + background: var(--highlighter-content-color); + opacity: 0.8; +} + +/* Element Geometry Highlighter */ + +:-moz-native-anonymous .geometry-editor-root { + /* The geometry editor can be interacted with, so it needs to react to + pointer events */ + pointer-events: auto; + -moz-user-select: none; +} + +:-moz-native-anonymous .geometry-editor-offset-parent { + stroke: var(--highlighter-guide-color); + shape-rendering: crispEdges; + stroke-dasharray: 5 3; + fill: transparent; +} + +:-moz-native-anonymous .geometry-editor-current-node { + stroke: var(--highlighter-guide-color); + fill: var(--highlighter-content-color); + shape-rendering: crispEdges; + opacity: 0.6; +} + +:-moz-native-anonymous .geometry-editor-arrow { + stroke: var(--highlighter-guide-color); + shape-rendering: crispEdges; +} + +:-moz-native-anonymous .geometry-editor-root circle { + stroke: var(--highlighter-guide-color); + fill: var(--highlighter-content-color); +} + +:-moz-native-anonymous .geometry-editor-handler-top, +:-moz-native-anonymous .geometry-editor-handler-bottom { + cursor: ns-resize; +} + +:-moz-native-anonymous .geometry-editor-handler-right, +:-moz-native-anonymous .geometry-editor-handler-left { + cursor: ew-resize; +} + +:-moz-native-anonymous [dragging] .geometry-editor-handler-top, +:-moz-native-anonymous [dragging] .geometry-editor-handler-right, +:-moz-native-anonymous [dragging] .geometry-editor-handler-bottom, +:-moz-native-anonymous [dragging] .geometry-editor-handler-left { + cursor: grabbing; +} + +:-moz-native-anonymous .geometry-editor-handler-top.dragging, +:-moz-native-anonymous .geometry-editor-handler-right.dragging, +:-moz-native-anonymous .geometry-editor-handler-bottom.dragging, +:-moz-native-anonymous .geometry-editor-handler-left.dragging { + fill: var(--highlighter-guide-color); +} + +:-moz-native-anonymous .geometry-editor-label-bubble { + fill: var(--highlighter-bubble-background-color); + shape-rendering: crispEdges; +} + +:-moz-native-anonymous .geometry-editor-label-text { + fill: var(--highlighter-bubble-text-color); + font: message-box; + font-size: 10px; + text-anchor: middle; + dominant-baseline: middle; +} + +/* Rulers Highlighter */ + +:-moz-native-anonymous .rulers-highlighter-elements { + shape-rendering: crispEdges; + pointer-events: none; + position: fixed; + top: 0; + left: 0; +} + +:-moz-native-anonymous .rulers-highlighter-elements > g { + opacity: 0.8; +} + +:-moz-native-anonymous .rulers-highlighter-elements > g > rect { + fill: #fff; +} + +:-moz-native-anonymous .rulers-highlighter-ruler-graduations { + stroke: #bebebe; +} + +:-moz-native-anonymous .rulers-highlighter-ruler-markers { + stroke: #202020; +} + +:-moz-native-anonymous .rulers-highlighter-horizontal-labels > text, +:-moz-native-anonymous .rulers-highlighter-vertical-labels > text { + stroke: none; + fill: #202020; + font: message-box; + font-size: 9px; + dominant-baseline: hanging; +} + +:-moz-native-anonymous .rulers-highlighter-horizontal-labels > text { + text-anchor: start; +} + +:-moz-native-anonymous .rulers-highlighter-vertical-labels > text { + transform: rotate(-90deg); + text-anchor: end; +} + +/* Measuring Tool Highlighter */ + +:-moz-native-anonymous .measuring-tool-highlighter-root { + position: absolute; + top: 0; + left: 0; + pointer-events: auto; + cursor: crosshair; +} + +:-moz-native-anonymous .measuring-tool-highlighter-root path { + shape-rendering: crispEdges; + fill: rgba(135, 206, 235, 0.6); + stroke: var(--highlighter-guide-color); + pointer-events: none; +} + +:-moz-native-anonymous .dragging path { + fill: rgba(135, 206, 235, 0.6); + stroke: var(--highlighter-guide-color); + opacity: 0.45; +} + +:-moz-native-anonymous .measuring-tool-highlighter-label-size, +:-moz-native-anonymous .measuring-tool-highlighter-label-position { + position: absolute; + top: 0; + left: 0; + display: inline-block; + border-radius: 4px; + padding: 4px; + white-space: pre-line; + font: message-box; + font-size: 10px; + pointer-events: none; + -moz-user-select: none; + box-sizing: border-box; +} + +:-moz-native-anonymous .measuring-tool-highlighter-label-position { + color: #fff; + background: hsla(214, 13%, 24%, 0.8); +} + +:-moz-native-anonymous .measuring-tool-highlighter-label-size { + color: var(--highlighter-bubble-text-color); + background: var(--highlighter-bubble-background-color); + border: 1px solid var(--highlighter-bubble-border-color); + line-height: 1.5em; +} + +:-moz-native-anonymous .measuring-tool-highlighter-guide-top, +:-moz-native-anonymous .measuring-tool-highlighter-guide-right, +:-moz-native-anonymous .measuring-tool-highlighter-guide-bottom, +:-moz-native-anonymous .measuring-tool-highlighter-guide-left { + stroke: var(--highlighter-guide-color); + stroke-dasharray: 5 3; + shape-rendering: crispEdges; +} + +/* Eye Dropper */ + +:-moz-native-anonymous .eye-dropper-root { + --magnifier-width: 96px; + --magnifier-height: 96px; + /* Width accounts for all color formats (hsl being the longest) */ + --label-width: 160px; + --label-height: 23px; + --color: #e0e0e0; + + position: absolute; + /* Tool start position. This should match the X/Y defines in JS */ + top: 100px; + left: 100px; + + /* Prevent interacting with the page when hovering and clicking */ + pointer-events: auto; + + /* Offset the UI so it is centered around the pointer */ + transform: translate( + calc(var(--magnifier-width) / -2), calc(var(--magnifier-height) / -2)); + + filter: drop-shadow(0 0 1px rgba(0,0,0,.4)); + + /* We don't need the UI to be reversed in RTL locales, otherwise the # would appear + to the right of the hex code. Force LTR */ + direction: ltr; +} + +:-moz-native-anonymous .eye-dropper-canvas { + image-rendering: -moz-crisp-edges; + cursor: none; + width: var(--magnifier-width); + height: var(--magnifier-height); + border-radius: 50%; + box-shadow: 0 0 0 3px var(--color); + display: block; +} + +:-moz-native-anonymous .eye-dropper-color-container { + background-color: var(--color); + border-radius: 2px; + width: var(--label-width); + height: var(--label-height); + position: relative; + + --label-horizontal-center: + translateX(calc((var(--magnifier-width) - var(--label-width)) / 2)); + --label-horizontal-left: + translateX(calc((-1 * var(--label-width) + var(--magnifier-width) / 2))); + --label-horizontal-right: + translateX(calc(var(--magnifier-width) / 2)); + --label-vertical-top: + translateY(calc((-1 * var(--magnifier-height)) - var(--label-height))); + + /* By default the color label container sits below the canvas. + Here we just center it horizontally */ + transform: var(--label-horizontal-center); + transition: transform .1s ease-in-out; +} + +/* If there isn't enough space below the canvas, we move the label container to the top */ +:-moz-native-anonymous .eye-dropper-root[top] .eye-dropper-color-container { + transform: var(--label-horizontal-center) var(--label-vertical-top); +} + +/* If there isn't enough space right of the canvas to horizontally center the label + container, offset it to the left */ +:-moz-native-anonymous .eye-dropper-root[left] .eye-dropper-color-container { + transform: var(--label-horizontal-left); +} +:-moz-native-anonymous .eye-dropper-root[left][top] .eye-dropper-color-container { + transform: var(--label-horizontal-left) var(--label-vertical-top); +} + +/* If there isn't enough space left of the canvas to horizontally center the label + container, offset it to the right */ +:-moz-native-anonymous .eye-dropper-root[right] .eye-dropper-color-container { + transform: var(--label-horizontal-right); +} +:-moz-native-anonymous .eye-dropper-root[right][top] .eye-dropper-color-container { + transform: var(--label-horizontal-right) var(--label-vertical-top); +} + +:-moz-native-anonymous .eye-dropper-color-preview { + width: 16px; + height: 16px; + position: absolute; + offset-inline-start: 3px; + offset-block-start: 3px; + box-shadow: 0px 0px 0px black; + border: solid 1px #fff; +} + +:-moz-native-anonymous .eye-dropper-color-value { + text-shadow: 1px 1px 1px #fff; + font: message-box; + font-size: 11px; + text-align: center; + padding: 4px 0; +} diff --git a/devtools/server/actors/highlighters.js b/devtools/server/actors/highlighters.js new file mode 100644 index 0000000000..087750ab30 --- /dev/null +++ b/devtools/server/actors/highlighters.js @@ -0,0 +1,715 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { Ci } = require("chrome"); + +const { XPCOMUtils } = require("resource://gre/modules/XPCOMUtils.jsm"); +const EventEmitter = require("devtools/shared/event-emitter"); +const events = require("sdk/event/core"); +const protocol = require("devtools/shared/protocol"); +const Services = require("Services"); +const { isWindowIncluded } = require("devtools/shared/layout/utils"); +const { highlighterSpec, customHighlighterSpec } = require("devtools/shared/specs/highlighters"); +const { isXUL } = require("./highlighters/utils/markup"); +const { SimpleOutlineHighlighter } = require("./highlighters/simple-outline"); + +const HIGHLIGHTER_PICKED_TIMER = 1000; +const IS_OSX = Services.appinfo.OS === "Darwin"; + +/** + * The registration mechanism for highlighters provide a quick way to + * have modular highlighters, instead of a hard coded list. + * It allow us to split highlighers in sub modules, and add them dynamically + * using add-on (useful for 3rd party developers, or prototyping) + * + * Note that currently, highlighters added using add-ons, can only work on + * Firefox desktop, or Fennec if the same add-on is installed in both. + */ +const highlighterTypes = new Map(); + +/** + * Returns `true` if a highlighter for the given `typeName` is registered, + * `false` otherwise. + */ +const isTypeRegistered = (typeName) => highlighterTypes.has(typeName); +exports.isTypeRegistered = isTypeRegistered; + +/** + * Registers a given constructor as highlighter, for the `typeName` given. + * If no `typeName` is provided, is looking for a `typeName` property in + * the prototype's constructor. + */ +const register = (constructor, typeName = constructor.prototype.typeName) => { + if (!typeName) { + throw Error("No type's name found, or provided."); + } + + if (highlighterTypes.has(typeName)) { + throw Error(`${typeName} is already registered.`); + } + + highlighterTypes.set(typeName, constructor); +}; +exports.register = register; + +/** + * The Highlighter is the server-side entry points for any tool that wishes to + * highlight elements in some way in the content document. + * + * A little bit of vocabulary: + * - <something>HighlighterActor classes are the actors that can be used from + * the client. They do very little else than instantiate a given + * <something>Highlighter and use it to highlight elements. + * - <something>Highlighter classes aren't actors, they're just JS classes that + * know how to create and attach the actual highlighter elements on top of the + * content + * + * The most used highlighter actor is the HighlighterActor which can be + * conveniently retrieved via the InspectorActor's 'getHighlighter' method. + * The InspectorActor will always return the same instance of + * HighlighterActor if asked several times and this instance is used in the + * toolbox to highlighter elements's box-model from the markup-view, + * box model view, console, debugger, ... as well as select elements with the + * pointer (pick). + * + * Other types of highlighter actors exist and can be accessed via the + * InspectorActor's 'getHighlighterByType' method. + */ + +/** + * The HighlighterActor class + */ +var HighlighterActor = exports.HighlighterActor = protocol.ActorClassWithSpec(highlighterSpec, { + initialize: function (inspector, autohide) { + protocol.Actor.prototype.initialize.call(this, null); + + this._autohide = autohide; + this._inspector = inspector; + this._walker = this._inspector.walker; + this._tabActor = this._inspector.tabActor; + this._highlighterEnv = new HighlighterEnvironment(); + this._highlighterEnv.initFromTabActor(this._tabActor); + + this._highlighterReady = this._highlighterReady.bind(this); + this._highlighterHidden = this._highlighterHidden.bind(this); + this._onNavigate = this._onNavigate.bind(this); + + let doc = this._tabActor.window.document; + // Only try to create the highlighter when the document is loaded, + // otherwise, wait for the navigate event to fire. + if (doc.documentElement && doc.readyState != "uninitialized") { + this._createHighlighter(); + } + + // Listen to navigation events to switch from the BoxModelHighlighter to the + // SimpleOutlineHighlighter, and back, if the top level window changes. + events.on(this._tabActor, "navigate", this._onNavigate); + }, + + get conn() { + return this._inspector && this._inspector.conn; + }, + + form: function () { + return { + actor: this.actorID, + traits: { + autoHideOnDestroy: true + } + }; + }, + + _createHighlighter: function () { + this._isPreviousWindowXUL = isXUL(this._tabActor.window); + + if (!this._isPreviousWindowXUL) { + this._highlighter = new BoxModelHighlighter(this._highlighterEnv, + this._inspector); + this._highlighter.on("ready", this._highlighterReady); + this._highlighter.on("hide", this._highlighterHidden); + } else { + this._highlighter = new SimpleOutlineHighlighter(this._highlighterEnv); + } + }, + + _destroyHighlighter: function () { + if (this._highlighter) { + if (!this._isPreviousWindowXUL) { + this._highlighter.off("ready", this._highlighterReady); + this._highlighter.off("hide", this._highlighterHidden); + } + this._highlighter.destroy(); + this._highlighter = null; + } + }, + + _onNavigate: function ({isTopLevel}) { + // Skip navigation events for non top-level windows, or if the document + // doesn't exist anymore. + if (!isTopLevel || !this._tabActor.window.document.documentElement) { + return; + } + + // Only rebuild the highlighter if the window type changed. + if (isXUL(this._tabActor.window) !== this._isPreviousWindowXUL) { + this._destroyHighlighter(); + this._createHighlighter(); + } + }, + + destroy: function () { + protocol.Actor.prototype.destroy.call(this); + + this.hideBoxModel(); + this._destroyHighlighter(); + events.off(this._tabActor, "navigate", this._onNavigate); + + this._highlighterEnv.destroy(); + this._highlighterEnv = null; + + this._autohide = null; + this._inspector = null; + this._walker = null; + this._tabActor = null; + }, + + /** + * Display the box model highlighting on a given NodeActor. + * There is only one instance of the box model highlighter, so calling this + * method several times won't display several highlighters, it will just move + * the highlighter instance to these nodes. + * + * @param NodeActor The node to be highlighted + * @param Options See the request part for existing options. Note that not + * all options may be supported by all types of highlighters. + */ + showBoxModel: function (node, options = {}) { + if (!node || !this._highlighter.show(node.rawNode, options)) { + this._highlighter.hide(); + } + }, + + /** + * Hide the box model highlighting if it was shown before + */ + hideBoxModel: function () { + if (this._highlighter) { + this._highlighter.hide(); + } + }, + + /** + * Returns `true` if the event was dispatched from a window included in + * the current highlighter environment; or if the highlighter environment has + * chrome privileges + * + * The method is specifically useful on B2G, where we do not want that events + * from app or main process are processed if we're inspecting the content. + * + * @param {Event} event + * The event to allow + * @return {Boolean} + */ + _isEventAllowed: function ({view}) { + let { window } = this._highlighterEnv; + + return window instanceof Ci.nsIDOMChromeWindow || + isWindowIncluded(window, view); + }, + + /** + * Pick a node on click, and highlight hovered nodes in the process. + * + * This method doesn't respond anything interesting, however, it starts + * mousemove, and click listeners on the content document to fire + * events and let connected clients know when nodes are hovered over or + * clicked. + * + * Once a node is picked, events will cease, and listeners will be removed. + */ + _isPicking: false, + _hoveredNode: null, + _currentNode: null, + + pick: function () { + if (this._isPicking) { + return null; + } + this._isPicking = true; + + this._preventContentEvent = event => { + event.stopPropagation(); + event.preventDefault(); + }; + + this._onPick = event => { + this._preventContentEvent(event); + + if (!this._isEventAllowed(event)) { + return; + } + + // If shift is pressed, this is only a preview click, send the event to + // the client, but don't stop picking. + if (event.shiftKey) { + events.emit(this._walker, "picker-node-previewed", this._findAndAttachElement(event)); + return; + } + + this._stopPickerListeners(); + this._isPicking = false; + if (this._autohide) { + this._tabActor.window.setTimeout(() => { + this._highlighter.hide(); + }, HIGHLIGHTER_PICKED_TIMER); + } + if (!this._currentNode) { + this._currentNode = this._findAndAttachElement(event); + } + events.emit(this._walker, "picker-node-picked", this._currentNode); + }; + + this._onHovered = event => { + this._preventContentEvent(event); + + if (!this._isEventAllowed(event)) { + return; + } + + this._currentNode = this._findAndAttachElement(event); + if (this._hoveredNode !== this._currentNode.node) { + this._highlighter.show(this._currentNode.node.rawNode); + events.emit(this._walker, "picker-node-hovered", this._currentNode); + this._hoveredNode = this._currentNode.node; + } + }; + + this._onKey = event => { + if (!this._currentNode || !this._isPicking) { + return; + } + + this._preventContentEvent(event); + + if (!this._isEventAllowed(event)) { + return; + } + + let currentNode = this._currentNode.node.rawNode; + + /** + * KEY: Action/scope + * LEFT_KEY: wider or parent + * RIGHT_KEY: narrower or child + * ENTER/CARRIAGE_RETURN: Picks currentNode + * ESC/CTRL+SHIFT+C: Cancels picker, picks currentNode + */ + switch (event.keyCode) { + // Wider. + case Ci.nsIDOMKeyEvent.DOM_VK_LEFT: + if (!currentNode.parentElement) { + return; + } + currentNode = currentNode.parentElement; + break; + + // Narrower. + case Ci.nsIDOMKeyEvent.DOM_VK_RIGHT: + if (!currentNode.children.length) { + return; + } + + // Set firstElementChild by default + let child = currentNode.firstElementChild; + // If currentNode is parent of hoveredNode, then + // previously selected childNode is set + let hoveredNode = this._hoveredNode.rawNode; + for (let sibling of currentNode.children) { + if (sibling.contains(hoveredNode) || sibling === hoveredNode) { + child = sibling; + } + } + + currentNode = child; + break; + + // Select the element. + case Ci.nsIDOMKeyEvent.DOM_VK_RETURN: + this._onPick(event); + return; + + // Cancel pick mode. + case Ci.nsIDOMKeyEvent.DOM_VK_ESCAPE: + this.cancelPick(); + events.emit(this._walker, "picker-node-canceled"); + return; + case Ci.nsIDOMKeyEvent.DOM_VK_C: + if ((IS_OSX && event.metaKey && event.altKey) || + (!IS_OSX && event.ctrlKey && event.shiftKey)) { + this.cancelPick(); + events.emit(this._walker, "picker-node-canceled"); + return; + } + default: return; + } + + // Store currently attached element + this._currentNode = this._walker.attachElement(currentNode); + this._highlighter.show(this._currentNode.node.rawNode); + events.emit(this._walker, "picker-node-hovered", this._currentNode); + }; + + this._startPickerListeners(); + + return null; + }, + + /** + * This pick method also focuses the highlighter's target window. + */ + pickAndFocus: function() { + // Go ahead and pass on the results to help future-proof this method. + let pickResults = this.pick(); + this._highlighterEnv.window.focus(); + return pickResults; + }, + + _findAndAttachElement: function (event) { + // originalTarget allows access to the "real" element before any retargeting + // is applied, such as in the case of XBL anonymous elements. See also + // https://developer.mozilla.org/docs/XBL/XBL_1.0_Reference/Anonymous_Content#Event_Flow_and_Targeting + let node = event.originalTarget || event.target; + return this._walker.attachElement(node); + }, + + _startPickerListeners: function () { + let target = this._highlighterEnv.pageListenerTarget; + target.addEventListener("mousemove", this._onHovered, true); + target.addEventListener("click", this._onPick, true); + target.addEventListener("mousedown", this._preventContentEvent, true); + target.addEventListener("mouseup", this._preventContentEvent, true); + target.addEventListener("dblclick", this._preventContentEvent, true); + target.addEventListener("keydown", this._onKey, true); + target.addEventListener("keyup", this._preventContentEvent, true); + }, + + _stopPickerListeners: function () { + let target = this._highlighterEnv.pageListenerTarget; + target.removeEventListener("mousemove", this._onHovered, true); + target.removeEventListener("click", this._onPick, true); + target.removeEventListener("mousedown", this._preventContentEvent, true); + target.removeEventListener("mouseup", this._preventContentEvent, true); + target.removeEventListener("dblclick", this._preventContentEvent, true); + target.removeEventListener("keydown", this._onKey, true); + target.removeEventListener("keyup", this._preventContentEvent, true); + }, + + _highlighterReady: function () { + events.emit(this._inspector.walker, "highlighter-ready"); + }, + + _highlighterHidden: function () { + events.emit(this._inspector.walker, "highlighter-hide"); + }, + + cancelPick: function () { + if (this._isPicking) { + this._highlighter.hide(); + this._stopPickerListeners(); + this._isPicking = false; + this._hoveredNode = null; + } + } +}); + +/** + * A generic highlighter actor class that instantiate a highlighter given its + * type name and allows to show/hide it. + */ +var CustomHighlighterActor = exports.CustomHighlighterActor = protocol.ActorClassWithSpec(customHighlighterSpec, { + /** + * Create a highlighter instance given its typename + * The typename must be one of HIGHLIGHTER_CLASSES and the class must + * implement constructor(tabActor), show(node), hide(), destroy() + */ + initialize: function (inspector, typeName) { + protocol.Actor.prototype.initialize.call(this, null); + + this._inspector = inspector; + + let constructor = highlighterTypes.get(typeName); + if (!constructor) { + let list = [...highlighterTypes.keys()]; + + throw new Error(`${typeName} isn't a valid highlighter class (${list})`); + } + + // The assumption is that all custom highlighters need the canvasframe + // container to append their elements, so if this is a XUL window, bail out. + if (!isXUL(this._inspector.tabActor.window)) { + this._highlighterEnv = new HighlighterEnvironment(); + this._highlighterEnv.initFromTabActor(inspector.tabActor); + this._highlighter = new constructor(this._highlighterEnv); + } else { + throw new Error("Custom " + typeName + + "highlighter cannot be created in a XUL window"); + } + }, + + get conn() { + return this._inspector && this._inspector.conn; + }, + + destroy: function () { + protocol.Actor.prototype.destroy.call(this); + this.finalize(); + this._inspector = null; + }, + + release: function () {}, + + /** + * Show the highlighter. + * This calls through to the highlighter instance's |show(node, options)| + * method. + * + * Most custom highlighters are made to highlight DOM nodes, hence the first + * NodeActor argument (NodeActor as in + * devtools/server/actor/inspector). + * Note however that some highlighters use this argument merely as a context + * node: the RectHighlighter for instance uses it to calculate the absolute + * position of the provided rect. The SelectHighlighter uses it as a base node + * to run the provided CSS selector on. + * + * @param {NodeActor} The node to be highlighted + * @param {Object} Options for the custom highlighter + * @return {Boolean} True, if the highlighter has been successfully shown + * (FF41+) + */ + show: function (node, options) { + if (!node || !this._highlighter) { + return false; + } + + return this._highlighter.show(node.rawNode, options); + }, + + /** + * Hide the highlighter if it was shown before + */ + hide: function () { + if (this._highlighter) { + this._highlighter.hide(); + } + }, + + /** + * Kill this actor. This method is called automatically just before the actor + * is destroyed. + */ + finalize: function () { + if (this._highlighter) { + this._highlighter.destroy(); + this._highlighter = null; + } + + if (this._highlighterEnv) { + this._highlighterEnv.destroy(); + this._highlighterEnv = null; + } + } +}); + +/** + * The HighlighterEnvironment is an object that holds all the required data for + * highlighters to work: the window, docShell, event listener target, ... + * It also emits "will-navigate" and "navigate" events, similarly to the + * TabActor. + * + * It can be initialized either from a TabActor (which is the most frequent way + * of using it, since highlighters are usually initialized by the + * HighlighterActor or CustomHighlighterActor, which have a tabActor reference). + * It can also be initialized just with a window object (which is useful for + * when a highlighter is used outside of the debugger server context, for + * instance from a gcli command). + */ +function HighlighterEnvironment() { + this.relayTabActorNavigate = this.relayTabActorNavigate.bind(this); + this.relayTabActorWillNavigate = this.relayTabActorWillNavigate.bind(this); + + EventEmitter.decorate(this); +} + +exports.HighlighterEnvironment = HighlighterEnvironment; + +HighlighterEnvironment.prototype = { + initFromTabActor: function (tabActor) { + this._tabActor = tabActor; + events.on(this._tabActor, "navigate", this.relayTabActorNavigate); + events.on(this._tabActor, "will-navigate", this.relayTabActorWillNavigate); + }, + + initFromWindow: function (win) { + this._win = win; + + // We need a progress listener to know when the window will navigate/has + // navigated. + let self = this; + this.listener = { + QueryInterface: XPCOMUtils.generateQI([ + Ci.nsIWebProgressListener, + Ci.nsISupportsWeakReference, + Ci.nsISupports + ]), + + onStateChange: function (progress, request, flag) { + let isStart = flag & Ci.nsIWebProgressListener.STATE_START; + let isStop = flag & Ci.nsIWebProgressListener.STATE_STOP; + let isWindow = flag & Ci.nsIWebProgressListener.STATE_IS_WINDOW; + let isDocument = flag & Ci.nsIWebProgressListener.STATE_IS_DOCUMENT; + + if (progress.DOMWindow !== win) { + return; + } + + if (isDocument && isStart) { + // One of the earliest events that tells us a new URI is being loaded + // in this window. + self.emit("will-navigate", { + window: win, + isTopLevel: true + }); + } + if (isWindow && isStop) { + self.emit("navigate", { + window: win, + isTopLevel: true + }); + } + } + }; + + this.webProgress.addProgressListener(this.listener, + Ci.nsIWebProgress.NOTIFY_STATUS | + Ci.nsIWebProgress.NOTIFY_STATE_WINDOW | + Ci.nsIWebProgress.NOTIFY_STATE_DOCUMENT); + }, + + get isInitialized() { + return this._win || this._tabActor; + }, + + get isXUL() { + return isXUL(this.window); + }, + + get window() { + if (!this.isInitialized) { + throw new Error("Initialize HighlighterEnvironment with a tabActor " + + "or window first"); + } + return this._tabActor ? this._tabActor.window : this._win; + }, + + get document() { + return this.window.document; + }, + + get docShell() { + return this.window.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIWebNavigation) + .QueryInterface(Ci.nsIDocShell); + }, + + get webProgress() { + return this.docShell.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIWebProgress); + }, + + /** + * Get the right target for listening to events on the page. + * - If the environment was initialized from a TabActor *and* if we're in the + * Browser Toolbox (to inspect firefox desktop): the tabActor is the + * RootActor, in which case, the window property can be used to listen to + * events. + * - With firefox desktop, that tabActor is a BrowserTabActor, and with B2G, + * a ContentActor (which overrides BrowserTabActor). In both cases we use + * the chromeEventHandler which gives us a target we can use to listen to + * events, even from nested iframes. + * - If the environment was initialized from a window, we also use the + * chromeEventHandler. + */ + get pageListenerTarget() { + if (this._tabActor && this._tabActor.isRootActor) { + return this.window; + } + return this.docShell.chromeEventHandler; + }, + + relayTabActorNavigate: function (data) { + this.emit("navigate", data); + }, + + relayTabActorWillNavigate: function (data) { + this.emit("will-navigate", data); + }, + + destroy: function () { + if (this._tabActor) { + events.off(this._tabActor, "navigate", this.relayTabActorNavigate); + events.off(this._tabActor, "will-navigate", this.relayTabActorWillNavigate); + } + + // In case the environment was initialized from a window, we need to remove + // the progress listener. + if (this._win) { + try { + this.webProgress.removeProgressListener(this.listener); + } catch (e) { + // Which may fail in case the window was already destroyed. + } + } + + this._tabActor = null; + this._win = null; + } +}; + +const { BoxModelHighlighter } = require("./highlighters/box-model"); +register(BoxModelHighlighter); +exports.BoxModelHighlighter = BoxModelHighlighter; + +const { CssGridHighlighter } = require("./highlighters/css-grid"); +register(CssGridHighlighter); +exports.CssGridHighlighter = CssGridHighlighter; + +const { CssTransformHighlighter } = require("./highlighters/css-transform"); +register(CssTransformHighlighter); +exports.CssTransformHighlighter = CssTransformHighlighter; + +const { SelectorHighlighter } = require("./highlighters/selector"); +register(SelectorHighlighter); +exports.SelectorHighlighter = SelectorHighlighter; + +const { RectHighlighter } = require("./highlighters/rect"); +register(RectHighlighter); +exports.RectHighlighter = RectHighlighter; + +const { GeometryEditorHighlighter } = require("./highlighters/geometry-editor"); +register(GeometryEditorHighlighter); +exports.GeometryEditorHighlighter = GeometryEditorHighlighter; + +const { RulersHighlighter } = require("./highlighters/rulers"); +register(RulersHighlighter); +exports.RulersHighlighter = RulersHighlighter; + +const { MeasuringToolHighlighter } = require("./highlighters/measuring-tool"); +register(MeasuringToolHighlighter); +exports.MeasuringToolHighlighter = MeasuringToolHighlighter; + +const { EyeDropper } = require("./highlighters/eye-dropper"); +register(EyeDropper); +exports.EyeDropper = EyeDropper; diff --git a/devtools/server/actors/highlighters/auto-refresh.js b/devtools/server/actors/highlighters/auto-refresh.js new file mode 100644 index 0000000000..31f89de20e --- /dev/null +++ b/devtools/server/actors/highlighters/auto-refresh.js @@ -0,0 +1,215 @@ +/* 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 { Cu } = require("chrome"); +const EventEmitter = require("devtools/shared/event-emitter"); +const { isNodeValid } = require("./utils/markup"); +const { getAdjustedQuads } = require("devtools/shared/layout/utils"); + +// Note that the order of items in this array is important because it is used +// for drawing the BoxModelHighlighter's path elements correctly. +const BOX_MODEL_REGIONS = ["margin", "border", "padding", "content"]; + +/** + * Base class for auto-refresh-on-change highlighters. Sub classes will have a + * chance to update whenever the current node's geometry changes. + * + * Sub classes must implement the following methods: + * _show: called when the highlighter should be shown, + * _hide: called when the highlighter should be hidden, + * _update: called while the highlighter is shown and the geometry of the + * current node changes. + * + * Sub classes will have access to the following properties: + * - this.currentNode: the node to be shown + * - this.currentQuads: all of the node's box model region quads + * - this.win: the current window + * + * Emits the following events: + * - shown + * - hidden + * - updated + */ +function AutoRefreshHighlighter(highlighterEnv) { + EventEmitter.decorate(this); + + this.highlighterEnv = highlighterEnv; + + this.currentNode = null; + this.currentQuads = {}; + + this.update = this.update.bind(this); +} + +AutoRefreshHighlighter.prototype = { + /** + * Window corresponding to the current highlighterEnv + */ + get win() { + if (!this.highlighterEnv) { + return null; + } + return this.highlighterEnv.window; + }, + + /** + * Show the highlighter on a given node + * @param {DOMNode} node + * @param {Object} options + * Object used for passing options + */ + show: function (node, options = {}) { + let isSameNode = node === this.currentNode; + let isSameOptions = this._isSameOptions(options); + + if (!this._isNodeValid(node) || (isSameNode && isSameOptions)) { + return false; + } + + this.options = options; + + this._stopRefreshLoop(); + this.currentNode = node; + this._updateAdjustedQuads(); + this._startRefreshLoop(); + + let shown = this._show(); + if (shown) { + this.emit("shown"); + } + return shown; + }, + + /** + * Hide the highlighter + */ + hide: function () { + if (!this._isNodeValid(this.currentNode)) { + return; + } + + this._hide(); + this._stopRefreshLoop(); + this.currentNode = null; + this.currentQuads = {}; + this.options = null; + + this.emit("hidden"); + }, + + /** + * Whether the current node is valid for this highlighter type. + * This is implemented by default to check if the node is an element node. Highlighter + * sub-classes should override this method if they want to highlight other node types. + * @param {DOMNode} node + * @return {Boolean} + */ + _isNodeValid: function (node) { + return isNodeValid(node); + }, + + /** + * Are the provided options the same as the currently stored options? + * Returns false if there are no options stored currently. + */ + _isSameOptions: function (options) { + if (!this.options) { + return false; + } + + let keys = Object.keys(options); + + if (keys.length !== Object.keys(this.options).length) { + return false; + } + + for (let key of keys) { + if (this.options[key] !== options[key]) { + return false; + } + } + + return true; + }, + + /** + * Update the stored box quads by reading the current node's box quads. + */ + _updateAdjustedQuads: function () { + for (let region of BOX_MODEL_REGIONS) { + this.currentQuads[region] = getAdjustedQuads( + this.win, + this.currentNode, region); + } + }, + + /** + * Update the knowledge we have of the current node's boxquads and return true + * if any of the points x/y or bounds have change since. + * @return {Boolean} + */ + _hasMoved: function () { + let oldQuads = JSON.stringify(this.currentQuads); + this._updateAdjustedQuads(); + let newQuads = JSON.stringify(this.currentQuads); + return oldQuads !== newQuads; + }, + + /** + * Update the highlighter if the node has moved since the last update. + */ + update: function () { + if (!this._isNodeValid(this.currentNode) || !this._hasMoved()) { + return; + } + + this._update(); + this.emit("updated"); + }, + + _show: function () { + // To be implemented by sub classes + // When called, sub classes should actually show the highlighter for + // this.currentNode, potentially using options in this.options + throw new Error("Custom highlighter class had to implement _show method"); + }, + + _update: function () { + // To be implemented by sub classes + // When called, sub classes should update the highlighter shown for + // this.currentNode + // This is called as a result of a page scroll, zoom or repaint + throw new Error("Custom highlighter class had to implement _update method"); + }, + + _hide: function () { + // To be implemented by sub classes + // When called, sub classes should actually hide the highlighter + throw new Error("Custom highlighter class had to implement _hide method"); + }, + + _startRefreshLoop: function () { + let win = this.currentNode.ownerDocument.defaultView; + this.rafID = win.requestAnimationFrame(this._startRefreshLoop.bind(this)); + this.rafWin = win; + this.update(); + }, + + _stopRefreshLoop: function () { + if (this.rafID && !Cu.isDeadWrapper(this.rafWin)) { + this.rafWin.cancelAnimationFrame(this.rafID); + } + this.rafID = this.rafWin = null; + }, + + destroy: function () { + this.hide(); + + this.highlighterEnv = null; + this.currentNode = null; + } +}; +exports.AutoRefreshHighlighter = AutoRefreshHighlighter; diff --git a/devtools/server/actors/highlighters/box-model.js b/devtools/server/actors/highlighters/box-model.js new file mode 100644 index 0000000000..35f201a041 --- /dev/null +++ b/devtools/server/actors/highlighters/box-model.js @@ -0,0 +1,712 @@ + /* 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 { extend } = require("sdk/core/heritage"); +const { AutoRefreshHighlighter } = require("./auto-refresh"); +const { + CanvasFrameAnonymousContentHelper, + createNode, + createSVGNode, + getBindingElementAndPseudo, + hasPseudoClassLock, + isNodeValid, + moveInfobar, +} = require("./utils/markup"); +const { setIgnoreLayoutChanges } = require("devtools/shared/layout/utils"); +const inspector = require("devtools/server/actors/inspector"); +const nodeConstants = require("devtools/shared/dom-node-constants"); + +// Note that the order of items in this array is important because it is used +// for drawing the BoxModelHighlighter's path elements correctly. +const BOX_MODEL_REGIONS = ["margin", "border", "padding", "content"]; +const BOX_MODEL_SIDES = ["top", "right", "bottom", "left"]; +// Width of boxmodelhighlighter guides +const GUIDE_STROKE_WIDTH = 1; +// FIXME: add ":visited" and ":link" after bug 713106 is fixed +const PSEUDO_CLASSES = [":hover", ":active", ":focus"]; + +/** + * The BoxModelHighlighter draws the box model regions on top of a node. + * If the node is a block box, then each region will be displayed as 1 polygon. + * If the node is an inline box though, each region may be represented by 1 or + * more polygons, depending on how many line boxes the inline element has. + * + * Usage example: + * + * let h = new BoxModelHighlighter(env); + * h.show(node, options); + * h.hide(); + * h.destroy(); + * + * Available options: + * - region {String} + * "content", "padding", "border" or "margin" + * This specifies the region that the guides should outline. + * Defaults to "content" + * - hideGuides {Boolean} + * Defaults to false + * - hideInfoBar {Boolean} + * Defaults to false + * - showOnly {String} + * "content", "padding", "border" or "margin" + * If set, only this region will be highlighted. Use with onlyRegionArea to + * only highlight the area of the region. + * - onlyRegionArea {Boolean} + * This can be set to true to make each region's box only highlight the area + * of the corresponding region rather than the area of nested regions too. + * This is useful when used with showOnly. + * + * Structure: + * <div class="highlighter-container"> + * <div class="box-model-root"> + * <svg class="box-model-elements" hidden="true"> + * <g class="box-model-regions"> + * <path class="box-model-margin" points="..." /> + * <path class="box-model-border" points="..." /> + * <path class="box-model-padding" points="..." /> + * <path class="box-model-content" points="..." /> + * </g> + * <line class="box-model-guide-top" x1="..." y1="..." x2="..." y2="..." /> + * <line class="box-model-guide-right" x1="..." y1="..." x2="..." y2="..." /> + * <line class="box-model-guide-bottom" x1="..." y1="..." x2="..." y2="..." /> + * <line class="box-model-guide-left" x1="..." y1="..." x2="..." y2="..." /> + * </svg> + * <div class="box-model-infobar-container"> + * <div class="box-model-infobar-arrow highlighter-infobar-arrow-top" /> + * <div class="box-model-infobar"> + * <div class="box-model-infobar-text" align="center"> + * <span class="box-model-infobar-tagname">Node name</span> + * <span class="box-model-infobar-id">Node id</span> + * <span class="box-model-infobar-classes">.someClass</span> + * <span class="box-model-infobar-pseudo-classes">:hover</span> + * </div> + * </div> + * <div class="box-model-infobar-arrow box-model-infobar-arrow-bottom"/> + * </div> + * </div> + * </div> + */ +function BoxModelHighlighter(highlighterEnv) { + AutoRefreshHighlighter.call(this, highlighterEnv); + + this.markup = new CanvasFrameAnonymousContentHelper(this.highlighterEnv, + this._buildMarkup.bind(this)); + + /** + * Optionally customize each region's fill color by adding an entry to the + * regionFill property: `highlighter.regionFill.margin = "red"; + */ + this.regionFill = {}; + + this.onWillNavigate = this.onWillNavigate.bind(this); + + this.highlighterEnv.on("will-navigate", this.onWillNavigate); +} + +BoxModelHighlighter.prototype = extend(AutoRefreshHighlighter.prototype, { + typeName: "BoxModelHighlighter", + + ID_CLASS_PREFIX: "box-model-", + + _buildMarkup: function () { + let doc = this.win.document; + + let highlighterContainer = doc.createElement("div"); + highlighterContainer.className = "highlighter-container box-model"; + + // Build the root wrapper, used to adapt to the page zoom. + let rootWrapper = createNode(this.win, { + parent: highlighterContainer, + attributes: { + "id": "root", + "class": "root" + }, + prefix: this.ID_CLASS_PREFIX + }); + + // Building the SVG element with its polygons and lines + + let svg = createSVGNode(this.win, { + nodeType: "svg", + parent: rootWrapper, + attributes: { + "id": "elements", + "width": "100%", + "height": "100%", + "hidden": "true" + }, + prefix: this.ID_CLASS_PREFIX + }); + + let regions = createSVGNode(this.win, { + nodeType: "g", + parent: svg, + attributes: { + "class": "regions" + }, + prefix: this.ID_CLASS_PREFIX + }); + + for (let region of BOX_MODEL_REGIONS) { + createSVGNode(this.win, { + nodeType: "path", + parent: regions, + attributes: { + "class": region, + "id": region + }, + prefix: this.ID_CLASS_PREFIX + }); + } + + for (let side of BOX_MODEL_SIDES) { + createSVGNode(this.win, { + nodeType: "line", + parent: svg, + attributes: { + "class": "guide-" + side, + "id": "guide-" + side, + "stroke-width": GUIDE_STROKE_WIDTH + }, + prefix: this.ID_CLASS_PREFIX + }); + } + + // Building the nodeinfo bar markup + + let infobarContainer = createNode(this.win, { + parent: rootWrapper, + attributes: { + "class": "infobar-container", + "id": "infobar-container", + "position": "top", + "hidden": "true" + }, + prefix: this.ID_CLASS_PREFIX + }); + + let infobar = createNode(this.win, { + parent: infobarContainer, + attributes: { + "class": "infobar" + }, + prefix: this.ID_CLASS_PREFIX + }); + + let texthbox = createNode(this.win, { + parent: infobar, + attributes: { + "class": "infobar-text" + }, + prefix: this.ID_CLASS_PREFIX + }); + createNode(this.win, { + nodeType: "span", + parent: texthbox, + attributes: { + "class": "infobar-tagname", + "id": "infobar-tagname" + }, + prefix: this.ID_CLASS_PREFIX + }); + createNode(this.win, { + nodeType: "span", + parent: texthbox, + attributes: { + "class": "infobar-id", + "id": "infobar-id" + }, + prefix: this.ID_CLASS_PREFIX + }); + createNode(this.win, { + nodeType: "span", + parent: texthbox, + attributes: { + "class": "infobar-classes", + "id": "infobar-classes" + }, + prefix: this.ID_CLASS_PREFIX + }); + createNode(this.win, { + nodeType: "span", + parent: texthbox, + attributes: { + "class": "infobar-pseudo-classes", + "id": "infobar-pseudo-classes" + }, + prefix: this.ID_CLASS_PREFIX + }); + createNode(this.win, { + nodeType: "span", + parent: texthbox, + attributes: { + "class": "infobar-dimensions", + "id": "infobar-dimensions" + }, + prefix: this.ID_CLASS_PREFIX + }); + + return highlighterContainer; + }, + + /** + * Destroy the nodes. Remove listeners. + */ + destroy: function () { + this.highlighterEnv.off("will-navigate", this.onWillNavigate); + this.markup.destroy(); + AutoRefreshHighlighter.prototype.destroy.call(this); + }, + + getElement: function (id) { + return this.markup.getElement(this.ID_CLASS_PREFIX + id); + }, + + /** + * Override the AutoRefreshHighlighter's _isNodeValid method to also return true for + * text nodes since these can also be highlighted. + * @param {DOMNode} node + * @return {Boolean} + */ + _isNodeValid: function (node) { + return node && (isNodeValid(node) || isNodeValid(node, nodeConstants.TEXT_NODE)); + }, + + /** + * Show the highlighter on a given node + */ + _show: function () { + if (BOX_MODEL_REGIONS.indexOf(this.options.region) == -1) { + this.options.region = "content"; + } + + let shown = this._update(); + this._trackMutations(); + this.emit("ready"); + return shown; + }, + + /** + * Track the current node markup mutations so that the node info bar can be + * updated to reflects the node's attributes + */ + _trackMutations: function () { + if (isNodeValid(this.currentNode)) { + let win = this.currentNode.ownerDocument.defaultView; + this.currentNodeObserver = new win.MutationObserver(this.update); + this.currentNodeObserver.observe(this.currentNode, {attributes: true}); + } + }, + + _untrackMutations: function () { + if (isNodeValid(this.currentNode) && this.currentNodeObserver) { + this.currentNodeObserver.disconnect(); + this.currentNodeObserver = null; + } + }, + + /** + * Update the highlighter on the current highlighted node (the one that was + * passed as an argument to show(node)). + * Should be called whenever node size or attributes change + */ + _update: function () { + let shown = false; + setIgnoreLayoutChanges(true); + + if (this._updateBoxModel()) { + // Show the infobar only if configured to do so and the node is an element or a text + // node. + if (!this.options.hideInfoBar && ( + this.currentNode.nodeType === this.currentNode.ELEMENT_NODE || + this.currentNode.nodeType === this.currentNode.TEXT_NODE)) { + this._showInfobar(); + } else { + this._hideInfobar(); + } + this._showBoxModel(); + shown = true; + } else { + // Nothing to highlight (0px rectangle like a <script> tag for instance) + this._hide(); + } + + setIgnoreLayoutChanges(false, this.highlighterEnv.window.document.documentElement); + + return shown; + }, + + /** + * Hide the highlighter, the outline and the infobar. + */ + _hide: function () { + setIgnoreLayoutChanges(true); + + this._untrackMutations(); + this._hideBoxModel(); + this._hideInfobar(); + + setIgnoreLayoutChanges(false, this.highlighterEnv.window.document.documentElement); + }, + + /** + * Hide the infobar + */ + _hideInfobar: function () { + this.getElement("infobar-container").setAttribute("hidden", "true"); + }, + + /** + * Show the infobar + */ + _showInfobar: function () { + this.getElement("infobar-container").removeAttribute("hidden"); + this._updateInfobar(); + }, + + /** + * Hide the box model + */ + _hideBoxModel: function () { + this.getElement("elements").setAttribute("hidden", "true"); + }, + + /** + * Show the box model + */ + _showBoxModel: function () { + this.getElement("elements").removeAttribute("hidden"); + }, + + /** + * Calculate an outer quad based on the quads returned by getAdjustedQuads. + * The BoxModelHighlighter may highlight more than one boxes, so in this case + * create a new quad that "contains" all of these quads. + * This is useful to position the guides and infobar. + * This may happen if the BoxModelHighlighter is used to highlight an inline + * element that spans line breaks. + * @param {String} region The box-model region to get the outer quad for. + * @return {Object} A quad-like object {p1,p2,p3,p4,bounds} + */ + _getOuterQuad: function (region) { + let quads = this.currentQuads[region]; + if (!quads.length) { + return null; + } + + let quad = { + p1: {x: Infinity, y: Infinity}, + p2: {x: -Infinity, y: Infinity}, + p3: {x: -Infinity, y: -Infinity}, + p4: {x: Infinity, y: -Infinity}, + bounds: { + bottom: -Infinity, + height: 0, + left: Infinity, + right: -Infinity, + top: Infinity, + width: 0, + x: 0, + y: 0, + } + }; + + for (let q of quads) { + quad.p1.x = Math.min(quad.p1.x, q.p1.x); + quad.p1.y = Math.min(quad.p1.y, q.p1.y); + quad.p2.x = Math.max(quad.p2.x, q.p2.x); + quad.p2.y = Math.min(quad.p2.y, q.p2.y); + quad.p3.x = Math.max(quad.p3.x, q.p3.x); + quad.p3.y = Math.max(quad.p3.y, q.p3.y); + quad.p4.x = Math.min(quad.p4.x, q.p4.x); + quad.p4.y = Math.max(quad.p4.y, q.p4.y); + + quad.bounds.bottom = Math.max(quad.bounds.bottom, q.bounds.bottom); + quad.bounds.top = Math.min(quad.bounds.top, q.bounds.top); + quad.bounds.left = Math.min(quad.bounds.left, q.bounds.left); + quad.bounds.right = Math.max(quad.bounds.right, q.bounds.right); + } + quad.bounds.x = quad.bounds.left; + quad.bounds.y = quad.bounds.top; + quad.bounds.width = quad.bounds.right - quad.bounds.left; + quad.bounds.height = quad.bounds.bottom - quad.bounds.top; + + return quad; + }, + + /** + * Update the box model as per the current node. + * + * @return {boolean} + * True if the current node has a box model to be highlighted + */ + _updateBoxModel: function () { + let options = this.options; + options.region = options.region || "content"; + + if (!this._nodeNeedsHighlighting()) { + this._hideBoxModel(); + return false; + } + + for (let i = 0; i < BOX_MODEL_REGIONS.length; i++) { + let boxType = BOX_MODEL_REGIONS[i]; + let nextBoxType = BOX_MODEL_REGIONS[i + 1]; + let box = this.getElement(boxType); + + if (this.regionFill[boxType]) { + box.setAttribute("style", "fill:" + this.regionFill[boxType]); + } else { + box.setAttribute("style", ""); + } + + // Highlight all quads for this region by setting the "d" attribute of the + // corresponding <path>. + let path = []; + for (let j = 0; j < this.currentQuads[boxType].length; j++) { + let boxQuad = this.currentQuads[boxType][j]; + let nextBoxQuad = this.currentQuads[nextBoxType] + ? this.currentQuads[nextBoxType][j] + : null; + path.push(this._getBoxPathCoordinates(boxQuad, nextBoxQuad)); + } + + box.setAttribute("d", path.join(" ")); + box.removeAttribute("faded"); + + // If showOnly is defined, either hide the other regions, or fade them out + // if onlyRegionArea is set too. + if (options.showOnly && options.showOnly !== boxType) { + if (options.onlyRegionArea) { + box.setAttribute("faded", "true"); + } else { + box.removeAttribute("d"); + } + } + + if (boxType === options.region && !options.hideGuides) { + this._showGuides(boxType); + } else if (options.hideGuides) { + this._hideGuides(); + } + } + + // Un-zoom the root wrapper if the page was zoomed. + let rootId = this.ID_CLASS_PREFIX + "root"; + this.markup.scaleRootElement(this.currentNode, rootId); + + return true; + }, + + _getBoxPathCoordinates: function (boxQuad, nextBoxQuad) { + let {p1, p2, p3, p4} = boxQuad; + + let path; + if (!nextBoxQuad || !this.options.onlyRegionArea) { + // If this is the content box (inner-most box) or if we're not being asked + // to highlight only region areas, then draw a simple rectangle. + path = "M" + p1.x + "," + p1.y + " " + + "L" + p2.x + "," + p2.y + " " + + "L" + p3.x + "," + p3.y + " " + + "L" + p4.x + "," + p4.y; + } else { + // Otherwise, just draw the region itself, not a filled rectangle. + let {p1: np1, p2: np2, p3: np3, p4: np4} = nextBoxQuad; + path = "M" + p1.x + "," + p1.y + " " + + "L" + p2.x + "," + p2.y + " " + + "L" + p3.x + "," + p3.y + " " + + "L" + p4.x + "," + p4.y + " " + + "L" + p1.x + "," + p1.y + " " + + "L" + np1.x + "," + np1.y + " " + + "L" + np4.x + "," + np4.y + " " + + "L" + np3.x + "," + np3.y + " " + + "L" + np2.x + "," + np2.y + " " + + "L" + np1.x + "," + np1.y; + } + + return path; + }, + + /** + * Can the current node be highlighted? Does it have quads. + * @return {Boolean} + */ + _nodeNeedsHighlighting: function () { + return this.currentQuads.margin.length || + this.currentQuads.border.length || + this.currentQuads.padding.length || + this.currentQuads.content.length; + }, + + _getOuterBounds: function () { + for (let region of ["margin", "border", "padding", "content"]) { + let quad = this._getOuterQuad(region); + + if (!quad) { + // Invisible element such as a script tag. + break; + } + + let {bottom, height, left, right, top, width, x, y} = quad.bounds; + + if (width > 0 || height > 0) { + return {bottom, height, left, right, top, width, x, y}; + } + } + + return { + bottom: 0, + height: 0, + left: 0, + right: 0, + top: 0, + width: 0, + x: 0, + y: 0 + }; + }, + + /** + * We only want to show guides for horizontal and vertical edges as this helps + * to line them up. This method finds these edges and displays a guide there. + * @param {String} region The region around which the guides should be shown. + */ + _showGuides: function (region) { + let {p1, p2, p3, p4} = this._getOuterQuad(region); + + let allX = [p1.x, p2.x, p3.x, p4.x].sort((a, b) => a - b); + let allY = [p1.y, p2.y, p3.y, p4.y].sort((a, b) => a - b); + let toShowX = []; + let toShowY = []; + + for (let arr of [allX, allY]) { + for (let i = 0; i < arr.length; i++) { + let val = arr[i]; + + if (i !== arr.lastIndexOf(val)) { + if (arr === allX) { + toShowX.push(val); + } else { + toShowY.push(val); + } + arr.splice(arr.lastIndexOf(val), 1); + } + } + } + + // Move guide into place or hide it if no valid co-ordinate was found. + this._updateGuide("top", toShowY[0]); + this._updateGuide("right", toShowX[1]); + this._updateGuide("bottom", toShowY[1]); + this._updateGuide("left", toShowX[0]); + }, + + _hideGuides: function () { + for (let side of BOX_MODEL_SIDES) { + this.getElement("guide-" + side).setAttribute("hidden", "true"); + } + }, + + /** + * Move a guide to the appropriate position and display it. If no point is + * passed then the guide is hidden. + * + * @param {String} side + * The guide to update + * @param {Integer} point + * x or y co-ordinate. If this is undefined we hide the guide. + */ + _updateGuide: function (side, point = -1) { + let guide = this.getElement("guide-" + side); + + if (point <= 0) { + guide.setAttribute("hidden", "true"); + return false; + } + + if (side === "top" || side === "bottom") { + guide.setAttribute("x1", "0"); + guide.setAttribute("y1", point + ""); + guide.setAttribute("x2", "100%"); + guide.setAttribute("y2", point + ""); + } else { + guide.setAttribute("x1", point + ""); + guide.setAttribute("y1", "0"); + guide.setAttribute("x2", point + ""); + guide.setAttribute("y2", "100%"); + } + + guide.removeAttribute("hidden"); + + return true; + }, + + /** + * Update node information (displayName#id.class) + */ + _updateInfobar: function () { + if (!this.currentNode) { + return; + } + + let {bindingElement: node, pseudo} = + getBindingElementAndPseudo(this.currentNode); + + // Update the tag, id, classes, pseudo-classes and dimensions + let displayName = inspector.getNodeDisplayName(node); + + let id = node.id ? "#" + node.id : ""; + + let classList = (node.classList || []).length + ? "." + [...node.classList].join(".") + : ""; + + let pseudos = this._getPseudoClasses(node).join(""); + if (pseudo) { + // Display :after as ::after + pseudos += ":" + pseudo; + } + + let rect = this._getOuterQuad("border").bounds; + let dim = parseFloat(rect.width.toPrecision(6)) + + " \u00D7 " + + parseFloat(rect.height.toPrecision(6)); + + this.getElement("infobar-tagname").setTextContent(displayName); + this.getElement("infobar-id").setTextContent(id); + this.getElement("infobar-classes").setTextContent(classList); + this.getElement("infobar-pseudo-classes").setTextContent(pseudos); + this.getElement("infobar-dimensions").setTextContent(dim); + + this._moveInfobar(); + }, + + _getPseudoClasses: function (node) { + if (node.nodeType !== nodeConstants.ELEMENT_NODE) { + // hasPseudoClassLock can only be used on Elements. + return []; + } + + return PSEUDO_CLASSES.filter(pseudo => hasPseudoClassLock(node, pseudo)); + }, + + /** + * Move the Infobar to the right place in the highlighter. + */ + _moveInfobar: function () { + let bounds = this._getOuterBounds(); + let container = this.getElement("infobar-container"); + + moveInfobar(container, bounds, this.win); + }, + + onWillNavigate: function ({ isTopLevel }) { + if (isTopLevel) { + this.hide(); + } + } +}); +exports.BoxModelHighlighter = BoxModelHighlighter; diff --git a/devtools/server/actors/highlighters/css-grid.js b/devtools/server/actors/highlighters/css-grid.js new file mode 100644 index 0000000000..0ed1ee9614 --- /dev/null +++ b/devtools/server/actors/highlighters/css-grid.js @@ -0,0 +1,737 @@ +/* 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 Services = require("Services"); +const { extend } = require("sdk/core/heritage"); +const { AutoRefreshHighlighter } = require("./auto-refresh"); +const { + CanvasFrameAnonymousContentHelper, + createNode, + createSVGNode, + moveInfobar, +} = require("./utils/markup"); +const { + getCurrentZoom, + setIgnoreLayoutChanges +} = require("devtools/shared/layout/utils"); +const { stringifyGridFragments } = require("devtools/server/actors/utils/css-grid-utils"); + +const CSS_GRID_ENABLED_PREF = "layout.css.grid.enabled"; +const ROWS = "rows"; +const COLUMNS = "cols"; +const GRID_LINES_PROPERTIES = { + "edge": { + lineDash: [0, 0], + strokeStyle: "#4B0082" + }, + "explicit": { + lineDash: [5, 3], + strokeStyle: "#8A2BE2" + }, + "implicit": { + lineDash: [2, 2], + strokeStyle: "#9370DB" + } +}; + +// px +const GRID_GAP_PATTERN_WIDTH = 14; +const GRID_GAP_PATTERN_HEIGHT = 14; +const GRID_GAP_PATTERN_LINE_DASH = [5, 3]; +const GRID_GAP_PATTERN_STROKE_STYLE = "#9370DB"; + +/** + * Cached used by `CssGridHighlighter.getGridGapPattern`. + */ +const gCachedGridPattern = new WeakMap(); +// WeakMap key for the Row grid pattern. +const ROW_KEY = {}; +// WeakMap key for the Column grid pattern. +const COLUMN_KEY = {}; + +/** + * The CssGridHighlighter is the class that overlays a visual grid on top of + * display:grid elements. + * + * Usage example: + * let h = new CssGridHighlighter(env); + * h.show(node, options); + * h.hide(); + * h.destroy(); + * + * Available Options: + * - showGridArea(areaName) + * @param {String} areaName + * Shows the grid area highlight for the given area name. + * - showAllGridAreas + * Shows all the grid area highlights for the current grid. + * - showGridLineNumbers(isShown) + * @param {Boolean} + * Displays the grid line numbers on the grid lines if isShown is true. + * - showInfiniteLines(isShown) + * @param {Boolean} isShown + * Displays an infinite line to represent the grid lines if isShown is true. + * + * Structure: + * <div class="highlighter-container"> + * <canvas id="css-grid-canvas" class="css-grid-canvas"> + * <svg class="css-grid-elements" hidden="true"> + * <g class="css-grid-regions"> + * <path class="css-grid-areas" points="..." /> + * </g> + * </svg> + * <div class="css-grid-infobar-container"> + * <div class="css-grid-infobar"> + * <div class="css-grid-infobar-text"> + * <span class="css-grid-infobar-areaname">Grid Area Name</span> + * <span class="css-grid-infobar-dimensions"Grid Area Dimensions></span> + * </div> + * </div> + * </div> + * </div> + */ +function CssGridHighlighter(highlighterEnv) { + AutoRefreshHighlighter.call(this, highlighterEnv); + + this.markup = new CanvasFrameAnonymousContentHelper(this.highlighterEnv, + this._buildMarkup.bind(this)); + + this.onNavigate = this.onNavigate.bind(this); + this.onWillNavigate = this.onWillNavigate.bind(this); + + this.highlighterEnv.on("navigate", this.onNavigate); + this.highlighterEnv.on("will-navigate", this.onWillNavigate); +} + +CssGridHighlighter.prototype = extend(AutoRefreshHighlighter.prototype, { + typeName: "CssGridHighlighter", + + ID_CLASS_PREFIX: "css-grid-", + + _buildMarkup() { + let container = createNode(this.win, { + attributes: { + "class": "highlighter-container" + } + }); + + // We use a <canvas> element so that we can draw an arbitrary number of lines + // which wouldn't be possible with HTML or SVG without having to insert and remove + // the whole markup on every update. + createNode(this.win, { + parent: container, + nodeType: "canvas", + attributes: { + "id": "canvas", + "class": "canvas", + "hidden": "true" + }, + prefix: this.ID_CLASS_PREFIX + }); + + // Build the SVG element + let svg = createSVGNode(this.win, { + nodeType: "svg", + parent: container, + attributes: { + "id": "elements", + "width": "100%", + "height": "100%", + "hidden": "true" + }, + prefix: this.ID_CLASS_PREFIX + }); + + let regions = createSVGNode(this.win, { + nodeType: "g", + parent: svg, + attributes: { + "class": "regions" + }, + prefix: this.ID_CLASS_PREFIX + }); + + createSVGNode(this.win, { + nodeType: "path", + parent: regions, + attributes: { + "class": "areas", + "id": "areas" + }, + prefix: this.ID_CLASS_PREFIX + }); + + // Building the grid infobar markup + let infobarContainer = createNode(this.win, { + parent: container, + attributes: { + "class": "infobar-container", + "id": "infobar-container", + "position": "top", + "hidden": "true" + }, + prefix: this.ID_CLASS_PREFIX + }); + + let infobar = createNode(this.win, { + parent: infobarContainer, + attributes: { + "class": "infobar" + }, + prefix: this.ID_CLASS_PREFIX + }); + + let textbox = createNode(this.win, { + parent: infobar, + attributes: { + "class": "infobar-text" + }, + prefix: this.ID_CLASS_PREFIX + }); + createNode(this.win, { + nodeType: "span", + parent: textbox, + attributes: { + "class": "infobar-areaname", + "id": "infobar-areaname" + }, + prefix: this.ID_CLASS_PREFIX + }); + createNode(this.win, { + nodeType: "span", + parent: textbox, + attributes: { + "class": "infobar-dimensions", + "id": "infobar-dimensions" + }, + prefix: this.ID_CLASS_PREFIX + }); + + return container; + }, + + destroy() { + this.highlighterEnv.off("navigate", this.onNavigate); + this.highlighterEnv.off("will-navigate", this.onWillNavigate); + this.markup.destroy(); + + // Clear the pattern cache to avoid dead object exceptions (Bug 1342051). + gCachedGridPattern.delete(ROW_KEY); + gCachedGridPattern.delete(COLUMN_KEY); + + AutoRefreshHighlighter.prototype.destroy.call(this); + }, + + getElement(id) { + return this.markup.getElement(this.ID_CLASS_PREFIX + id); + }, + + get ctx() { + return this.canvas.getCanvasContext("2d"); + }, + + get canvas() { + return this.getElement("canvas"); + }, + + /** + * Gets the grid gap pattern used to render the gap regions. + * + * @param {Object} dimension + * Refers to the WeakMap key for the grid dimension type which is either the + * constant COLUMN or ROW. + * @return {CanvasPattern} grid gap pattern. + */ + getGridGapPattern(dimension) { + if (gCachedGridPattern.has(dimension)) { + return gCachedGridPattern.get(dimension); + } + + // Create the diagonal lines pattern for the rendering the grid gaps. + let canvas = createNode(this.win, { nodeType: "canvas" }); + canvas.width = GRID_GAP_PATTERN_WIDTH; + canvas.height = GRID_GAP_PATTERN_HEIGHT; + + let ctx = canvas.getContext("2d"); + ctx.setLineDash(GRID_GAP_PATTERN_LINE_DASH); + ctx.beginPath(); + ctx.translate(.5, .5); + + if (dimension === COLUMN_KEY) { + ctx.moveTo(0, 0); + ctx.lineTo(GRID_GAP_PATTERN_WIDTH, GRID_GAP_PATTERN_HEIGHT); + } else { + ctx.moveTo(GRID_GAP_PATTERN_WIDTH, 0); + ctx.lineTo(0, GRID_GAP_PATTERN_HEIGHT); + } + + ctx.strokeStyle = GRID_GAP_PATTERN_STROKE_STYLE; + ctx.stroke(); + + let pattern = ctx.createPattern(canvas, "repeat"); + gCachedGridPattern.set(dimension, pattern); + return pattern; + }, + + /** + * Called when the page navigates. Used to clear the cached gap patterns and avoid + * using DeadWrapper objects as gap patterns the next time. + */ + onNavigate() { + gCachedGridPattern.delete(ROW_KEY); + gCachedGridPattern.delete(COLUMN_KEY); + }, + + onWillNavigate({ isTopLevel }) { + if (isTopLevel) { + this.hide(); + } + }, + + _show() { + if (Services.prefs.getBoolPref(CSS_GRID_ENABLED_PREF) && !this.isGrid()) { + this.hide(); + return false; + } + + return this._update(); + }, + + /** + * Shows the grid area highlight for the given area name. + * + * @param {String} areaName + * Grid area name. + */ + showGridArea(areaName) { + this.renderGridArea(areaName); + this._showGridArea(); + }, + + /** + * Shows all the grid area highlights for the current grid. + */ + showAllGridAreas() { + this.renderGridArea(); + this._showGridArea(); + }, + + /** + * Clear the grid area highlights. + */ + clearGridAreas() { + let box = this.getElement("areas"); + box.setAttribute("d", ""); + }, + + /** + * Checks if the current node has a CSS Grid layout. + * + * @return {Boolean} true if the current node has a CSS grid layout, false otherwise. + */ + isGrid() { + return this.currentNode.getGridFragments().length > 0; + }, + + /** + * The AutoRefreshHighlighter's _hasMoved method returns true only if the + * element's quads have changed. Override it so it also returns true if the + * element's grid has changed (which can happen when you change the + * grid-template-* CSS properties with the highlighter displayed). + */ + _hasMoved() { + let hasMoved = AutoRefreshHighlighter.prototype._hasMoved.call(this); + + let oldGridData = stringifyGridFragments(this.gridData); + this.gridData = this.currentNode.getGridFragments(); + let newGridData = stringifyGridFragments(this.gridData); + + return hasMoved || oldGridData !== newGridData; + }, + + /** + * Update the highlighter on the current highlighted node (the one that was + * passed as an argument to show(node)). + * Should be called whenever node's geometry or grid changes. + */ + _update() { + setIgnoreLayoutChanges(true); + + // Clear the canvas the grid area highlights. + this.clearCanvas(); + this.clearGridAreas(); + + // Start drawing the grid fragments. + for (let i = 0; i < this.gridData.length; i++) { + let fragment = this.gridData[i]; + let quad = this.currentQuads.content[i]; + this.renderFragment(fragment, quad); + } + + // Display the grid area highlights if needed. + if (this.options.showAllGridAreas) { + this.showAllGridAreas(); + } else if (this.options.showGridArea) { + this.showGridArea(this.options.showGridArea); + } + + this._showGrid(); + + setIgnoreLayoutChanges(false, this.highlighterEnv.window.document.documentElement); + return true; + }, + + /** + * Update the grid information displayed in the grid info bar. + * + * @param {GridArea} area + * The grid area object. + * @param {Number} x1 + * The first x-coordinate of the grid area rectangle. + * @param {Number} x2 + * The second x-coordinate of the grid area rectangle. + * @param {Number} y1 + * The first y-coordinate of the grid area rectangle. + * @param {Number} y2 + * The second y-coordinate of the grid area rectangle. + */ + _updateInfobar(area, x1, x2, y1, y2) { + let width = x2 - x1; + let height = y2 - y1; + let dim = parseFloat(width.toPrecision(6)) + + " \u00D7 " + + parseFloat(height.toPrecision(6)); + + this.getElement("infobar-areaname").setTextContent(area.name); + this.getElement("infobar-dimensions").setTextContent(dim); + + this._moveInfobar(x1, x2, y1, y2); + }, + + /** + * Move the grid infobar to the right place in the highlighter. + * + * @param {Number} x1 + * The first x-coordinate of the grid area rectangle. + * @param {Number} x2 + * The second x-coordinate of the grid area rectangle. + * @param {Number} y1 + * The first y-coordinate of the grid area rectangle. + * @param {Number} y2 + * The second y-coordinate of the grid area rectangle. + */ + _moveInfobar(x1, x2, y1, y2) { + let bounds = { + bottom: y2, + height: y2 - y1, + left: x1, + right: x2, + top: y1, + width: x2 - x1, + x: x1, + y: y1, + }; + let container = this.getElement("infobar-container"); + + moveInfobar(container, bounds, this.win); + }, + + clearCanvas() { + let ratio = parseFloat((this.win.devicePixelRatio || 1).toFixed(2)); + let width = this.win.innerWidth; + let height = this.win.innerHeight; + + // Resize the canvas taking the dpr into account so as to have crisp lines. + this.canvas.setAttribute("width", width * ratio); + this.canvas.setAttribute("height", height * ratio); + this.canvas.setAttribute("style", `width:${width}px;height:${height}px`); + this.ctx.scale(ratio, ratio); + + this.ctx.clearRect(0, 0, width, height); + }, + + getFirstRowLinePos(fragment) { + return fragment.rows.lines[0].start; + }, + + getLastRowLinePos(fragment) { + return fragment.rows.lines[fragment.rows.lines.length - 1].start; + }, + + getFirstColLinePos(fragment) { + return fragment.cols.lines[0].start; + }, + + getLastColLinePos(fragment) { + return fragment.cols.lines[fragment.cols.lines.length - 1].start; + }, + + /** + * Get the GridLine index of the last edge of the explicit grid for a grid dimension. + * + * @param {GridTracks} tracks + * The grid track of a given grid dimension. + * @return {Number} index of the last edge of the explicit grid for a grid dimension. + */ + getLastEdgeLineIndex(tracks) { + let trackIndex = tracks.length - 1; + + // Traverse the grid track backwards until we find an explicit track. + while (trackIndex >= 0 && tracks[trackIndex].type != "explicit") { + trackIndex--; + } + + // The grid line index is the grid track index + 1. + return trackIndex + 1; + }, + + renderFragment(fragment, quad) { + this.renderLines(fragment.cols, quad, COLUMNS, "left", "top", "height", + this.getFirstRowLinePos(fragment), + this.getLastRowLinePos(fragment)); + this.renderLines(fragment.rows, quad, ROWS, "top", "left", "width", + this.getFirstColLinePos(fragment), + this.getLastColLinePos(fragment)); + }, + + /** + * Render the grid lines given the grid dimension information of the + * column or row lines. + * + * @param {GridDimension} gridDimension + * Column or row grid dimension object. + * @param {Object} quad.bounds + * The content bounds of the box model region quads. + * @param {String} dimensionType + * The grid dimension type which is either the constant COLUMNS or ROWS. + * @param {String} mainSide + * The main side of the given grid dimension - "top" for rows and + * "left" for columns. + * @param {String} crossSide + * The cross side of the given grid dimension - "left" for rows and + * "top" for columns. + * @param {String} mainSize + * The main size of the given grid dimension - "width" for rows and + * "height" for columns. + * @param {Number} startPos + * The start position of the cross side of the grid dimension. + * @param {Number} endPos + * The end position of the cross side of the grid dimension. + */ + renderLines(gridDimension, {bounds}, dimensionType, mainSide, crossSide, + mainSize, startPos, endPos) { + let lineStartPos = (bounds[crossSide] / getCurrentZoom(this.win)) + startPos; + let lineEndPos = (bounds[crossSide] / getCurrentZoom(this.win)) + endPos; + + if (this.options.showInfiniteLines) { + lineStartPos = 0; + lineEndPos = parseInt(this.canvas.getAttribute(mainSize), 10); + } + + let lastEdgeLineIndex = this.getLastEdgeLineIndex(gridDimension.tracks); + + for (let i = 0; i < gridDimension.lines.length; i++) { + let line = gridDimension.lines[i]; + let linePos = (bounds[mainSide] / getCurrentZoom(this.win)) + line.start; + + if (this.options.showGridLineNumbers) { + this.renderGridLineNumber(line.number, linePos, lineStartPos, dimensionType); + } + + if (i == 0 || i == lastEdgeLineIndex) { + this.renderLine(linePos, lineStartPos, lineEndPos, dimensionType, "edge"); + } else { + this.renderLine(linePos, lineStartPos, lineEndPos, dimensionType, + gridDimension.tracks[i - 1].type); + } + + // Render a second line to illustrate the gutter for non-zero breadth. + if (line.breadth > 0) { + this.renderGridGap(linePos, lineStartPos, lineEndPos, line.breadth, + dimensionType); + this.renderLine(linePos + line.breadth, lineStartPos, lineEndPos, dimensionType, + gridDimension.tracks[i].type); + } + } + }, + + /** + * Render the grid line on the css grid highlighter canvas. + * + * @param {Number} linePos + * The line position along the x-axis for a column grid line and + * y-axis for a row grid line. + * @param {Number} startPos + * The start position of the cross side of the grid line. + * @param {Number} endPos + * The end position of the cross side of the grid line. + * @param {String} dimensionType + * The grid dimension type which is either the constant COLUMNS or ROWS. + * @param {[type]} lineType + * The grid line type - "edge", "explicit", or "implicit". + */ + renderLine(linePos, startPos, endPos, dimensionType, lineType) { + this.ctx.save(); + this.ctx.setLineDash(GRID_LINES_PROPERTIES[lineType].lineDash); + this.ctx.beginPath(); + this.ctx.translate(.5, .5); + + if (dimensionType === COLUMNS) { + this.ctx.moveTo(linePos, startPos); + this.ctx.lineTo(linePos, endPos); + } else { + this.ctx.moveTo(startPos, linePos); + this.ctx.lineTo(endPos, linePos); + } + + this.ctx.strokeStyle = GRID_LINES_PROPERTIES[lineType].strokeStyle; + this.ctx.stroke(); + this.ctx.restore(); + }, + + /** + * Render the grid line number on the css grid highlighter canvas. + * + * @param {Number} lineNumber + * The grid line number. + * @param {Number} linePos + * The line position along the x-axis for a column grid line and + * y-axis for a row grid line. + * @param {Number} startPos + * The start position of the cross side of the grid line. + * @param {String} dimensionType + * The grid dimension type which is either the constant COLUMNS or ROWS. + */ + renderGridLineNumber(lineNumber, linePos, startPos, dimensionType) { + this.ctx.save(); + + if (dimensionType === COLUMNS) { + this.ctx.fillText(lineNumber, linePos, startPos); + } else { + let textWidth = this.ctx.measureText(lineNumber).width; + this.ctx.fillText(lineNumber, startPos - textWidth, linePos); + } + + this.ctx.restore(); + }, + + /** + * Render the grid gap area on the css grid highlighter canvas. + * + * @param {Number} linePos + * The line position along the x-axis for a column grid line and + * y-axis for a row grid line. + * @param {Number} startPos + * The start position of the cross side of the grid line. + * @param {Number} endPos + * The end position of the cross side of the grid line. + * @param {Number} breadth + * The grid line breadth value. + * @param {String} dimensionType + * The grid dimension type which is either the constant COLUMNS or ROWS. + */ + renderGridGap(linePos, startPos, endPos, breadth, dimensionType) { + this.ctx.save(); + + if (dimensionType === COLUMNS) { + this.ctx.fillStyle = this.getGridGapPattern(COLUMN_KEY); + this.ctx.fillRect(linePos, startPos, breadth, endPos - startPos); + } else { + this.ctx.fillStyle = this.getGridGapPattern(ROW_KEY); + this.ctx.fillRect(startPos, linePos, endPos - startPos, breadth); + } + + this.ctx.restore(); + }, + + /** + * Render the grid area highlight for the given area name or for all the grid areas. + * + * @param {String} areaName + * Name of the grid area to be highlighted. If no area name is provided, all + * the grid areas should be highlighted. + */ + renderGridArea(areaName) { + let paths = []; + let currentZoom = getCurrentZoom(this.win); + + for (let i = 0; i < this.gridData.length; i++) { + let fragment = this.gridData[i]; + let {bounds} = this.currentQuads.content[i]; + + for (let area of fragment.areas) { + if (areaName && areaName != area.name) { + continue; + } + + let rowStart = fragment.rows.lines[area.rowStart - 1]; + let rowEnd = fragment.rows.lines[area.rowEnd - 1]; + let columnStart = fragment.cols.lines[area.columnStart - 1]; + let columnEnd = fragment.cols.lines[area.columnEnd - 1]; + + let x1 = columnStart.start + columnStart.breadth + + (bounds.left / currentZoom); + let x2 = columnEnd.start + (bounds.left / currentZoom); + let y1 = rowStart.start + rowStart.breadth + + (bounds.top / currentZoom); + let y2 = rowEnd.start + (bounds.top / currentZoom); + + let path = "M" + x1 + "," + y1 + " " + + "L" + x2 + "," + y1 + " " + + "L" + x2 + "," + y2 + " " + + "L" + x1 + "," + y2; + paths.push(path); + + // Update and show the info bar when only displaying a single grid area. + if (areaName) { + this._updateInfobar(area, x1, x2, y1, y2); + this._showInfoBar(); + } + } + } + + let box = this.getElement("areas"); + box.setAttribute("d", paths.join(" ")); + }, + + /** + * Hide the highlighter, the canvas and the infobar. + */ + _hide() { + setIgnoreLayoutChanges(true); + this._hideGrid(); + this._hideGridArea(); + this._hideInfoBar(); + setIgnoreLayoutChanges(false, this.highlighterEnv.window.document.documentElement); + }, + + _hideGrid() { + this.getElement("canvas").setAttribute("hidden", "true"); + }, + + _showGrid() { + this.getElement("canvas").removeAttribute("hidden"); + }, + + _hideGridArea() { + this.getElement("elements").setAttribute("hidden", "true"); + }, + + _showGridArea() { + this.getElement("elements").removeAttribute("hidden"); + }, + + _hideInfoBar() { + this.getElement("infobar-container").setAttribute("hidden", "true"); + }, + + _showInfoBar() { + this.getElement("infobar-container").removeAttribute("hidden"); + }, + +}); + +exports.CssGridHighlighter = CssGridHighlighter; diff --git a/devtools/server/actors/highlighters/css-transform.js b/devtools/server/actors/highlighters/css-transform.js new file mode 100644 index 0000000000..83a2c7e591 --- /dev/null +++ b/devtools/server/actors/highlighters/css-transform.js @@ -0,0 +1,243 @@ +/* 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 { extend } = require("sdk/core/heritage"); +const { AutoRefreshHighlighter } = require("./auto-refresh"); +const { + CanvasFrameAnonymousContentHelper, getComputedStyle, + createSVGNode, createNode } = require("./utils/markup"); +const { setIgnoreLayoutChanges, + getNodeBounds } = require("devtools/shared/layout/utils"); + +// The minimum distance a line should be before it has an arrow marker-end +const ARROW_LINE_MIN_DISTANCE = 10; + +var MARKER_COUNTER = 1; + +/** + * The CssTransformHighlighter is the class that draws an outline around a + * transformed element and an outline around where it would be if untransformed + * as well as arrows connecting the 2 outlines' corners. + */ +function CssTransformHighlighter(highlighterEnv) { + AutoRefreshHighlighter.call(this, highlighterEnv); + + this.markup = new CanvasFrameAnonymousContentHelper(this.highlighterEnv, + this._buildMarkup.bind(this)); +} + +CssTransformHighlighter.prototype = extend(AutoRefreshHighlighter.prototype, { + typeName: "CssTransformHighlighter", + + ID_CLASS_PREFIX: "css-transform-", + + _buildMarkup: function () { + let container = createNode(this.win, { + attributes: { + "class": "highlighter-container" + } + }); + + // The root wrapper is used to unzoom the highlighter when needed. + let rootWrapper = createNode(this.win, { + parent: container, + attributes: { + "id": "root", + "class": "root" + }, + prefix: this.ID_CLASS_PREFIX + }); + + let svg = createSVGNode(this.win, { + nodeType: "svg", + parent: rootWrapper, + attributes: { + "id": "elements", + "hidden": "true", + "width": "100%", + "height": "100%" + }, + prefix: this.ID_CLASS_PREFIX + }); + + // Add a marker tag to the svg root for the arrow tip + this.markerId = "arrow-marker-" + MARKER_COUNTER; + MARKER_COUNTER++; + let marker = createSVGNode(this.win, { + nodeType: "marker", + parent: svg, + attributes: { + "id": this.markerId, + "markerWidth": "10", + "markerHeight": "5", + "orient": "auto", + "markerUnits": "strokeWidth", + "refX": "10", + "refY": "5", + "viewBox": "0 0 10 10" + }, + prefix: this.ID_CLASS_PREFIX + }); + createSVGNode(this.win, { + nodeType: "path", + parent: marker, + attributes: { + "d": "M 0 0 L 10 5 L 0 10 z", + "fill": "#08C" + } + }); + + let shapesGroup = createSVGNode(this.win, { + nodeType: "g", + parent: svg + }); + + // Create the 2 polygons (transformed and untransformed) + createSVGNode(this.win, { + nodeType: "polygon", + parent: shapesGroup, + attributes: { + "id": "untransformed", + "class": "untransformed" + }, + prefix: this.ID_CLASS_PREFIX + }); + createSVGNode(this.win, { + nodeType: "polygon", + parent: shapesGroup, + attributes: { + "id": "transformed", + "class": "transformed" + }, + prefix: this.ID_CLASS_PREFIX + }); + + // Create the arrows + for (let nb of ["1", "2", "3", "4"]) { + createSVGNode(this.win, { + nodeType: "line", + parent: shapesGroup, + attributes: { + "id": "line" + nb, + "class": "line", + "marker-end": "url(#" + this.markerId + ")" + }, + prefix: this.ID_CLASS_PREFIX + }); + } + + return container; + }, + + /** + * Destroy the nodes. Remove listeners. + */ + destroy: function () { + AutoRefreshHighlighter.prototype.destroy.call(this); + this.markup.destroy(); + }, + + getElement: function (id) { + return this.markup.getElement(this.ID_CLASS_PREFIX + id); + }, + + /** + * Show the highlighter on a given node + */ + _show: function () { + if (!this._isTransformed(this.currentNode)) { + this.hide(); + return false; + } + + return this._update(); + }, + + /** + * Checks if the supplied node is transformed and not inline + */ + _isTransformed: function (node) { + let style = getComputedStyle(node); + return style && (style.transform !== "none" && style.display !== "inline"); + }, + + _setPolygonPoints: function (quad, id) { + let points = []; + for (let point of ["p1", "p2", "p3", "p4"]) { + points.push(quad[point].x + "," + quad[point].y); + } + this.getElement(id).setAttribute("points", points.join(" ")); + }, + + _setLinePoints: function (p1, p2, id) { + let line = this.getElement(id); + line.setAttribute("x1", p1.x); + line.setAttribute("y1", p1.y); + line.setAttribute("x2", p2.x); + line.setAttribute("y2", p2.y); + + let dist = Math.sqrt(Math.pow(p2.x - p1.x, 2) + Math.pow(p2.y - p1.y, 2)); + if (dist < ARROW_LINE_MIN_DISTANCE) { + line.removeAttribute("marker-end"); + } else { + line.setAttribute("marker-end", "url(#" + this.markerId + ")"); + } + }, + + /** + * Update the highlighter on the current highlighted node (the one that was + * passed as an argument to show(node)). + * Should be called whenever node size or attributes change + */ + _update: function () { + setIgnoreLayoutChanges(true); + + // Getting the points for the transformed shape + let quads = this.currentQuads.border; + if (!quads.length || + quads[0].bounds.width <= 0 || quads[0].bounds.height <= 0) { + this._hideShapes(); + return false; + } + + let [quad] = quads; + + // Getting the points for the untransformed shape + let untransformedQuad = getNodeBounds(this.win, this.currentNode); + + this._setPolygonPoints(quad, "transformed"); + this._setPolygonPoints(untransformedQuad, "untransformed"); + for (let nb of ["1", "2", "3", "4"]) { + this._setLinePoints(untransformedQuad["p" + nb], quad["p" + nb], "line" + nb); + } + + // Adapt to the current zoom + this.markup.scaleRootElement(this.currentNode, this.ID_CLASS_PREFIX + "root"); + + this._showShapes(); + + setIgnoreLayoutChanges(false, this.highlighterEnv.window.document.documentElement); + return true; + }, + + /** + * Hide the highlighter, the outline and the infobar. + */ + _hide: function () { + setIgnoreLayoutChanges(true); + this._hideShapes(); + setIgnoreLayoutChanges(false, this.highlighterEnv.window.document.documentElement); + }, + + _hideShapes: function () { + this.getElement("elements").setAttribute("hidden", "true"); + }, + + _showShapes: function () { + this.getElement("elements").removeAttribute("hidden"); + } +}); +exports.CssTransformHighlighter = CssTransformHighlighter; diff --git a/devtools/server/actors/highlighters/eye-dropper.js b/devtools/server/actors/highlighters/eye-dropper.js new file mode 100644 index 0000000000..a90ec22bd0 --- /dev/null +++ b/devtools/server/actors/highlighters/eye-dropper.js @@ -0,0 +1,534 @@ +/* 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"; + +// Eye-dropper tool. This is implemented as a highlighter so it can be displayed in the +// content page. +// It basically displays a magnifier that tracks mouse moves and shows a magnified version +// of the page. On click, it samples the color at the pixel being hovered. + +const {Ci, Cc} = require("chrome"); +const {CanvasFrameAnonymousContentHelper, createNode} = require("./utils/markup"); +const Services = require("Services"); +const EventEmitter = require("devtools/shared/event-emitter"); +const {rgbToHsl, rgbToColorName} = require("devtools/shared/css/color").colorUtils; +const {getCurrentZoom, getFrameOffsets} = require("devtools/shared/layout/utils"); + +loader.lazyGetter(this, "clipboardHelper", + () => Cc["@mozilla.org/widget/clipboardhelper;1"].getService(Ci.nsIClipboardHelper)); +loader.lazyGetter(this, "l10n", + () => Services.strings.createBundle("chrome://devtools/locale/eyedropper.properties")); + +const ZOOM_LEVEL_PREF = "devtools.eyedropper.zoom"; +const FORMAT_PREF = "devtools.defaultColorUnit"; +// Width of the canvas. +const MAGNIFIER_WIDTH = 96; +// Height of the canvas. +const MAGNIFIER_HEIGHT = 96; +// Start position, when the tool is first shown. This should match the top/left position +// defined in CSS. +const DEFAULT_START_POS_X = 100; +const DEFAULT_START_POS_Y = 100; +// How long to wait before closing after copy. +const CLOSE_DELAY = 750; + +/** + * The EyeDropper is the class that draws the gradient line and + * color stops as an overlay on top of a linear-gradient background-image. + */ +function EyeDropper(highlighterEnv) { + EventEmitter.decorate(this); + + this.highlighterEnv = highlighterEnv; + this.markup = new CanvasFrameAnonymousContentHelper(this.highlighterEnv, + this._buildMarkup.bind(this)); + + // Get a couple of settings from prefs. + this.format = Services.prefs.getCharPref(FORMAT_PREF); + this.eyeDropperZoomLevel = Services.prefs.getIntPref(ZOOM_LEVEL_PREF); +} + +EyeDropper.prototype = { + typeName: "EyeDropper", + + ID_CLASS_PREFIX: "eye-dropper-", + + get win() { + return this.highlighterEnv.window; + }, + + _buildMarkup() { + // Highlighter main container. + let container = createNode(this.win, { + attributes: {"class": "highlighter-container"} + }); + + // Wrapper element. + let wrapper = createNode(this.win, { + parent: container, + attributes: { + "id": "root", + "class": "root", + "hidden": "true" + }, + prefix: this.ID_CLASS_PREFIX + }); + + // The magnifier canvas element. + createNode(this.win, { + parent: wrapper, + nodeType: "canvas", + attributes: { + "id": "canvas", + "class": "canvas", + "width": MAGNIFIER_WIDTH, + "height": MAGNIFIER_HEIGHT + }, + prefix: this.ID_CLASS_PREFIX + }); + + // The color label element. + let colorLabelContainer = createNode(this.win, { + parent: wrapper, + attributes: {"class": "color-container"}, + prefix: this.ID_CLASS_PREFIX + }); + createNode(this.win, { + nodeType: "div", + parent: colorLabelContainer, + attributes: {"id": "color-preview", "class": "color-preview"}, + prefix: this.ID_CLASS_PREFIX + }); + createNode(this.win, { + nodeType: "div", + parent: colorLabelContainer, + attributes: {"id": "color-value", "class": "color-value"}, + prefix: this.ID_CLASS_PREFIX + }); + + return container; + }, + + destroy() { + this.hide(); + this.markup.destroy(); + }, + + getElement(id) { + return this.markup.getElement(this.ID_CLASS_PREFIX + id); + }, + + /** + * Show the eye-dropper highlighter. + * @param {DOMNode} node The node which document the highlighter should be inserted in. + * @param {Object} options The options object may contain the following properties: + * - {Boolean} copyOnSelect Whether selecting a color should copy it to the clipboard. + */ + show(node, options = {}) { + if (this.highlighterEnv.isXUL) { + return false; + } + + this.options = options; + + // Get the page's current zoom level. + this.pageZoom = getCurrentZoom(this.win); + + // Take a screenshot of the viewport. This needs to be done first otherwise the + // eyedropper UI will appear in the screenshot itself (since the UI is injected as + // native anonymous content in the page). + // Once the screenshot is ready, the magnified area will be drawn. + this.prepareImageCapture(); + + // Start listening for user events. + let {pageListenerTarget} = this.highlighterEnv; + pageListenerTarget.addEventListener("mousemove", this); + pageListenerTarget.addEventListener("click", this, true); + pageListenerTarget.addEventListener("keydown", this); + pageListenerTarget.addEventListener("DOMMouseScroll", this); + pageListenerTarget.addEventListener("FullZoomChange", this); + + // Show the eye-dropper. + this.getElement("root").removeAttribute("hidden"); + + // Prepare the canvas context on which we're drawing the magnified page portion. + this.ctx = this.getElement("canvas").getCanvasContext(); + this.ctx.imageSmoothingEnabled = false; + + this.magnifiedArea = {width: MAGNIFIER_WIDTH, height: MAGNIFIER_HEIGHT, + x: DEFAULT_START_POS_X, y: DEFAULT_START_POS_Y}; + + this.moveTo(DEFAULT_START_POS_X, DEFAULT_START_POS_Y); + + // Focus the content so the keyboard can be used. + this.win.focus(); + + return true; + }, + + /** + * Hide the eye-dropper highlighter. + */ + hide() { + if (this.highlighterEnv.isXUL) { + return; + } + + this.pageImage = null; + + let {pageListenerTarget} = this.highlighterEnv; + pageListenerTarget.removeEventListener("mousemove", this); + pageListenerTarget.removeEventListener("click", this, true); + pageListenerTarget.removeEventListener("keydown", this); + pageListenerTarget.removeEventListener("DOMMouseScroll", this); + pageListenerTarget.removeEventListener("FullZoomChange", this); + + this.getElement("root").setAttribute("hidden", "true"); + this.getElement("root").removeAttribute("drawn"); + + this.emit("hidden"); + }, + + prepareImageCapture() { + // Get the image data from the content window. + let imageData = getWindowAsImageData(this.win); + + // We need to transform imageData to something drawWindow will consume. An ImageBitmap + // works well. We could have used an Image, but doing so results in errors if the page + // defines CSP headers. + this.win.createImageBitmap(imageData).then(image => { + this.pageImage = image; + // We likely haven't drawn anything yet (no mousemove events yet), so start now. + this.draw(); + + // Set an attribute on the root element to be able to run tests after the first draw + // was done. + this.getElement("root").setAttribute("drawn", "true"); + }); + }, + + /** + * Get the number of cells (blown-up pixels) per direction in the grid. + */ + get cellsWide() { + // Canvas will render whole "pixels" (cells) only, and an even number at that. Round + // up to the nearest even number of pixels. + let cellsWide = Math.ceil(this.magnifiedArea.width / this.eyeDropperZoomLevel); + cellsWide += cellsWide % 2; + + return cellsWide; + }, + + /** + * Get the size of each cell (blown-up pixel) in the grid. + */ + get cellSize() { + return this.magnifiedArea.width / this.cellsWide; + }, + + /** + * Get index of cell in the center of the grid. + */ + get centerCell() { + return Math.floor(this.cellsWide / 2); + }, + + /** + * Get color of center cell in the grid. + */ + get centerColor() { + let pos = (this.centerCell * this.cellSize) + (this.cellSize / 2); + let rgb = this.ctx.getImageData(pos, pos, 1, 1).data; + return rgb; + }, + + draw() { + // If the image of the page isn't ready yet, bail out, we'll draw later on mousemove. + if (!this.pageImage) { + return; + } + + let {width, height, x, y} = this.magnifiedArea; + + let zoomedWidth = width / this.eyeDropperZoomLevel; + let zoomedHeight = height / this.eyeDropperZoomLevel; + + let sx = x - (zoomedWidth / 2); + let sy = y - (zoomedHeight / 2); + let sw = zoomedWidth; + let sh = zoomedHeight; + + this.ctx.drawImage(this.pageImage, sx, sy, sw, sh, 0, 0, width, height); + + // Draw the grid on top, but only at 3x or more, otherwise it's too busy. + if (this.eyeDropperZoomLevel > 2) { + this.drawGrid(); + } + + this.drawCrosshair(); + + // Update the color preview and value. + let rgb = this.centerColor; + this.getElement("color-preview").setAttribute("style", + `background-color:${toColorString(rgb, "rgb")};`); + this.getElement("color-value").setTextContent(toColorString(rgb, this.format)); + }, + + /** + * Draw a grid on the canvas representing pixel boundaries. + */ + drawGrid() { + let {width, height} = this.magnifiedArea; + + this.ctx.lineWidth = 1; + this.ctx.strokeStyle = "rgba(143, 143, 143, 0.2)"; + + for (let i = 0; i < width; i += this.cellSize) { + this.ctx.beginPath(); + this.ctx.moveTo(i - .5, 0); + this.ctx.lineTo(i - .5, height); + this.ctx.stroke(); + + this.ctx.beginPath(); + this.ctx.moveTo(0, i - .5); + this.ctx.lineTo(width, i - .5); + this.ctx.stroke(); + } + }, + + /** + * Draw a box on the canvas to highlight the center cell. + */ + drawCrosshair() { + let pos = this.centerCell * this.cellSize; + + this.ctx.lineWidth = 1; + this.ctx.lineJoin = "miter"; + this.ctx.strokeStyle = "rgba(0, 0, 0, 1)"; + this.ctx.strokeRect(pos - 1.5, pos - 1.5, this.cellSize + 2, this.cellSize + 2); + + this.ctx.strokeStyle = "rgba(255, 255, 255, 1)"; + this.ctx.strokeRect(pos - 0.5, pos - 0.5, this.cellSize, this.cellSize); + }, + + handleEvent(e) { + switch (e.type) { + case "mousemove": + // We might be getting an event from a child frame, so account for the offset. + let [xOffset, yOffset] = getFrameOffsets(this.win, e.target); + let x = xOffset + e.pageX - this.win.scrollX; + let y = yOffset + e.pageY - this.win.scrollY; + // Update the zoom area. + this.magnifiedArea.x = x * this.pageZoom; + this.magnifiedArea.y = y * this.pageZoom; + // Redraw the portion of the screenshot that is now under the mouse. + this.draw(); + // And move the eye-dropper's UI so it follows the mouse. + this.moveTo(x, y); + break; + case "click": + this.selectColor(); + break; + case "keydown": + this.handleKeyDown(e); + break; + case "DOMMouseScroll": + // Prevent scrolling. That's because we only took a screenshot of the viewport, so + // scrolling out of the viewport wouldn't draw the expected things. In the future + // we can take the screenshot again on scroll, but for now it doesn't seem + // important. + e.preventDefault(); + break; + case "FullZoomChange": + this.hide(); + this.show(); + break; + } + }, + + moveTo(x, y) { + let root = this.getElement("root"); + root.setAttribute("style", `top:${y}px;left:${x}px;`); + + // Move the label container to the top if the magnifier is close to the bottom edge. + if (y >= this.win.innerHeight - MAGNIFIER_HEIGHT) { + root.setAttribute("top", ""); + } else { + root.removeAttribute("top"); + } + + // Also offset the label container to the right or left if the magnifier is close to + // the edge. + root.removeAttribute("left"); + root.removeAttribute("right"); + if (x <= MAGNIFIER_WIDTH) { + root.setAttribute("right", ""); + } else if (x >= this.win.innerWidth - MAGNIFIER_WIDTH) { + root.setAttribute("left", ""); + } + }, + + /** + * Select the current color that's being previewed. Depending on the current options, + * selecting might mean copying to the clipboard and closing the + */ + selectColor() { + let onColorSelected = Promise.resolve(); + if (this.options.copyOnSelect) { + onColorSelected = this.copyColor(); + } + + this.emit("selected", toColorString(this.centerColor, this.format)); + onColorSelected.then(() => this.hide(), e => console.error(e)); + }, + + /** + * Handler for the keydown event. Either select the color or move the panel in a + * direction depending on the key pressed. + */ + handleKeyDown(e) { + // Bail out early if any unsupported modifier is used, so that we let + // keyboard shortcuts through. + if (e.metaKey || e.ctrlKey || e.altKey) { + return; + } + + if (e.keyCode === e.DOM_VK_RETURN) { + this.selectColor(); + e.preventDefault(); + return; + } + + if (e.keyCode === e.DOM_VK_ESCAPE) { + this.emit("canceled"); + this.hide(); + e.preventDefault(); + return; + } + + let offsetX = 0; + let offsetY = 0; + let modifier = 1; + + if (e.keyCode === e.DOM_VK_LEFT) { + offsetX = -1; + } else if (e.keyCode === e.DOM_VK_RIGHT) { + offsetX = 1; + } else if (e.keyCode === e.DOM_VK_UP) { + offsetY = -1; + } else if (e.keyCode === e.DOM_VK_DOWN) { + offsetY = 1; + } + + if (e.shiftKey) { + modifier = 10; + } + + offsetY *= modifier; + offsetX *= modifier; + + if (offsetX !== 0 || offsetY !== 0) { + this.magnifiedArea.x = cap(this.magnifiedArea.x + offsetX, + 0, this.win.innerWidth * this.pageZoom); + this.magnifiedArea.y = cap(this.magnifiedArea.y + offsetY, 0, + this.win.innerHeight * this.pageZoom); + + this.draw(); + + this.moveTo(this.magnifiedArea.x / this.pageZoom, + this.magnifiedArea.y / this.pageZoom); + + e.preventDefault(); + } + }, + + /** + * Copy the currently inspected color to the clipboard. + * @return {Promise} Resolves when the copy has been done (after a delay that is used to + * let users know that something was copied). + */ + copyColor() { + // Copy to the clipboard. + let color = toColorString(this.centerColor, this.format); + clipboardHelper.copyString(color); + + // Provide some feedback. + this.getElement("color-value").setTextContent( + "✓ " + l10n.GetStringFromName("colorValue.copied")); + + // Hide the tool after a delay. + clearTimeout(this._copyTimeout); + return new Promise(resolve => { + this._copyTimeout = setTimeout(resolve, CLOSE_DELAY); + }); + } +}; + +exports.EyeDropper = EyeDropper; + +/** + * Draw the visible portion of the window on a canvas and get the resulting ImageData. + * @param {Window} win + * @return {ImageData} The image data for the window. + */ +function getWindowAsImageData(win) { + let canvas = win.document.createElementNS("http://www.w3.org/1999/xhtml", "canvas"); + let scale = getCurrentZoom(win); + let width = win.innerWidth; + let height = win.innerHeight; + canvas.width = width * scale; + canvas.height = height * scale; + canvas.mozOpaque = true; + + let ctx = canvas.getContext("2d"); + + ctx.scale(scale, scale); + ctx.drawWindow(win, win.scrollX, win.scrollY, width, height, "#fff"); + + return ctx.getImageData(0, 0, canvas.width, canvas.height); +} + +/** + * Get a formatted CSS color string from a color value. + * @param {array} rgb Rgb values of a color to format. + * @param {string} format Format of string. One of "hex", "rgb", "hsl", "name". + * @return {string} Formatted color value, e.g. "#FFF" or "hsl(20, 10%, 10%)". + */ +function toColorString(rgb, format) { + let [r, g, b] = rgb; + + switch (format) { + case "hex": + return hexString(rgb); + case "rgb": + return "rgb(" + r + ", " + g + ", " + b + ")"; + case "hsl": + let [h, s, l] = rgbToHsl(rgb); + return "hsl(" + h + ", " + s + "%, " + l + "%)"; + case "name": + let str; + try { + str = rgbToColorName(r, g, b); + } catch (e) { + str = hexString(rgb); + } + return str; + default: + return hexString(rgb); + } +} + +/** + * Produce a hex-formatted color string from rgb values. + * @param {array} rgb Rgb values of color to stringify. + * @return {string} Hex formatted string for color, e.g. "#FFEE00". + */ +function hexString([r, g, b]) { + let val = (1 << 24) + (r << 16) + (g << 8) + (b << 0); + return "#" + val.toString(16).substr(-6).toUpperCase(); +} + +function cap(value, min, max) { + return Math.max(min, Math.min(value, max)); +} diff --git a/devtools/server/actors/highlighters/geometry-editor.js b/devtools/server/actors/highlighters/geometry-editor.js new file mode 100644 index 0000000000..35b33eec14 --- /dev/null +++ b/devtools/server/actors/highlighters/geometry-editor.js @@ -0,0 +1,704 @@ +/* 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 { extend } = require("sdk/core/heritage"); +const { AutoRefreshHighlighter } = require("./auto-refresh"); +const { CanvasFrameAnonymousContentHelper, getCSSStyleRules, getComputedStyle, + createSVGNode, createNode } = require("./utils/markup"); +const { setIgnoreLayoutChanges, getAdjustedQuads } = require("devtools/shared/layout/utils"); + +const GEOMETRY_LABEL_SIZE = 6; + +// List of all DOM Events subscribed directly to the document from the +// Geometry Editor highlighter +const DOM_EVENTS = ["mousemove", "mouseup", "pagehide"]; + +const _dragging = Symbol("geometry/dragging"); + +/** + * Element geometry properties helper that gives names of position and size + * properties. + */ +var GeoProp = { + SIDES: ["top", "right", "bottom", "left"], + SIZES: ["width", "height"], + + allProps: function () { + return [...this.SIDES, ...this.SIZES]; + }, + + isSide: function (name) { + return this.SIDES.indexOf(name) !== -1; + }, + + isSize: function (name) { + return this.SIZES.indexOf(name) !== -1; + }, + + containsSide: function (names) { + return names.some(name => this.SIDES.indexOf(name) !== -1); + }, + + containsSize: function (names) { + return names.some(name => this.SIZES.indexOf(name) !== -1); + }, + + isHorizontal: function (name) { + return name === "left" || name === "right" || name === "width"; + }, + + isInverted: function (name) { + return name === "right" || name === "bottom"; + }, + + mainAxisStart: function (name) { + return this.isHorizontal(name) ? "left" : "top"; + }, + + crossAxisStart: function (name) { + return this.isHorizontal(name) ? "top" : "left"; + }, + + mainAxisSize: function (name) { + return this.isHorizontal(name) ? "width" : "height"; + }, + + crossAxisSize: function (name) { + return this.isHorizontal(name) ? "height" : "width"; + }, + + axis: function (name) { + return this.isHorizontal(name) ? "x" : "y"; + }, + + crossAxis: function (name) { + return this.isHorizontal(name) ? "y" : "x"; + } +}; + +/** + * Get the provided node's offsetParent dimensions. + * Returns an object with the {parent, dimension} properties. + * Note that the returned parent will be null if the offsetParent is the + * default, non-positioned, body or html node. + * + * node.offsetParent returns the nearest positioned ancestor but if it is + * non-positioned itself, we just return null to let consumers know the node is + * actually positioned relative to the viewport. + * + * @return {Object} + */ +function getOffsetParent(node) { + let win = node.ownerDocument.defaultView; + + let offsetParent = node.offsetParent; + if (offsetParent && + getComputedStyle(offsetParent).position === "static") { + offsetParent = null; + } + + let width, height; + if (!offsetParent) { + height = win.innerHeight; + width = win.innerWidth; + } else { + height = offsetParent.offsetHeight; + width = offsetParent.offsetWidth; + } + + return { + element: offsetParent, + dimension: {width, height} + }; +} + +/** + * Get the list of geometry properties that are actually set on the provided + * node. + * + * @param {nsIDOMNode} node The node to analyze. + * @return {Map} A map indexed by property name and where the value is an + * object having the cssRule property. + */ +function getDefinedGeometryProperties(node) { + let props = new Map(); + if (!node) { + return props; + } + + // Get the list of css rules applying to the current node. + let cssRules = getCSSStyleRules(node); + for (let i = 0; i < cssRules.Count(); i++) { + let rule = cssRules.GetElementAt(i); + for (let name of GeoProp.allProps()) { + let value = rule.style.getPropertyValue(name); + if (value && value !== "auto") { + // getCSSStyleRules returns rules ordered from least to most specific + // so just override any previous properties we have set. + props.set(name, { + cssRule: rule + }); + } + } + } + + // Go through the inline styles last, only if the node supports inline style + // (e.g. pseudo elements don't have a style property) + if (node.style) { + for (let name of GeoProp.allProps()) { + let value = node.style.getPropertyValue(name); + if (value && value !== "auto") { + props.set(name, { + // There's no cssRule to store here, so store the node instead since + // node.style exists. + cssRule: node + }); + } + } + } + + // Post-process the list for invalid properties. This is done after the fact + // because of cases like relative positioning with both top and bottom where + // only top will actually be used, but both exists in css rules and computed + // styles. + let { position } = getComputedStyle(node); + for (let [name] of props) { + // Top/left/bottom/right on static positioned elements have no effect. + if (position === "static" && GeoProp.SIDES.indexOf(name) !== -1) { + props.delete(name); + } + + // Bottom/right on relative positioned elements are only used if top/left + // are not defined. + let hasRightAndLeft = name === "right" && props.has("left"); + let hasBottomAndTop = name === "bottom" && props.has("top"); + if (position === "relative" && (hasRightAndLeft || hasBottomAndTop)) { + props.delete(name); + } + } + + return props; +} +exports.getDefinedGeometryProperties = getDefinedGeometryProperties; + +/** + * The GeometryEditor highlights an elements's top, left, bottom, right, width + * and height dimensions, when they are set. + * + * To determine if an element has a set size and position, the highlighter lists + * the CSS rules that apply to the element and checks for the top, left, bottom, + * right, width and height properties. + * The highlighter won't be shown if the element doesn't have any of these + * properties set, but will be shown when at least 1 property is defined. + * + * The highlighter displays lines and labels for each of the defined properties + * in and around the element (relative to the offset parent when one exists). + * The highlighter also highlights the element itself and its offset parent if + * there is one. + * + * Note that the class name contains the word Editor because the aim is for the + * handles to be draggable in content to make the geometry editable. + */ +function GeometryEditorHighlighter(highlighterEnv) { + AutoRefreshHighlighter.call(this, highlighterEnv); + + // The list of element geometry properties that can be set. + this.definedProperties = new Map(); + + this.markup = new CanvasFrameAnonymousContentHelper(highlighterEnv, + this._buildMarkup.bind(this)); + + let { pageListenerTarget } = this.highlighterEnv; + + // Register the geometry editor instance to all events we're interested in. + DOM_EVENTS.forEach(type => pageListenerTarget.addEventListener(type, this)); + + // Register the mousedown event for each Geometry Editor's handler. + // Those events are automatically removed when the markup is destroyed. + let onMouseDown = this.handleEvent.bind(this); + + for (let side of GeoProp.SIDES) { + this.getElement("handler-" + side) + .addEventListener("mousedown", onMouseDown); + } +} + +GeometryEditorHighlighter.prototype = extend(AutoRefreshHighlighter.prototype, { + typeName: "GeometryEditorHighlighter", + + ID_CLASS_PREFIX: "geometry-editor-", + + _buildMarkup: function () { + let container = createNode(this.win, { + attributes: {"class": "highlighter-container"} + }); + + let root = createNode(this.win, { + parent: container, + attributes: { + "id": "root", + "class": "root", + "hidden": "true" + }, + prefix: this.ID_CLASS_PREFIX + }); + + let svg = createSVGNode(this.win, { + nodeType: "svg", + parent: root, + attributes: { + "id": "elements", + "width": "100%", + "height": "100%" + }, + prefix: this.ID_CLASS_PREFIX + }); + + // Offset parent node highlighter. + createSVGNode(this.win, { + nodeType: "polygon", + parent: svg, + attributes: { + "class": "offset-parent", + "id": "offset-parent", + "hidden": "true" + }, + prefix: this.ID_CLASS_PREFIX + }); + + // Current node highlighter (margin box). + createSVGNode(this.win, { + nodeType: "polygon", + parent: svg, + attributes: { + "class": "current-node", + "id": "current-node", + "hidden": "true" + }, + prefix: this.ID_CLASS_PREFIX + }); + + // Build the 4 side arrows, handlers and labels. + for (let name of GeoProp.SIDES) { + createSVGNode(this.win, { + nodeType: "line", + parent: svg, + attributes: { + "class": "arrow " + name, + "id": "arrow-" + name, + "hidden": "true" + }, + prefix: this.ID_CLASS_PREFIX + }); + + createSVGNode(this.win, { + nodeType: "circle", + parent: svg, + attributes: { + "class": "handler-" + name, + "id": "handler-" + name, + "r": "4", + "data-side": name, + "hidden": "true" + }, + prefix: this.ID_CLASS_PREFIX + }); + + // Labels are positioned by using a translated <g>. This group contains + // a path and text that are themselves positioned using another translated + // <g>. This is so that the label arrow points at the 0,0 coordinates of + // parent <g>. + let labelG = createSVGNode(this.win, { + nodeType: "g", + parent: svg, + attributes: { + "id": "label-" + name, + "hidden": "true" + }, + prefix: this.ID_CLASS_PREFIX + }); + + let subG = createSVGNode(this.win, { + nodeType: "g", + parent: labelG, + attributes: { + "transform": GeoProp.isHorizontal(name) + ? "translate(-30 -30)" + : "translate(5 -10)" + } + }); + + createSVGNode(this.win, { + nodeType: "path", + parent: subG, + attributes: { + "class": "label-bubble", + "d": GeoProp.isHorizontal(name) + ? "M0 0 L60 0 L60 20 L35 20 L30 25 L25 20 L0 20z" + : "M5 0 L65 0 L65 20 L5 20 L5 15 L0 10 L5 5z" + }, + prefix: this.ID_CLASS_PREFIX + }); + + createSVGNode(this.win, { + nodeType: "text", + parent: subG, + attributes: { + "class": "label-text", + "id": "label-text-" + name, + "x": GeoProp.isHorizontal(name) ? "30" : "35", + "y": "10" + }, + prefix: this.ID_CLASS_PREFIX + }); + } + + return container; + }, + + destroy: function () { + // Avoiding exceptions if `destroy` is called multiple times; and / or the + // highlighter environment was already destroyed. + if (!this.highlighterEnv) { + return; + } + + let { pageListenerTarget } = this.highlighterEnv; + + DOM_EVENTS.forEach(type => + pageListenerTarget.removeEventListener(type, this)); + + AutoRefreshHighlighter.prototype.destroy.call(this); + + this.markup.destroy(); + this.definedProperties.clear(); + this.definedProperties = null; + this.offsetParent = null; + }, + + handleEvent: function (event, id) { + // No event handling if the highlighter is hidden + if (this.getElement("root").hasAttribute("hidden")) { + return; + } + + const { type, pageX, pageY } = event; + + switch (type) { + case "pagehide": + this.destroy(); + break; + case "mousedown": + // The mousedown event is intended only for the handler + if (!id) { + return; + } + + let handlerSide = this.markup.getElement(id).getAttribute("data-side"); + + if (handlerSide) { + let side = handlerSide; + let sideProp = this.definedProperties.get(side); + + if (!sideProp) { + return; + } + + let value = sideProp.cssRule.style.getPropertyValue(side); + let computedValue = this.computedStyle.getPropertyValue(side); + + let [unit] = value.match(/[^\d]+$/) || [""]; + + value = parseFloat(value); + + let ratio = (value / parseFloat(computedValue)) || 1; + let dir = GeoProp.isInverted(side) ? -1 : 1; + + // Store all the initial values needed for drag & drop + this[_dragging] = { + side, + value, + unit, + x: pageX, + y: pageY, + inc: ratio * dir + }; + + this.getElement("handler-" + side).classList.add("dragging"); + } + + this.getElement("root").setAttribute("dragging", "true"); + break; + case "mouseup": + // If we're dragging, drop it. + if (this[_dragging]) { + let { side } = this[_dragging]; + this.getElement("root").removeAttribute("dragging"); + this.getElement("handler-" + side).classList.remove("dragging"); + this[_dragging] = null; + } + break; + case "mousemove": + if (!this[_dragging]) { + return; + } + + let { side, x, y, value, unit, inc } = this[_dragging]; + let sideProps = this.definedProperties.get(side); + + if (!sideProps) { + return; + } + + let delta = (GeoProp.isHorizontal(side) ? pageX - x : pageY - y) * inc; + + // The inline style has usually the priority over any other CSS rule + // set in stylesheets. However, if a rule has `!important` keyword, + // it will override the inline style too. To ensure Geometry Editor + // will always update the element, we have to add `!important` as + // well. + this.currentNode.style.setProperty( + side, (value + delta) + unit, "important"); + + break; + } + }, + + getElement: function (id) { + return this.markup.getElement(this.ID_CLASS_PREFIX + id); + }, + + _show: function () { + this.computedStyle = getComputedStyle(this.currentNode); + let pos = this.computedStyle.position; + // XXX: sticky positioning is ignored for now. To be implemented next. + if (pos === "sticky") { + this.hide(); + return false; + } + + let hasUpdated = this._update(); + if (!hasUpdated) { + this.hide(); + return false; + } + + this.getElement("root").removeAttribute("hidden"); + + return true; + }, + + _update: function () { + // At each update, the position or/and size may have changed, so get the + // list of defined properties, and re-position the arrows and highlighters. + this.definedProperties = getDefinedGeometryProperties(this.currentNode); + + if (!this.definedProperties.size) { + console.warn("The element does not have editable geometry properties"); + return false; + } + + setIgnoreLayoutChanges(true); + + // Update the highlighters and arrows. + this.updateOffsetParent(); + this.updateCurrentNode(); + this.updateArrows(); + + // Avoid zooming the arrows when content is zoomed. + let node = this.currentNode; + this.markup.scaleRootElement(node, this.ID_CLASS_PREFIX + "root"); + + setIgnoreLayoutChanges(false, node.ownerDocument.documentElement); + return true; + }, + + /** + * Update the offset parent rectangle. + * There are 3 different cases covered here: + * - the node is absolutely/fixed positioned, and an offsetParent is defined + * (i.e. it's not just positioned in the viewport): the offsetParent node + * is highlighted (i.e. the rectangle is shown), + * - the node is relatively positioned: the rectangle is shown where the node + * would originally have been (because that's where the relative positioning + * is calculated from), + * - the node has no offset parent at all: the offsetParent rectangle is + * hidden. + */ + updateOffsetParent: function () { + // Get the offsetParent, if any. + this.offsetParent = getOffsetParent(this.currentNode); + // And the offsetParent quads. + this.parentQuads = getAdjustedQuads( + this.win, this.offsetParent.element, "padding"); + + let el = this.getElement("offset-parent"); + + let isPositioned = this.computedStyle.position === "absolute" || + this.computedStyle.position === "fixed"; + let isRelative = this.computedStyle.position === "relative"; + let isHighlighted = false; + + if (this.offsetParent.element && isPositioned) { + let {p1, p2, p3, p4} = this.parentQuads[0]; + let points = p1.x + "," + p1.y + " " + + p2.x + "," + p2.y + " " + + p3.x + "," + p3.y + " " + + p4.x + "," + p4.y; + el.setAttribute("points", points); + isHighlighted = true; + } else if (isRelative) { + let xDelta = parseFloat(this.computedStyle.left); + let yDelta = parseFloat(this.computedStyle.top); + if (xDelta || yDelta) { + let {p1, p2, p3, p4} = this.currentQuads.margin[0]; + let points = (p1.x - xDelta) + "," + (p1.y - yDelta) + " " + + (p2.x - xDelta) + "," + (p2.y - yDelta) + " " + + (p3.x - xDelta) + "," + (p3.y - yDelta) + " " + + (p4.x - xDelta) + "," + (p4.y - yDelta); + el.setAttribute("points", points); + isHighlighted = true; + } + } + + if (isHighlighted) { + el.removeAttribute("hidden"); + } else { + el.setAttribute("hidden", "true"); + } + }, + + updateCurrentNode: function () { + let box = this.getElement("current-node"); + let {p1, p2, p3, p4} = this.currentQuads.margin[0]; + let attr = p1.x + "," + p1.y + " " + + p2.x + "," + p2.y + " " + + p3.x + "," + p3.y + " " + + p4.x + "," + p4.y; + box.setAttribute("points", attr); + box.removeAttribute("hidden"); + }, + + _hide: function () { + setIgnoreLayoutChanges(true); + + this.getElement("root").setAttribute("hidden", "true"); + this.getElement("current-node").setAttribute("hidden", "true"); + this.getElement("offset-parent").setAttribute("hidden", "true"); + this.hideArrows(); + + this.definedProperties.clear(); + + setIgnoreLayoutChanges(false, + this.currentNode.ownerDocument.documentElement); + }, + + hideArrows: function () { + for (let side of GeoProp.SIDES) { + this.getElement("arrow-" + side).setAttribute("hidden", "true"); + this.getElement("label-" + side).setAttribute("hidden", "true"); + this.getElement("handler-" + side).setAttribute("hidden", "true"); + } + }, + + updateArrows: function () { + this.hideArrows(); + + // Position arrows always end at the node's margin box. + let marginBox = this.currentQuads.margin[0].bounds; + + // Position the side arrows which need to be visible. + // Arrows always start at the offsetParent edge, and end at the middle + // position of the node's margin edge. + // Note that for relative positioning, the offsetParent is considered to be + // the node itself, where it would have been originally. + // +------------------+----------------+ + // | offsetparent | top | + // | or viewport | | + // | +--------+--------+ | + // | | node | | + // +---------+ +-------+ + // | left | | right | + // | +--------+--------+ | + // | | bottom | + // +------------------+----------------+ + let getSideArrowStartPos = side => { + // In case an offsetParent exists and is highlighted. + if (this.parentQuads && this.parentQuads.length) { + return this.parentQuads[0].bounds[side]; + } + + // In case of relative positioning. + if (this.computedStyle.position === "relative") { + if (GeoProp.isInverted(side)) { + return marginBox[side] + parseFloat(this.computedStyle[side]); + } + return marginBox[side] - parseFloat(this.computedStyle[side]); + } + + // In case the element is positioned in the viewport. + if (GeoProp.isInverted(side)) { + return this.offsetParent.dimension[GeoProp.mainAxisSize(side)]; + } + return -1 * this.currentNode.ownerDocument.defaultView["scroll" + + GeoProp.axis(side).toUpperCase()]; + }; + + for (let side of GeoProp.SIDES) { + let sideProp = this.definedProperties.get(side); + if (!sideProp) { + continue; + } + + let mainAxisStartPos = getSideArrowStartPos(side); + let mainAxisEndPos = marginBox[side]; + let crossAxisPos = marginBox[GeoProp.crossAxisStart(side)] + + marginBox[GeoProp.crossAxisSize(side)] / 2; + + this.updateArrow(side, mainAxisStartPos, mainAxisEndPos, crossAxisPos, + sideProp.cssRule.style.getPropertyValue(side)); + } + }, + + updateArrow: function (side, mainStart, mainEnd, crossPos, labelValue) { + let arrowEl = this.getElement("arrow-" + side); + let labelEl = this.getElement("label-" + side); + let labelTextEl = this.getElement("label-text-" + side); + let handlerEl = this.getElement("handler-" + side); + + // Position the arrow <line>. + arrowEl.setAttribute(GeoProp.axis(side) + "1", mainStart); + arrowEl.setAttribute(GeoProp.crossAxis(side) + "1", crossPos); + arrowEl.setAttribute(GeoProp.axis(side) + "2", mainEnd); + arrowEl.setAttribute(GeoProp.crossAxis(side) + "2", crossPos); + arrowEl.removeAttribute("hidden"); + + handlerEl.setAttribute("c" + GeoProp.axis(side), mainEnd); + handlerEl.setAttribute("c" + GeoProp.crossAxis(side), crossPos); + handlerEl.removeAttribute("hidden"); + + // Position the label <text> in the middle of the arrow (making sure it's + // not hidden below the fold). + let capitalize = str => str[0].toUpperCase() + str.substring(1); + let winMain = this.win["inner" + capitalize(GeoProp.mainAxisSize(side))]; + let labelMain = mainStart + (mainEnd - mainStart) / 2; + if ((mainStart > 0 && mainStart < winMain) || + (mainEnd > 0 && mainEnd < winMain)) { + if (labelMain < GEOMETRY_LABEL_SIZE) { + labelMain = GEOMETRY_LABEL_SIZE; + } else if (labelMain > winMain - GEOMETRY_LABEL_SIZE) { + labelMain = winMain - GEOMETRY_LABEL_SIZE; + } + } + let labelCross = crossPos; + labelEl.setAttribute("transform", GeoProp.isHorizontal(side) + ? "translate(" + labelMain + " " + labelCross + ")" + : "translate(" + labelCross + " " + labelMain + ")"); + labelEl.removeAttribute("hidden"); + labelTextEl.setTextContent(labelValue); + } +}); +exports.GeometryEditorHighlighter = GeometryEditorHighlighter; diff --git a/devtools/server/actors/highlighters/measuring-tool.js b/devtools/server/actors/highlighters/measuring-tool.js new file mode 100644 index 0000000000..e1e1de94f6 --- /dev/null +++ b/devtools/server/actors/highlighters/measuring-tool.js @@ -0,0 +1,563 @@ +/* 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 events = require("sdk/event/core"); +const { getCurrentZoom, + setIgnoreLayoutChanges } = require("devtools/shared/layout/utils"); +const { + CanvasFrameAnonymousContentHelper, + createSVGNode, createNode } = require("./utils/markup"); + +// Hard coded value about the size of measuring tool label, in order to +// position and flip it when is needed. +const LABEL_SIZE_MARGIN = 8; +const LABEL_SIZE_WIDTH = 80; +const LABEL_SIZE_HEIGHT = 52; +const LABEL_POS_MARGIN = 4; +const LABEL_POS_WIDTH = 40; +const LABEL_POS_HEIGHT = 34; + +const SIDES = ["top", "right", "bottom", "left"]; + +/** + * The MeasuringToolHighlighter is used to measure distances in a content page. + * It allows users to click and drag with their mouse to draw an area whose + * dimensions will be displayed in a tooltip next to it. + * This allows users to measure distances between elements on a page. + */ +function MeasuringToolHighlighter(highlighterEnv) { + this.env = highlighterEnv; + this.markup = new CanvasFrameAnonymousContentHelper(highlighterEnv, + this._buildMarkup.bind(this)); + + this.coords = { + x: 0, + y: 0 + }; + + let { pageListenerTarget } = highlighterEnv; + + pageListenerTarget.addEventListener("mousedown", this); + pageListenerTarget.addEventListener("mousemove", this); + pageListenerTarget.addEventListener("mouseleave", this); + pageListenerTarget.addEventListener("scroll", this); + pageListenerTarget.addEventListener("pagehide", this); +} + +MeasuringToolHighlighter.prototype = { + typeName: "MeasuringToolHighlighter", + + ID_CLASS_PREFIX: "measuring-tool-highlighter-", + + _buildMarkup() { + let prefix = this.ID_CLASS_PREFIX; + let { window } = this.env; + + let container = createNode(window, { + attributes: {"class": "highlighter-container"} + }); + + let root = createNode(window, { + parent: container, + attributes: { + "id": "root", + "class": "root", + }, + prefix + }); + + let svg = createSVGNode(window, { + nodeType: "svg", + parent: root, + attributes: { + id: "elements", + "class": "elements", + width: "100%", + height: "100%", + hidden: "true" + }, + prefix + }); + + createNode(window, { + nodeType: "label", + attributes: { + id: "label-size", + "class": "label-size", + "hidden": "true" + }, + parent: root, + prefix + }); + + createNode(window, { + nodeType: "label", + attributes: { + id: "label-position", + "class": "label-position", + "hidden": "true" + }, + parent: root, + prefix + }); + + // Creating a <g> element in order to group all the paths below, that + // together represent the measuring tool; so that would be easier move them + // around + let g = createSVGNode(window, { + nodeType: "g", + attributes: { + id: "tool", + }, + parent: svg, + prefix + }); + + createSVGNode(window, { + nodeType: "path", + attributes: { + id: "box-path" + }, + parent: g, + prefix + }); + + createSVGNode(window, { + nodeType: "path", + attributes: { + id: "diagonal-path" + }, + parent: g, + prefix + }); + + for (let side of SIDES) { + createSVGNode(window, { + nodeType: "line", + parent: svg, + attributes: { + "class": `guide-${side}`, + id: `guide-${side}`, + hidden: "true" + }, + prefix + }); + } + + return container; + }, + + _update() { + let { window } = this.env; + + setIgnoreLayoutChanges(true); + + let zoom = getCurrentZoom(window); + + let { documentElement } = window.document; + + let width = Math.max(documentElement.clientWidth, + documentElement.scrollWidth, + documentElement.offsetWidth); + + let height = Math.max(documentElement.clientHeight, + documentElement.scrollHeight, + documentElement.offsetHeight); + + let { body } = window.document; + + // get the size of the content document despite the compatMode + if (body) { + width = Math.max(width, body.scrollWidth, body.offsetWidth); + height = Math.max(height, body.scrollHeight, body.offsetHeight); + } + + let { coords } = this; + + let isZoomChanged = zoom !== coords.zoom; + + if (isZoomChanged) { + coords.zoom = zoom; + this.updateLabel(); + } + + let isDocumentSizeChanged = width !== coords.documentWidth || + height !== coords.documentHeight; + + if (isDocumentSizeChanged) { + coords.documentWidth = width; + coords.documentHeight = height; + } + + // If either the document's size or the zoom is changed since the last + // repaint, we update the tool's size as well. + if (isZoomChanged || isDocumentSizeChanged) { + this.updateViewport(); + } + + setIgnoreLayoutChanges(false, documentElement); + + this._rafID = window.requestAnimationFrame(() => this._update()); + }, + + _cancelUpdate() { + if (this._rafID) { + this.env.window.cancelAnimationFrame(this._rafID); + this._rafID = 0; + } + }, + + destroy() { + this.hide(); + + this._cancelUpdate(); + + let { pageListenerTarget } = this.env; + + pageListenerTarget.removeEventListener("mousedown", this); + pageListenerTarget.removeEventListener("mousemove", this); + pageListenerTarget.removeEventListener("mouseup", this); + pageListenerTarget.removeEventListener("scroll", this); + pageListenerTarget.removeEventListener("pagehide", this); + pageListenerTarget.removeEventListener("mouseleave", this); + + this.markup.destroy(); + + events.emit(this, "destroy"); + }, + + show() { + setIgnoreLayoutChanges(true); + + this.getElement("elements").removeAttribute("hidden"); + + this._update(); + + setIgnoreLayoutChanges(false, this.env.window.document.documentElement); + }, + + hide() { + setIgnoreLayoutChanges(true); + + this.hideLabel("size"); + this.hideLabel("position"); + + this.getElement("elements").setAttribute("hidden", "true"); + + this._cancelUpdate(); + + setIgnoreLayoutChanges(false, this.env.window.document.documentElement); + }, + + getElement(id) { + return this.markup.getElement(this.ID_CLASS_PREFIX + id); + }, + + setSize(w, h) { + this.setCoords(undefined, undefined, w, h); + }, + + setCoords(x, y, w, h) { + let { coords } = this; + + if (typeof x !== "undefined") { + coords.x = x; + } + + if (typeof y !== "undefined") { + coords.y = y; + } + + if (typeof w !== "undefined") { + coords.w = w; + } + + if (typeof h !== "undefined") { + coords.h = h; + } + + setIgnoreLayoutChanges(true); + + if (this._isDragging) { + this.updatePaths(); + } + + this.updateLabel(); + + setIgnoreLayoutChanges(false, this.env.window.document.documentElement); + }, + + updatePaths() { + let { x, y, w, h } = this.coords; + let dir = `M0 0 L${w} 0 L${w} ${h} L0 ${h}z`; + + // Adding correction to the line path, otherwise some pixels are drawn + // outside the main rectangle area. + let x1 = w > 0 ? 0.5 : 0; + let y1 = w < 0 && h < 0 ? -0.5 : 0; + let w1 = w + (h < 0 && w < 0 ? 0.5 : 0); + let h1 = h + (h > 0 && w > 0 ? -0.5 : 0); + + let linedir = `M${x1} ${y1} L${w1} ${h1}`; + + this.getElement("box-path").setAttribute("d", dir); + this.getElement("diagonal-path").setAttribute("d", linedir); + this.getElement("tool").setAttribute("transform", `translate(${x},${y})`); + }, + + updateLabel(type) { + type = type || this._isDragging ? "size" : "position"; + + let isSizeLabel = type === "size"; + + let label = this.getElement(`label-${type}`); + + let origin = "top left"; + + let { innerWidth, innerHeight, scrollX, scrollY } = this.env.window; + let { x, y, w, h, zoom } = this.coords; + let scale = 1 / zoom; + + w = w || 0; + h = h || 0; + x = (x || 0) + w; + y = (y || 0) + h; + + let labelMargin, labelHeight, labelWidth; + + if (isSizeLabel) { + labelMargin = LABEL_SIZE_MARGIN; + labelWidth = LABEL_SIZE_WIDTH; + labelHeight = LABEL_SIZE_HEIGHT; + + let d = Math.hypot(w, h).toFixed(2); + + label.setTextContent(`W: ${Math.abs(w)} px + H: ${Math.abs(h)} px + ↘: ${d}px`); + } else { + labelMargin = LABEL_POS_MARGIN; + labelWidth = LABEL_POS_WIDTH; + labelHeight = LABEL_POS_HEIGHT; + + label.setTextContent(`${x} + ${y}`); + } + + // Size used to position properly the label + let labelBoxWidth = (labelWidth + labelMargin) * scale; + let labelBoxHeight = (labelHeight + labelMargin) * scale; + + let isGoingLeft = w < scrollX; + let isSizeGoingLeft = isSizeLabel && isGoingLeft; + let isExceedingLeftMargin = x - labelBoxWidth < scrollX; + let isExceedingRightMargin = x + labelBoxWidth > innerWidth + scrollX; + let isExceedingTopMargin = y - labelBoxHeight < scrollY; + let isExceedingBottomMargin = y + labelBoxHeight > innerHeight + scrollY; + + if ((isSizeGoingLeft && !isExceedingLeftMargin) || isExceedingRightMargin) { + x -= labelBoxWidth; + origin = "top right"; + } else { + x += labelMargin * scale; + } + + if (isSizeLabel) { + y += isExceedingTopMargin ? labelMargin * scale : -labelBoxHeight; + } else { + y += isExceedingBottomMargin ? -labelBoxHeight : labelMargin * scale; + } + + label.setAttribute("style", ` + width: ${labelWidth}px; + height: ${labelHeight}px; + transform-origin: ${origin}; + transform: translate(${x}px,${y}px) scale(${scale}) + `); + + if (!isSizeLabel) { + let labelSize = this.getElement("label-size"); + let style = labelSize.getAttribute("style"); + + if (style) { + labelSize.setAttribute("style", + style.replace(/scale[^)]+\)/, `scale(${scale})`)); + } + } + }, + + updateViewport() { + let { scrollX, scrollY, devicePixelRatio } = this.env.window; + let { documentWidth, documentHeight, zoom } = this.coords; + + // Because `devicePixelRatio` is affected by zoom (see bug 809788), + // in order to get the "real" device pixel ratio, we need divide by `zoom` + let pixelRatio = devicePixelRatio / zoom; + + // The "real" device pixel ratio is used to calculate the max stroke + // width we can actually assign: on retina, for instance, it would be 0.5, + // where on non high dpi monitor would be 1. + let minWidth = 1 / pixelRatio; + let strokeWidth = Math.min(minWidth, minWidth / zoom); + + this.getElement("root").setAttribute("style", + `stroke-width:${strokeWidth}; + width:${documentWidth}px; + height:${documentHeight}px; + transform: translate(${-scrollX}px,${-scrollY}px)`); + }, + + updateGuides() { + let { x, y, w, h } = this.coords; + + let guide = this.getElement("guide-top"); + + guide.setAttribute("x1", "0"); + guide.setAttribute("y1", y); + guide.setAttribute("x2", "100%"); + guide.setAttribute("y2", y); + + guide = this.getElement("guide-right"); + + guide.setAttribute("x1", x + w); + guide.setAttribute("y1", 0); + guide.setAttribute("x2", x + w); + guide.setAttribute("y2", "100%"); + + guide = this.getElement("guide-bottom"); + + guide.setAttribute("x1", "0"); + guide.setAttribute("y1", y + h); + guide.setAttribute("x2", "100%"); + guide.setAttribute("y2", y + h); + + guide = this.getElement("guide-left"); + + guide.setAttribute("x1", x); + guide.setAttribute("y1", 0); + guide.setAttribute("x2", x); + guide.setAttribute("y2", "100%"); + }, + + showLabel(type) { + setIgnoreLayoutChanges(true); + + this.getElement(`label-${type}`).removeAttribute("hidden"); + + setIgnoreLayoutChanges(false, this.env.window.document.documentElement); + }, + + hideLabel(type) { + setIgnoreLayoutChanges(true); + + this.getElement(`label-${type}`).setAttribute("hidden", "true"); + + setIgnoreLayoutChanges(false, this.env.window.document.documentElement); + }, + + showGuides() { + let prefix = this.ID_CLASS_PREFIX + "guide-"; + + for (let side of SIDES) { + this.markup.removeAttributeForElement(`${prefix + side}`, "hidden"); + } + }, + + hideGuides() { + let prefix = this.ID_CLASS_PREFIX + "guide-"; + + for (let side of SIDES) { + this.markup.setAttributeForElement(`${prefix + side}`, "hidden", "true"); + } + }, + + handleEvent(event) { + let scrollX, scrollY, innerWidth, innerHeight; + let x, y; + + let { pageListenerTarget } = this.env; + + switch (event.type) { + case "mousedown": + if (event.button) { + return; + } + + this._isDragging = true; + + let { window } = this.env; + + ({ scrollX, scrollY } = window); + x = event.clientX + scrollX; + y = event.clientY + scrollY; + + pageListenerTarget.addEventListener("mouseup", this); + + setIgnoreLayoutChanges(true); + + this.getElement("tool").setAttribute("class", "dragging"); + + this.hideLabel("size"); + this.hideLabel("position"); + + this.hideGuides(); + this.setCoords(x, y, 0, 0); + + setIgnoreLayoutChanges(false, window.document.documentElement); + + break; + case "mouseup": + this._isDragging = false; + + pageListenerTarget.removeEventListener("mouseup", this); + + setIgnoreLayoutChanges(true); + + this.getElement("tool").removeAttribute("class", ""); + + // Shows the guides only if an actual area is selected + if (this.coords.w !== 0 && this.coords.h !== 0) { + this.updateGuides(); + this.showGuides(); + } + + setIgnoreLayoutChanges(false, this.env.window.document.documentElement); + + break; + case "mousemove": + ({ scrollX, scrollY, innerWidth, innerHeight } = this.env.window); + x = event.clientX + scrollX; + y = event.clientY + scrollY; + + let { coords } = this; + + x = Math.min(innerWidth + scrollX - 1, Math.max(0 + scrollX, x)); + y = Math.min(innerHeight + scrollY, Math.max(1 + scrollY, y)); + + this.setSize(x - coords.x, y - coords.y); + + let type = this._isDragging ? "size" : "position"; + + this.showLabel(type); + break; + case "mouseleave": + if (!this._isDragging) { + this.hideLabel("position"); + } + break; + case "scroll": + setIgnoreLayoutChanges(true); + this.updateViewport(); + setIgnoreLayoutChanges(false, this.env.window.document.documentElement); + + break; + case "pagehide": + this.destroy(); + break; + } + } +}; +exports.MeasuringToolHighlighter = MeasuringToolHighlighter; diff --git a/devtools/server/actors/highlighters/moz.build b/devtools/server/actors/highlighters/moz.build new file mode 100644 index 0000000000..317d0832ca --- /dev/null +++ b/devtools/server/actors/highlighters/moz.build @@ -0,0 +1,23 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +DIRS += [ + 'utils', +] + +DevToolsModules( + 'auto-refresh.js', + 'box-model.js', + 'css-grid.js', + 'css-transform.js', + 'eye-dropper.js', + 'geometry-editor.js', + 'measuring-tool.js', + 'rect.js', + 'rulers.js', + 'selector.js', + 'simple-outline.js' +) diff --git a/devtools/server/actors/highlighters/rect.js b/devtools/server/actors/highlighters/rect.js new file mode 100644 index 0000000000..69ff098806 --- /dev/null +++ b/devtools/server/actors/highlighters/rect.js @@ -0,0 +1,102 @@ +/* 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 { CanvasFrameAnonymousContentHelper } = require("./utils/markup"); +const { getAdjustedQuads } = require("devtools/shared/layout/utils"); +/** + * The RectHighlighter is a class that draws a rectangle highlighter at specific + * coordinates. + * It does *not* highlight DOM nodes, but rects. + * It also does *not* update dynamically, it only highlights a rect and remains + * there as long as it is shown. + */ +function RectHighlighter(highlighterEnv) { + this.win = highlighterEnv.window; + this.markup = new CanvasFrameAnonymousContentHelper(highlighterEnv, + this._buildMarkup.bind(this)); +} + +RectHighlighter.prototype = { + typeName: "RectHighlighter", + + _buildMarkup: function () { + let doc = this.win.document; + + let container = doc.createElement("div"); + container.className = "highlighter-container"; + container.innerHTML = "<div id=\"highlighted-rect\" " + + "class=\"highlighted-rect\" hidden=\"true\">"; + + return container; + }, + + destroy: function () { + this.win = null; + this.markup.destroy(); + }, + + getElement: function (id) { + return this.markup.getElement(id); + }, + + _hasValidOptions: function (options) { + let isValidNb = n => typeof n === "number" && n >= 0 && isFinite(n); + return options && options.rect && + isValidNb(options.rect.x) && + isValidNb(options.rect.y) && + options.rect.width && isValidNb(options.rect.width) && + options.rect.height && isValidNb(options.rect.height); + }, + + /** + * @param {DOMNode} node The highlighter rect is relatively positioned to the + * viewport this node is in. Using the provided node, the highligther will get + * the parent documentElement and use it as context to position the + * highlighter correctly. + * @param {Object} options Accepts the following options: + * - rect: mandatory object that should have the x, y, width, height + * properties + * - fill: optional fill color for the rect + */ + show: function (node, options) { + if (!this._hasValidOptions(options) || !node || !node.ownerDocument) { + this.hide(); + return false; + } + + let contextNode = node.ownerDocument.documentElement; + + // Caculate the absolute rect based on the context node's adjusted quads. + let quads = getAdjustedQuads(this.win, contextNode); + if (!quads.length) { + this.hide(); + return false; + } + + let {bounds} = quads[0]; + let x = "left:" + (bounds.x + options.rect.x) + "px;"; + let y = "top:" + (bounds.y + options.rect.y) + "px;"; + let width = "width:" + options.rect.width + "px;"; + let height = "height:" + options.rect.height + "px;"; + + let style = x + y + width + height; + if (options.fill) { + style += "background:" + options.fill + ";"; + } + + // Set the coordinates of the highlighter and show it + let rect = this.getElement("highlighted-rect"); + rect.setAttribute("style", style); + rect.removeAttribute("hidden"); + + return true; + }, + + hide: function () { + this.getElement("highlighted-rect").setAttribute("hidden", "true"); + } +}; +exports.RectHighlighter = RectHighlighter; diff --git a/devtools/server/actors/highlighters/rulers.js b/devtools/server/actors/highlighters/rulers.js new file mode 100644 index 0000000000..01e082e674 --- /dev/null +++ b/devtools/server/actors/highlighters/rulers.js @@ -0,0 +1,294 @@ +/* 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 events = require("sdk/event/core"); +const { getCurrentZoom, + setIgnoreLayoutChanges } = require("devtools/shared/layout/utils"); +const { + CanvasFrameAnonymousContentHelper, + createSVGNode, createNode } = require("./utils/markup"); + +// Maximum size, in pixel, for the horizontal ruler and vertical ruler +// used by RulersHighlighter +const RULERS_MAX_X_AXIS = 10000; +const RULERS_MAX_Y_AXIS = 15000; +// Number of steps after we add a graduation, marker and text in +// RulersHighliter; currently the unit is in pixel. +const RULERS_GRADUATION_STEP = 5; +const RULERS_MARKER_STEP = 50; +const RULERS_TEXT_STEP = 100; + +/** + * The RulersHighlighter is a class that displays both horizontal and + * vertical rules on the page, along the top and left edges, with pixel + * graduations, useful for users to quickly check distances + */ +function RulersHighlighter(highlighterEnv) { + this.env = highlighterEnv; + this.markup = new CanvasFrameAnonymousContentHelper(highlighterEnv, + this._buildMarkup.bind(this)); + + let { pageListenerTarget } = highlighterEnv; + pageListenerTarget.addEventListener("scroll", this); + pageListenerTarget.addEventListener("pagehide", this); +} + +RulersHighlighter.prototype = { + typeName: "RulersHighlighter", + + ID_CLASS_PREFIX: "rulers-highlighter-", + + _buildMarkup: function () { + let { window } = this.env; + let prefix = this.ID_CLASS_PREFIX; + + function createRuler(axis, size) { + let width, height; + let isHorizontal = true; + + if (axis === "x") { + width = size; + height = 16; + } else if (axis === "y") { + width = 16; + height = size; + isHorizontal = false; + } else { + throw new Error( + `Invalid type of axis given; expected "x" or "y" but got "${axis}"`); + } + + let g = createSVGNode(window, { + nodeType: "g", + attributes: { + id: `${axis}-axis` + }, + parent: svg, + prefix + }); + + createSVGNode(window, { + nodeType: "rect", + attributes: { + y: isHorizontal ? 0 : 16, + width, + height + }, + parent: g + }); + + let gRule = createSVGNode(window, { + nodeType: "g", + attributes: { + id: `${axis}-axis-ruler` + }, + parent: g, + prefix + }); + + let pathGraduations = createSVGNode(window, { + nodeType: "path", + attributes: { + "class": "ruler-graduations", + width, + height + }, + parent: gRule, + prefix + }); + + let pathMarkers = createSVGNode(window, { + nodeType: "path", + attributes: { + "class": "ruler-markers", + width, + height + }, + parent: gRule, + prefix + }); + + let gText = createSVGNode(window, { + nodeType: "g", + attributes: { + id: `${axis}-axis-text`, + "class": (isHorizontal ? "horizontal" : "vertical") + "-labels" + }, + parent: g, + prefix + }); + + let dGraduations = ""; + let dMarkers = ""; + let graduationLength; + + for (let i = 0; i < size; i += RULERS_GRADUATION_STEP) { + if (i === 0) { + continue; + } + + graduationLength = (i % 2 === 0) ? 6 : 4; + + if (i % RULERS_TEXT_STEP === 0) { + graduationLength = 8; + createSVGNode(window, { + nodeType: "text", + parent: gText, + attributes: { + x: isHorizontal ? 2 + i : -i - 1, + y: 5 + } + }).textContent = i; + } + + if (isHorizontal) { + if (i % RULERS_MARKER_STEP === 0) { + dMarkers += `M${i} 0 L${i} ${graduationLength}`; + } else { + dGraduations += `M${i} 0 L${i} ${graduationLength} `; + } + } else { + if (i % 50 === 0) { + dMarkers += `M0 ${i} L${graduationLength} ${i}`; + } else { + dGraduations += `M0 ${i} L${graduationLength} ${i}`; + } + } + } + + pathGraduations.setAttribute("d", dGraduations); + pathMarkers.setAttribute("d", dMarkers); + + return g; + } + + let container = createNode(window, { + attributes: {"class": "highlighter-container"} + }); + + let root = createNode(window, { + parent: container, + attributes: { + "id": "root", + "class": "root" + }, + prefix + }); + + let svg = createSVGNode(window, { + nodeType: "svg", + parent: root, + attributes: { + id: "elements", + "class": "elements", + width: "100%", + height: "100%", + hidden: "true" + }, + prefix + }); + + createRuler("x", RULERS_MAX_X_AXIS); + createRuler("y", RULERS_MAX_Y_AXIS); + + return container; + }, + + handleEvent: function (event) { + switch (event.type) { + case "scroll": + this._onScroll(event); + break; + case "pagehide": + this.destroy(); + break; + } + }, + + _onScroll: function (event) { + let prefix = this.ID_CLASS_PREFIX; + let { scrollX, scrollY } = event.view; + + this.markup.getElement(`${prefix}x-axis-ruler`) + .setAttribute("transform", `translate(${-scrollX})`); + this.markup.getElement(`${prefix}x-axis-text`) + .setAttribute("transform", `translate(${-scrollX})`); + this.markup.getElement(`${prefix}y-axis-ruler`) + .setAttribute("transform", `translate(0, ${-scrollY})`); + this.markup.getElement(`${prefix}y-axis-text`) + .setAttribute("transform", `translate(0, ${-scrollY})`); + }, + + _update: function () { + let { window } = this.env; + + setIgnoreLayoutChanges(true); + + let zoom = getCurrentZoom(window); + let isZoomChanged = zoom !== this._zoom; + + if (isZoomChanged) { + this._zoom = zoom; + this.updateViewport(); + } + + setIgnoreLayoutChanges(false, window.document.documentElement); + + this._rafID = window.requestAnimationFrame(() => this._update()); + }, + + _cancelUpdate: function () { + if (this._rafID) { + this.env.window.cancelAnimationFrame(this._rafID); + this._rafID = 0; + } + }, + updateViewport: function () { + let { devicePixelRatio } = this.env.window; + + // Because `devicePixelRatio` is affected by zoom (see bug 809788), + // in order to get the "real" device pixel ratio, we need divide by `zoom` + let pixelRatio = devicePixelRatio / this._zoom; + + // The "real" device pixel ratio is used to calculate the max stroke + // width we can actually assign: on retina, for instance, it would be 0.5, + // where on non high dpi monitor would be 1. + let minWidth = 1 / pixelRatio; + let strokeWidth = Math.min(minWidth, minWidth / this._zoom); + + this.markup.getElement(this.ID_CLASS_PREFIX + "root").setAttribute("style", + `stroke-width:${strokeWidth};`); + }, + + destroy: function () { + this.hide(); + + let { pageListenerTarget } = this.env; + pageListenerTarget.removeEventListener("scroll", this); + pageListenerTarget.removeEventListener("pagehide", this); + + this.markup.destroy(); + + events.emit(this, "destroy"); + }, + + show: function () { + this.markup.removeAttributeForElement(this.ID_CLASS_PREFIX + "elements", + "hidden"); + + this._update(); + + return true; + }, + + hide: function () { + this.markup.setAttributeForElement(this.ID_CLASS_PREFIX + "elements", + "hidden", "true"); + + this._cancelUpdate(); + } +}; +exports.RulersHighlighter = RulersHighlighter; diff --git a/devtools/server/actors/highlighters/selector.js b/devtools/server/actors/highlighters/selector.js new file mode 100644 index 0000000000..557a6d5416 --- /dev/null +++ b/devtools/server/actors/highlighters/selector.js @@ -0,0 +1,83 @@ +/* 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 { isNodeValid } = require("./utils/markup"); +const { BoxModelHighlighter } = require("./box-model"); + +// How many maximum nodes can be highlighted at the same time by the +// SelectorHighlighter +const MAX_HIGHLIGHTED_ELEMENTS = 100; + +/** + * The SelectorHighlighter runs a given selector through querySelectorAll on the + * document of the provided context node and then uses the BoxModelHighlighter + * to highlight the matching nodes + */ +function SelectorHighlighter(highlighterEnv) { + this.highlighterEnv = highlighterEnv; + this._highlighters = []; +} + +SelectorHighlighter.prototype = { + typeName: "SelectorHighlighter", + + /** + * Show BoxModelHighlighter on each node that matches that provided selector. + * @param {DOMNode} node A context node that is used to get the document on + * which querySelectorAll should be executed. This node will NOT be + * highlighted. + * @param {Object} options Should at least contain the 'selector' option, a + * string that will be used in querySelectorAll. On top of this, all of the + * valid options to BoxModelHighlighter.show are also valid here. + */ + show: function (node, options = {}) { + this.hide(); + + if (!isNodeValid(node) || !options.selector) { + return false; + } + + let nodes = []; + try { + nodes = [...node.ownerDocument.querySelectorAll(options.selector)]; + } catch (e) { + // It's fine if the provided selector is invalid, nodes will be an empty + // array. + } + + delete options.selector; + + let i = 0; + for (let matchingNode of nodes) { + if (i >= MAX_HIGHLIGHTED_ELEMENTS) { + break; + } + + let highlighter = new BoxModelHighlighter(this.highlighterEnv); + if (options.fill) { + highlighter.regionFill[options.region || "border"] = options.fill; + } + highlighter.show(matchingNode, options); + this._highlighters.push(highlighter); + i++; + } + + return true; + }, + + hide: function () { + for (let highlighter of this._highlighters) { + highlighter.destroy(); + } + this._highlighters = []; + }, + + destroy: function () { + this.hide(); + this.highlighterEnv = null; + } +}; +exports.SelectorHighlighter = SelectorHighlighter; diff --git a/devtools/server/actors/highlighters/simple-outline.js b/devtools/server/actors/highlighters/simple-outline.js new file mode 100644 index 0000000000..dae20f2d9d --- /dev/null +++ b/devtools/server/actors/highlighters/simple-outline.js @@ -0,0 +1,67 @@ +/* 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 { + installHelperSheet, + isNodeValid, + addPseudoClassLock, + removePseudoClassLock +} = require("./utils/markup"); + +// SimpleOutlineHighlighter's stylesheet +const HIGHLIGHTED_PSEUDO_CLASS = ":-moz-devtools-highlighted"; +const SIMPLE_OUTLINE_SHEET = `.__fx-devtools-hide-shortcut__ { + visibility: hidden !important + } + ${HIGHLIGHTED_PSEUDO_CLASS} { + outline: 2px dashed #F06!important; + outline-offset: -2px!important + }`; +/** + * The SimpleOutlineHighlighter is a class that has the same API than the + * BoxModelHighlighter, but adds a pseudo-class on the target element itself + * to draw a simple css outline around the element. + * It is used by the HighlighterActor when canvasframe-based highlighters can't + * be used. This is the case for XUL windows. + */ +function SimpleOutlineHighlighter(highlighterEnv) { + this.chromeDoc = highlighterEnv.document; +} + +SimpleOutlineHighlighter.prototype = { + /** + * Destroy the nodes. Remove listeners. + */ + destroy: function () { + this.hide(); + this.chromeDoc = null; + }, + + /** + * Show the highlighter on a given node + * @param {DOMNode} node + */ + show: function (node) { + if (isNodeValid(node) && (!this.currentNode || node !== this.currentNode)) { + this.hide(); + this.currentNode = node; + installHelperSheet(node.ownerDocument.defaultView, SIMPLE_OUTLINE_SHEET); + addPseudoClassLock(node, HIGHLIGHTED_PSEUDO_CLASS); + } + return true; + }, + + /** + * Hide the highlighter, the outline and the infobar. + */ + hide: function () { + if (this.currentNode) { + removePseudoClassLock(this.currentNode, HIGHLIGHTED_PSEUDO_CLASS); + this.currentNode = null; + } + } +}; +exports.SimpleOutlineHighlighter = SimpleOutlineHighlighter; diff --git a/devtools/server/actors/highlighters/utils/markup.js b/devtools/server/actors/highlighters/utils/markup.js new file mode 100644 index 0000000000..8750014bc0 --- /dev/null +++ b/devtools/server/actors/highlighters/utils/markup.js @@ -0,0 +1,609 @@ +/* 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 { Cc, Ci, Cu } = require("chrome"); +const { getCurrentZoom, + getRootBindingParent } = require("devtools/shared/layout/utils"); +const { on, emit } = require("sdk/event/core"); + +const lazyContainer = {}; + +loader.lazyRequireGetter(lazyContainer, "CssLogic", + "devtools/server/css-logic", true); +exports.getComputedStyle = (node) => + lazyContainer.CssLogic.getComputedStyle(node); + +exports.getBindingElementAndPseudo = (node) => + lazyContainer.CssLogic.getBindingElementAndPseudo(node); + +loader.lazyGetter(lazyContainer, "DOMUtils", () => + Cc["@mozilla.org/inspector/dom-utils;1"].getService(Ci.inIDOMUtils)); +exports.hasPseudoClassLock = (...args) => + lazyContainer.DOMUtils.hasPseudoClassLock(...args); + +exports.addPseudoClassLock = (...args) => + lazyContainer.DOMUtils.addPseudoClassLock(...args); + +exports.removePseudoClassLock = (...args) => + lazyContainer.DOMUtils.removePseudoClassLock(...args); + +exports.getCSSStyleRules = (...args) => + lazyContainer.DOMUtils.getCSSStyleRules(...args); + +const SVG_NS = "http://www.w3.org/2000/svg"; +const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"; +const STYLESHEET_URI = "resource://devtools/server/actors/" + + "highlighters.css"; +// How high is the infobar (px). +const INFOBAR_HEIGHT = 34; +// What's the size of the infobar arrow (px). +const INFOBAR_ARROW_SIZE = 9; + +const _tokens = Symbol("classList/tokens"); + +/** + * Shims the element's `classList` for anonymous content elements; used + * internally by `CanvasFrameAnonymousContentHelper.getElement()` method. + */ +function ClassList(className) { + let trimmed = (className || "").trim(); + this[_tokens] = trimmed ? trimmed.split(/\s+/) : []; +} + +ClassList.prototype = { + item(index) { + return this[_tokens][index]; + }, + contains(token) { + return this[_tokens].includes(token); + }, + add(token) { + if (!this.contains(token)) { + this[_tokens].push(token); + } + emit(this, "update"); + }, + remove(token) { + let index = this[_tokens].indexOf(token); + + if (index > -1) { + this[_tokens].splice(index, 1); + } + emit(this, "update"); + }, + toggle(token) { + if (this.contains(token)) { + this.remove(token); + } else { + this.add(token); + } + }, + get length() { + return this[_tokens].length; + }, + [Symbol.iterator]: function* () { + for (let i = 0; i < this.tokens.length; i++) { + yield this[_tokens][i]; + } + }, + toString() { + return this[_tokens].join(" "); + } +}; + +/** + * Is this content window a XUL window? + * @param {Window} window + * @return {Boolean} + */ +function isXUL(window) { + return window.document.documentElement.namespaceURI === XUL_NS; +} +exports.isXUL = isXUL; + +/** + * Inject a helper stylesheet in the window. + */ +var installedHelperSheets = new WeakMap(); + +function installHelperSheet(win, source, type = "agent") { + if (installedHelperSheets.has(win.document)) { + return; + } + let {Style} = require("sdk/stylesheet/style"); + let {attach} = require("sdk/content/mod"); + let style = Style({source, type}); + attach(style, win); + installedHelperSheets.set(win.document, style); +} +exports.installHelperSheet = installHelperSheet; + +/** + * Returns true if a DOM node is "valid", where "valid" means that the node isn't a dead + * object wrapper, is still attached to a document, and is of a given type. + * @param {DOMNode} node + * @param {Number} nodeType Optional, defaults to ELEMENT_NODE + * @return {Boolean} + */ +function isNodeValid(node, nodeType = Ci.nsIDOMNode.ELEMENT_NODE) { + // Is it still alive? + if (!node || Cu.isDeadWrapper(node)) { + return false; + } + + // Is it of the right type? + if (node.nodeType !== nodeType) { + return false; + } + + // Is its document accessible? + let doc = node.ownerDocument; + if (!doc || !doc.defaultView) { + return false; + } + + // Is the node connected to the document? Using getBindingParent adds + // support for anonymous elements generated by a node in the document. + let bindingParent = getRootBindingParent(node); + if (!doc.documentElement.contains(bindingParent)) { + return false; + } + + return true; +} +exports.isNodeValid = isNodeValid; + +/** + * Helper function that creates SVG DOM nodes. + * @param {Window} This window's document will be used to create the element + * @param {Object} Options for the node include: + * - nodeType: the type of node, defaults to "box". + * - attributes: a {name:value} object to be used as attributes for the node. + * - prefix: a string that will be used to prefix the values of the id and class + * attributes. + * - parent: if provided, the newly created element will be appended to this + * node. + */ +function createSVGNode(win, options) { + if (!options.nodeType) { + options.nodeType = "box"; + } + options.namespace = SVG_NS; + return createNode(win, options); +} +exports.createSVGNode = createSVGNode; + +/** + * Helper function that creates DOM nodes. + * @param {Window} This window's document will be used to create the element + * @param {Object} Options for the node include: + * - nodeType: the type of node, defaults to "div". + * - namespace: if passed, doc.createElementNS will be used instead of + * doc.creatElement. + * - attributes: a {name:value} object to be used as attributes for the node. + * - prefix: a string that will be used to prefix the values of the id and class + * attributes. + * - parent: if provided, the newly created element will be appended to this + * node. + */ +function createNode(win, options) { + let type = options.nodeType || "div"; + + let node; + if (options.namespace) { + node = win.document.createElementNS(options.namespace, type); + } else { + node = win.document.createElement(type); + } + + for (let name in options.attributes || {}) { + let value = options.attributes[name]; + if (options.prefix && (name === "class" || name === "id")) { + value = options.prefix + value; + } + node.setAttribute(name, value); + } + + if (options.parent) { + options.parent.appendChild(node); + } + + return node; +} +exports.createNode = createNode; + +/** + * Every highlighters should insert their markup content into the document's + * canvasFrame anonymous content container (see dom/webidl/Document.webidl). + * + * Since this container gets cleared when the document navigates, highlighters + * should use this helper to have their markup content automatically re-inserted + * in the new document. + * + * Since the markup content is inserted in the canvasFrame using + * insertAnonymousContent, this means that it can be modified using the API + * described in AnonymousContent.webidl. + * To retrieve the AnonymousContent instance, use the content getter. + * + * @param {HighlighterEnv} highlighterEnv + * The environemnt which windows will be used to insert the node. + * @param {Function} nodeBuilder + * A function that, when executed, returns a DOM node to be inserted into + * the canvasFrame. + */ +function CanvasFrameAnonymousContentHelper(highlighterEnv, nodeBuilder) { + this.highlighterEnv = highlighterEnv; + this.nodeBuilder = nodeBuilder; + this.anonymousContentDocument = this.highlighterEnv.document; + // XXX the next line is a wallpaper for bug 1123362. + this.anonymousContentGlobal = Cu.getGlobalForObject( + this.anonymousContentDocument); + + // Only try to create the highlighter when the document is loaded, + // otherwise, wait for the navigate event to fire. + let doc = this.highlighterEnv.document; + if (doc.documentElement && doc.readyState != "uninitialized") { + this._insert(); + } + + this._onNavigate = this._onNavigate.bind(this); + this.highlighterEnv.on("navigate", this._onNavigate); + + this.listeners = new Map(); +} + +CanvasFrameAnonymousContentHelper.prototype = { + destroy: function () { + try { + let doc = this.anonymousContentDocument; + doc.removeAnonymousContent(this._content); + } catch (e) { + // If the current window isn't the one the content was inserted into, this + // will fail, but that's fine. + } + this.highlighterEnv.off("navigate", this._onNavigate); + this.highlighterEnv = this.nodeBuilder = this._content = null; + this.anonymousContentDocument = null; + this.anonymousContentGlobal = null; + + this._removeAllListeners(); + }, + + _insert: function () { + let doc = this.highlighterEnv.document; + // Insert the content node only if the document: + // * is loaded (navigate event will fire once it is), + // * still exists, + // * isn't in XUL. + if (doc.readyState == "uninitialized" || + !doc.documentElement || + isXUL(this.highlighterEnv.window)) { + return; + } + + // For now highlighters.css is injected in content as a ua sheet because + // <style scoped> doesn't work inside anonymous content (see bug 1086532). + // If it did, highlighters.css would be injected as an anonymous content + // node using CanvasFrameAnonymousContentHelper instead. + installHelperSheet(this.highlighterEnv.window, + "@import url('" + STYLESHEET_URI + "');"); + let node = this.nodeBuilder(); + + // It was stated that hidden documents don't accept + // `insertAnonymousContent` calls yet. That doesn't seems the case anymore, + // at least on desktop. Therefore, removing the code that was dealing with + // that scenario, fixes when we're adding anonymous content in a tab that + // is not the active one (see bug 1260043 and bug 1260044) + this._content = doc.insertAnonymousContent(node); + }, + + _onNavigate: function (e, {isTopLevel}) { + if (isTopLevel) { + this._removeAllListeners(); + this._insert(); + this.anonymousContentDocument = this.highlighterEnv.document; + } + }, + + getTextContentForElement: function (id) { + if (!this.content) { + return null; + } + return this.content.getTextContentForElement(id); + }, + + setTextContentForElement: function (id, text) { + if (this.content) { + this.content.setTextContentForElement(id, text); + } + }, + + setAttributeForElement: function (id, name, value) { + if (this.content) { + this.content.setAttributeForElement(id, name, value); + } + }, + + getAttributeForElement: function (id, name) { + if (!this.content) { + return null; + } + return this.content.getAttributeForElement(id, name); + }, + + removeAttributeForElement: function (id, name) { + if (this.content) { + this.content.removeAttributeForElement(id, name); + } + }, + + hasAttributeForElement: function (id, name) { + return typeof this.getAttributeForElement(id, name) === "string"; + }, + + getCanvasContext: function (id, type = "2d") { + return this.content ? this.content.getCanvasContext(id, type) : null; + }, + + /** + * Add an event listener to one of the elements inserted in the canvasFrame + * native anonymous container. + * Like other methods in this helper, this requires the ID of the element to + * be passed in. + * + * Note that if the content page navigates, the event listeners won't be + * added again. + * + * Also note that unlike traditional DOM events, the events handled by + * listeners added here will propagate through the document only through + * bubbling phase, so the useCapture parameter isn't supported. + * It is possible however to call e.stopPropagation() to stop the bubbling. + * + * IMPORTANT: the chrome-only canvasFrame insertion API takes great care of + * not leaking references to inserted elements to chrome JS code. That's + * because otherwise, chrome JS code could freely modify native anon elements + * inside the canvasFrame and probably change things that are assumed not to + * change by the C++ code managing this frame. + * See https://wiki.mozilla.org/DevTools/Highlighter#The_AnonymousContent_API + * Unfortunately, the inserted nodes are still available via + * event.originalTarget, and that's what the event handler here uses to check + * that the event actually occured on the right element, but that also means + * consumers of this code would be able to access the inserted elements. + * Therefore, the originalTarget property will be nullified before the event + * is passed to your handler. + * + * IMPL DETAIL: A single event listener is added per event types only, at + * browser level and if the event originalTarget is found to have the provided + * ID, the callback is executed (and then IDs of parent nodes of the + * originalTarget are checked too). + * + * @param {String} id + * @param {String} type + * @param {Function} handler + */ + addEventListenerForElement: function (id, type, handler) { + if (typeof id !== "string") { + throw new Error("Expected a string ID in addEventListenerForElement but" + + " got: " + id); + } + + // If no one is listening for this type of event yet, add one listener. + if (!this.listeners.has(type)) { + let target = this.highlighterEnv.pageListenerTarget; + target.addEventListener(type, this, true); + // Each type entry in the map is a map of ids:handlers. + this.listeners.set(type, new Map()); + } + + let listeners = this.listeners.get(type); + listeners.set(id, handler); + }, + + /** + * Remove an event listener from one of the elements inserted in the + * canvasFrame native anonymous container. + * @param {String} id + * @param {String} type + */ + removeEventListenerForElement: function (id, type) { + let listeners = this.listeners.get(type); + if (!listeners) { + return; + } + listeners.delete(id); + + // If no one is listening for event type anymore, remove the listener. + if (!this.listeners.has(type)) { + let target = this.highlighterEnv.pageListenerTarget; + target.removeEventListener(type, this, true); + } + }, + + handleEvent: function (event) { + let listeners = this.listeners.get(event.type); + if (!listeners) { + return; + } + + // Hide the originalTarget property to avoid exposing references to native + // anonymous elements. See addEventListenerForElement's comment. + let isPropagationStopped = false; + let eventProxy = new Proxy(event, { + get: (obj, name) => { + if (name === "originalTarget") { + return null; + } else if (name === "stopPropagation") { + return () => { + isPropagationStopped = true; + }; + } + return obj[name]; + } + }); + + // Start at originalTarget, bubble through ancestors and call handlers when + // needed. + let node = event.originalTarget; + while (node) { + let handler = listeners.get(node.id); + if (handler) { + handler(eventProxy, node.id); + if (isPropagationStopped) { + break; + } + } + node = node.parentNode; + } + }, + + _removeAllListeners: function () { + if (this.highlighterEnv) { + let target = this.highlighterEnv.pageListenerTarget; + for (let [type] of this.listeners) { + target.removeEventListener(type, this, true); + } + } + this.listeners.clear(); + }, + + getElement: function (id) { + let classList = new ClassList(this.getAttributeForElement(id, "class")); + + on(classList, "update", () => { + this.setAttributeForElement(id, "class", classList.toString()); + }); + + return { + getTextContent: () => this.getTextContentForElement(id), + setTextContent: text => this.setTextContentForElement(id, text), + setAttribute: (name, val) => this.setAttributeForElement(id, name, val), + getAttribute: name => this.getAttributeForElement(id, name), + removeAttribute: name => this.removeAttributeForElement(id, name), + hasAttribute: name => this.hasAttributeForElement(id, name), + getCanvasContext: type => this.getCanvasContext(id, type), + addEventListener: (type, handler) => { + return this.addEventListenerForElement(id, type, handler); + }, + removeEventListener: (type, handler) => { + return this.removeEventListenerForElement(id, type, handler); + }, + classList + }; + }, + + get content() { + if (!this._content || Cu.isDeadWrapper(this._content)) { + return null; + } + return this._content; + }, + + /** + * The canvasFrame anonymous content container gets zoomed in/out with the + * page. If this is unwanted, i.e. if you want the inserted element to remain + * unzoomed, then this method can be used. + * + * Consumers of the CanvasFrameAnonymousContentHelper should call this method, + * it isn't executed automatically. Typically, AutoRefreshHighlighter can call + * it when _update is executed. + * + * The matching element will be scaled down or up by 1/zoomLevel (using css + * transform) to cancel the current zoom. The element's width and height + * styles will also be set according to the scale. Finally, the element's + * position will be set as absolute. + * + * Note that if the matching element already has an inline style attribute, it + * *won't* be preserved. + * + * @param {DOMNode} node This node is used to determine which container window + * should be used to read the current zoom value. + * @param {String} id The ID of the root element inserted with this API. + */ + scaleRootElement: function (node, id) { + let zoom = getCurrentZoom(node); + let value = "position:absolute;width:100%;height:100%;"; + + if (zoom !== 1) { + value = "position:absolute;"; + value += "transform-origin:top left;transform:scale(" + (1 / zoom) + ");"; + value += "width:" + (100 * zoom) + "%;height:" + (100 * zoom) + "%;"; + } + + this.setAttributeForElement(id, "style", value); + } +}; +exports.CanvasFrameAnonymousContentHelper = CanvasFrameAnonymousContentHelper; + +/** + * Move the infobar to the right place in the highlighter. This helper method is utilized + * in both css-grid.js and box-model.js to help position the infobar in an appropriate + * space over the highlighted node element or grid area. The infobar is used to display + * relevant information about the highlighted item (ex, node or grid name and dimensions). + * + * This method will first try to position the infobar to top or bottom of the container + * such that it has enough space for the height of the infobar. Afterwards, it will try + * to horizontally center align with the container element if possible. + * + * @param {DOMNode} container + * The container element which will be used to position the infobar. + * @param {Object} bounds + * The content bounds of the container element. + * @param {Window} win + * The window object. + */ +function moveInfobar(container, bounds, win) { + let winHeight = win.innerHeight * getCurrentZoom(win); + let winWidth = win.innerWidth * getCurrentZoom(win); + let winScrollY = win.scrollY; + + // Ensure that containerBottom and containerTop are at least zero to avoid + // showing tooltips outside the viewport. + let containerBottom = Math.max(0, bounds.bottom) + INFOBAR_ARROW_SIZE; + let containerTop = Math.min(winHeight, bounds.top); + + // Can the bar be above the node? + let top; + if (containerTop < INFOBAR_HEIGHT) { + // No. Can we move the bar under the node? + if (containerBottom + INFOBAR_HEIGHT > winHeight) { + // No. Let's move it inside. Can we show it at the top of the element? + if (containerTop < winScrollY) { + // No. Window is scrolled past the top of the element. + top = 0; + } else { + // Yes. Show it at the top of the element + top = containerTop; + } + container.setAttribute("position", "overlap"); + } else { + // Yes. Let's move it under the node. + top = containerBottom; + container.setAttribute("position", "bottom"); + } + } else { + // Yes. Let's move it on top of the node. + top = containerTop - INFOBAR_HEIGHT; + container.setAttribute("position", "top"); + } + + // Align the bar with the box's center if possible. + let left = bounds.right - bounds.width / 2; + // Make sure the while infobar is visible. + let buffer = 100; + if (left < buffer) { + left = buffer; + container.setAttribute("hide-arrow", "true"); + } else if (left > winWidth - buffer) { + left = winWidth - buffer; + container.setAttribute("hide-arrow", "true"); + } else { + container.removeAttribute("hide-arrow"); + } + + let style = "top:" + top + "px;left:" + left + "px;"; + container.setAttribute("style", style); +} +exports.moveInfobar = moveInfobar; diff --git a/devtools/server/actors/highlighters/utils/moz.build b/devtools/server/actors/highlighters/utils/moz.build new file mode 100644 index 0000000000..4bb429bc3c --- /dev/null +++ b/devtools/server/actors/highlighters/utils/moz.build @@ -0,0 +1,9 @@ +# -*- 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( + 'markup.js' +) diff --git a/devtools/server/actors/inspector.js b/devtools/server/actors/inspector.js new file mode 100644 index 0000000000..20a227a40d --- /dev/null +++ b/devtools/server/actors/inspector.js @@ -0,0 +1,3186 @@ +/* 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"; + +/** + * Here's the server side of the remote inspector. + * + * The WalkerActor is the client's view of the debuggee's DOM. It's gives + * the client a tree of NodeActor objects. + * + * The walker presents the DOM tree mostly unmodified from the source DOM + * tree, but with a few key differences: + * + * - Empty text nodes are ignored. This is pretty typical of developer + * tools, but maybe we should reconsider that on the server side. + * - iframes with documents loaded have the loaded document as the child, + * the walker provides one big tree for the whole document tree. + * + * There are a few ways to get references to NodeActors: + * + * - When you first get a WalkerActor reference, it comes with a free + * reference to the root document's node. + * - Given a node, you can ask for children, siblings, and parents. + * - You can issue querySelector and querySelectorAll requests to find + * other elements. + * - Requests that return arbitrary nodes from the tree (like querySelector + * and querySelectorAll) will also return any nodes the client hasn't + * seen in order to have a complete set of parents. + * + * Once you have a NodeFront, you should be able to answer a few questions + * without further round trips, like the node's name, namespace/tagName, + * attributes, etc. Other questions (like a text node's full nodeValue) + * might require another round trip. + * + * The protocol guarantees that the client will always know the parent of + * any node that is returned by the server. This means that some requests + * (like querySelector) will include the extra nodes needed to satisfy this + * requirement. The client keeps track of this parent relationship, so the + * node fronts form a tree that is a subset of the actual DOM tree. + * + * + * We maintain this guarantee to support the ability to release subtrees on + * the client - when a node is disconnected from the DOM tree we want to be + * able to free the client objects for all the children nodes. + * + * So to be able to answer "all the children of a given node that we have + * seen on the client side", we guarantee that every time we've seen a node, + * we connect it up through its parents. + */ + +const {Cc, Ci, Cu} = require("chrome"); +const Services = require("Services"); +const protocol = require("devtools/shared/protocol"); +const {LayoutActor} = require("devtools/server/actors/layout"); +const {LongStringActor} = require("devtools/server/actors/string"); +const promise = require("promise"); +const {Task} = require("devtools/shared/task"); +const events = require("sdk/event/core"); +const {WalkerSearch} = require("devtools/server/actors/utils/walker-search"); +const {PageStyleActor, getFontPreviewData} = require("devtools/server/actors/styles"); +const { + HighlighterActor, + CustomHighlighterActor, + isTypeRegistered, + HighlighterEnvironment +} = require("devtools/server/actors/highlighters"); +const {EyeDropper} = require("devtools/server/actors/highlighters/eye-dropper"); +const { + isAnonymous, + isNativeAnonymous, + isXBLAnonymous, + isShadowAnonymous, + getFrameElement +} = require("devtools/shared/layout/utils"); +const {getLayoutChangesObserver, releaseLayoutChangesObserver} = require("devtools/server/actors/reflow"); +const nodeFilterConstants = require("devtools/shared/dom-node-filter-constants"); + +const {EventParsers} = require("devtools/server/event-parsers"); +const {nodeSpec, nodeListSpec, walkerSpec, inspectorSpec} = require("devtools/shared/specs/inspector"); + +const FONT_FAMILY_PREVIEW_TEXT = "The quick brown fox jumps over the lazy dog"; +const FONT_FAMILY_PREVIEW_TEXT_SIZE = 20; +const PSEUDO_CLASSES = [":hover", ":active", ":focus"]; +const HIDDEN_CLASS = "__fx-devtools-hide-shortcut__"; +const XHTML_NS = "http://www.w3.org/1999/xhtml"; +const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"; +const IMAGE_FETCHING_TIMEOUT = 500; +const RX_FUNC_NAME = + /((var|const|let)\s+)?([\w$.]+\s*[:=]\s*)*(function)?\s*\*?\s*([\w$]+)?\s*$/; + +// The possible completions to a ':' with added score to give certain values +// some preference. +const PSEUDO_SELECTORS = [ + [":active", 1], + [":hover", 1], + [":focus", 1], + [":visited", 0], + [":link", 0], + [":first-letter", 0], + [":first-child", 2], + [":before", 2], + [":after", 2], + [":lang(", 0], + [":not(", 3], + [":first-of-type", 0], + [":last-of-type", 0], + [":only-of-type", 0], + [":only-child", 2], + [":nth-child(", 3], + [":nth-last-child(", 0], + [":nth-of-type(", 0], + [":nth-last-of-type(", 0], + [":last-child", 2], + [":root", 0], + [":empty", 0], + [":target", 0], + [":enabled", 0], + [":disabled", 0], + [":checked", 1], + ["::selection", 0] +]; + +var HELPER_SHEET = ` + .__fx-devtools-hide-shortcut__ { + visibility: hidden !important; + } + + :-moz-devtools-highlighted { + outline: 2px dashed #F06!important; + outline-offset: -2px !important; + } +`; + +const flags = require("devtools/shared/flags"); + +loader.lazyRequireGetter(this, "DevToolsUtils", + "devtools/shared/DevToolsUtils"); + +loader.lazyRequireGetter(this, "AsyncUtils", "devtools/shared/async-utils"); + +loader.lazyGetter(this, "DOMParser", function () { + return Cc["@mozilla.org/xmlextras/domparser;1"] + .createInstance(Ci.nsIDOMParser); +}); + +loader.lazyGetter(this, "eventListenerService", function () { + return Cc["@mozilla.org/eventlistenerservice;1"] + .getService(Ci.nsIEventListenerService); +}); + +loader.lazyGetter(this, "CssLogic", () => require("devtools/server/css-logic").CssLogic); + +/** + * We only send nodeValue up to a certain size by default. This stuff + * controls that size. + */ +exports.DEFAULT_VALUE_SUMMARY_LENGTH = 50; +var gValueSummaryLength = exports.DEFAULT_VALUE_SUMMARY_LENGTH; + +exports.getValueSummaryLength = function () { + return gValueSummaryLength; +}; + +exports.setValueSummaryLength = function (val) { + gValueSummaryLength = val; +}; + +// When the user selects a node to inspect in e10s, the parent process +// has a CPOW that wraps the node being inspected. It uses the +// message manager to send this node to the child, which stores the +// node in gInspectingNode. Then a findInspectingNode request is sent +// over the remote debugging protocol, and gInspectingNode is returned +// to the parent as a NodeFront. +var gInspectingNode = null; + +// We expect this function to be called from the child.js frame script +// when it receives the node to be inspected over the message manager. +exports.setInspectingNode = function (val) { + gInspectingNode = val; +}; + +/** + * Returns the properly cased version of the node's tag name, which can be + * used when displaying said name in the UI. + * + * @param {Node} rawNode + * Node for which we want the display name + * @return {String} + * Properly cased version of the node tag name + */ +const getNodeDisplayName = function (rawNode) { + if (rawNode.nodeName && !rawNode.localName) { + // The localName & prefix APIs have been moved from the Node interface to the Element + // interface. Use Node.nodeName as a fallback. + return rawNode.nodeName; + } + return (rawNode.prefix ? rawNode.prefix + ":" : "") + rawNode.localName; +}; +exports.getNodeDisplayName = getNodeDisplayName; + +/** + * Server side of the node actor. + */ +var NodeActor = exports.NodeActor = protocol.ActorClassWithSpec(nodeSpec, { + initialize: function (walker, node) { + protocol.Actor.prototype.initialize.call(this, null); + this.walker = walker; + this.rawNode = node; + this._eventParsers = new EventParsers().parsers; + + // Storing the original display of the node, to track changes when reflows + // occur + this.wasDisplayed = this.isDisplayed; + }, + + toString: function () { + return "[NodeActor " + this.actorID + " for " + + this.rawNode.toString() + "]"; + }, + + /** + * Instead of storing a connection object, the NodeActor gets its connection + * from its associated walker. + */ + get conn() { + return this.walker.conn; + }, + + isDocumentElement: function () { + return this.rawNode.ownerDocument && + this.rawNode.ownerDocument.documentElement === this.rawNode; + }, + + destroy: function () { + protocol.Actor.prototype.destroy.call(this); + + if (this.mutationObserver) { + if (!Cu.isDeadWrapper(this.mutationObserver)) { + this.mutationObserver.disconnect(); + } + this.mutationObserver = null; + } + this.rawNode = null; + this.walker = null; + }, + + // Returns the JSON representation of this object over the wire. + form: function (detail) { + if (detail === "actorid") { + return this.actorID; + } + + let parentNode = this.walker.parentNode(this); + let inlineTextChild = this.walker.inlineTextChild(this); + + let form = { + actor: this.actorID, + baseURI: this.rawNode.baseURI, + parent: parentNode ? parentNode.actorID : undefined, + nodeType: this.rawNode.nodeType, + namespaceURI: this.rawNode.namespaceURI, + nodeName: this.rawNode.nodeName, + nodeValue: this.rawNode.nodeValue, + displayName: getNodeDisplayName(this.rawNode), + numChildren: this.numChildren, + inlineTextChild: inlineTextChild ? inlineTextChild.form() : undefined, + + // doctype attributes + name: this.rawNode.name, + publicId: this.rawNode.publicId, + systemId: this.rawNode.systemId, + + attrs: this.writeAttrs(), + isBeforePseudoElement: this.isBeforePseudoElement, + isAfterPseudoElement: this.isAfterPseudoElement, + isAnonymous: isAnonymous(this.rawNode), + isNativeAnonymous: isNativeAnonymous(this.rawNode), + isXBLAnonymous: isXBLAnonymous(this.rawNode), + isShadowAnonymous: isShadowAnonymous(this.rawNode), + pseudoClassLocks: this.writePseudoClassLocks(), + + isDisplayed: this.isDisplayed, + isInHTMLDocument: this.rawNode.ownerDocument && + this.rawNode.ownerDocument.contentType === "text/html", + hasEventListeners: this._hasEventListeners, + }; + + if (this.isDocumentElement()) { + form.isDocumentElement = true; + } + + // Add an extra API for custom properties added by other + // modules/extensions. + form.setFormProperty = (name, value) => { + if (!form.props) { + form.props = {}; + } + form.props[name] = value; + }; + + // Fire an event so, other modules can create its own properties + // that should be passed to the client (within the form.props field). + events.emit(NodeActor, "form", { + target: this, + data: form + }); + + return form; + }, + + /** + * Watch the given document node for mutations using the DOM observer + * API. + */ + watchDocument: function (callback) { + let node = this.rawNode; + // Create the observer on the node's actor. The node will make sure + // the observer is cleaned up when the actor is released. + let observer = new node.defaultView.MutationObserver(callback); + observer.mergeAttributeRecords = true; + observer.observe(node, { + nativeAnonymousChildList: true, + attributes: true, + characterData: true, + characterDataOldValue: true, + childList: true, + subtree: true + }); + this.mutationObserver = observer; + }, + + get isBeforePseudoElement() { + return this.rawNode.nodeName === "_moz_generated_content_before"; + }, + + get isAfterPseudoElement() { + return this.rawNode.nodeName === "_moz_generated_content_after"; + }, + + // Estimate the number of children that the walker will return without making + // a call to children() if possible. + get numChildren() { + // For pseudo elements, childNodes.length returns 1, but the walker + // will return 0. + if (this.isBeforePseudoElement || this.isAfterPseudoElement) { + return 0; + } + + let rawNode = this.rawNode; + let numChildren = rawNode.childNodes.length; + let hasAnonChildren = rawNode.nodeType === Ci.nsIDOMNode.ELEMENT_NODE && + rawNode.ownerDocument.getAnonymousNodes(rawNode); + + let hasContentDocument = rawNode.contentDocument; + let hasSVGDocument = rawNode.getSVGDocument && rawNode.getSVGDocument(); + if (numChildren === 0 && (hasContentDocument || hasSVGDocument)) { + // This might be an iframe with virtual children. + numChildren = 1; + } + + // Normal counting misses ::before/::after. Also, some anonymous children + // may ultimately be skipped, so we have to consult with the walker. + if (numChildren === 0 || hasAnonChildren) { + numChildren = this.walker.children(this).nodes.length; + } + + return numChildren; + }, + + get computedStyle() { + return CssLogic.getComputedStyle(this.rawNode); + }, + + /** + * Is the node's display computed style value other than "none" + */ + get isDisplayed() { + // Consider all non-element nodes as displayed. + if (isNodeDead(this) || + this.rawNode.nodeType !== Ci.nsIDOMNode.ELEMENT_NODE || + this.isAfterPseudoElement || + this.isBeforePseudoElement) { + return true; + } + + let style = this.computedStyle; + if (!style) { + return true; + } + + return style.display !== "none"; + }, + + /** + * Are there event listeners that are listening on this node? This method + * uses all parsers registered via event-parsers.js.registerEventParser() to + * check if there are any event listeners. + */ + get _hasEventListeners() { + let parsers = this._eventParsers; + for (let [, {hasListeners}] of parsers) { + try { + if (hasListeners && hasListeners(this.rawNode)) { + return true; + } + } catch (e) { + // An object attached to the node looked like a listener but wasn't... + // do nothing. + } + } + return false; + }, + + writeAttrs: function () { + if (!this.rawNode.attributes) { + return undefined; + } + + return [...this.rawNode.attributes].map(attr => { + return {namespace: attr.namespace, name: attr.name, value: attr.value }; + }); + }, + + writePseudoClassLocks: function () { + if (this.rawNode.nodeType !== Ci.nsIDOMNode.ELEMENT_NODE) { + return undefined; + } + let ret = undefined; + for (let pseudo of PSEUDO_CLASSES) { + if (DOMUtils.hasPseudoClassLock(this.rawNode, pseudo)) { + ret = ret || []; + ret.push(pseudo); + } + } + return ret; + }, + + /** + * Gets event listeners and adds their information to the events array. + * + * @param {Node} node + * Node for which we are to get listeners. + */ + getEventListeners: function (node) { + let parsers = this._eventParsers; + let dbg = this.parent().tabActor.makeDebugger(); + let listeners = []; + + for (let [, {getListeners, normalizeHandler}] of parsers) { + try { + let eventInfos = getListeners(node); + + if (!eventInfos) { + continue; + } + + for (let eventInfo of eventInfos) { + if (normalizeHandler) { + eventInfo.normalizeHandler = normalizeHandler; + } + + this.processHandlerForEvent(node, listeners, dbg, eventInfo); + } + } catch (e) { + // An object attached to the node looked like a listener but wasn't... + // do nothing. + } + } + + listeners.sort((a, b) => { + return a.type.localeCompare(b.type); + }); + + return listeners; + }, + + /** + * Process a handler + * + * @param {Node} node + * The node for which we want information. + * @param {Array} events + * The events array contains all event objects that we have gathered + * so far. + * @param {Debugger} dbg + * JSDebugger instance. + * @param {Object} eventInfo + * See event-parsers.js.registerEventParser() for a description of the + * eventInfo object. + * + * @return {Array} + * An array of objects where a typical object looks like this: + * { + * type: "click", + * handler: function() { doSomething() }, + * origin: "http://www.mozilla.com", + * searchString: 'onclick="doSomething()"', + * tags: tags, + * DOM0: true, + * capturing: true, + * hide: { + * dom0: true + * } + * } + */ + processHandlerForEvent: function (node, listeners, dbg, eventInfo) { + let type = eventInfo.type || ""; + let handler = eventInfo.handler; + let tags = eventInfo.tags || ""; + let hide = eventInfo.hide || {}; + let override = eventInfo.override || {}; + let global = Cu.getGlobalForObject(handler); + let globalDO = dbg.addDebuggee(global); + let listenerDO = globalDO.makeDebuggeeValue(handler); + + if (eventInfo.normalizeHandler) { + listenerDO = eventInfo.normalizeHandler(listenerDO); + } + + // If the listener is an object with a 'handleEvent' method, use that. + if (listenerDO.class === "Object" || listenerDO.class === "XULElement") { + let desc; + + while (!desc && listenerDO) { + desc = listenerDO.getOwnPropertyDescriptor("handleEvent"); + listenerDO = listenerDO.proto; + } + + if (desc && desc.value) { + listenerDO = desc.value; + } + } + + if (listenerDO.isBoundFunction) { + listenerDO = listenerDO.boundTargetFunction; + } + + let script = listenerDO.script; + let scriptSource = script.source.text; + let functionSource = + scriptSource.substr(script.sourceStart, script.sourceLength); + + /* + The script returned is the whole script and + scriptSource.substr(script.sourceStart, script.sourceLength) returns + something like this: + () { doSomething(); } + + So we need to use some regex magic to get the appropriate function info + e.g.: + () => { ... } + function doit() { ... } + doit: function() { ... } + es6func() { ... } + var|let|const foo = function () { ... } + function generator*() { ... } + */ + let scriptBeforeFunc = scriptSource.substr(0, script.sourceStart); + let matches = scriptBeforeFunc.match(RX_FUNC_NAME); + if (matches && matches.length > 0) { + functionSource = matches[0].trim() + functionSource; + } + + let dom0 = false; + + if (typeof node.hasAttribute !== "undefined") { + dom0 = !!node.hasAttribute("on" + type); + } else { + dom0 = !!node["on" + type]; + } + + let line = script.startLine; + let url = script.url; + let origin = url + (dom0 ? "" : ":" + line); + let searchString; + + if (dom0) { + searchString = "on" + type + "=\"" + script.source.text + "\""; + } else { + scriptSource = " " + scriptSource; + } + + let eventObj = { + type: typeof override.type !== "undefined" ? override.type : type, + handler: functionSource.trim(), + origin: typeof override.origin !== "undefined" ? + override.origin : origin, + searchString: typeof override.searchString !== "undefined" ? + override.searchString : searchString, + tags: tags, + DOM0: typeof override.dom0 !== "undefined" ? override.dom0 : dom0, + capturing: typeof override.capturing !== "undefined" ? + override.capturing : eventInfo.capturing, + hide: hide + }; + + listeners.push(eventObj); + + dbg.removeDebuggee(globalDO); + }, + + /** + * Returns a LongStringActor with the node's value. + */ + getNodeValue: function () { + return new LongStringActor(this.conn, this.rawNode.nodeValue || ""); + }, + + /** + * Set the node's value to a given string. + */ + setNodeValue: function (value) { + this.rawNode.nodeValue = value; + }, + + /** + * Get a unique selector string for this node. + */ + getUniqueSelector: function () { + if (Cu.isDeadWrapper(this.rawNode)) { + return ""; + } + return CssLogic.findCssSelector(this.rawNode); + }, + + /** + * Scroll the selected node into view. + */ + scrollIntoView: function () { + this.rawNode.scrollIntoView(true); + }, + + /** + * Get the node's image data if any (for canvas and img nodes). + * Returns an imageData object with the actual data being a LongStringActor + * and a size json object. + * The image data is transmitted as a base64 encoded png data-uri. + * The method rejects if the node isn't an image or if the image is missing + * + * Accepts a maxDim request parameter to resize images that are larger. This + * is important as the resizing occurs server-side so that image-data being + * transfered in the longstring back to the client will be that much smaller + */ + getImageData: function (maxDim) { + return imageToImageData(this.rawNode, maxDim).then(imageData => { + return { + data: LongStringActor(this.conn, imageData.data), + size: imageData.size + }; + }); + }, + + /** + * Get all event listeners that are listening on this node. + */ + getEventListenerInfo: function () { + if (this.rawNode.nodeName.toLowerCase() === "html") { + return this.getEventListeners(this.rawNode.ownerGlobal); + } + return this.getEventListeners(this.rawNode); + }, + + /** + * Modify a node's attributes. Passed an array of modifications + * similar in format to "attributes" mutations. + * { + * attributeName: <string> + * attributeNamespace: <optional string> + * newValue: <optional string> - If null or undefined, the attribute + * will be removed. + * } + * + * Returns when the modifications have been made. Mutations will + * be queued for any changes made. + */ + modifyAttributes: function (modifications) { + let rawNode = this.rawNode; + for (let change of modifications) { + if (change.newValue == null) { + if (change.attributeNamespace) { + rawNode.removeAttributeNS(change.attributeNamespace, + change.attributeName); + } else { + rawNode.removeAttribute(change.attributeName); + } + } else if (change.attributeNamespace) { + rawNode.setAttributeNS(change.attributeNamespace, change.attributeName, + change.newValue); + } else { + rawNode.setAttribute(change.attributeName, change.newValue); + } + } + }, + + /** + * Given the font and fill style, get the image data of a canvas with the + * preview text and font. + * Returns an imageData object with the actual data being a LongStringActor + * and the width of the text as a string. + * The image data is transmitted as a base64 encoded png data-uri. + */ + getFontFamilyDataURL: function (font, fillStyle = "black") { + let doc = this.rawNode.ownerDocument; + let options = { + previewText: FONT_FAMILY_PREVIEW_TEXT, + previewFontSize: FONT_FAMILY_PREVIEW_TEXT_SIZE, + fillStyle: fillStyle + }; + let { dataURL, size } = getFontPreviewData(font, doc, options); + + return { data: LongStringActor(this.conn, dataURL), size: size }; + } +}); + +/** + * Server side of a node list as returned by querySelectorAll() + */ +var NodeListActor = exports.NodeListActor = protocol.ActorClassWithSpec(nodeListSpec, { + typeName: "domnodelist", + + initialize: function (walker, nodeList) { + protocol.Actor.prototype.initialize.call(this); + this.walker = walker; + this.nodeList = nodeList || []; + }, + + destroy: function () { + protocol.Actor.prototype.destroy.call(this); + }, + + /** + * Instead of storing a connection object, the NodeActor gets its connection + * from its associated walker. + */ + get conn() { + return this.walker.conn; + }, + + /** + * Items returned by this actor should belong to the parent walker. + */ + marshallPool: function () { + return this.walker; + }, + + // Returns the JSON representation of this object over the wire. + form: function () { + return { + actor: this.actorID, + length: this.nodeList ? this.nodeList.length : 0 + }; + }, + + /** + * Get a single node from the node list. + */ + item: function (index) { + return this.walker.attachElement(this.nodeList[index]); + }, + + /** + * Get a range of the items from the node list. + */ + items: function (start = 0, end = this.nodeList.length) { + let items = Array.prototype.slice.call(this.nodeList, start, end) + .map(item => this.walker._ref(item)); + return this.walker.attachElements(items); + }, + + release: function () {} +}); + +/** + * Server side of the DOM walker. + */ +var WalkerActor = protocol.ActorClassWithSpec(walkerSpec, { + /** + * Create the WalkerActor + * @param DebuggerServerConnection conn + * The server connection. + */ + initialize: function (conn, tabActor, options) { + protocol.Actor.prototype.initialize.call(this, conn); + this.tabActor = tabActor; + this.rootWin = tabActor.window; + this.rootDoc = this.rootWin.document; + this._refMap = new Map(); + this._pendingMutations = []; + this._activePseudoClassLocks = new Set(); + this.showAllAnonymousContent = options.showAllAnonymousContent; + + this.walkerSearch = new WalkerSearch(this); + + // Nodes which have been removed from the client's known + // ownership tree are considered "orphaned", and stored in + // this set. + this._orphaned = new Set(); + + // The client can tell the walker that it is interested in a node + // even when it is orphaned with the `retainNode` method. This + // list contains orphaned nodes that were so retained. + this._retainedOrphans = new Set(); + + this.onMutations = this.onMutations.bind(this); + this.onFrameLoad = this.onFrameLoad.bind(this); + this.onFrameUnload = this.onFrameUnload.bind(this); + + events.on(tabActor, "will-navigate", this.onFrameUnload); + events.on(tabActor, "navigate", this.onFrameLoad); + + // Ensure that the root document node actor is ready and + // managed. + this.rootNode = this.document(); + + this.layoutChangeObserver = getLayoutChangesObserver(this.tabActor); + this._onReflows = this._onReflows.bind(this); + this.layoutChangeObserver.on("reflows", this._onReflows); + this._onResize = this._onResize.bind(this); + this.layoutChangeObserver.on("resize", this._onResize); + + this._onEventListenerChange = this._onEventListenerChange.bind(this); + eventListenerService.addListenerChangeListener(this._onEventListenerChange); + }, + + /** + * Callback for eventListenerService.addListenerChangeListener + * @param nsISimpleEnumerator changesEnum + * enumerator of nsIEventListenerChange + */ + _onEventListenerChange: function (changesEnum) { + let changes = changesEnum.enumerate(); + while (changes.hasMoreElements()) { + let current = changes.getNext().QueryInterface(Ci.nsIEventListenerChange); + let target = current.target; + + if (this._refMap.has(target)) { + let actor = this.getNode(target); + let mutation = { + type: "events", + target: actor.actorID, + hasEventListeners: actor._hasEventListeners + }; + this.queueMutation(mutation); + } + } + }, + + // Returns the JSON representation of this object over the wire. + form: function () { + return { + actor: this.actorID, + root: this.rootNode.form(), + traits: { + // FF42+ Inspector starts managing the Walker, while the inspector also + // starts cleaning itself up automatically on client disconnection. + // So that there is no need to manually release the walker anymore. + autoReleased: true, + // XXX: It seems silly that we need to tell the front which capabilities + // its actor has in this way when the target can use actorHasMethod. If + // this was ported to the protocol (Bug 1157048) we could call that + // inside of custom front methods and not need to do traits for this. + multiFrameQuerySelectorAll: true, + textSearch: true, + } + }; + }, + + toString: function () { + return "[WalkerActor " + this.actorID + "]"; + }, + + getDocumentWalker: function (node, whatToShow) { + // Allow native anon content (like <video> controls) if preffed on + let nodeFilter = this.showAllAnonymousContent + ? allAnonymousContentTreeWalkerFilter + : standardTreeWalkerFilter; + return new DocumentWalker(node, this.rootWin, whatToShow, nodeFilter); + }, + + destroy: function () { + if (this._destroyed) { + return; + } + this._destroyed = true; + protocol.Actor.prototype.destroy.call(this); + try { + this.clearPseudoClassLocks(); + this._activePseudoClassLocks = null; + + this._hoveredNode = null; + this.rootWin = null; + this.rootDoc = null; + this.rootNode = null; + this.layoutHelpers = null; + this._orphaned = null; + this._retainedOrphans = null; + this._refMap = null; + + events.off(this.tabActor, "will-navigate", this.onFrameUnload); + events.off(this.tabActor, "navigate", this.onFrameLoad); + + this.onFrameLoad = null; + this.onFrameUnload = null; + + this.walkerSearch.destroy(); + + this.layoutChangeObserver.off("reflows", this._onReflows); + this.layoutChangeObserver.off("resize", this._onResize); + this.layoutChangeObserver = null; + releaseLayoutChangesObserver(this.tabActor); + + eventListenerService.removeListenerChangeListener( + this._onEventListenerChange); + + this.onMutations = null; + + this.layoutActor = null; + this.tabActor = null; + + events.emit(this, "destroyed"); + } catch (e) { + console.error(e); + } + }, + + release: function () {}, + + unmanage: function (actor) { + if (actor instanceof NodeActor) { + if (this._activePseudoClassLocks && + this._activePseudoClassLocks.has(actor)) { + this.clearPseudoClassLocks(actor); + } + this._refMap.delete(actor.rawNode); + } + protocol.Actor.prototype.unmanage.call(this, actor); + }, + + /** + * Determine if the walker has come across this DOM node before. + * @param {DOMNode} rawNode + * @return {Boolean} + */ + hasNode: function (rawNode) { + return this._refMap.has(rawNode); + }, + + /** + * If the walker has come across this DOM node before, then get the + * corresponding node actor. + * @param {DOMNode} rawNode + * @return {NodeActor} + */ + getNode: function (rawNode) { + return this._refMap.get(rawNode); + }, + + _ref: function (node) { + let actor = this.getNode(node); + if (actor) { + return actor; + } + + actor = new NodeActor(this, node); + + // Add the node actor as a child of this walker actor, assigning + // it an actorID. + this.manage(actor); + this._refMap.set(node, actor); + + if (node.nodeType === Ci.nsIDOMNode.DOCUMENT_NODE) { + actor.watchDocument(this.onMutations); + } + return actor; + }, + + _onReflows: function (reflows) { + // Going through the nodes the walker knows about, see which ones have + // had their display changed and send a display-change event if any + let changes = []; + for (let [node, actor] of this._refMap) { + if (Cu.isDeadWrapper(node)) { + continue; + } + + let isDisplayed = actor.isDisplayed; + if (isDisplayed !== actor.wasDisplayed) { + changes.push(actor); + // Updating the original value + actor.wasDisplayed = isDisplayed; + } + } + + if (changes.length) { + events.emit(this, "display-change", changes); + } + }, + + /** + * When the browser window gets resized, relay the event to the front. + */ + _onResize: function () { + events.emit(this, "resize"); + }, + + /** + * This is kept for backward-compatibility reasons with older remote targets. + * Targets prior to bug 916443. + * + * pick/cancelPick are used to pick a node on click on the content + * document. But in their implementation prior to bug 916443, they don't allow + * highlighting on hover. + * The client-side now uses the highlighter actor's pick and cancelPick + * methods instead. The client-side uses the the highlightable trait found in + * the root actor to determine which version of pick to use. + * + * As for highlight, the new highlighter actor is used instead of the walker's + * highlight method. Same here though, the client-side uses the highlightable + * trait to dertermine which to use. + * + * Keeping these actor methods for now allows newer client-side debuggers to + * inspect fxos 1.2 remote targets or older firefox desktop remote targets. + */ + pick: function () {}, + cancelPick: function () {}, + highlight: function (node) {}, + + /** + * Ensures that the node is attached and it can be accessed from the root. + * + * @param {(Node|NodeActor)} nodes The nodes + * @return {Object} An object compatible with the disconnectedNode type. + */ + attachElement: function (node) { + let { nodes, newParents } = this.attachElements([node]); + return { + node: nodes[0], + newParents: newParents + }; + }, + + /** + * Ensures that the nodes are attached and they can be accessed from the root. + * + * @param {(Node[]|NodeActor[])} nodes The nodes + * @return {Object} An object compatible with the disconnectedNodeArray type. + */ + attachElements: function (nodes) { + let nodeActors = []; + let newParents = new Set(); + for (let node of nodes) { + if (!(node instanceof NodeActor)) { + // If an anonymous node was passed in and we aren't supposed to know + // about it, then consult with the document walker as the source of + // truth about which elements exist. + if (!this.showAllAnonymousContent && isAnonymous(node)) { + node = this.getDocumentWalker(node).currentNode; + } + + node = this._ref(node); + } + + this.ensurePathToRoot(node, newParents); + // If nodes may be an array of raw nodes, we're sure to only have + // NodeActors with the following array. + nodeActors.push(node); + } + + return { + nodes: nodeActors, + newParents: [...newParents] + }; + }, + + /** + * Return the document node that contains the given node, + * or the root node if no node is specified. + * @param NodeActor node + * The node whose document is needed, or null to + * return the root. + */ + document: function (node) { + let doc = isNodeDead(node) ? this.rootDoc : nodeDocument(node.rawNode); + return this._ref(doc); + }, + + /** + * Return the documentElement for the document containing the + * given node. + * @param NodeActor node + * The node whose documentElement is requested, or null + * to use the root document. + */ + documentElement: function (node) { + let elt = isNodeDead(node) + ? this.rootDoc.documentElement + : nodeDocument(node.rawNode).documentElement; + return this._ref(elt); + }, + + /** + * Return all parents of the given node, ordered from immediate parent + * to root. + * @param NodeActor node + * The node whose parents are requested. + * @param object options + * Named options, including: + * `sameDocument`: If true, parents will be restricted to the same + * document as the node. + * `sameTypeRootTreeItem`: If true, this will not traverse across + * different types of docshells. + */ + parents: function (node, options = {}) { + if (isNodeDead(node)) { + return []; + } + + let walker = this.getDocumentWalker(node.rawNode); + let parents = []; + let cur; + while ((cur = walker.parentNode())) { + if (options.sameDocument && + nodeDocument(cur) != nodeDocument(node.rawNode)) { + break; + } + + if (options.sameTypeRootTreeItem && + nodeDocshell(cur).sameTypeRootTreeItem != + nodeDocshell(node.rawNode).sameTypeRootTreeItem) { + break; + } + + parents.push(this._ref(cur)); + } + return parents; + }, + + parentNode: function (node) { + let walker = this.getDocumentWalker(node.rawNode); + let parent = walker.parentNode(); + if (parent) { + return this._ref(parent); + } + return null; + }, + + /** + * If the given NodeActor only has a single text node as a child with a text + * content small enough to be inlined, return that child's NodeActor. + * + * @param NodeActor node + */ + inlineTextChild: function (node) { + // Quick checks to prevent creating a new walker if possible. + if (node.isBeforePseudoElement || + node.isAfterPseudoElement || + node.rawNode.nodeType != Ci.nsIDOMNode.ELEMENT_NODE || + node.rawNode.children.length > 0) { + return undefined; + } + + let docWalker = this.getDocumentWalker(node.rawNode); + let firstChild = docWalker.firstChild(); + + // Bail out if: + // - more than one child + // - unique child is not a text node + // - unique child is a text node, but is too long to be inlined + if (!firstChild || + docWalker.nextSibling() || + firstChild.nodeType !== Ci.nsIDOMNode.TEXT_NODE || + firstChild.nodeValue.length > gValueSummaryLength + ) { + return undefined; + } + + return this._ref(firstChild); + }, + + /** + * Mark a node as 'retained'. + * + * A retained node is not released when `releaseNode` is called on its + * parent, or when a parent is released with the `cleanup` option to + * `getMutations`. + * + * When a retained node's parent is released, a retained mode is added to + * the walker's "retained orphans" list. + * + * Retained nodes can be deleted by providing the `force` option to + * `releaseNode`. They will also be released when their document + * has been destroyed. + * + * Retaining a node makes no promise about its children; They can + * still be removed by normal means. + */ + retainNode: function (node) { + node.retained = true; + }, + + /** + * Remove the 'retained' mark from a node. If the node was a + * retained orphan, release it. + */ + unretainNode: function (node) { + node.retained = false; + if (this._retainedOrphans.has(node)) { + this._retainedOrphans.delete(node); + this.releaseNode(node); + } + }, + + /** + * Release actors for a node and all child nodes. + */ + releaseNode: function (node, options = {}) { + if (isNodeDead(node)) { + return; + } + + if (node.retained && !options.force) { + this._retainedOrphans.add(node); + return; + } + + if (node.retained) { + // Forcing a retained node to go away. + this._retainedOrphans.delete(node); + } + + let walker = this.getDocumentWalker(node.rawNode); + + let child = walker.firstChild(); + while (child) { + let childActor = this.getNode(child); + if (childActor) { + this.releaseNode(childActor, options); + } + child = walker.nextSibling(); + } + + node.destroy(); + }, + + /** + * Add any nodes between `node` and the walker's root node that have not + * yet been seen by the client. + */ + ensurePathToRoot: function (node, newParents = new Set()) { + if (!node) { + return newParents; + } + let walker = this.getDocumentWalker(node.rawNode); + let cur; + while ((cur = walker.parentNode())) { + let parent = this.getNode(cur); + if (!parent) { + // This parent didn't exist, so hasn't been seen by the client yet. + newParents.add(this._ref(cur)); + } else { + // This parent did exist, so the client knows about it. + return newParents; + } + } + return newParents; + }, + + /** + * Return children of the given node. By default this method will return + * all children of the node, but there are options that can restrict this + * to a more manageable subset. + * + * @param NodeActor node + * The node whose children you're curious about. + * @param object options + * Named options: + * `maxNodes`: The set of nodes returned by the method will be no longer + * than maxNodes. + * `start`: If a node is specified, the list of nodes will start + * with the given child. Mutally exclusive with `center`. + * `center`: If a node is specified, the given node will be as centered + * as possible in the list, given how close to the ends of the child + * list it is. Mutually exclusive with `start`. + * `whatToShow`: A bitmask of node types that should be included. See + * https://developer.mozilla.org/en-US/docs/Web/API/NodeFilter. + * + * @returns an object with three items: + * hasFirst: true if the first child of the node is included in the list. + * hasLast: true if the last child of the node is included in the list. + * nodes: Child nodes returned by the request. + */ + children: function (node, options = {}) { + if (isNodeDead(node)) { + return { hasFirst: true, hasLast: true, nodes: [] }; + } + + if (options.center && options.start) { + throw Error("Can't specify both 'center' and 'start' options."); + } + let maxNodes = options.maxNodes || -1; + if (maxNodes == -1) { + maxNodes = Number.MAX_VALUE; + } + + // We're going to create a few document walkers with the same filter, + // make it easier. + let getFilteredWalker = documentWalkerNode => { + return this.getDocumentWalker(documentWalkerNode, options.whatToShow); + }; + + // Need to know the first and last child. + let rawNode = node.rawNode; + let firstChild = getFilteredWalker(rawNode).firstChild(); + let lastChild = getFilteredWalker(rawNode).lastChild(); + + if (!firstChild) { + // No children, we're done. + return { hasFirst: true, hasLast: true, nodes: [] }; + } + + let start; + if (options.center) { + start = options.center.rawNode; + } else if (options.start) { + start = options.start.rawNode; + } else { + start = firstChild; + } + + let nodes = []; + + // Start by reading backward from the starting point if we're centering... + let backwardWalker = getFilteredWalker(start); + if (start != firstChild && options.center) { + backwardWalker.previousSibling(); + let backwardCount = Math.floor(maxNodes / 2); + let backwardNodes = this._readBackward(backwardWalker, backwardCount); + nodes = backwardNodes; + } + + // Then read forward by any slack left in the max children... + let forwardWalker = getFilteredWalker(start); + let forwardCount = maxNodes - nodes.length; + nodes = nodes.concat(this._readForward(forwardWalker, forwardCount)); + + // If there's any room left, it means we've run all the way to the end. + // If we're centering, check if there are more items to read at the front. + let remaining = maxNodes - nodes.length; + if (options.center && remaining > 0 && nodes[0].rawNode != firstChild) { + let firstNodes = this._readBackward(backwardWalker, remaining); + + // Then put it all back together. + nodes = firstNodes.concat(nodes); + } + + return { + hasFirst: nodes[0].rawNode == firstChild, + hasLast: nodes[nodes.length - 1].rawNode == lastChild, + nodes: nodes + }; + }, + + /** + * Return siblings of the given node. By default this method will return + * all siblings of the node, but there are options that can restrict this + * to a more manageable subset. + * + * If `start` or `center` are not specified, this method will center on the + * node whose siblings are requested. + * + * @param NodeActor node + * The node whose children you're curious about. + * @param object options + * Named options: + * `maxNodes`: The set of nodes returned by the method will be no longer + * than maxNodes. + * `start`: If a node is specified, the list of nodes will start + * with the given child. Mutally exclusive with `center`. + * `center`: If a node is specified, the given node will be as centered + * as possible in the list, given how close to the ends of the child + * list it is. Mutually exclusive with `start`. + * `whatToShow`: A bitmask of node types that should be included. See + * https://developer.mozilla.org/en-US/docs/Web/API/NodeFilter. + * + * @returns an object with three items: + * hasFirst: true if the first child of the node is included in the list. + * hasLast: true if the last child of the node is included in the list. + * nodes: Child nodes returned by the request. + */ + siblings: function (node, options = {}) { + if (isNodeDead(node)) { + return { hasFirst: true, hasLast: true, nodes: [] }; + } + + let parentNode = this.getDocumentWalker(node.rawNode, options.whatToShow) + .parentNode(); + if (!parentNode) { + return { + hasFirst: true, + hasLast: true, + nodes: [node] + }; + } + + if (!(options.start || options.center)) { + options.center = node; + } + + return this.children(this._ref(parentNode), options); + }, + + /** + * Get the next sibling of a given node. Getting nodes one at a time + * might be inefficient, be careful. + * + * @param object options + * Named options: + * `whatToShow`: A bitmask of node types that should be included. See + * https://developer.mozilla.org/en-US/docs/Web/API/NodeFilter. + */ + nextSibling: function (node, options = {}) { + if (isNodeDead(node)) { + return null; + } + + let walker = this.getDocumentWalker(node.rawNode, options.whatToShow); + let sibling = walker.nextSibling(); + return sibling ? this._ref(sibling) : null; + }, + + /** + * Get the previous sibling of a given node. Getting nodes one at a time + * might be inefficient, be careful. + * + * @param object options + * Named options: + * `whatToShow`: A bitmask of node types that should be included. See + * https://developer.mozilla.org/en-US/docs/Web/API/NodeFilter. + */ + previousSibling: function (node, options = {}) { + if (isNodeDead(node)) { + return null; + } + + let walker = this.getDocumentWalker(node.rawNode, options.whatToShow); + let sibling = walker.previousSibling(); + return sibling ? this._ref(sibling) : null; + }, + + /** + * Helper function for the `children` method: Read forward in the sibling + * list into an array with `count` items, including the current node. + */ + _readForward: function (walker, count) { + let ret = []; + let node = walker.currentNode; + do { + ret.push(this._ref(node)); + node = walker.nextSibling(); + } while (node && --count); + return ret; + }, + + /** + * Helper function for the `children` method: Read backward in the sibling + * list into an array with `count` items, including the current node. + */ + _readBackward: function (walker, count) { + let ret = []; + let node = walker.currentNode; + do { + ret.push(this._ref(node)); + node = walker.previousSibling(); + } while (node && --count); + ret.reverse(); + return ret; + }, + + /** + * Return the node that the parent process has asked to + * inspect. This node is expected to be stored in gInspectingNode + * (which is set by a message manager message to the child.js frame + * script). The node is returned over the remote debugging protocol + * as a NodeFront. + */ + findInspectingNode: function () { + let node = gInspectingNode; + if (!node) { + return {}; + } + + return this.attachElement(node); + }, + + /** + * Return the first node in the document that matches the given selector. + * See https://developer.mozilla.org/en-US/docs/Web/API/Element.querySelector + * + * @param NodeActor baseNode + * @param string selector + */ + querySelector: function (baseNode, selector) { + if (isNodeDead(baseNode)) { + return {}; + } + + let node = baseNode.rawNode.querySelector(selector); + if (!node) { + return {}; + } + + return this.attachElement(node); + }, + + /** + * Return a NodeListActor with all nodes that match the given selector. + * See https://developer.mozilla.org/en-US/docs/Web/API/Element.querySelectorAll + * + * @param NodeActor baseNode + * @param string selector + */ + querySelectorAll: function (baseNode, selector) { + let nodeList = null; + + try { + nodeList = baseNode.rawNode.querySelectorAll(selector); + } catch (e) { + // Bad selector. Do nothing as the selector can come from a searchbox. + } + + return new NodeListActor(this, nodeList); + }, + + /** + * Get a list of nodes that match the given selector in all known frames of + * the current content page. + * @param {String} selector. + * @return {Array} + */ + _multiFrameQuerySelectorAll: function (selector) { + let nodes = []; + + for (let {document} of this.tabActor.windows) { + try { + nodes = [...nodes, ...document.querySelectorAll(selector)]; + } catch (e) { + // Bad selector. Do nothing as the selector can come from a searchbox. + } + } + + return nodes; + }, + + /** + * Return a NodeListActor with all nodes that match the given selector in all + * frames of the current content page. + * @param {String} selector + */ + multiFrameQuerySelectorAll: function (selector) { + return new NodeListActor(this, this._multiFrameQuerySelectorAll(selector)); + }, + + /** + * Search the document for a given string. + * Results will be searched with the walker-search module (searches through + * tag names, attribute names and values, and text contents). + * + * @returns {searchresult} + * - {NodeList} list + * - {Array<Object>} metadata. Extra information with indices that + * match up with node list. + */ + search: function (query) { + let results = this.walkerSearch.search(query); + let nodeList = new NodeListActor(this, results.map(r => r.node)); + + return { + list: nodeList, + metadata: [] + }; + }, + + /** + * Returns a list of matching results for CSS selector autocompletion. + * + * @param string query + * The selector query being completed + * @param string completing + * The exact token being completed out of the query + * @param string selectorState + * One of "pseudo", "id", "tag", "class", "null" + */ + getSuggestionsForQuery: function (query, completing, selectorState) { + let sugs = { + classes: new Map(), + tags: new Map(), + ids: new Map() + }; + let result = []; + let nodes = null; + // Filtering and sorting the results so that protocol transfer is miminal. + switch (selectorState) { + case "pseudo": + result = PSEUDO_SELECTORS.filter(item => { + return item[0].startsWith(":" + completing); + }); + break; + + case "class": + if (!query) { + nodes = this._multiFrameQuerySelectorAll("[class]"); + } else { + nodes = this._multiFrameQuerySelectorAll(query); + } + for (let node of nodes) { + for (let className of node.classList) { + sugs.classes.set(className, (sugs.classes.get(className)|0) + 1); + } + } + sugs.classes.delete(""); + sugs.classes.delete(HIDDEN_CLASS); + for (let [className, count] of sugs.classes) { + if (className.startsWith(completing)) { + result.push(["." + CSS.escape(className), count, selectorState]); + } + } + break; + + case "id": + if (!query) { + nodes = this._multiFrameQuerySelectorAll("[id]"); + } else { + nodes = this._multiFrameQuerySelectorAll(query); + } + for (let node of nodes) { + sugs.ids.set(node.id, (sugs.ids.get(node.id)|0) + 1); + } + for (let [id, count] of sugs.ids) { + if (id.startsWith(completing) && id !== "") { + result.push(["#" + CSS.escape(id), count, selectorState]); + } + } + break; + + case "tag": + if (!query) { + nodes = this._multiFrameQuerySelectorAll("*"); + } else { + nodes = this._multiFrameQuerySelectorAll(query); + } + for (let node of nodes) { + let tag = node.localName; + sugs.tags.set(tag, (sugs.tags.get(tag)|0) + 1); + } + for (let [tag, count] of sugs.tags) { + if ((new RegExp("^" + completing + ".*", "i")).test(tag)) { + result.push([tag, count, selectorState]); + } + } + + // For state 'tag' (no preceding # or .) and when there's no query (i.e. + // only one word) then search for the matching classes and ids + if (!query) { + result = [ + ...result, + ...this.getSuggestionsForQuery(null, completing, "class") + .suggestions, + ...this.getSuggestionsForQuery(null, completing, "id") + .suggestions + ]; + } + + break; + + case "null": + nodes = this._multiFrameQuerySelectorAll(query); + for (let node of nodes) { + sugs.ids.set(node.id, (sugs.ids.get(node.id)|0) + 1); + let tag = node.localName; + sugs.tags.set(tag, (sugs.tags.get(tag)|0) + 1); + for (let className of node.classList) { + sugs.classes.set(className, (sugs.classes.get(className)|0) + 1); + } + } + for (let [tag, count] of sugs.tags) { + tag && result.push([tag, count]); + } + for (let [id, count] of sugs.ids) { + id && result.push(["#" + id, count]); + } + sugs.classes.delete(""); + sugs.classes.delete(HIDDEN_CLASS); + for (let [className, count] of sugs.classes) { + className && result.push(["." + className, count]); + } + } + + // Sort by count (desc) and name (asc) + result = result.sort((a, b) => { + // Computed a sortable string with first the inverted count, then the name + let sortA = (10000 - a[1]) + a[0]; + let sortB = (10000 - b[1]) + b[0]; + + // Prefixing ids, classes and tags, to group results + let firstA = a[0].substring(0, 1); + let firstB = b[0].substring(0, 1); + + if (firstA === "#") { + sortA = "2" + sortA; + } else if (firstA === ".") { + sortA = "1" + sortA; + } else { + sortA = "0" + sortA; + } + + if (firstB === "#") { + sortB = "2" + sortB; + } else if (firstB === ".") { + sortB = "1" + sortB; + } else { + sortB = "0" + sortB; + } + + // String compare + return sortA.localeCompare(sortB); + }); + + result.slice(0, 25); + + return { + query: query, + suggestions: result + }; + }, + + /** + * Add a pseudo-class lock to a node. + * + * @param NodeActor node + * @param string pseudo + * A pseudoclass: ':hover', ':active', ':focus' + * @param options + * Options object: + * `parents`: True if the pseudo-class should be added + * to parent nodes. + * + * @returns An empty packet. A "pseudoClassLock" mutation will + * be queued for any changed nodes. + */ + addPseudoClassLock: function (node, pseudo, options = {}) { + if (isNodeDead(node)) { + return; + } + + // There can be only one node locked per pseudo, so dismiss all existing + // ones + for (let locked of this._activePseudoClassLocks) { + if (DOMUtils.hasPseudoClassLock(locked.rawNode, pseudo)) { + this._removePseudoClassLock(locked, pseudo); + } + } + + this._addPseudoClassLock(node, pseudo); + + if (!options.parents) { + return; + } + + let walker = this.getDocumentWalker(node.rawNode); + let cur; + while ((cur = walker.parentNode())) { + let curNode = this._ref(cur); + this._addPseudoClassLock(curNode, pseudo); + } + }, + + _queuePseudoClassMutation: function (node) { + this.queueMutation({ + target: node.actorID, + type: "pseudoClassLock", + pseudoClassLocks: node.writePseudoClassLocks() + }); + }, + + _addPseudoClassLock: function (node, pseudo) { + if (node.rawNode.nodeType !== Ci.nsIDOMNode.ELEMENT_NODE) { + return false; + } + DOMUtils.addPseudoClassLock(node.rawNode, pseudo); + this._activePseudoClassLocks.add(node); + this._queuePseudoClassMutation(node); + return true; + }, + + _installHelperSheet: function (node) { + if (!this.installedHelpers) { + this.installedHelpers = new WeakMap(); + } + let win = node.rawNode.ownerDocument.defaultView; + if (!this.installedHelpers.has(win)) { + let { Style } = require("sdk/stylesheet/style"); + let { attach } = require("sdk/content/mod"); + let style = Style({source: HELPER_SHEET, type: "agent" }); + attach(style, win); + this.installedHelpers.set(win, style); + } + }, + + hideNode: function (node) { + if (isNodeDead(node)) { + return; + } + + this._installHelperSheet(node); + node.rawNode.classList.add(HIDDEN_CLASS); + }, + + unhideNode: function (node) { + if (isNodeDead(node)) { + return; + } + + node.rawNode.classList.remove(HIDDEN_CLASS); + }, + + /** + * Remove a pseudo-class lock from a node. + * + * @param NodeActor node + * @param string pseudo + * A pseudoclass: ':hover', ':active', ':focus' + * @param options + * Options object: + * `parents`: True if the pseudo-class should be removed + * from parent nodes. + * + * @returns An empty response. "pseudoClassLock" mutations + * will be emitted for any changed nodes. + */ + removePseudoClassLock: function (node, pseudo, options = {}) { + if (isNodeDead(node)) { + return; + } + + this._removePseudoClassLock(node, pseudo); + + // Remove pseudo class for children as we don't want to allow + // turning it on for some childs without setting it on some parents + for (let locked of this._activePseudoClassLocks) { + if (node.rawNode.contains(locked.rawNode) && + DOMUtils.hasPseudoClassLock(locked.rawNode, pseudo)) { + this._removePseudoClassLock(locked, pseudo); + } + } + + if (!options.parents) { + return; + } + + let walker = this.getDocumentWalker(node.rawNode); + let cur; + while ((cur = walker.parentNode())) { + let curNode = this._ref(cur); + this._removePseudoClassLock(curNode, pseudo); + } + }, + + _removePseudoClassLock: function (node, pseudo) { + if (node.rawNode.nodeType != Ci.nsIDOMNode.ELEMENT_NODE) { + return false; + } + DOMUtils.removePseudoClassLock(node.rawNode, pseudo); + if (!node.writePseudoClassLocks()) { + this._activePseudoClassLocks.delete(node); + } + + this._queuePseudoClassMutation(node); + return true; + }, + + /** + * Clear all the pseudo-classes on a given node or all nodes. + * @param {NodeActor} node Optional node to clear pseudo-classes on + */ + clearPseudoClassLocks: function (node) { + if (node && isNodeDead(node)) { + return; + } + + if (node) { + DOMUtils.clearPseudoClassLocks(node.rawNode); + this._activePseudoClassLocks.delete(node); + this._queuePseudoClassMutation(node); + } else { + for (let locked of this._activePseudoClassLocks) { + DOMUtils.clearPseudoClassLocks(locked.rawNode); + this._activePseudoClassLocks.delete(locked); + this._queuePseudoClassMutation(locked); + } + } + }, + + /** + * Get a node's innerHTML property. + */ + innerHTML: function (node) { + let html = ""; + if (!isNodeDead(node)) { + html = node.rawNode.innerHTML; + } + return LongStringActor(this.conn, html); + }, + + /** + * Set a node's innerHTML property. + * + * @param {NodeActor} node The node. + * @param {string} value The piece of HTML content. + */ + setInnerHTML: function (node, value) { + if (isNodeDead(node)) { + return; + } + + let rawNode = node.rawNode; + if (rawNode.nodeType !== rawNode.ownerDocument.ELEMENT_NODE) { + throw new Error("Can only change innerHTML to element nodes"); + } + rawNode.innerHTML = value; + }, + + /** + * Get a node's outerHTML property. + * + * @param {NodeActor} node The node. + */ + outerHTML: function (node) { + let outerHTML = ""; + if (!isNodeDead(node)) { + outerHTML = node.rawNode.outerHTML; + } + return LongStringActor(this.conn, outerHTML); + }, + + /** + * Set a node's outerHTML property. + * + * @param {NodeActor} node The node. + * @param {string} value The piece of HTML content. + */ + setOuterHTML: function (node, value) { + if (isNodeDead(node)) { + return; + } + + let parsedDOM = DOMParser.parseFromString(value, "text/html"); + let rawNode = node.rawNode; + let parentNode = rawNode.parentNode; + + // Special case for head and body. Setting document.body.outerHTML + // creates an extra <head> tag, and document.head.outerHTML creates + // an extra <body>. So instead we will call replaceChild with the + // parsed DOM, assuming that they aren't trying to set both tags at once. + if (rawNode.tagName === "BODY") { + if (parsedDOM.head.innerHTML === "") { + parentNode.replaceChild(parsedDOM.body, rawNode); + } else { + rawNode.outerHTML = value; + } + } else if (rawNode.tagName === "HEAD") { + if (parsedDOM.body.innerHTML === "") { + parentNode.replaceChild(parsedDOM.head, rawNode); + } else { + rawNode.outerHTML = value; + } + } else if (node.isDocumentElement()) { + // Unable to set outerHTML on the document element. Fall back by + // setting attributes manually, then replace the body and head elements. + let finalAttributeModifications = []; + let attributeModifications = {}; + for (let attribute of rawNode.attributes) { + attributeModifications[attribute.name] = null; + } + for (let attribute of parsedDOM.documentElement.attributes) { + attributeModifications[attribute.name] = attribute.value; + } + for (let key in attributeModifications) { + finalAttributeModifications.push({ + attributeName: key, + newValue: attributeModifications[key] + }); + } + node.modifyAttributes(finalAttributeModifications); + rawNode.replaceChild(parsedDOM.head, rawNode.querySelector("head")); + rawNode.replaceChild(parsedDOM.body, rawNode.querySelector("body")); + } else { + rawNode.outerHTML = value; + } + }, + + /** + * Insert adjacent HTML to a node. + * + * @param {Node} node + * @param {string} position One of "beforeBegin", "afterBegin", "beforeEnd", + * "afterEnd" (see Element.insertAdjacentHTML). + * @param {string} value The HTML content. + */ + insertAdjacentHTML: function (node, position, value) { + if (isNodeDead(node)) { + return {node: [], newParents: []}; + } + + let rawNode = node.rawNode; + let isInsertAsSibling = position === "beforeBegin" || + position === "afterEnd"; + + // Don't insert anything adjacent to the document element. + if (isInsertAsSibling && node.isDocumentElement()) { + throw new Error("Can't insert adjacent element to the root."); + } + + let rawParentNode = rawNode.parentNode; + if (!rawParentNode && isInsertAsSibling) { + throw new Error("Can't insert as sibling without parent node."); + } + + // We can't use insertAdjacentHTML, because we want to return the nodes + // being created (so the front can remove them if the user undoes + // the change). So instead, use Range.createContextualFragment(). + let range = rawNode.ownerDocument.createRange(); + if (position === "beforeBegin" || position === "afterEnd") { + range.selectNode(rawNode); + } else { + range.selectNodeContents(rawNode); + } + let docFrag = range.createContextualFragment(value); + let newRawNodes = Array.from(docFrag.childNodes); + switch (position) { + case "beforeBegin": + rawParentNode.insertBefore(docFrag, rawNode); + break; + case "afterEnd": + // Note: if the second argument is null, rawParentNode.insertBefore + // behaves like rawParentNode.appendChild. + rawParentNode.insertBefore(docFrag, rawNode.nextSibling); + break; + case "afterBegin": + rawNode.insertBefore(docFrag, rawNode.firstChild); + break; + case "beforeEnd": + rawNode.appendChild(docFrag); + break; + default: + throw new Error("Invalid position value. Must be either " + + "'beforeBegin', 'beforeEnd', 'afterBegin' or 'afterEnd'."); + } + + return this.attachElements(newRawNodes); + }, + + /** + * Duplicate a specified node + * + * @param {NodeActor} node The node to duplicate. + */ + duplicateNode: function ({rawNode}) { + let clonedNode = rawNode.cloneNode(true); + rawNode.parentNode.insertBefore(clonedNode, rawNode.nextSibling); + }, + + /** + * Test whether a node is a document or a document element. + * + * @param {NodeActor} node The node to remove. + * @return {boolean} True if the node is a document or a document element. + */ + isDocumentOrDocumentElementNode: function (node) { + return ((node.rawNode.ownerDocument && + node.rawNode.ownerDocument.documentElement === this.rawNode) || + node.rawNode.nodeType === Ci.nsIDOMNode.DOCUMENT_NODE); + }, + + /** + * Removes a node from its parent node. + * + * @param {NodeActor} node The node to remove. + * @returns The node's nextSibling before it was removed. + */ + removeNode: function (node) { + if (isNodeDead(node) || this.isDocumentOrDocumentElementNode(node)) { + throw Error("Cannot remove document, document elements or dead nodes."); + } + + let nextSibling = this.nextSibling(node); + node.rawNode.remove(); + // Mutation events will take care of the rest. + return nextSibling; + }, + + /** + * Removes an array of nodes from their parent node. + * + * @param {NodeActor[]} nodes The nodes to remove. + */ + removeNodes: function (nodes) { + // Check that all nodes are valid before processing the removals. + for (let node of nodes) { + if (isNodeDead(node) || this.isDocumentOrDocumentElementNode(node)) { + throw Error("Cannot remove document, document elements or dead nodes"); + } + } + + for (let node of nodes) { + node.rawNode.remove(); + // Mutation events will take care of the rest. + } + }, + + /** + * Insert a node into the DOM. + */ + insertBefore: function (node, parent, sibling) { + if (isNodeDead(node) || + isNodeDead(parent) || + (sibling && isNodeDead(sibling))) { + return; + } + + let rawNode = node.rawNode; + let rawParent = parent.rawNode; + let rawSibling = sibling ? sibling.rawNode : null; + + // Don't bother inserting a node if the document position isn't going + // to change. This prevents needless iframes reloading and mutations. + if (rawNode.parentNode === rawParent) { + let currentNextSibling = this.nextSibling(node); + currentNextSibling = currentNextSibling ? currentNextSibling.rawNode : + null; + + if (rawNode === rawSibling || currentNextSibling === rawSibling) { + return; + } + } + + rawParent.insertBefore(rawNode, rawSibling); + }, + + /** + * Editing a node's tagname actually means creating a new node with the same + * attributes, removing the node and inserting the new one instead. + * This method does not return anything as mutation events are taking care of + * informing the consumers about changes. + */ + editTagName: function (node, tagName) { + if (isNodeDead(node)) { + return null; + } + + let oldNode = node.rawNode; + + // Create a new element with the same attributes as the current element and + // prepare to replace the current node with it. + let newNode; + try { + newNode = nodeDocument(oldNode).createElement(tagName); + } catch (x) { + // Failed to create a new element with that tag name, ignore the change, + // and signal the error to the front. + return Promise.reject(new Error("Could not change node's tagName to " + tagName)); + } + + let attrs = oldNode.attributes; + for (let i = 0; i < attrs.length; i++) { + newNode.setAttribute(attrs[i].name, attrs[i].value); + } + + // Insert the new node, and transfer the old node's children. + oldNode.parentNode.insertBefore(newNode, oldNode); + while (oldNode.firstChild) { + newNode.appendChild(oldNode.firstChild); + } + + oldNode.remove(); + return null; + }, + + /** + * Get any pending mutation records. Must be called by the client after + * the `new-mutations` notification is received. Returns an array of + * mutation records. + * + * Mutation records have a basic structure: + * + * { + * type: attributes|characterData|childList, + * target: <domnode actor ID>, + * } + * + * And additional attributes based on the mutation type: + * + * `attributes` type: + * attributeName: <string> - the attribute that changed + * attributeNamespace: <string> - the attribute's namespace URI, if any. + * newValue: <string> - The new value of the attribute, if any. + * + * `characterData` type: + * newValue: <string> - the new nodeValue for the node + * + * `childList` type is returned when the set of children for a node + * has changed. Includes extra data, which can be used by the client to + * maintain its ownership subtree. + * + * added: array of <domnode actor ID> - The list of actors *previously + * seen by the client* that were added to the target node. + * removed: array of <domnode actor ID> The list of actors *previously + * seen by the client* that were removed from the target node. + * inlineTextChild: If the node now has a single text child, it will + * be sent here. + * + * Actors that are included in a MutationRecord's `removed` but + * not in an `added` have been removed from the client's ownership + * tree (either by being moved under a node the client has seen yet + * or by being removed from the tree entirely), and is considered + * 'orphaned'. + * + * Keep in mind that if a node that the client hasn't seen is moved + * into or out of the target node, it will not be included in the + * removedNodes and addedNodes list, so if the client is interested + * in the new set of children it needs to issue a `children` request. + */ + getMutations: function (options = {}) { + let pending = this._pendingMutations || []; + this._pendingMutations = []; + + if (options.cleanup) { + for (let node of this._orphaned) { + // Release the orphaned node. Nodes or children that have been + // retained will be moved to this._retainedOrphans. + this.releaseNode(node); + } + this._orphaned = new Set(); + } + + return pending; + }, + + queueMutation: function (mutation) { + if (!this.actorID || this._destroyed) { + // We've been destroyed, don't bother queueing this mutation. + return; + } + // We only send the `new-mutations` notification once, until the client + // fetches mutations with the `getMutations` packet. + let needEvent = this._pendingMutations.length === 0; + + this._pendingMutations.push(mutation); + + if (needEvent) { + events.emit(this, "new-mutations"); + } + }, + + /** + * Handles mutations from the DOM mutation observer API. + * + * @param array[MutationRecord] mutations + * See https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver#MutationRecord + */ + onMutations: function (mutations) { + // Notify any observers that want *all* mutations (even on nodes that aren't + // referenced). This is not sent over the protocol so can only be used by + // scripts running in the server process. + events.emit(this, "any-mutation"); + + for (let change of mutations) { + let targetActor = this.getNode(change.target); + if (!targetActor) { + continue; + } + let targetNode = change.target; + let type = change.type; + let mutation = { + type: type, + target: targetActor.actorID, + }; + + if (type === "attributes") { + mutation.attributeName = change.attributeName; + mutation.attributeNamespace = change.attributeNamespace || undefined; + mutation.newValue = targetNode.hasAttribute(mutation.attributeName) ? + targetNode.getAttribute(mutation.attributeName) + : null; + } else if (type === "characterData") { + mutation.newValue = targetNode.nodeValue; + this._maybeQueueInlineTextChildMutation(change, targetNode); + } else if (type === "childList" || type === "nativeAnonymousChildList") { + // Get the list of removed and added actors that the client has seen + // so that it can keep its ownership tree up to date. + let removedActors = []; + let addedActors = []; + for (let removed of change.removedNodes) { + let removedActor = this.getNode(removed); + if (!removedActor) { + // If the client never encountered this actor we don't need to + // mention that it was removed. + continue; + } + // While removed from the tree, nodes are saved as orphaned. + this._orphaned.add(removedActor); + removedActors.push(removedActor.actorID); + } + for (let added of change.addedNodes) { + let addedActor = this.getNode(added); + if (!addedActor) { + // If the client never encounted this actor we don't need to tell + // it about its addition for ownership tree purposes - if the + // client wants to see the new nodes it can ask for children. + continue; + } + // The actor is reconnected to the ownership tree, unorphan + // it and let the client know so that its ownership tree is up + // to date. + this._orphaned.delete(addedActor); + addedActors.push(addedActor.actorID); + } + + mutation.numChildren = targetActor.numChildren; + mutation.removed = removedActors; + mutation.added = addedActors; + + let inlineTextChild = this.inlineTextChild(targetActor); + if (inlineTextChild) { + mutation.inlineTextChild = inlineTextChild.form(); + } + } + this.queueMutation(mutation); + } + }, + + /** + * Check if the provided mutation could change the way the target element is + * inlined with its parent node. If it might, a custom mutation of type + * "inlineTextChild" will be queued. + * + * @param {MutationRecord} mutation + * A characterData type mutation + */ + _maybeQueueInlineTextChildMutation: function (mutation) { + let {oldValue, target} = mutation; + let newValue = target.nodeValue; + let limit = gValueSummaryLength; + + if ((oldValue.length <= limit && newValue.length <= limit) || + (oldValue.length > limit && newValue.length > limit)) { + // Bail out if the new & old values are both below/above the size limit. + return; + } + + let parentActor = this.getNode(target.parentNode); + if (!parentActor || parentActor.rawNode.children.length > 0) { + // If the parent node has other children, a character data mutation will + // not change anything regarding inlining text nodes. + return; + } + + let inlineTextChild = this.inlineTextChild(parentActor); + this.queueMutation({ + type: "inlineTextChild", + target: parentActor.actorID, + inlineTextChild: + inlineTextChild ? inlineTextChild.form() : undefined + }); + }, + + onFrameLoad: function ({ window, isTopLevel }) { + if (isTopLevel) { + // If we initialize the inspector while the document is loading, + // we may already have a root document set in the constructor. + if (this.rootDoc && !Cu.isDeadWrapper(this.rootDoc) && + this.rootDoc.defaultView) { + this.onFrameUnload({ window: this.rootDoc.defaultView }); + } + this.rootDoc = window.document; + this.rootNode = this.document(); + this.queueMutation({ + type: "newRoot", + target: this.rootNode.form() + }); + return; + } + let frame = getFrameElement(window); + let frameActor = this.getNode(frame); + if (!frameActor) { + return; + } + + this.queueMutation({ + type: "frameLoad", + target: frameActor.actorID, + }); + + // Send a childList mutation on the frame. + this.queueMutation({ + type: "childList", + target: frameActor.actorID, + added: [], + removed: [] + }); + }, + + // Returns true if domNode is in window or a subframe. + _childOfWindow: function (window, domNode) { + let win = nodeDocument(domNode).defaultView; + while (win) { + if (win === window) { + return true; + } + win = getFrameElement(win); + } + return false; + }, + + onFrameUnload: function ({ window }) { + // Any retained orphans that belong to this document + // or its children need to be released, and a mutation sent + // to notify of that. + let releasedOrphans = []; + + for (let retained of this._retainedOrphans) { + if (Cu.isDeadWrapper(retained.rawNode) || + this._childOfWindow(window, retained.rawNode)) { + this._retainedOrphans.delete(retained); + releasedOrphans.push(retained.actorID); + this.releaseNode(retained, { force: true }); + } + } + + if (releasedOrphans.length > 0) { + this.queueMutation({ + target: this.rootNode.actorID, + type: "unretained", + nodes: releasedOrphans + }); + } + + let doc = window.document; + let documentActor = this.getNode(doc); + if (!documentActor) { + return; + } + + if (this.rootDoc === doc) { + this.rootDoc = null; + this.rootNode = null; + } + + this.queueMutation({ + type: "documentUnload", + target: documentActor.actorID + }); + + let walker = this.getDocumentWalker(doc); + let parentNode = walker.parentNode(); + if (parentNode) { + // Send a childList mutation on the frame so that clients know + // they should reread the children list. + this.queueMutation({ + type: "childList", + target: this.getNode(parentNode).actorID, + added: [], + removed: [] + }); + } + + // Need to force a release of this node, because those nodes can't + // be accessed anymore. + this.releaseNode(documentActor, { force: true }); + }, + + /** + * Check if a node is attached to the DOM tree of the current page. + * @param {nsIDomNode} rawNode + * @return {Boolean} false if the node is removed from the tree or within a + * document fragment + */ + _isInDOMTree: function (rawNode) { + let walker = this.getDocumentWalker(rawNode); + let current = walker.currentNode; + + // Reaching the top of tree + while (walker.parentNode()) { + current = walker.currentNode; + } + + // The top of the tree is a fragment or is not rootDoc, hence rawNode isn't + // attached + if (current.nodeType === Ci.nsIDOMNode.DOCUMENT_FRAGMENT_NODE || + current !== this.rootDoc) { + return false; + } + + // Otherwise the top of the tree is rootDoc, hence rawNode is in rootDoc + return true; + }, + + /** + * @see _isInDomTree + */ + isInDOMTree: function (node) { + if (isNodeDead(node)) { + return false; + } + return this._isInDOMTree(node.rawNode); + }, + + /** + * Given an ObjectActor (identified by its ID), commonly used in the debugger, + * webconsole and variablesView, return the corresponding inspector's + * NodeActor + */ + getNodeActorFromObjectActor: function (objectActorID) { + let actor = this.conn.getActor(objectActorID); + if (!actor) { + return null; + } + + let debuggerObject = this.conn.getActor(objectActorID).obj; + let rawNode = debuggerObject.unsafeDereference(); + + if (!this._isInDOMTree(rawNode)) { + return null; + } + + // This is a special case for the document object whereby it is considered + // as document.documentElement (the <html> node) + if (rawNode.defaultView && rawNode === rawNode.defaultView.document) { + rawNode = rawNode.documentElement; + } + + return this.attachElement(rawNode); + }, + + /** + * Given a StyleSheetActor (identified by its ID), commonly used in the + * style-editor, get its ownerNode and return the corresponding walker's + * NodeActor. + * Note that getNodeFromActor was added later and can now be used instead. + */ + getStyleSheetOwnerNode: function (styleSheetActorID) { + return this.getNodeFromActor(styleSheetActorID, ["ownerNode"]); + }, + + /** + * This method can be used to retrieve NodeActor for DOM nodes from other + * actors in a way that they can later be highlighted in the page, or + * selected in the inspector. + * If an actor has a reference to a DOM node, and the UI needs to know about + * this DOM node (and possibly select it in the inspector), the UI should + * first retrieve a reference to the walkerFront: + * + * // Make sure the inspector/walker have been initialized first. + * toolbox.initInspector().then(() => { + * // Retrieve the walker. + * let walker = toolbox.walker; + * }); + * + * And then call this method: + * + * // Get the nodeFront from my actor, passing the ID and properties path. + * walker.getNodeFromActor(myActorID, ["element"]).then(nodeFront => { + * // Use the nodeFront, e.g. select the node in the inspector. + * toolbox.getPanel("inspector").selection.setNodeFront(nodeFront); + * }); + * + * @param {String} actorID The ID for the actor that has a reference to the + * DOM node. + * @param {Array} path Where, on the actor, is the DOM node stored. If in the + * scope of the actor, the node is available as `this.data.node`, then this + * should be ["data", "node"]. + * @return {NodeActor} The attached NodeActor, or null if it couldn't be + * found. + */ + getNodeFromActor: function (actorID, path) { + let actor = this.conn.getActor(actorID); + if (!actor) { + return null; + } + + let obj = actor; + for (let name of path) { + if (!(name in obj)) { + return null; + } + obj = obj[name]; + } + + return this.attachElement(obj); + }, + + /** + * Returns an instance of the LayoutActor that is used to retrieve CSS layout-related + * information. + * + * @return {LayoutActor} + */ + getLayoutInspector: function () { + if (!this.layoutActor) { + this.layoutActor = new LayoutActor(this.conn, this.tabActor, this); + } + + return this.layoutActor; + }, +}); + +/** + * Server side of the inspector actor, which is used to create + * inspector-related actors, including the walker. + */ +exports.InspectorActor = protocol.ActorClassWithSpec(inspectorSpec, { + initialize: function (conn, tabActor) { + protocol.Actor.prototype.initialize.call(this, conn); + this.tabActor = tabActor; + + this._onColorPicked = this._onColorPicked.bind(this); + this._onColorPickCanceled = this._onColorPickCanceled.bind(this); + this.destroyEyeDropper = this.destroyEyeDropper.bind(this); + }, + + destroy: function () { + protocol.Actor.prototype.destroy.call(this); + + this.destroyEyeDropper(); + + this._highlighterPromise = null; + this._pageStylePromise = null; + this._walkerPromise = null; + this.walker = null; + this.tabActor = null; + }, + + // Forces destruction of the actor and all its children + // like highlighter, walker and style actors. + disconnect: function () { + this.destroy(); + }, + + get window() { + return this.tabActor.window; + }, + + getWalker: function (options = {}) { + if (this._walkerPromise) { + return this._walkerPromise; + } + + let deferred = promise.defer(); + this._walkerPromise = deferred.promise; + + let window = this.window; + let domReady = () => { + let tabActor = this.tabActor; + window.removeEventListener("DOMContentLoaded", domReady, true); + this.walker = WalkerActor(this.conn, tabActor, options); + this.manage(this.walker); + events.once(this.walker, "destroyed", () => { + this._walkerPromise = null; + this._pageStylePromise = null; + }); + deferred.resolve(this.walker); + }; + + if (window.document.readyState === "loading") { + window.addEventListener("DOMContentLoaded", domReady, true); + } else { + domReady(); + } + + return this._walkerPromise; + }, + + getPageStyle: function () { + if (this._pageStylePromise) { + return this._pageStylePromise; + } + + this._pageStylePromise = this.getWalker().then(walker => { + let pageStyle = PageStyleActor(this); + this.manage(pageStyle); + return pageStyle; + }); + return this._pageStylePromise; + }, + + /** + * The most used highlighter actor is the HighlighterActor which can be + * conveniently retrieved by this method. + * The same instance will always be returned by this method when called + * several times. + * The highlighter actor returned here is used to highlighter elements's + * box-models from the markup-view, box model, console, debugger, ... as + * well as select elements with the pointer (pick). + * + * @param {Boolean} autohide Optionally autohide the highlighter after an + * element has been picked + * @return {HighlighterActor} + */ + getHighlighter: function (autohide) { + if (this._highlighterPromise) { + return this._highlighterPromise; + } + + this._highlighterPromise = this.getWalker().then(walker => { + let highlighter = HighlighterActor(this, autohide); + this.manage(highlighter); + return highlighter; + }); + return this._highlighterPromise; + }, + + /** + * If consumers need to display several highlighters at the same time or + * different types of highlighters, then this method should be used, passing + * the type name of the highlighter needed as argument. + * A new instance will be created everytime the method is called, so it's up + * to the consumer to release it when it is not needed anymore + * + * @param {String} type The type of highlighter to create + * @return {Highlighter} The highlighter actor instance or null if the + * typeName passed doesn't match any available highlighter + */ + getHighlighterByType: function (typeName) { + if (isTypeRegistered(typeName)) { + return CustomHighlighterActor(this, typeName); + } + return null; + }, + + /** + * Get the node's image data if any (for canvas and img nodes). + * Returns an imageData object with the actual data being a LongStringActor + * and a size json object. + * The image data is transmitted as a base64 encoded png data-uri. + * The method rejects if the node isn't an image or if the image is missing + * + * Accepts a maxDim request parameter to resize images that are larger. This + * is important as the resizing occurs server-side so that image-data being + * transfered in the longstring back to the client will be that much smaller + */ + getImageDataFromURL: function (url, maxDim) { + let img = new this.window.Image(); + img.src = url; + + // imageToImageData waits for the image to load. + return imageToImageData(img, maxDim).then(imageData => { + return { + data: LongStringActor(this.conn, imageData.data), + size: imageData.size + }; + }); + }, + + /** + * Resolve a URL to its absolute form, in the scope of a given content window. + * @param {String} url. + * @param {NodeActor} node If provided, the owner window of this node will be + * used to resolve the URL. Otherwise, the top-level content window will be + * used instead. + * @return {String} url. + */ + resolveRelativeURL: function (url, node) { + let document = isNodeDead(node) + ? this.window.document + : nodeDocument(node.rawNode); + + if (!document) { + return url; + } + + let baseURI = Services.io.newURI(document.location.href, null, null); + return Services.io.newURI(url, null, baseURI).spec; + }, + + /** + * Create an instance of the eye-dropper highlighter and store it on this._eyeDropper. + * Note that for now, a new instance is created every time to deal with page navigation. + */ + createEyeDropper: function () { + this.destroyEyeDropper(); + this._highlighterEnv = new HighlighterEnvironment(); + this._highlighterEnv.initFromTabActor(this.tabActor); + this._eyeDropper = new EyeDropper(this._highlighterEnv); + }, + + /** + * Destroy the current eye-dropper highlighter instance. + */ + destroyEyeDropper: function () { + if (this._eyeDropper) { + this.cancelPickColorFromPage(); + this._eyeDropper.destroy(); + this._eyeDropper = null; + this._highlighterEnv.destroy(); + this._highlighterEnv = null; + } + }, + + /** + * Pick a color from the page using the eye-dropper. This method doesn't return anything + * but will cause events to be sent to the front when a color is picked or when the user + * cancels the picker. + * @param {Object} options + */ + pickColorFromPage: function (options) { + this.createEyeDropper(); + this._eyeDropper.show(this.window.document.documentElement, options); + this._eyeDropper.once("selected", this._onColorPicked); + this._eyeDropper.once("canceled", this._onColorPickCanceled); + events.once(this.tabActor, "will-navigate", this.destroyEyeDropper); + }, + + /** + * After the pickColorFromPage method is called, the only way to dismiss the eye-dropper + * highlighter is for the user to click in the page and select a color. If you need to + * dismiss the eye-dropper programatically instead, use this method. + */ + cancelPickColorFromPage: function () { + if (this._eyeDropper) { + this._eyeDropper.hide(); + this._eyeDropper.off("selected", this._onColorPicked); + this._eyeDropper.off("canceled", this._onColorPickCanceled); + events.off(this.tabActor, "will-navigate", this.destroyEyeDropper); + } + }, + + _onColorPicked: function (e, color) { + events.emit(this, "color-picked", color); + }, + + _onColorPickCanceled: function () { + events.emit(this, "color-pick-canceled"); + } +}); + +// Exported for test purposes. +exports._documentWalker = DocumentWalker; + +function nodeDocument(node) { + if (Cu.isDeadWrapper(node)) { + return null; + } + return node.ownerDocument || + (node.nodeType == Ci.nsIDOMNode.DOCUMENT_NODE ? node : null); +} + +function nodeDocshell(node) { + let doc = node ? nodeDocument(node) : null; + let win = doc ? doc.defaultView : null; + if (win) { + return win.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDocShell); + } + return null; +} + +function isNodeDead(node) { + return !node || !node.rawNode || Cu.isDeadWrapper(node.rawNode); +} + +/** + * Wrapper for inDeepTreeWalker. Adds filtering to the traversal methods. + * See inDeepTreeWalker for more information about the methods. + * + * @param {DOMNode} node + * @param {Window} rootWin + * @param {Int} whatToShow See nodeFilterConstants / inIDeepTreeWalker for + * options. + * @param {Function} filter A custom filter function Taking in a DOMNode + * and returning an Int. See WalkerActor.nodeFilter for an example. + */ +function DocumentWalker(node, rootWin, + whatToShow = nodeFilterConstants.SHOW_ALL, + filter = standardTreeWalkerFilter) { + if (!rootWin.location) { + throw new Error("Got an invalid root window in DocumentWalker"); + } + + this.walker = Cc["@mozilla.org/inspector/deep-tree-walker;1"] + .createInstance(Ci.inIDeepTreeWalker); + this.walker.showAnonymousContent = true; + this.walker.showSubDocuments = true; + this.walker.showDocumentsAsNodes = true; + this.walker.init(rootWin.document, whatToShow); + this.filter = filter; + + // Make sure that the walker knows about the initial node (which could + // be skipped due to a filter). Note that simply calling parentNode() + // causes currentNode to be updated. + this.walker.currentNode = node; + while (node && + this.filter(node) === nodeFilterConstants.FILTER_SKIP) { + node = this.walker.parentNode(); + } +} + +DocumentWalker.prototype = { + get node() { + return this.walker.node; + }, + get whatToShow() { + return this.walker.whatToShow; + }, + get currentNode() { + return this.walker.currentNode; + }, + set currentNode(val) { + this.walker.currentNode = val; + }, + + parentNode: function () { + return this.walker.parentNode(); + }, + + nextNode: function () { + let node = this.walker.currentNode; + if (!node) { + return null; + } + + let nextNode = this.walker.nextNode(); + while (nextNode && + this.filter(nextNode) === nodeFilterConstants.FILTER_SKIP) { + nextNode = this.walker.nextNode(); + } + + return nextNode; + }, + + firstChild: function () { + let node = this.walker.currentNode; + if (!node) { + return null; + } + + let firstChild = this.walker.firstChild(); + while (firstChild && + this.filter(firstChild) === nodeFilterConstants.FILTER_SKIP) { + firstChild = this.walker.nextSibling(); + } + + return firstChild; + }, + + lastChild: function () { + let node = this.walker.currentNode; + if (!node) { + return null; + } + + let lastChild = this.walker.lastChild(); + while (lastChild && + this.filter(lastChild) === nodeFilterConstants.FILTER_SKIP) { + lastChild = this.walker.previousSibling(); + } + + return lastChild; + }, + + previousSibling: function () { + let node = this.walker.previousSibling(); + while (node && this.filter(node) === nodeFilterConstants.FILTER_SKIP) { + node = this.walker.previousSibling(); + } + return node; + }, + + nextSibling: function () { + let node = this.walker.nextSibling(); + while (node && this.filter(node) === nodeFilterConstants.FILTER_SKIP) { + node = this.walker.nextSibling(); + } + return node; + } +}; + +function isInXULDocument(el) { + let doc = nodeDocument(el); + return doc && + doc.documentElement && + doc.documentElement.namespaceURI === XUL_NS; +} + +/** + * This DeepTreeWalker filter skips whitespace text nodes and anonymous + * content with the exception of ::before and ::after and anonymous content + * in XUL document (needed to show all elements in the browser toolbox). + */ +function standardTreeWalkerFilter(node) { + // ::before and ::after are native anonymous content, but we always + // want to show them + if (node.nodeName === "_moz_generated_content_before" || + node.nodeName === "_moz_generated_content_after") { + return nodeFilterConstants.FILTER_ACCEPT; + } + + // Ignore empty whitespace text nodes that do not impact the layout. + if (isWhitespaceTextNode(node)) { + return nodeHasSize(node) + ? nodeFilterConstants.FILTER_ACCEPT + : nodeFilterConstants.FILTER_SKIP; + } + + // Ignore all native and XBL anonymous content inside a non-XUL document + if (!isInXULDocument(node) && (isXBLAnonymous(node) || + isNativeAnonymous(node))) { + // Note: this will skip inspecting the contents of feedSubscribeLine since + // that's XUL content injected in an HTML document, but we need to because + // this also skips many other elements that need to be skipped - like form + // controls, scrollbars, video controls, etc (see bug 1187482). + return nodeFilterConstants.FILTER_SKIP; + } + + return nodeFilterConstants.FILTER_ACCEPT; +} + +/** + * This DeepTreeWalker filter is like standardTreeWalkerFilter except that + * it also includes all anonymous content (like internal form controls). + */ +function allAnonymousContentTreeWalkerFilter(node) { + // Ignore empty whitespace text nodes that do not impact the layout. + if (isWhitespaceTextNode(node)) { + return nodeHasSize(node) + ? nodeFilterConstants.FILTER_ACCEPT + : nodeFilterConstants.FILTER_SKIP; + } + return nodeFilterConstants.FILTER_ACCEPT; +} + +/** + * Is the given node a text node composed of whitespace only? + * @param {DOMNode} node + * @return {Boolean} + */ +function isWhitespaceTextNode(node) { + return node.nodeType == Ci.nsIDOMNode.TEXT_NODE && !/[^\s]/.exec(node.nodeValue); +} + +/** + * Does the given node have non-0 width and height? + * @param {DOMNode} node + * @return {Boolean} + */ +function nodeHasSize(node) { + if (!node.getBoxQuads) { + return false; + } + + let quads = node.getBoxQuads(); + return quads.length && quads.some(quad => quad.bounds.width && quad.bounds.height); +} + +/** + * Returns a promise that is settled once the given HTMLImageElement has + * finished loading. + * + * @param {HTMLImageElement} image - The image element. + * @param {Number} timeout - Maximum amount of time the image is allowed to load + * before the waiting is aborted. Ignored if flags.testing is set. + * + * @return {Promise} that is fulfilled once the image has loaded. If the image + * fails to load or the load takes too long, the promise is rejected. + */ +function ensureImageLoaded(image, timeout) { + let { HTMLImageElement } = image.ownerDocument.defaultView; + if (!(image instanceof HTMLImageElement)) { + return promise.reject("image must be an HTMLImageELement"); + } + + if (image.complete) { + // The image has already finished loading. + return promise.resolve(); + } + + // This image is still loading. + let onLoad = AsyncUtils.listenOnce(image, "load"); + + // Reject if loading fails. + let onError = AsyncUtils.listenOnce(image, "error").then(() => { + return promise.reject("Image '" + image.src + "' failed to load."); + }); + + // Don't timeout when testing. This is never settled. + let onAbort = new Promise(() => {}); + + if (!flags.testing) { + // Tests are not running. Reject the promise after given timeout. + onAbort = DevToolsUtils.waitForTime(timeout).then(() => { + return promise.reject("Image '" + image.src + "' took too long to load."); + }); + } + + // See which happens first. + return promise.race([onLoad, onError, onAbort]); +} + +/** + * Given an <img> or <canvas> element, return the image data-uri. If @param node + * is an <img> element, the method waits a while for the image to load before + * the data is generated. If the image does not finish loading in a reasonable + * time (IMAGE_FETCHING_TIMEOUT milliseconds) the process aborts. + * + * @param {HTMLImageElement|HTMLCanvasElement} node - The <img> or <canvas> + * element, or Image() object. Other types cause the method to reject. + * @param {Number} maxDim - Optionally pass a maximum size you want the longest + * side of the image to be resized to before getting the image data. + + * @return {Promise} A promise that is fulfilled with an object containing the + * data-uri and size-related information: + * { data: "...", + * size: { + * naturalWidth: 400, + * naturalHeight: 300, + * resized: true } + * }. + * + * If something goes wrong, the promise is rejected. + */ +var imageToImageData = Task.async(function* (node, maxDim) { + let { HTMLCanvasElement, HTMLImageElement } = node.ownerDocument.defaultView; + + let isImg = node instanceof HTMLImageElement; + let isCanvas = node instanceof HTMLCanvasElement; + + if (!isImg && !isCanvas) { + throw new Error("node is not a <canvas> or <img> element."); + } + + if (isImg) { + // Ensure that the image is ready. + yield ensureImageLoaded(node, IMAGE_FETCHING_TIMEOUT); + } + + // Get the image resize ratio if a maxDim was provided + let resizeRatio = 1; + let imgWidth = node.naturalWidth || node.width; + let imgHeight = node.naturalHeight || node.height; + let imgMax = Math.max(imgWidth, imgHeight); + if (maxDim && imgMax > maxDim) { + resizeRatio = maxDim / imgMax; + } + + // Extract the image data + let imageData; + // The image may already be a data-uri, in which case, save ourselves the + // trouble of converting via the canvas.drawImage.toDataURL method, but only + // if the image doesn't need resizing + if (isImg && node.src.startsWith("data:") && resizeRatio === 1) { + imageData = node.src; + } else { + // Create a canvas to copy the rawNode into and get the imageData from + let canvas = node.ownerDocument.createElementNS(XHTML_NS, "canvas"); + canvas.width = imgWidth * resizeRatio; + canvas.height = imgHeight * resizeRatio; + let ctx = canvas.getContext("2d"); + + // Copy the rawNode image or canvas in the new canvas and extract data + ctx.drawImage(node, 0, 0, canvas.width, canvas.height); + imageData = canvas.toDataURL("image/png"); + } + + return { + data: imageData, + size: { + naturalWidth: imgWidth, + naturalHeight: imgHeight, + resized: resizeRatio !== 1 + } + }; +}); + +loader.lazyGetter(this, "DOMUtils", function () { + return Cc["@mozilla.org/inspector/dom-utils;1"].getService(Ci.inIDOMUtils); +}); diff --git a/devtools/server/actors/layout.js b/devtools/server/actors/layout.js new file mode 100644 index 0000000000..0b9242b5fd --- /dev/null +++ b/devtools/server/actors/layout.js @@ -0,0 +1,131 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { Actor, ActorClassWithSpec } = require("devtools/shared/protocol"); +const { gridSpec, layoutSpec } = require("devtools/shared/specs/layout"); +const { getStringifiableFragments } = require("devtools/server/actors/utils/css-grid-utils"); + +/** + * Set of actors the expose the CSS layout information to the devtools protocol clients. + * + * The |Layout| actor is the main entry point. It is used to get various CSS + * layout-related information from the document. + * + * The |Grid| actor provides the grid fragment information to inspect the grid container. + */ + +/** + * The GridActor provides information about a given grid's fragment data. + */ +var GridActor = ActorClassWithSpec(gridSpec, { + /** + * @param {LayoutActor} layoutActor + * The LayoutActor instance. + * @param {DOMNode} containerEl + * The grid container element. + */ + initialize: function (layoutActor, containerEl) { + Actor.prototype.initialize.call(this, layoutActor.conn); + + this.containerEl = containerEl; + this.walker = layoutActor.walker; + }, + + destroy: function () { + Actor.prototype.destroy.call(this); + + this.containerEl = null; + this.gridFragments = null; + this.walker = null; + }, + + form: function (detail) { + if (detail === "actorid") { + return this.actorID; + } + + // Seralize the grid fragment data into JSON so protocol.js knows how to write + // and read the data. + let gridFragments = this.containerEl.getGridFragments(); + this.gridFragments = getStringifiableFragments(gridFragments); + + let form = { + actor: this.actorID, + gridFragments: this.gridFragments + }; + + return form; + }, +}); + +/** + * The CSS layout actor provides layout information for the given document. + */ +var LayoutActor = ActorClassWithSpec(layoutSpec, { + initialize: function (conn, tabActor, walker) { + Actor.prototype.initialize.call(this, conn); + + this.tabActor = tabActor; + this.walker = walker; + }, + + destroy: function () { + Actor.prototype.destroy.call(this); + + this.tabActor = null; + this.walker = null; + }, + + /** + * Returns an array of GridActor objects for all the grid containers found by iterating + * below the given rootNode. + * + * @param {Node|NodeActor} rootNode + * The root node to start iterating at. + * @return {Array} An array of GridActor objects. + */ + getGrids: function (rootNode) { + let grids = []; + + let treeWalker = this.walker.getDocumentWalker(rootNode); + while (treeWalker.nextNode()) { + let currentNode = treeWalker.currentNode; + + if (currentNode.getGridFragments && currentNode.getGridFragments().length > 0) { + let gridActor = new GridActor(this, currentNode); + grids.push(gridActor); + } + } + + return grids; + }, + + /** + * Returns an array of GridActor objects for all existing grid containers found by + * iterating below the given rootNode and optionally including nested frames. + * + * @param {NodeActor} rootNode + * @param {Boolean} traverseFrames + * Whether or not we should iterate through nested frames. + * @return {Array} An array of GridActor objects. + */ + getAllGrids: function (rootNode, traverseFrames) { + if (!traverseFrames) { + return this.getGridActors(rootNode); + } + + let grids = []; + for (let {document} of this.tabActor.windows) { + grids = [...grids, ...this.getGrids(document.documentElement)]; + } + + return grids; + }, + +}); + +exports.GridActor = GridActor; +exports.LayoutActor = LayoutActor; diff --git a/devtools/server/actors/memory.js b/devtools/server/actors/memory.js new file mode 100644 index 0000000000..5c41a7dc19 --- /dev/null +++ b/devtools/server/actors/memory.js @@ -0,0 +1,83 @@ +/* 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 protocol = require("devtools/shared/protocol"); +const { Memory } = require("devtools/server/performance/memory"); +const { actorBridgeWithSpec } = require("devtools/server/actors/common"); +const { memorySpec } = require("devtools/shared/specs/memory"); +loader.lazyRequireGetter(this, "events", "sdk/event/core"); +loader.lazyRequireGetter(this, "StackFrameCache", + "devtools/server/actors/utils/stack", true); + +/** + * An actor that returns memory usage data for its parent actor's window. + * A tab-scoped instance of this actor will measure the memory footprint of its + * parent tab. A global-scoped instance however, will measure the memory + * footprint of the chrome window referenced by the root actor. + * + * This actor wraps the Memory module at devtools/server/performance/memory.js + * and provides RDP definitions. + * + * @see devtools/server/performance/memory.js for documentation. + */ +exports.MemoryActor = protocol.ActorClassWithSpec(memorySpec, { + initialize: function (conn, parent, frameCache = new StackFrameCache()) { + protocol.Actor.prototype.initialize.call(this, conn); + + this._onGarbageCollection = this._onGarbageCollection.bind(this); + this._onAllocations = this._onAllocations.bind(this); + this.bridge = new Memory(parent, frameCache); + this.bridge.on("garbage-collection", this._onGarbageCollection); + this.bridge.on("allocations", this._onAllocations); + }, + + destroy: function () { + this.bridge.off("garbage-collection", this._onGarbageCollection); + this.bridge.off("allocations", this._onAllocations); + this.bridge.destroy(); + protocol.Actor.prototype.destroy.call(this); + }, + + attach: actorBridgeWithSpec("attach"), + + detach: actorBridgeWithSpec("detach"), + + getState: actorBridgeWithSpec("getState"), + + saveHeapSnapshot: function (boundaries) { + return this.bridge.saveHeapSnapshot(boundaries); + }, + + takeCensus: actorBridgeWithSpec("takeCensus"), + + startRecordingAllocations: actorBridgeWithSpec("startRecordingAllocations"), + + stopRecordingAllocations: actorBridgeWithSpec("stopRecordingAllocations"), + + getAllocationsSettings: actorBridgeWithSpec("getAllocationsSettings"), + + getAllocations: actorBridgeWithSpec("getAllocations"), + + forceGarbageCollection: actorBridgeWithSpec("forceGarbageCollection"), + + forceCycleCollection: actorBridgeWithSpec("forceCycleCollection"), + + measure: actorBridgeWithSpec("measure"), + + residentUnique: actorBridgeWithSpec("residentUnique"), + + _onGarbageCollection: function (data) { + if (this.conn.transport) { + events.emit(this, "garbage-collection", data); + } + }, + + _onAllocations: function (data) { + if (this.conn.transport) { + events.emit(this, "allocations", data); + } + }, +}); diff --git a/devtools/server/actors/monitor.js b/devtools/server/actors/monitor.js new file mode 100644 index 0000000000..17b076a9e1 --- /dev/null +++ b/devtools/server/actors/monitor.js @@ -0,0 +1,145 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const {Ci, Cc} = require("chrome"); +const {XPCOMUtils} = require("resource://gre/modules/XPCOMUtils.jsm"); +const Services = require("Services"); + +function MonitorActor(aConnection) { + this.conn = aConnection; + this._updates = []; + this._started = false; +} + +MonitorActor.prototype = { + actorPrefix: "monitor", + + // Updates. + + _sendUpdate: function () { + if (this._started) { + this.conn.sendActorEvent(this.actorID, "update", { data: this._updates }); + this._updates = []; + } + }, + + // Methods available from the front. + + start: function () { + if (!this._started) { + this._started = true; + Services.obs.addObserver(this, "devtools-monitor-update", false); + Services.obs.notifyObservers(null, "devtools-monitor-start", ""); + this._agents.forEach(agent => this._startAgent(agent)); + } + return {}; + }, + + stop: function () { + if (this._started) { + this._agents.forEach(agent => agent.stop()); + Services.obs.notifyObservers(null, "devtools-monitor-stop", ""); + Services.obs.removeObserver(this, "devtools-monitor-update"); + this._started = false; + } + return {}; + }, + + disconnect: function () { + this.stop(); + }, + + // nsIObserver. + + observe: function (subject, topic, data) { + if (topic == "devtools-monitor-update") { + try { + data = JSON.parse(data); + } catch (e) { + console.error("Observer notification data is not a valid JSON-string:", + data, e.message); + return; + } + if (!Array.isArray(data)) { + this._updates.push(data); + } else { + this._updates = this._updates.concat(data); + } + this._sendUpdate(); + } + }, + + QueryInterface: XPCOMUtils.generateQI([Ci.nsIObserver]), + + // Update agents (see USSAgent for an example). + + _agents: [], + + _startAgent: function (agent) { + try { + agent.start(); + } catch (e) { + this._removeAgent(agent); + } + }, + + _addAgent: function (agent) { + this._agents.push(agent); + if (this._started) { + this._startAgent(agent); + } + }, + + _removeAgent: function (agent) { + let index = this._agents.indexOf(agent); + if (index > -1) { + this._agents.splice(index, 1); + } + }, +}; + +MonitorActor.prototype.requestTypes = { + "start": MonitorActor.prototype.start, + "stop": MonitorActor.prototype.stop, +}; + +exports.MonitorActor = MonitorActor; + +var USSAgent = { + _mgr: null, + _timeout: null, + _packet: { + graph: "USS", + time: null, + value: null + }, + + start: function () { + USSAgent._mgr = Cc["@mozilla.org/memory-reporter-manager;1"].getService(Ci.nsIMemoryReporterManager); + if (!USSAgent._mgr.residentUnique) { + throw "Couldn't get USS."; + } + USSAgent.update(); + }, + + update: function () { + if (!USSAgent._mgr) { + USSAgent.stop(); + return; + } + USSAgent._packet.time = Date.now(); + USSAgent._packet.value = USSAgent._mgr.residentUnique; + Services.obs.notifyObservers(null, "devtools-monitor-update", JSON.stringify(USSAgent._packet)); + USSAgent._timeout = setTimeout(USSAgent.update, 300); + }, + + stop: function () { + clearTimeout(USSAgent._timeout); + USSAgent._mgr = null; + } +}; + +MonitorActor.prototype._addAgent(USSAgent); diff --git a/devtools/server/actors/moz.build b/devtools/server/actors/moz.build new file mode 100644 index 0000000000..5980876e21 --- /dev/null +++ b/devtools/server/actors/moz.build @@ -0,0 +1,69 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +DIRS += [ + 'highlighters', + 'utils', +] + +DevToolsModules( + 'actor-registry.js', + 'addon.js', + 'addons.js', + 'animation.js', + 'breakpoint.js', + 'call-watcher.js', + 'canvas.js', + 'child-process.js', + 'childtab.js', + 'chrome.js', + 'common.js', + 'css-properties.js', + 'csscoverage.js', + 'device.js', + 'director-manager.js', + 'director-registry.js', + 'emulation.js', + 'environment.js', + 'errordocs.js', + 'eventlooplag.js', + 'frame.js', + 'framerate.js', + 'gcli.js', + 'heap-snapshot-file.js', + 'highlighters.css', + 'highlighters.js', + 'inspector.js', + 'layout.js', + 'memory.js', + 'monitor.js', + 'object.js', + 'performance-entries.js', + 'performance-recording.js', + 'performance.js', + 'preference.js', + 'pretty-print-worker.js', + 'process.js', + 'profiler.js', + 'promises.js', + 'reflow.js', + 'root.js', + 'script.js', + 'settings.js', + 'source.js', + 'storage.js', + 'string.js', + 'styleeditor.js', + 'styles.js', + 'stylesheets.js', + 'timeline.js', + 'webaudio.js', + 'webbrowser.js', + 'webconsole.js', + 'webextension.js', + 'webgl.js', + 'worker.js', +) diff --git a/devtools/server/actors/object.js b/devtools/server/actors/object.js new file mode 100644 index 0000000000..1f417b9515 --- /dev/null +++ b/devtools/server/actors/object.js @@ -0,0 +1,2251 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2; js-indent-level: 2 -*- */ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* 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 { Cu, Ci } = require("chrome"); +const { GeneratedLocation } = require("devtools/server/actors/common"); +const { DebuggerServer } = require("devtools/server/main"); +const DevToolsUtils = require("devtools/shared/DevToolsUtils"); +const { assert, dumpn } = DevToolsUtils; + +loader.lazyRequireGetter(this, "ThreadSafeChromeUtils"); + +const TYPED_ARRAY_CLASSES = ["Uint8Array", "Uint8ClampedArray", "Uint16Array", + "Uint32Array", "Int8Array", "Int16Array", "Int32Array", "Float32Array", + "Float64Array"]; + +// Number of items to preview in objects, arrays, maps, sets, lists, +// collections, etc. +const OBJECT_PREVIEW_MAX_ITEMS = 10; + +/** + * Creates an actor for the specified object. + * + * @param obj Debugger.Object + * The debuggee object. + * @param hooks Object + * A collection of abstract methods that are implemented by the caller. + * ObjectActor requires the following functions to be implemented by + * the caller: + * - createValueGrip + * Creates a value grip for the given object + * - sources + * TabSources getter that manages the sources of a thread + * - createEnvironmentActor + * Creates and return an environment actor + * - getGripDepth + * An actor's grip depth getter + * - incrementGripDepth + * Increment the actor's grip depth + * - decrementGripDepth + * Decrement the actor's grip depth + * - globalDebugObject + * The Debuggee Global Object as given by the ThreadActor + */ +function ObjectActor(obj, { + createValueGrip, + sources, + createEnvironmentActor, + getGripDepth, + incrementGripDepth, + decrementGripDepth, + getGlobalDebugObject +}) { + assert(!obj.optimizedOut, + "Should not create object actors for optimized out values!"); + this.obj = obj; + this.hooks = { + createValueGrip, + sources, + createEnvironmentActor, + getGripDepth, + incrementGripDepth, + decrementGripDepth, + getGlobalDebugObject + }; + this.iterators = new Set(); +} + +ObjectActor.prototype = { + actorPrefix: "obj", + + /** + * Returns a grip for this actor for returning in a protocol message. + */ + grip: function () { + this.hooks.incrementGripDepth(); + + let g = { + "type": "object", + "actor": this.actorID + }; + + // If it's a proxy, lie and tell that it belongs to an invented + // "Proxy" class, and avoid calling the [[IsExtensible]] trap + if(this.obj.isProxy) { + g.class = "Proxy"; + g.proxyTarget = this.hooks.createValueGrip(this.obj.proxyTarget); + g.proxyHandler = this.hooks.createValueGrip(this.obj.proxyHandler); + } else { + g.class = this.obj.class; + g.extensible = this.obj.isExtensible(); + g.frozen = this.obj.isFrozen(); + g.sealed = this.obj.isSealed(); + } + + if (g.class != "DeadObject") { + if (g.class == "Promise") { + g.promiseState = this._createPromiseState(); + } + + // FF40+: Allow to know how many properties an object has + // to lazily display them when there is a bunch. + // Throws on some MouseEvent object in tests. + try { + // Bug 1163520: Assert on internal functions + if (!["Function", "Proxy"].includes(g.class)) { + g.ownPropertyLength = this.obj.getOwnPropertyNames().length; + } + } catch (e) {} + + let raw = this.obj.unsafeDereference(); + + // If Cu is not defined, we are running on a worker thread, where xrays + // don't exist. + if (Cu) { + raw = Cu.unwaiveXrays(raw); + } + + if (!DevToolsUtils.isSafeJSObject(raw)) { + raw = null; + } + + let previewers = DebuggerServer.ObjectActorPreviewers[g.class] || + DebuggerServer.ObjectActorPreviewers.Object; + for (let fn of previewers) { + try { + if (fn(this, g, raw)) { + break; + } + } catch (e) { + let msg = "ObjectActor.prototype.grip previewer function"; + DevToolsUtils.reportException(msg, e); + } + } + } + + this.hooks.decrementGripDepth(); + return g; + }, + + /** + * Returns an object exposing the internal Promise state. + */ + _createPromiseState: function () { + const { state, value, reason } = getPromiseState(this.obj); + let promiseState = { state }; + + if (state == "fulfilled") { + promiseState.value = this.hooks.createValueGrip(value); + } else if (state == "rejected") { + promiseState.reason = this.hooks.createValueGrip(reason); + } + + promiseState.creationTimestamp = Date.now() - this.obj.promiseLifetime; + + // Only add the timeToSettle property if the Promise isn't pending. + if (state !== "pending") { + promiseState.timeToSettle = this.obj.promiseTimeToResolution; + } + + return promiseState; + }, + + /** + * Releases this actor from the pool. + */ + release: function () { + if (this.registeredPool.objectActors) { + this.registeredPool.objectActors.delete(this.obj); + } + this.iterators.forEach(actor => this.registeredPool.removeActor(actor)); + this.iterators.clear(); + this.registeredPool.removeActor(this); + }, + + /** + * Handle a protocol request to provide the definition site of this function + * object. + */ + onDefinitionSite: function () { + if (this.obj.class != "Function") { + return { + from: this.actorID, + error: "objectNotFunction", + message: this.actorID + " is not a function." + }; + } + + if (!this.obj.script) { + return { + from: this.actorID, + error: "noScript", + message: this.actorID + " has no Debugger.Script" + }; + } + + return this.hooks.sources().getOriginalLocation(new GeneratedLocation( + this.hooks.sources().createNonSourceMappedActor(this.obj.script.source), + this.obj.script.startLine, + 0 // TODO bug 901138: use Debugger.Script.prototype.startColumn + )).then((originalLocation) => { + return { + source: originalLocation.originalSourceActor.form(), + line: originalLocation.originalLine, + column: originalLocation.originalColumn + }; + }); + }, + + /** + * Handle a protocol request to provide the names of the properties defined on + * the object and not its prototype. + */ + onOwnPropertyNames: function () { + return { from: this.actorID, + ownPropertyNames: this.obj.getOwnPropertyNames() }; + }, + + /** + * Creates an actor to iterate over an object property names and values. + * See PropertyIteratorActor constructor for more info about options param. + * + * @param request object + * The protocol request object. + */ + onEnumProperties: function (request) { + let actor = new PropertyIteratorActor(this, request.options); + this.registeredPool.addActor(actor); + this.iterators.add(actor); + return { iterator: actor.grip() }; + }, + + /** + * Creates an actor to iterate over entries of a Map/Set-like object. + */ + onEnumEntries: function () { + let actor = new PropertyIteratorActor(this, { enumEntries: true }); + this.registeredPool.addActor(actor); + this.iterators.add(actor); + return { iterator: actor.grip() }; + }, + + /** + * Handle a protocol request to provide the prototype and own properties of + * the object. + */ + onPrototypeAndProperties: function () { + let ownProperties = Object.create(null); + let names; + try { + names = this.obj.getOwnPropertyNames(); + } catch (ex) { + // The above can throw if this.obj points to a dead object. + // TODO: we should use Cu.isDeadWrapper() - see bug 885800. + return { from: this.actorID, + prototype: this.hooks.createValueGrip(null), + ownProperties: ownProperties, + safeGetterValues: Object.create(null) }; + } + for (let name of names) { + ownProperties[name] = this._propertyDescriptor(name); + } + return { from: this.actorID, + prototype: this.hooks.createValueGrip(this.obj.proto), + ownProperties: ownProperties, + safeGetterValues: this._findSafeGetterValues(names) }; + }, + + /** + * Find the safe getter values for the current Debugger.Object, |this.obj|. + * + * @private + * @param array ownProperties + * The array that holds the list of known ownProperties names for + * |this.obj|. + * @param number [limit=0] + * Optional limit of getter values to find. + * @return object + * An object that maps property names to safe getter descriptors as + * defined by the remote debugging protocol. + */ + _findSafeGetterValues: function (ownProperties, limit = 0) { + let safeGetterValues = Object.create(null); + let obj = this.obj; + let level = 0, i = 0; + + // Most objects don't have any safe getters but inherit some from their + // prototype. Avoid calling getOwnPropertyNames on objects that may have + // many properties like Array, strings or js objects. That to avoid + // freezing firefox when doing so. + if (TYPED_ARRAY_CLASSES.includes(this.obj.class) || + ["Array", "Object", "String"].includes(this.obj.class)) { + obj = obj.proto; + level++; + } + + while (obj) { + let getters = this._findSafeGetters(obj); + for (let name of getters) { + // Avoid overwriting properties from prototypes closer to this.obj. Also + // avoid providing safeGetterValues from prototypes if property |name| + // is already defined as an own property. + if (name in safeGetterValues || + (obj != this.obj && ownProperties.indexOf(name) !== -1)) { + continue; + } + + // Ignore __proto__ on Object.prototye. + if (!obj.proto && name == "__proto__") { + continue; + } + + let desc = null, getter = null; + try { + desc = obj.getOwnPropertyDescriptor(name); + getter = desc.get; + } catch (ex) { + // The above can throw if the cache becomes stale. + } + if (!getter) { + obj._safeGetters = null; + continue; + } + + let result = getter.call(this.obj); + if (result && !("throw" in result)) { + let getterValue = undefined; + if ("return" in result) { + getterValue = result.return; + } else if ("yield" in result) { + getterValue = result.yield; + } + // WebIDL attributes specified with the LenientThis extended attribute + // return undefined and should be ignored. + if (getterValue !== undefined) { + safeGetterValues[name] = { + getterValue: this.hooks.createValueGrip(getterValue), + getterPrototypeLevel: level, + enumerable: desc.enumerable, + writable: level == 0 ? desc.writable : true, + }; + if (limit && ++i == limit) { + break; + } + } + } + } + if (limit && i == limit) { + break; + } + + obj = obj.proto; + level++; + } + + return safeGetterValues; + }, + + /** + * Find the safe getters for a given Debugger.Object. Safe getters are native + * getters which are safe to execute. + * + * @private + * @param Debugger.Object object + * The Debugger.Object where you want to find safe getters. + * @return Set + * A Set of names of safe getters. This result is cached for each + * Debugger.Object. + */ + _findSafeGetters: function (object) { + if (object._safeGetters) { + return object._safeGetters; + } + + let getters = new Set(); + let names = []; + try { + names = object.getOwnPropertyNames(); + } catch (ex) { + // Calling getOwnPropertyNames() on some wrapped native prototypes is not + // allowed: "cannot modify properties of a WrappedNative". See bug 952093. + } + + for (let name of names) { + let desc = null; + try { + desc = object.getOwnPropertyDescriptor(name); + } catch (e) { + // Calling getOwnPropertyDescriptor on wrapped native prototypes is not + // allowed (bug 560072). + } + if (!desc || desc.value !== undefined || !("get" in desc)) { + continue; + } + + if (DevToolsUtils.hasSafeGetter(desc)) { + getters.add(name); + } + } + + object._safeGetters = getters; + return getters; + }, + + /** + * Handle a protocol request to provide the prototype of the object. + */ + onPrototype: function () { + return { from: this.actorID, + prototype: this.hooks.createValueGrip(this.obj.proto) }; + }, + + /** + * Handle a protocol request to provide the property descriptor of the + * object's specified property. + * + * @param request object + * The protocol request object. + */ + onProperty: function (request) { + if (!request.name) { + return { error: "missingParameter", + message: "no property name was specified" }; + } + + return { from: this.actorID, + descriptor: this._propertyDescriptor(request.name) }; + }, + + /** + * Handle a protocol request to provide the display string for the object. + */ + onDisplayString: function () { + const string = stringify(this.obj); + return { from: this.actorID, + displayString: this.hooks.createValueGrip(string) }; + }, + + /** + * A helper method that creates a property descriptor for the provided object, + * properly formatted for sending in a protocol response. + * + * @private + * @param string name + * The property that the descriptor is generated for. + * @param boolean [onlyEnumerable] + * Optional: true if you want a descriptor only for an enumerable + * property, false otherwise. + * @return object|undefined + * The property descriptor, or undefined if this is not an enumerable + * property and onlyEnumerable=true. + */ + _propertyDescriptor: function (name, onlyEnumerable) { + let desc; + try { + desc = this.obj.getOwnPropertyDescriptor(name); + } catch (e) { + // Calling getOwnPropertyDescriptor on wrapped native prototypes is not + // allowed (bug 560072). Inform the user with a bogus, but hopefully + // explanatory, descriptor. + return { + configurable: false, + writable: false, + enumerable: false, + value: e.name + }; + } + + if (!desc || onlyEnumerable && !desc.enumerable) { + return undefined; + } + + let retval = { + configurable: desc.configurable, + enumerable: desc.enumerable + }; + + if ("value" in desc) { + retval.writable = desc.writable; + retval.value = this.hooks.createValueGrip(desc.value); + } else { + if ("get" in desc) { + retval.get = this.hooks.createValueGrip(desc.get); + } + if ("set" in desc) { + retval.set = this.hooks.createValueGrip(desc.set); + } + } + return retval; + }, + + /** + * Handle a protocol request to provide the source code of a function. + * + * @param request object + * The protocol request object. + */ + onDecompile: function (request) { + if (this.obj.class !== "Function") { + return { error: "objectNotFunction", + message: "decompile request is only valid for object grips " + + "with a 'Function' class." }; + } + + return { from: this.actorID, + decompiledCode: this.obj.decompile(!!request.pretty) }; + }, + + /** + * Handle a protocol request to provide the parameters of a function. + */ + onParameterNames: function () { + if (this.obj.class !== "Function") { + return { error: "objectNotFunction", + message: "'parameterNames' request is only valid for object " + + "grips with a 'Function' class." }; + } + + return { parameterNames: this.obj.parameterNames }; + }, + + /** + * Handle a protocol request to release a thread-lifetime grip. + */ + onRelease: function () { + this.release(); + return {}; + }, + + /** + * Handle a protocol request to provide the lexical scope of a function. + */ + onScope: function () { + if (this.obj.class !== "Function") { + return { error: "objectNotFunction", + message: "scope request is only valid for object grips with a" + + " 'Function' class." }; + } + + let envActor = this.hooks.createEnvironmentActor(this.obj.environment, + this.registeredPool); + if (!envActor) { + return { error: "notDebuggee", + message: "cannot access the environment of this function." }; + } + + return { from: this.actorID, scope: envActor.form() }; + }, + + /** + * Handle a protocol request to get the list of dependent promises of a + * promise. + * + * @return object + * Returns an object containing an array of object grips of the + * dependent promises + */ + onDependentPromises: function () { + if (this.obj.class != "Promise") { + return { error: "objectNotPromise", + message: "'dependentPromises' request is only valid for " + + "object grips with a 'Promise' class." }; + } + + let promises = this.obj.promiseDependentPromises.map(p => this.hooks.createValueGrip(p)); + + return { promises }; + }, + + /** + * Handle a protocol request to get the allocation stack of a promise. + */ + onAllocationStack: function () { + if (this.obj.class != "Promise") { + return { error: "objectNotPromise", + message: "'allocationStack' request is only valid for " + + "object grips with a 'Promise' class." }; + } + + let stack = this.obj.promiseAllocationSite; + let allocationStacks = []; + + while (stack) { + if (stack.source) { + let source = this._getSourceOriginalLocation(stack); + + if (source) { + allocationStacks.push(source); + } + } + stack = stack.parent; + } + + return Promise.all(allocationStacks).then(stacks => { + return { allocationStack: stacks }; + }); + }, + + /** + * Handle a protocol request to get the fulfillment stack of a promise. + */ + onFulfillmentStack: function () { + if (this.obj.class != "Promise") { + return { error: "objectNotPromise", + message: "'fulfillmentStack' request is only valid for " + + "object grips with a 'Promise' class." }; + } + + let stack = this.obj.promiseResolutionSite; + let fulfillmentStacks = []; + + while (stack) { + if (stack.source) { + let source = this._getSourceOriginalLocation(stack); + + if (source) { + fulfillmentStacks.push(source); + } + } + stack = stack.parent; + } + + return Promise.all(fulfillmentStacks).then(stacks => { + return { fulfillmentStack: stacks }; + }); + }, + + /** + * Handle a protocol request to get the rejection stack of a promise. + */ + onRejectionStack: function () { + if (this.obj.class != "Promise") { + return { error: "objectNotPromise", + message: "'rejectionStack' request is only valid for " + + "object grips with a 'Promise' class." }; + } + + let stack = this.obj.promiseResolutionSite; + let rejectionStacks = []; + + while (stack) { + if (stack.source) { + let source = this._getSourceOriginalLocation(stack); + + if (source) { + rejectionStacks.push(source); + } + } + stack = stack.parent; + } + + return Promise.all(rejectionStacks).then(stacks => { + return { rejectionStack: stacks }; + }); + }, + + /** + * Helper function for fetching the source location of a SavedFrame stack. + * + * @param SavedFrame stack + * The promise allocation stack frame + * @return object + * Returns an object containing the source location of the SavedFrame + * stack. + */ + _getSourceOriginalLocation: function (stack) { + let source; + + // Catch any errors if the source actor cannot be found + try { + source = this.hooks.sources().getSourceActorByURL(stack.source); + } catch (e) {} + + if (!source) { + return null; + } + + return this.hooks.sources().getOriginalLocation(new GeneratedLocation( + source, + stack.line, + stack.column + )).then((originalLocation) => { + return { + source: originalLocation.originalSourceActor.form(), + line: originalLocation.originalLine, + column: originalLocation.originalColumn, + functionDisplayName: stack.functionDisplayName + }; + }); + } +}; + +ObjectActor.prototype.requestTypes = { + "definitionSite": ObjectActor.prototype.onDefinitionSite, + "parameterNames": ObjectActor.prototype.onParameterNames, + "prototypeAndProperties": ObjectActor.prototype.onPrototypeAndProperties, + "enumProperties": ObjectActor.prototype.onEnumProperties, + "prototype": ObjectActor.prototype.onPrototype, + "property": ObjectActor.prototype.onProperty, + "displayString": ObjectActor.prototype.onDisplayString, + "ownPropertyNames": ObjectActor.prototype.onOwnPropertyNames, + "decompile": ObjectActor.prototype.onDecompile, + "release": ObjectActor.prototype.onRelease, + "scope": ObjectActor.prototype.onScope, + "dependentPromises": ObjectActor.prototype.onDependentPromises, + "allocationStack": ObjectActor.prototype.onAllocationStack, + "fulfillmentStack": ObjectActor.prototype.onFulfillmentStack, + "rejectionStack": ObjectActor.prototype.onRejectionStack, + "enumEntries": ObjectActor.prototype.onEnumEntries, +}; + +/** + * Creates an actor to iterate over an object's property names and values. + * + * @param objectActor ObjectActor + * The object actor. + * @param options Object + * A dictionary object with various boolean attributes: + * - enumEntries Boolean + * If true, enumerates the entries of a Map or Set object + * instead of enumerating properties. + * - ignoreIndexedProperties Boolean + * If true, filters out Array items. + * e.g. properties names between `0` and `object.length`. + * - ignoreNonIndexedProperties Boolean + * If true, filters out items that aren't array items + * e.g. properties names that are not a number between `0` + * and `object.length`. + * - sort Boolean + * If true, the iterator will sort the properties by name + * before dispatching them. + * - query String + * If non-empty, will filter the properties by names and values + * containing this query string. The match is not case-sensitive. + * Regarding value filtering it just compare to the stringification + * of the property value. + */ +function PropertyIteratorActor(objectActor, options) { + if (options.enumEntries) { + let cls = objectActor.obj.class; + if (cls == "Map") { + this.iterator = enumMapEntries(objectActor); + } else if (cls == "WeakMap") { + this.iterator = enumWeakMapEntries(objectActor); + } else if (cls == "Set") { + this.iterator = enumSetEntries(objectActor); + } else if (cls == "WeakSet") { + this.iterator = enumWeakSetEntries(objectActor); + } else { + throw new Error("Unsupported class to enumerate entries from: " + cls); + } + } else if (options.ignoreNonIndexedProperties && !options.query) { + this.iterator = enumArrayProperties(objectActor, options); + } else { + this.iterator = enumObjectProperties(objectActor, options); + } +} + +PropertyIteratorActor.prototype = { + actorPrefix: "propertyIterator", + + grip() { + return { + type: this.actorPrefix, + actor: this.actorID, + count: this.iterator.size + }; + }, + + names({ indexes }) { + let list = []; + for (let idx of indexes) { + list.push(this.iterator.propertyName(idx)); + } + return { + names: indexes + }; + }, + + slice({ start, count }) { + let ownProperties = Object.create(null); + for (let i = start, m = start + count; i < m; i++) { + let name = this.iterator.propertyName(i); + ownProperties[name] = this.iterator.propertyDescription(i); + } + return { + ownProperties + }; + }, + + all() { + return this.slice({ start: 0, count: this.length }); + } +}; + +PropertyIteratorActor.prototype.requestTypes = { + "names": PropertyIteratorActor.prototype.names, + "slice": PropertyIteratorActor.prototype.slice, + "all": PropertyIteratorActor.prototype.all, +}; + +function enumArrayProperties(objectActor, options) { + let length = DevToolsUtils.getProperty(objectActor.obj, "length"); + if (typeof length !== "number") { + // Pseudo arrays are flagged as ArrayLike if they have + // subsequent indexed properties without having any length attribute. + length = 0; + let names = objectActor.obj.getOwnPropertyNames(); + for (let key of names) { + if (isNaN(key) || key != length++) { + break; + } + } + } + + return { + size: length, + propertyName(index) { + return index; + }, + propertyDescription(index) { + return objectActor._propertyDescriptor(index); + } + }; +} + +function enumObjectProperties(objectActor, options) { + let names = []; + try { + names = objectActor.obj.getOwnPropertyNames(); + } catch (ex) { + // Calling getOwnPropertyNames() on some wrapped native prototypes is not + // allowed: "cannot modify properties of a WrappedNative". See bug 952093. + } + + if (options.ignoreNonIndexedProperties || options.ignoreIndexedProperties) { + let length = DevToolsUtils.getProperty(objectActor.obj, "length"); + if (typeof length !== "number") { + // Pseudo arrays are flagged as ArrayLike if they have + // subsequent indexed properties without having any length attribute. + length = 0; + for (let key of names) { + if (isNaN(key) || key != length++) { + break; + } + } + } + + // It appears that getOwnPropertyNames always returns indexed properties + // first, so we can safely slice `names` for/against indexed properties. + // We do such clever operation to optimize very large array inspection, + // like webaudio buffers. + if (options.ignoreIndexedProperties) { + // Keep items after `length` index + names = names.slice(length); + } else if (options.ignoreNonIndexedProperties) { + // Remove `length` first items + names.splice(length); + } + } + + let safeGetterValues = objectActor._findSafeGetterValues(names, 0); + let safeGetterNames = Object.keys(safeGetterValues); + // Merge the safe getter values into the existing properties list. + for (let name of safeGetterNames) { + if (!names.includes(name)) { + names.push(name); + } + } + + if (options.query) { + let { query } = options; + query = query.toLowerCase(); + names = names.filter(name => { + // Filter on attribute names + if (name.toLowerCase().includes(query)) { + return true; + } + // and then on attribute values + let desc; + try { + desc = objectActor.obj.getOwnPropertyDescriptor(name); + } catch (e) { + // Calling getOwnPropertyDescriptor on wrapped native prototypes is not + // allowed (bug 560072). + } + if (desc && desc.value && + String(desc.value).includes(query)) { + return true; + } + return false; + }); + } + + if (options.sort) { + names.sort(); + } + + return { + size: names.length, + propertyName(index) { + return names[index]; + }, + propertyDescription(index) { + let name = names[index]; + let desc = objectActor._propertyDescriptor(name); + if (!desc) { + desc = safeGetterValues[name]; + } else if (name in safeGetterValues) { + // Merge the safe getter values into the existing properties list. + let { getterValue, getterPrototypeLevel } = safeGetterValues[name]; + desc.getterValue = getterValue; + desc.getterPrototypeLevel = getterPrototypeLevel; + } + return desc; + } + }; +} + +/** + * Helper function to create a grip from a Map/Set entry + */ +function gripFromEntry({ obj, hooks }, entry) { + return hooks.createValueGrip( + makeDebuggeeValueIfNeeded(obj, Cu.unwaiveXrays(entry))); +} + +function enumMapEntries(objectActor) { + // Iterating over a Map via .entries goes through various intermediate + // objects - an Iterator object, then a 2-element Array object, then the + // actual values we care about. We don't have Xrays to Iterator objects, + // so we get Opaque wrappers for them. And even though we have Xrays to + // Arrays, the semantics often deny access to the entires based on the + // nature of the values. So we need waive Xrays for the iterator object + // and the tupes, and then re-apply them on the underlying values until + // we fix bug 1023984. + // + // Even then though, we might want to continue waiving Xrays here for the + // same reason we do so for Arrays above - this filtering behavior is likely + // to be more confusing than beneficial in the case of Object previews. + let raw = objectActor.obj.unsafeDereference(); + + let keys = [...Cu.waiveXrays(Map.prototype.keys.call(raw))]; + return { + [Symbol.iterator]: function* () { + for (let key of keys) { + let value = Map.prototype.get.call(raw, key); + yield [ key, value ].map(val => gripFromEntry(objectActor, val)); + } + }, + size: keys.length, + propertyName(index) { + return index; + }, + propertyDescription(index) { + let key = keys[index]; + let val = Map.prototype.get.call(raw, key); + return { + enumerable: true, + value: { + type: "mapEntry", + preview: { + key: gripFromEntry(objectActor, key), + value: gripFromEntry(objectActor, val) + } + } + }; + } + }; +} + +function enumWeakMapEntries(objectActor) { + // We currently lack XrayWrappers for WeakMap, so when we iterate over + // the values, the temporary iterator objects get created in the target + // compartment. However, we _do_ have Xrays to Object now, so we end up + // Xraying those temporary objects, and filtering access to |it.value| + // based on whether or not it's Xrayable and/or callable, which breaks + // the for/of iteration. + // + // This code is designed to handle untrusted objects, so we can safely + // waive Xrays on the iterable, and relying on the Debugger machinery to + // make sure we handle the resulting objects carefully. + let raw = objectActor.obj.unsafeDereference(); + let keys = Cu.waiveXrays( + ThreadSafeChromeUtils.nondeterministicGetWeakMapKeys(raw)); + + return { + [Symbol.iterator]: function* () { + for (let key of keys) { + let value = WeakMap.prototype.get.call(raw, key); + yield [ key, value ].map(val => gripFromEntry(objectActor, val)); + } + }, + size: keys.length, + propertyName(index) { + return index; + }, + propertyDescription(index) { + let key = keys[index]; + let val = WeakMap.prototype.get.call(raw, key); + return { + enumerable: true, + value: { + type: "mapEntry", + preview: { + key: gripFromEntry(objectActor, key), + value: gripFromEntry(objectActor, val) + } + } + }; + } + }; +} + +function enumSetEntries(objectActor) { + // We currently lack XrayWrappers for Set, so when we iterate over + // the values, the temporary iterator objects get created in the target + // compartment. However, we _do_ have Xrays to Object now, so we end up + // Xraying those temporary objects, and filtering access to |it.value| + // based on whether or not it's Xrayable and/or callable, which breaks + // the for/of iteration. + // + // This code is designed to handle untrusted objects, so we can safely + // waive Xrays on the iterable, and relying on the Debugger machinery to + // make sure we handle the resulting objects carefully. + let raw = objectActor.obj.unsafeDereference(); + let values = [...Cu.waiveXrays(Set.prototype.values.call(raw))]; + + return { + [Symbol.iterator]: function* () { + for (let item of values) { + yield gripFromEntry(objectActor, item); + } + }, + size: values.length, + propertyName(index) { + return index; + }, + propertyDescription(index) { + let val = values[index]; + return { + enumerable: true, + value: gripFromEntry(objectActor, val) + }; + } + }; +} + +function enumWeakSetEntries(objectActor) { + // We currently lack XrayWrappers for WeakSet, so when we iterate over + // the values, the temporary iterator objects get created in the target + // compartment. However, we _do_ have Xrays to Object now, so we end up + // Xraying those temporary objects, and filtering access to |it.value| + // based on whether or not it's Xrayable and/or callable, which breaks + // the for/of iteration. + // + // This code is designed to handle untrusted objects, so we can safely + // waive Xrays on the iterable, and relying on the Debugger machinery to + // make sure we handle the resulting objects carefully. + let raw = objectActor.obj.unsafeDereference(); + let keys = Cu.waiveXrays( + ThreadSafeChromeUtils.nondeterministicGetWeakSetKeys(raw)); + + return { + [Symbol.iterator]: function* () { + for (let item of keys) { + yield gripFromEntry(objectActor, item); + } + }, + size: keys.length, + propertyName(index) { + return index; + }, + propertyDescription(index) { + let val = keys[index]; + return { + enumerable: true, + value: gripFromEntry(objectActor, val) + }; + } + }; +} + +/** + * Functions for adding information to ObjectActor grips for the purpose of + * having customized output. This object holds arrays mapped by + * Debugger.Object.prototype.class. + * + * In each array you can add functions that take three + * arguments: + * - the ObjectActor instance and its hooks to make a preview for, + * - the grip object being prepared for the client, + * - the raw JS object after calling Debugger.Object.unsafeDereference(). This + * argument is only provided if the object is safe for reading properties and + * executing methods. See DevToolsUtils.isSafeJSObject(). + * + * Functions must return false if they cannot provide preview + * information for the debugger object, or true otherwise. + */ +DebuggerServer.ObjectActorPreviewers = { + String: [function (objectActor, grip, rawObj) { + return wrappedPrimitivePreviewer("String", String, objectActor, grip, rawObj); + }], + + Boolean: [function (objectActor, grip, rawObj) { + return wrappedPrimitivePreviewer("Boolean", Boolean, objectActor, grip, rawObj); + }], + + Number: [function (objectActor, grip, rawObj) { + return wrappedPrimitivePreviewer("Number", Number, objectActor, grip, rawObj); + }], + + Function: [function ({obj, hooks}, grip) { + if (obj.name) { + grip.name = obj.name; + } + + if (obj.displayName) { + grip.displayName = obj.displayName.substr(0, 500); + } + + if (obj.parameterNames) { + grip.parameterNames = obj.parameterNames; + } + + // Check if the developer has added a de-facto standard displayName + // property for us to use. + let userDisplayName; + try { + userDisplayName = obj.getOwnPropertyDescriptor("displayName"); + } catch (e) { + // Calling getOwnPropertyDescriptor with displayName might throw + // with "permission denied" errors for some functions. + dumpn(e); + } + + if (userDisplayName && typeof userDisplayName.value == "string" && + userDisplayName.value) { + grip.userDisplayName = hooks.createValueGrip(userDisplayName.value); + } + + let dbgGlobal = hooks.getGlobalDebugObject(); + if (dbgGlobal) { + let script = dbgGlobal.makeDebuggeeValue(obj.unsafeDereference()).script; + if (script) { + grip.location = { + url: script.url, + line: script.startLine + }; + } + } + + return true; + }], + + RegExp: [function ({obj, hooks}, grip) { + // Avoid having any special preview for the RegExp.prototype itself. + if (!obj.proto || obj.proto.class != "RegExp") { + return false; + } + + let str = RegExp.prototype.toString.call(obj.unsafeDereference()); + grip.displayString = hooks.createValueGrip(str); + return true; + }], + + Date: [function ({obj, hooks}, grip) { + let time = Date.prototype.getTime.call(obj.unsafeDereference()); + + grip.preview = { + timestamp: hooks.createValueGrip(time), + }; + return true; + }], + + Array: [function ({obj, hooks}, grip) { + let length = DevToolsUtils.getProperty(obj, "length"); + if (typeof length != "number") { + return false; + } + + grip.preview = { + kind: "ArrayLike", + length: length, + }; + + if (hooks.getGripDepth() > 1) { + return true; + } + + let raw = obj.unsafeDereference(); + let items = grip.preview.items = []; + + for (let i = 0; i < length; ++i) { + // Array Xrays filter out various possibly-unsafe properties (like + // functions, and claim that the value is undefined instead. This + // is generally the right thing for privileged code accessing untrusted + // objects, but quite confusing for Object previews. So we manually + // override this protection by waiving Xrays on the array, and re-applying + // Xrays on any indexed value props that we pull off of it. + let desc = Object.getOwnPropertyDescriptor(Cu.waiveXrays(raw), i); + if (desc && !desc.get && !desc.set) { + let value = Cu.unwaiveXrays(desc.value); + value = makeDebuggeeValueIfNeeded(obj, value); + items.push(hooks.createValueGrip(value)); + } else { + items.push(null); + } + + if (items.length == OBJECT_PREVIEW_MAX_ITEMS) { + break; + } + } + + return true; + }], + + Set: [function (objectActor, grip) { + let size = DevToolsUtils.getProperty(objectActor.obj, "size"); + if (typeof size != "number") { + return false; + } + + grip.preview = { + kind: "ArrayLike", + length: size, + }; + + // Avoid recursive object grips. + if (objectActor.hooks.getGripDepth() > 1) { + return true; + } + + let items = grip.preview.items = []; + for (let item of enumSetEntries(objectActor)) { + items.push(item); + if (items.length == OBJECT_PREVIEW_MAX_ITEMS) { + break; + } + } + + return true; + }], + + WeakSet: [function (objectActor, grip) { + let enumEntries = enumWeakSetEntries(objectActor); + + grip.preview = { + kind: "ArrayLike", + length: enumEntries.size + }; + + // Avoid recursive object grips. + if (objectActor.hooks.getGripDepth() > 1) { + return true; + } + + let items = grip.preview.items = []; + for (let item of enumEntries) { + items.push(item); + if (items.length == OBJECT_PREVIEW_MAX_ITEMS) { + break; + } + } + + return true; + }], + + Map: [function (objectActor, grip) { + let size = DevToolsUtils.getProperty(objectActor.obj, "size"); + if (typeof size != "number") { + return false; + } + + grip.preview = { + kind: "MapLike", + size: size, + }; + + if (objectActor.hooks.getGripDepth() > 1) { + return true; + } + + let entries = grip.preview.entries = []; + for (let entry of enumMapEntries(objectActor)) { + entries.push(entry); + if (entries.length == OBJECT_PREVIEW_MAX_ITEMS) { + break; + } + } + + return true; + }], + + WeakMap: [function (objectActor, grip) { + let enumEntries = enumWeakMapEntries(objectActor); + + grip.preview = { + kind: "MapLike", + size: enumEntries.size + }; + + if (objectActor.hooks.getGripDepth() > 1) { + return true; + } + + let entries = grip.preview.entries = []; + for (let entry of enumEntries) { + entries.push(entry); + if (entries.length == OBJECT_PREVIEW_MAX_ITEMS) { + break; + } + } + + return true; + }], + + DOMStringMap: [function ({obj, hooks}, grip, rawObj) { + if (!rawObj) { + return false; + } + + let keys = obj.getOwnPropertyNames(); + grip.preview = { + kind: "MapLike", + size: keys.length, + }; + + if (hooks.getGripDepth() > 1) { + return true; + } + + let entries = grip.preview.entries = []; + for (let key of keys) { + let value = makeDebuggeeValueIfNeeded(obj, rawObj[key]); + entries.push([key, hooks.createValueGrip(value)]); + if (entries.length == OBJECT_PREVIEW_MAX_ITEMS) { + break; + } + } + + return true; + }], + + Proxy: [function ({obj, hooks}, grip, rawObj) { + grip.preview = { + kind: "Object", + ownProperties: Object.create(null), + ownPropertiesLength: 2 + }; + + if (hooks.getGripDepth() > 1) { + return true; + } + + grip.preview.ownProperties['<target>'] = {value: grip.proxyTarget}; + grip.preview.ownProperties['<handler>'] = {value: grip.proxyHandler}; + + return true; + }], +}; + +/** + * Generic previewer for classes wrapping primitives, like String, + * Number and Boolean. + * + * @param string className + * Class name to expect. + * @param object classObj + * The class to expect, eg. String. The valueOf() method of the class is + * invoked on the given object. + * @param ObjectActor objectActor + * The object actor + * @param Object grip + * The result grip to fill in + * @return Booolean true if the object was handled, false otherwise + */ +function wrappedPrimitivePreviewer(className, classObj, objectActor, grip, rawObj) { + let {obj, hooks} = objectActor; + + if (!obj.proto || obj.proto.class != className) { + return false; + } + + let v = null; + try { + v = classObj.prototype.valueOf.call(rawObj); + } catch (ex) { + // valueOf() can throw if the raw JS object is "misbehaved". + return false; + } + + if (v === null) { + return false; + } + + let canHandle = GenericObject(objectActor, grip, rawObj, className === "String"); + if (!canHandle) { + return false; + } + + grip.preview.wrappedValue = + hooks.createValueGrip(makeDebuggeeValueIfNeeded(obj, v)); + return true; +} + +function GenericObject(objectActor, grip, rawObj, specialStringBehavior = false) { + let {obj, hooks} = objectActor; + if (grip.preview || grip.displayString || hooks.getGripDepth() > 1) { + return false; + } + + let i = 0, names = []; + let preview = grip.preview = { + kind: "Object", + ownProperties: Object.create(null), + }; + + try { + names = obj.getOwnPropertyNames(); + } catch (ex) { + // Calling getOwnPropertyNames() on some wrapped native prototypes is not + // allowed: "cannot modify properties of a WrappedNative". See bug 952093. + } + + preview.ownPropertiesLength = names.length; + + let length; + if (specialStringBehavior) { + length = DevToolsUtils.getProperty(obj, "length"); + if (typeof length != "number") { + specialStringBehavior = false; + } + } + + for (let name of names) { + if (specialStringBehavior && /^[0-9]+$/.test(name)) { + let num = parseInt(name, 10); + if (num.toString() === name && num >= 0 && num < length) { + continue; + } + } + + let desc = objectActor._propertyDescriptor(name, true); + if (!desc) { + continue; + } + + preview.ownProperties[name] = desc; + if (++i == OBJECT_PREVIEW_MAX_ITEMS) { + break; + } + } + + if (i < OBJECT_PREVIEW_MAX_ITEMS) { + preview.safeGetterValues = objectActor._findSafeGetterValues( + Object.keys(preview.ownProperties), + OBJECT_PREVIEW_MAX_ITEMS - i); + } + + return true; +} + +// Preview functions that do not rely on the object class. +DebuggerServer.ObjectActorPreviewers.Object = [ + function TypedArray({obj, hooks}, grip) { + if (TYPED_ARRAY_CLASSES.indexOf(obj.class) == -1) { + return false; + } + + let length = DevToolsUtils.getProperty(obj, "length"); + if (typeof length != "number") { + return false; + } + + grip.preview = { + kind: "ArrayLike", + length: length, + }; + + if (hooks.getGripDepth() > 1) { + return true; + } + + let raw = obj.unsafeDereference(); + let global = Cu.getGlobalForObject(DebuggerServer); + let classProto = global[obj.class].prototype; + // The Xray machinery for TypedArrays denies indexed access on the grounds + // that it's slow, and advises callers to do a structured clone instead. + let safeView = Cu.cloneInto(classProto.subarray.call(raw, 0, + OBJECT_PREVIEW_MAX_ITEMS), global); + let items = grip.preview.items = []; + for (let i = 0; i < safeView.length; i++) { + items.push(safeView[i]); + } + + return true; + }, + + function Error({obj, hooks}, grip) { + switch (obj.class) { + case "Error": + case "EvalError": + case "RangeError": + case "ReferenceError": + case "SyntaxError": + case "TypeError": + case "URIError": + let name = DevToolsUtils.getProperty(obj, "name"); + let msg = DevToolsUtils.getProperty(obj, "message"); + let stack = DevToolsUtils.getProperty(obj, "stack"); + let fileName = DevToolsUtils.getProperty(obj, "fileName"); + let lineNumber = DevToolsUtils.getProperty(obj, "lineNumber"); + let columnNumber = DevToolsUtils.getProperty(obj, "columnNumber"); + grip.preview = { + kind: "Error", + name: hooks.createValueGrip(name), + message: hooks.createValueGrip(msg), + stack: hooks.createValueGrip(stack), + fileName: hooks.createValueGrip(fileName), + lineNumber: hooks.createValueGrip(lineNumber), + columnNumber: hooks.createValueGrip(columnNumber), + }; + return true; + default: + return false; + } + }, + + function CSSMediaRule({obj, hooks}, grip, rawObj) { + if (isWorker || !rawObj || !(rawObj instanceof Ci.nsIDOMCSSMediaRule)) { + return false; + } + grip.preview = { + kind: "ObjectWithText", + text: hooks.createValueGrip(rawObj.conditionText), + }; + return true; + }, + + function CSSStyleRule({obj, hooks}, grip, rawObj) { + if (isWorker || !rawObj || !(rawObj instanceof Ci.nsIDOMCSSStyleRule)) { + return false; + } + grip.preview = { + kind: "ObjectWithText", + text: hooks.createValueGrip(rawObj.selectorText), + }; + return true; + }, + + function ObjectWithURL({obj, hooks}, grip, rawObj) { + if (isWorker || !rawObj || !(rawObj instanceof Ci.nsIDOMCSSImportRule || + rawObj instanceof Ci.nsIDOMCSSStyleSheet || + rawObj instanceof Ci.nsIDOMLocation || + rawObj instanceof Ci.nsIDOMWindow)) { + return false; + } + + let url; + if (rawObj instanceof Ci.nsIDOMWindow && rawObj.location) { + url = rawObj.location.href; + } else if (rawObj.href) { + url = rawObj.href; + } else { + return false; + } + + grip.preview = { + kind: "ObjectWithURL", + url: hooks.createValueGrip(url), + }; + + return true; + }, + + function ArrayLike({obj, hooks}, grip, rawObj) { + if (isWorker || !rawObj || + obj.class != "DOMStringList" && + obj.class != "DOMTokenList" && + !(rawObj instanceof Ci.nsIDOMMozNamedAttrMap || + rawObj instanceof Ci.nsIDOMCSSRuleList || + rawObj instanceof Ci.nsIDOMCSSValueList || + rawObj instanceof Ci.nsIDOMFileList || + rawObj instanceof Ci.nsIDOMFontFaceList || + rawObj instanceof Ci.nsIDOMMediaList || + rawObj instanceof Ci.nsIDOMNodeList || + rawObj instanceof Ci.nsIDOMStyleSheetList)) { + return false; + } + + if (typeof rawObj.length != "number") { + return false; + } + + grip.preview = { + kind: "ArrayLike", + length: rawObj.length, + }; + + if (hooks.getGripDepth() > 1) { + return true; + } + + let items = grip.preview.items = []; + + for (let i = 0; i < rawObj.length && + items.length < OBJECT_PREVIEW_MAX_ITEMS; i++) { + let value = makeDebuggeeValueIfNeeded(obj, rawObj[i]); + items.push(hooks.createValueGrip(value)); + } + + return true; + }, + + function CSSStyleDeclaration({obj, hooks}, grip, rawObj) { + if (isWorker || !rawObj || + !(rawObj instanceof Ci.nsIDOMCSSStyleDeclaration)) { + return false; + } + + grip.preview = { + kind: "MapLike", + size: rawObj.length, + }; + + let entries = grip.preview.entries = []; + + for (let i = 0; i < OBJECT_PREVIEW_MAX_ITEMS && + i < rawObj.length; i++) { + let prop = rawObj[i]; + let value = rawObj.getPropertyValue(prop); + entries.push([prop, hooks.createValueGrip(value)]); + } + + return true; + }, + + function DOMNode({obj, hooks}, grip, rawObj) { + if (isWorker || obj.class == "Object" || !rawObj || + !(rawObj instanceof Ci.nsIDOMNode)) { + return false; + } + + let preview = grip.preview = { + kind: "DOMNode", + nodeType: rawObj.nodeType, + nodeName: rawObj.nodeName, + }; + + if (rawObj instanceof Ci.nsIDOMDocument && rawObj.location) { + preview.location = hooks.createValueGrip(rawObj.location.href); + } else if (rawObj instanceof Ci.nsIDOMDocumentFragment) { + preview.childNodesLength = rawObj.childNodes.length; + + if (hooks.getGripDepth() < 2) { + preview.childNodes = []; + for (let node of rawObj.childNodes) { + let actor = hooks.createValueGrip(obj.makeDebuggeeValue(node)); + preview.childNodes.push(actor); + if (preview.childNodes.length == OBJECT_PREVIEW_MAX_ITEMS) { + break; + } + } + } + } else if (rawObj instanceof Ci.nsIDOMElement) { + // Add preview for DOM element attributes. + if (rawObj instanceof Ci.nsIDOMHTMLElement) { + preview.nodeName = preview.nodeName.toLowerCase(); + } + + let i = 0; + preview.attributes = {}; + preview.attributesLength = rawObj.attributes.length; + for (let attr of rawObj.attributes) { + preview.attributes[attr.nodeName] = hooks.createValueGrip(attr.value); + } + } else if (rawObj instanceof Ci.nsIDOMAttr) { + preview.value = hooks.createValueGrip(rawObj.value); + } else if (rawObj instanceof Ci.nsIDOMText || + rawObj instanceof Ci.nsIDOMComment) { + preview.textContent = hooks.createValueGrip(rawObj.textContent); + } + + return true; + }, + + function DOMEvent({obj, hooks}, grip, rawObj) { + if (isWorker || !rawObj || !(rawObj instanceof Ci.nsIDOMEvent)) { + return false; + } + + let preview = grip.preview = { + kind: "DOMEvent", + type: rawObj.type, + properties: Object.create(null), + }; + + if (hooks.getGripDepth() < 2) { + let target = obj.makeDebuggeeValue(rawObj.target); + preview.target = hooks.createValueGrip(target); + } + + let props = []; + if (rawObj instanceof Ci.nsIDOMMouseEvent) { + props.push("buttons", "clientX", "clientY", "layerX", "layerY"); + } else if (rawObj instanceof Ci.nsIDOMKeyEvent) { + let modifiers = []; + if (rawObj.altKey) { + modifiers.push("Alt"); + } + if (rawObj.ctrlKey) { + modifiers.push("Control"); + } + if (rawObj.metaKey) { + modifiers.push("Meta"); + } + if (rawObj.shiftKey) { + modifiers.push("Shift"); + } + preview.eventKind = "key"; + preview.modifiers = modifiers; + + props.push("key", "charCode", "keyCode"); + } else if (rawObj instanceof Ci.nsIDOMTransitionEvent) { + props.push("propertyName", "pseudoElement"); + } else if (rawObj instanceof Ci.nsIDOMAnimationEvent) { + props.push("animationName", "pseudoElement"); + } else if (rawObj instanceof Ci.nsIDOMClipboardEvent) { + props.push("clipboardData"); + } + + // Add event-specific properties. + for (let prop of props) { + let value = rawObj[prop]; + if (value && (typeof value == "object" || typeof value == "function")) { + // Skip properties pointing to objects. + if (hooks.getGripDepth() > 1) { + continue; + } + value = obj.makeDebuggeeValue(value); + } + preview.properties[prop] = hooks.createValueGrip(value); + } + + // Add any properties we find on the event object. + if (!props.length) { + let i = 0; + for (let prop in rawObj) { + let value = rawObj[prop]; + if (prop == "target" || prop == "type" || value === null || + typeof value == "function") { + continue; + } + if (value && typeof value == "object") { + if (hooks.getGripDepth() > 1) { + continue; + } + value = obj.makeDebuggeeValue(value); + } + preview.properties[prop] = hooks.createValueGrip(value); + if (++i == OBJECT_PREVIEW_MAX_ITEMS) { + break; + } + } + } + + return true; + }, + + function DOMException({obj, hooks}, grip, rawObj) { + if (isWorker || !rawObj || !(rawObj instanceof Ci.nsIDOMDOMException)) { + return false; + } + + grip.preview = { + kind: "DOMException", + name: hooks.createValueGrip(rawObj.name), + message: hooks.createValueGrip(rawObj.message), + code: hooks.createValueGrip(rawObj.code), + result: hooks.createValueGrip(rawObj.result), + filename: hooks.createValueGrip(rawObj.filename), + lineNumber: hooks.createValueGrip(rawObj.lineNumber), + columnNumber: hooks.createValueGrip(rawObj.columnNumber), + }; + + return true; + }, + + function PseudoArray({obj, hooks}, grip, rawObj) { + let length; + + let keys = obj.getOwnPropertyNames(); + if (keys.length == 0) { + return false; + } + + // If no item is going to be displayed in preview, better display as sparse object. + // The first key should contain the smallest integer index (if any). + if(keys[0] >= OBJECT_PREVIEW_MAX_ITEMS) { + return false; + } + + // Pseudo-arrays should only have array indices and, optionally, a "length" property. + // Since integer indices are sorted first, check if the last property is "length". + if(keys[keys.length-1] === "length") { + keys.pop(); + length = DevToolsUtils.getProperty(obj, "length"); + } else { + // Otherwise, let length be the (presumably) greatest array index plus 1. + length = +keys[keys.length-1] + 1; + } + // Check if length is a valid array length, i.e. is a Uint32 number. + if(typeof length !== "number" || length >>> 0 !== length) { + return false; + } + + // Ensure all keys are increasing array indices smaller than length. The order is not + // guaranteed for exotic objects but, in most cases, big array indices and properties + // which are not integer indices should be at the end. Then, iterating backwards + // allows us to return earlier when the object is not completely a pseudo-array. + let prev = length; + for(let i = keys.length - 1; i >= 0; --i) { + let key = keys[i]; + let numKey = key >>> 0; // ToUint32(key) + if (numKey + '' !== key || numKey >= prev) { + return false; + } + prev = numKey; + } + + grip.preview = { + kind: "ArrayLike", + length: length, + }; + + // Avoid recursive object grips. + if (hooks.getGripDepth() > 1) { + return true; + } + + let items = grip.preview.items = []; + let numItems = Math.min(OBJECT_PREVIEW_MAX_ITEMS, length); + + for (let i = 0; i < numItems; ++i) { + let desc = obj.getOwnPropertyDescriptor(i); + if (desc && 'value' in desc) { + items.push(hooks.createValueGrip(desc.value)); + } else { + items.push(null); + } + } + + return true; + }, + + function Object(objectActor, grip, rawObj) { + return GenericObject(objectActor, grip, rawObj, /* specialStringBehavior = */ false); + }, +]; + +/** + * Get thisDebugger.Object referent's `promiseState`. + * + * @returns Object + * An object of one of the following forms: + * - { state: "pending" } + * - { state: "fulfilled", value } + * - { state: "rejected", reason } + */ +function getPromiseState(obj) { + if (obj.class != "Promise") { + throw new Error( + "Can't call `getPromiseState` on `Debugger.Object`s that don't " + + "refer to Promise objects."); + } + + let state = { state: obj.promiseState }; + if (state.state === "fulfilled") { + state.value = obj.promiseValue; + } else if (state.state === "rejected") { + state.reason = obj.promiseReason; + } + return state; +} + +/** + * Determine if a given value is non-primitive. + * + * @param Any value + * The value to test. + * @return Boolean + * Whether the value is non-primitive. + */ +function isObject(value) { + const type = typeof value; + return type == "object" ? value !== null : type == "function"; +} + +/** + * Create a function that can safely stringify Debugger.Objects of a given + * builtin type. + * + * @param Function ctor + * The builtin class constructor. + * @return Function + * The stringifier for the class. + */ +function createBuiltinStringifier(ctor) { + return obj => ctor.prototype.toString.call(obj.unsafeDereference()); +} + +/** + * Stringify a Debugger.Object-wrapped Error instance. + * + * @param Debugger.Object obj + * The object to stringify. + * @return String + * The stringification of the object. + */ +function errorStringify(obj) { + let name = DevToolsUtils.getProperty(obj, "name"); + if (name === "" || name === undefined) { + name = obj.class; + } else if (isObject(name)) { + name = stringify(name); + } + + let message = DevToolsUtils.getProperty(obj, "message"); + if (isObject(message)) { + message = stringify(message); + } + + if (message === "" || message === undefined) { + return name; + } + return name + ": " + message; +} + +/** + * Stringify a Debugger.Object based on its class. + * + * @param Debugger.Object obj + * The object to stringify. + * @return String + * The stringification for the object. + */ +function stringify(obj) { + if (obj.class == "DeadObject") { + const error = new Error("Dead object encountered."); + DevToolsUtils.reportException("stringify", error); + return "<dead object>"; + } + + const stringifier = stringifiers[obj.class] || stringifiers.Object; + + try { + return stringifier(obj); + } catch (e) { + DevToolsUtils.reportException("stringify", e); + return "<failed to stringify object>"; + } +} + +// Used to prevent infinite recursion when an array is found inside itself. +var seen = null; + +var stringifiers = { + Error: errorStringify, + EvalError: errorStringify, + RangeError: errorStringify, + ReferenceError: errorStringify, + SyntaxError: errorStringify, + TypeError: errorStringify, + URIError: errorStringify, + Boolean: createBuiltinStringifier(Boolean), + Function: createBuiltinStringifier(Function), + Number: createBuiltinStringifier(Number), + RegExp: createBuiltinStringifier(RegExp), + String: createBuiltinStringifier(String), + Object: obj => "[object " + obj.class + "]", + Array: obj => { + // If we're at the top level then we need to create the Set for tracking + // previously stringified arrays. + const topLevel = !seen; + if (topLevel) { + seen = new Set(); + } else if (seen.has(obj)) { + return ""; + } + + seen.add(obj); + + const len = DevToolsUtils.getProperty(obj, "length"); + let string = ""; + + // The following check is only required because the debuggee could possibly + // be a Proxy and return any value. For normal objects, array.length is + // always a non-negative integer. + if (typeof len == "number" && len > 0) { + for (let i = 0; i < len; i++) { + const desc = obj.getOwnPropertyDescriptor(i); + if (desc) { + const { value } = desc; + if (value != null) { + string += isObject(value) ? stringify(value) : value; + } + } + + if (i < len - 1) { + string += ","; + } + } + } + + if (topLevel) { + seen = null; + } + + return string; + }, + DOMException: obj => { + const message = DevToolsUtils.getProperty(obj, "message") || "<no message>"; + const result = (+DevToolsUtils.getProperty(obj, "result")).toString(16); + const code = DevToolsUtils.getProperty(obj, "code"); + const name = DevToolsUtils.getProperty(obj, "name") || "<unknown>"; + + return '[Exception... "' + message + '" ' + + 'code: "' + code + '" ' + + 'nsresult: "0x' + result + " (" + name + ')"]'; + }, + Promise: obj => { + const { state, value, reason } = getPromiseState(obj); + let statePreview = state; + if (state != "pending") { + const settledValue = state === "fulfilled" ? value : reason; + statePreview += ": " + (typeof settledValue === "object" && settledValue !== null + ? stringify(settledValue) + : settledValue); + } + return "Promise (" + statePreview + ")"; + }, +}; + +/** + * Make a debuggee value for the given object, if needed. Primitive values + * are left the same. + * + * Use case: you have a raw JS object (after unsafe dereference) and you want to + * send it to the client. In that case you need to use an ObjectActor which + * requires a debuggee value. The Debugger.Object.prototype.makeDebuggeeValue() + * method works only for JS objects and functions. + * + * @param Debugger.Object obj + * @param any value + * @return object + */ +function makeDebuggeeValueIfNeeded(obj, value) { + if (value && (typeof value == "object" || typeof value == "function")) { + return obj.makeDebuggeeValue(value); + } + return value; +} + +/** + * Creates an actor for the specied "very long" string. "Very long" is specified + * at the server's discretion. + * + * @param string String + * The string. + */ +function LongStringActor(string) { + this.string = string; + this.stringLength = string.length; +} + +LongStringActor.prototype = { + actorPrefix: "longString", + + disconnect: function () { + // Because longStringActors is not a weak map, we won't automatically leave + // it so we need to manually leave on disconnect so that we don't leak + // memory. + this._releaseActor(); + }, + + /** + * Returns a grip for this actor for returning in a protocol message. + */ + grip: function () { + return { + "type": "longString", + "initial": this.string.substring( + 0, DebuggerServer.LONG_STRING_INITIAL_LENGTH), + "length": this.stringLength, + "actor": this.actorID + }; + }, + + /** + * Handle a request to extract part of this actor's string. + * + * @param request object + * The protocol request object. + */ + onSubstring: function (request) { + return { + "from": this.actorID, + "substring": this.string.substring(request.start, request.end) + }; + }, + + /** + * Handle a request to release this LongStringActor instance. + */ + onRelease: function () { + // TODO: also check if registeredPool === threadActor.threadLifetimePool + // when the web console moves aray from manually releasing pause-scoped + // actors. + this._releaseActor(); + this.registeredPool.removeActor(this); + return {}; + }, + + _releaseActor: function () { + if (this.registeredPool && this.registeredPool.longStringActors) { + delete this.registeredPool.longStringActors[this.string]; + } + } +}; + +LongStringActor.prototype.requestTypes = { + "substring": LongStringActor.prototype.onSubstring, + "release": LongStringActor.prototype.onRelease +}; + +/** + * Create a grip for the given debuggee value. If the value is an + * object, will create an actor with the given lifetime. + */ +function createValueGrip(value, pool, makeObjectGrip) { + switch (typeof value) { + case "boolean": + return value; + + case "string": + if (stringIsLong(value)) { + return longStringGrip(value, pool); + } + return value; + + case "number": + if (value === Infinity) { + return { type: "Infinity" }; + } else if (value === -Infinity) { + return { type: "-Infinity" }; + } else if (Number.isNaN(value)) { + return { type: "NaN" }; + } else if (!value && 1 / value === -Infinity) { + return { type: "-0" }; + } + return value; + + case "undefined": + return { type: "undefined" }; + + case "object": + if (value === null) { + return { type: "null" }; + } + else if (value.optimizedOut || + value.uninitialized || + value.missingArguments) { + // The slot is optimized out, an uninitialized binding, or + // arguments on a dead scope + return { + type: "null", + optimizedOut: value.optimizedOut, + uninitialized: value.uninitialized, + missingArguments: value.missingArguments + }; + } + return makeObjectGrip(value, pool); + + case "symbol": + let form = { + type: "symbol" + }; + let name = getSymbolName(value); + if (name !== undefined) { + form.name = createValueGrip(name, pool, makeObjectGrip); + } + return form; + + default: + assert(false, "Failed to provide a grip for: " + value); + return null; + } +} + +const symbolProtoToString = Symbol.prototype.toString; + +function getSymbolName(symbol) { + const name = symbolProtoToString.call(symbol).slice("Symbol(".length, -1); + return name || undefined; +} + +/** + * Returns true if the string is long enough to use a LongStringActor instead + * of passing the value directly over the protocol. + * + * @param str String + * The string we are checking the length of. + */ +function stringIsLong(str) { + return str.length >= DebuggerServer.LONG_STRING_LENGTH; +} + +/** + * Create a grip for the given string. + * + * @param str String + * The string we are creating a grip for. + * @param pool ActorPool + * The actor pool where the new actor will be added. + */ +function longStringGrip(str, pool) { + if (!pool.longStringActors) { + pool.longStringActors = {}; + } + + if (pool.longStringActors.hasOwnProperty(str)) { + return pool.longStringActors[str].grip(); + } + + let actor = new LongStringActor(str); + pool.addActor(actor); + pool.longStringActors[str] = actor; + return actor.grip(); +} + +exports.ObjectActor = ObjectActor; +exports.PropertyIteratorActor = PropertyIteratorActor; +exports.LongStringActor = LongStringActor; +exports.createValueGrip = createValueGrip; +exports.stringIsLong = stringIsLong; +exports.longStringGrip = longStringGrip; diff --git a/devtools/server/actors/performance-entries.js b/devtools/server/actors/performance-entries.js new file mode 100644 index 0000000000..89434324ae --- /dev/null +++ b/devtools/server/actors/performance-entries.js @@ -0,0 +1,65 @@ +/* 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/. */ + +/** + * The performanceEntries actor emits events corresponding to performance + * entries. It receives `performanceentry` events containing the performance + * entry details and emits an event containing the name, type, origin, and + * epoch of the performance entry. + */ + +const { Actor, ActorClassWithSpec } = require("devtools/shared/protocol"); +const performanceSpec = require("devtools/shared/specs/performance-entries"); +const events = require("sdk/event/core"); + +var PerformanceEntriesActor = ActorClassWithSpec(performanceSpec, { + listenerAdded: false, + + initialize: function (conn, tabActor) { + Actor.prototype.initialize.call(this, conn); + this.window = tabActor.window; + }, + + /** + * Start tracking the user timings. + */ + start: function () { + if (!this.listenerAdded) { + this.onPerformanceEntry = this.onPerformanceEntry.bind(this); + this.window.addEventListener("performanceentry", this.onPerformanceEntry, true); + this.listenerAdded = true; + } + }, + + /** + * Stop tracking the user timings. + */ + stop: function () { + if (this.listenerAdded) { + this.window.removeEventListener("performanceentry", this.onPerformanceEntry, true); + this.listenerAdded = false; + } + }, + + disconnect: function () { + this.destroy(); + }, + + destroy: function () { + this.stop(); + Actor.prototype.destroy.call(this); + }, + + onPerformanceEntry: function (e) { + let emitDetail = { + type: e.entryType, + name: e.name, + origin: e.origin, + epoch: e.epoch + }; + events.emit(this, "entry", emitDetail); + } +}); + +exports.PerformanceEntriesActor = PerformanceEntriesActor; diff --git a/devtools/server/actors/performance-recording.js b/devtools/server/actors/performance-recording.js new file mode 100644 index 0000000000..ef59074953 --- /dev/null +++ b/devtools/server/actors/performance-recording.js @@ -0,0 +1,148 @@ +/* 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 { Cu } = require("chrome"); +const { Actor, ActorClassWithSpec } = require("devtools/shared/protocol"); +const { performanceRecordingSpec } = require("devtools/shared/specs/performance-recording"); + +loader.lazyRequireGetter(this, "merge", "sdk/util/object", true); +loader.lazyRequireGetter(this, "RecordingUtils", + "devtools/shared/performance/recording-utils"); +loader.lazyRequireGetter(this, "PerformanceRecordingCommon", + "devtools/shared/performance/recording-common", true); + +/** + * This actor wraps the Performance module at devtools/shared/shared/performance.js + * and provides RDP definitions. + * + * @see devtools/shared/shared/performance.js for documentation. + */ +const PerformanceRecordingActor = ActorClassWithSpec(performanceRecordingSpec, merge({ + form: function (detail) { + if (detail === "actorid") { + return this.actorID; + } + + let form = { + actor: this.actorID, // actorID is set when this is added to a pool + configuration: this._configuration, + startingBufferStatus: this._startingBufferStatus, + console: this._console, + label: this._label, + startTime: this._startTime, + localStartTime: this._localStartTime, + recording: this._recording, + completed: this._completed, + duration: this._duration, + }; + + // Only send profiler data once it exists and it has + // not yet been sent + if (this._profile && !this._sentFinalizedData) { + form.finalizedData = true; + form.profile = this.getProfile(); + form.systemHost = this.getHostSystemInfo(); + form.systemClient = this.getClientSystemInfo(); + this._sentFinalizedData = true; + } + + return form; + }, + + /** + * @param {object} conn + * @param {object} options + * A hash of features that this recording is utilizing. + * @param {object} meta + * A hash of temporary metadata for a recording that is recording + * (as opposed to an imported recording). + */ + initialize: function (conn, options, meta) { + Actor.prototype.initialize.call(this, conn); + this._configuration = { + withMarkers: options.withMarkers || false, + withTicks: options.withTicks || false, + withMemory: options.withMemory || false, + withAllocations: options.withAllocations || false, + allocationsSampleProbability: options.allocationsSampleProbability || 0, + allocationsMaxLogLength: options.allocationsMaxLogLength || 0, + bufferSize: options.bufferSize || 0, + sampleFrequency: options.sampleFrequency || 1 + }; + + this._console = !!options.console; + this._label = options.label || ""; + + if (meta) { + // Store the start time roughly with Date.now() so when we + // are checking the duration during a recording, we can get close + // to the approximate duration to render elements without + // making a real request + this._localStartTime = Date.now(); + + this._startTime = meta.startTime; + this._startingBufferStatus = { + position: meta.position, + totalSize: meta.totalSize, + generation: meta.generation + }; + + this._recording = true; + this._markers = []; + this._frames = []; + this._memory = []; + this._ticks = []; + this._allocations = { sites: [], timestamps: [], frames: [], sizes: [] }; + + this._systemHost = meta.systemHost || {}; + this._systemClient = meta.systemClient || {}; + } + }, + + destroy: function () { + Actor.prototype.destroy.call(this); + }, + + /** + * Internal utility called by the PerformanceActor and PerformanceFront on state changes + * to update the internal state of the PerformanceRecording. + * + * @param {string} state + * @param {object} extraData + */ + _setState: function (state, extraData) { + switch (state) { + case "recording-started": { + this._recording = true; + break; + } + case "recording-stopping": { + this._recording = false; + break; + } + case "recording-stopped": { + this._profile = extraData.profile; + this._duration = extraData.duration; + + // We filter out all samples that fall out of current profile's range + // since the profiler is continuously running. Because of this, sample + // times are not guaranteed to have a zero epoch, so offset the + // timestamps. + RecordingUtils.offsetSampleTimes(this._profile, this._startTime); + + // Markers need to be sorted ascending by time, to be properly displayed + // in a waterfall view. + this._markers = this._markers.sort((a, b) => (a.start > b.start)); + + this._completed = true; + break; + } + } + }, + +}, PerformanceRecordingCommon)); + +exports.PerformanceRecordingActor = PerformanceRecordingActor; diff --git a/devtools/server/actors/performance.js b/devtools/server/actors/performance.js new file mode 100644 index 0000000000..8b294a4de5 --- /dev/null +++ b/devtools/server/actors/performance.js @@ -0,0 +1,116 @@ +/* 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 { Cu } = require("chrome"); +const { Task } = require("devtools/shared/task"); +const { Actor, ActorClassWithSpec } = require("devtools/shared/protocol"); +const { actorBridgeWithSpec } = require("devtools/server/actors/common"); +const { performanceSpec } = require("devtools/shared/specs/performance"); + +loader.lazyRequireGetter(this, "events", "sdk/event/core"); +loader.lazyRequireGetter(this, "extend", "sdk/util/object", true); + +loader.lazyRequireGetter(this, "PerformanceRecorder", + "devtools/server/performance/recorder", true); +loader.lazyRequireGetter(this, "normalizePerformanceFeatures", + "devtools/shared/performance/recording-utils", true); + +const PIPE_TO_FRONT_EVENTS = new Set([ + "recording-started", "recording-stopping", "recording-stopped", + "profiler-status", "timeline-data", "console-profile-start" +]); + +const RECORDING_STATE_CHANGE_EVENTS = new Set([ + "recording-started", "recording-stopping", "recording-stopped" +]); + +/** + * This actor wraps the Performance module at devtools/shared/shared/performance.js + * and provides RDP definitions. + * + * @see devtools/shared/shared/performance.js for documentation. + */ +var PerformanceActor = ActorClassWithSpec(performanceSpec, { + traits: { + features: { + withMarkers: true, + withTicks: true, + withMemory: true, + withFrames: true, + withGCEvents: true, + withDocLoadingEvents: true, + withAllocations: true, + }, + }, + + initialize: function (conn, tabActor) { + Actor.prototype.initialize.call(this, conn); + this._onRecorderEvent = this._onRecorderEvent.bind(this); + this.bridge = new PerformanceRecorder(conn, tabActor); + events.on(this.bridge, "*", this._onRecorderEvent); + }, + + /** + * `disconnect` method required to call destroy, since this + * actor is not managed by a parent actor. + */ + disconnect: function () { + this.destroy(); + }, + + destroy: function () { + events.off(this.bridge, "*", this._onRecorderEvent); + this.bridge.destroy(); + Actor.prototype.destroy.call(this); + }, + + connect: function (config) { + this.bridge.connect({ systemClient: config.systemClient }); + return { traits: this.traits }; + }, + + canCurrentlyRecord: function () { + return this.bridge.canCurrentlyRecord(); + }, + + startRecording: Task.async(function* (options = {}) { + if (!this.bridge.canCurrentlyRecord().success) { + return null; + } + + let normalizedOptions = normalizePerformanceFeatures(options, this.traits.features); + let recording = yield this.bridge.startRecording(normalizedOptions); + this.manage(recording); + + return recording; + }), + + stopRecording: actorBridgeWithSpec("stopRecording"), + isRecording: actorBridgeWithSpec("isRecording"), + getRecordings: actorBridgeWithSpec("getRecordings"), + getConfiguration: actorBridgeWithSpec("getConfiguration"), + setProfilerStatusInterval: actorBridgeWithSpec("setProfilerStatusInterval"), + + /** + * Filter which events get piped to the front. + */ + _onRecorderEvent: function (eventName, ...data) { + // If this is a recording state change, call + // a method on the related PerformanceRecordingActor so it can + // update its internal state. + if (RECORDING_STATE_CHANGE_EVENTS.has(eventName)) { + let recording = data[0]; + let extraData = data[1]; + recording._setState(eventName, extraData); + } + + if (PIPE_TO_FRONT_EVENTS.has(eventName)) { + events.emit(this, eventName, ...data); + } + }, +}); + +exports.PerformanceActor = PerformanceActor; diff --git a/devtools/server/actors/preference.js b/devtools/server/actors/preference.js new file mode 100644 index 0000000000..8d41401558 --- /dev/null +++ b/devtools/server/actors/preference.js @@ -0,0 +1,81 @@ +/* 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/. */ + +const {Cc, Ci, Cu, CC} = require("chrome"); +const protocol = require("devtools/shared/protocol"); +const {Arg, method, RetVal} = protocol; +const Services = require("Services"); +const {preferenceSpec} = require("devtools/shared/specs/preference"); + +exports.register = function (handle) { + handle.addGlobalActor(PreferenceActor, "preferenceActor"); +}; + +exports.unregister = function (handle) { +}; + +var PreferenceActor = exports.PreferenceActor = protocol.ActorClassWithSpec(preferenceSpec, { + typeName: "preference", + + getBoolPref: function (name) { + return Services.prefs.getBoolPref(name); + }, + + getCharPref: function (name) { + return Services.prefs.getCharPref(name); + }, + + getIntPref: function (name) { + return Services.prefs.getIntPref(name); + }, + + getAllPrefs: function () { + let prefs = {}; + Services.prefs.getChildList("").forEach(function (name, index) { + // append all key/value pairs into a huge json object. + try { + let value; + switch (Services.prefs.getPrefType(name)) { + case Ci.nsIPrefBranch.PREF_STRING: + value = Services.prefs.getCharPref(name); + break; + case Ci.nsIPrefBranch.PREF_INT: + value = Services.prefs.getIntPref(name); + break; + case Ci.nsIPrefBranch.PREF_BOOL: + value = Services.prefs.getBoolPref(name); + break; + default: + } + prefs[name] = { + value: value, + hasUserValue: Services.prefs.prefHasUserValue(name) + }; + } catch (e) { + // pref exists but has no user or default value + } + }); + return prefs; + }, + + setBoolPref: function (name, value) { + Services.prefs.setBoolPref(name, value); + Services.prefs.savePrefFile(null); + }, + + setCharPref: function (name, value) { + Services.prefs.setCharPref(name, value); + Services.prefs.savePrefFile(null); + }, + + setIntPref: function (name, value) { + Services.prefs.setIntPref(name, value); + Services.prefs.savePrefFile(null); + }, + + clearUserPref: function (name) { + Services.prefs.clearUserPref(name); + Services.prefs.savePrefFile(null); + }, +}); diff --git a/devtools/server/actors/pretty-print-worker.js b/devtools/server/actors/pretty-print-worker.js new file mode 100644 index 0000000000..5fc6b6959c --- /dev/null +++ b/devtools/server/actors/pretty-print-worker.js @@ -0,0 +1,50 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2; js-indent-level: 2 -*- */ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* 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/. */ + +/** + * This file is meant to be loaded as a ChromeWorker. It accepts messages which + * have data of the form: + * + * { id, url, indent, source } + * + * Where `id` is a unique ID to identify this request, `url` is the url of the + * source being pretty printed, `indent` is the number of spaces to indent the + * code by, and `source` is the source text. + * + * On success, the worker responds with a message of the form: + * + * { id, code, mappings } + * + * Where `id` is the same unique ID from the request, `code` is the pretty + * printed source text, and `mappings` is an array or source mappings from the + * pretty printed code back to the ugly source text. + * + * In the case of an error, the worker responds with a message of the form: + * + * { id, error } + */ + +importScripts("resource://devtools/shared/worker/helper.js"); +importScripts("resource://devtools/shared/acorn/acorn.js"); +importScripts("resource://devtools/shared/sourcemap/source-map.js"); +importScripts("resource://devtools/shared/pretty-fast/pretty-fast.js"); + +workerHelper.createTask(self, "pretty-print", ({ url, indent, source }) => { + try { + const prettified = prettyFast(source, { + url: url, + indent: " ".repeat(indent) + }); + + return { + code: prettified.code, + mappings: prettified.map._mappings + }; + } + catch (e) { + return new Error(e.message + "\n" + e.stack); + } +}); diff --git a/devtools/server/actors/process.js b/devtools/server/actors/process.js new file mode 100644 index 0000000000..ff1c4313f7 --- /dev/null +++ b/devtools/server/actors/process.js @@ -0,0 +1,83 @@ +/* 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 { Cc, Ci } = require("chrome"); + +loader.lazyGetter(this, "ppmm", () => { + return Cc["@mozilla.org/parentprocessmessagemanager;1"].getService(Ci.nsIMessageBroadcaster); +}); + +function ProcessActorList() { + this._actors = new Map(); + this._onListChanged = null; + this._mustNotify = false; + + this._onMessage = this._onMessage.bind(this); + this._processScript = "data:text/javascript,sendAsyncMessage('debug:new-process');"; +} + +ProcessActorList.prototype = { + getList: function () { + let processes = []; + for (let i = 0; i < ppmm.childCount; i++) { + processes.push({ + id: i, // XXX: may not be a perfect id, but process message manager doesn't expose anything... + parent: i == 0, // XXX Weak, but appear to be stable + tabCount: undefined, // TODO: exposes process message manager on frameloaders in order to compute this + }); + } + this._mustNotify = true; + this._checkListening(); + + return processes; + }, + + get onListChanged() { + return this._onListChanged; + }, + + set onListChanged(onListChanged) { + if (typeof onListChanged !== "function" && onListChanged !== null) { + throw new Error("onListChanged must be either a function or null."); + } + if (onListChanged === this._onListChanged) { + return; + } + + this._onListChanged = onListChanged; + this._checkListening(); + }, + + _checkListening: function () { + if (this._onListChanged !== null && this._mustNotify) { + this._knownProcesses = []; + for (let i = 0; i < ppmm.childCount; i++) { + this._knownProcesses.push(ppmm.getChildAt(i)); + } + ppmm.addMessageListener("debug:new-process", this._onMessage); + ppmm.loadProcessScript(this._processScript, true); + } else { + ppmm.removeMessageListener("debug:new-process", this._onMessage); + ppmm.removeDelayedProcessScript(this._processScript); + } + }, + + _notifyListChanged: function () { + if (this._mustNotify) { + this._onListChanged(); + this._mustNotify = false; + } + }, + + _onMessage: function ({ target }) { + if (this._knownProcesses.includes(target)) { + return; + } + this._notifyListChanged(); + }, +}; + +exports.ProcessActorList = ProcessActorList; diff --git a/devtools/server/actors/profiler.js b/devtools/server/actors/profiler.js new file mode 100644 index 0000000000..c4b594408c --- /dev/null +++ b/devtools/server/actors/profiler.js @@ -0,0 +1,60 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +const { Actor, ActorClassWithSpec } = require("devtools/shared/protocol"); +const { Profiler } = require("devtools/server/performance/profiler"); +const { actorBridgeWithSpec } = require("devtools/server/actors/common"); +const { profilerSpec } = require("devtools/shared/specs/profiler"); + +loader.lazyRequireGetter(this, "events", "sdk/event/core"); + +/** + * This actor wraps the Profiler module at devtools/server/performance/profiler.js + * and provides RDP definitions. + * + * @see devtools/server/performance/profiler.js for documentation. + */ +var ProfilerActor = exports.ProfilerActor = ActorClassWithSpec(profilerSpec, { + initialize: function (conn) { + Actor.prototype.initialize.call(this, conn); + this._onProfilerEvent = this._onProfilerEvent.bind(this); + + this.bridge = new Profiler(); + events.on(this.bridge, "*", this._onProfilerEvent); + }, + + /** + * `disconnect` method required to call destroy, since this + * actor is not managed by a parent actor. + */ + disconnect: function () { + this.destroy(); + }, + + destroy: function () { + events.off(this.bridge, "*", this._onProfilerEvent); + this.bridge.destroy(); + Actor.prototype.destroy.call(this); + }, + + startProfiler: actorBridgeWithSpec("start"), + stopProfiler: actorBridgeWithSpec("stop"), + getProfile: actorBridgeWithSpec("getProfile"), + getFeatures: actorBridgeWithSpec("getFeatures"), + getBufferInfo: actorBridgeWithSpec("getBufferInfo"), + getStartOptions: actorBridgeWithSpec("getStartOptions"), + isActive: actorBridgeWithSpec("isActive"), + getSharedLibraryInformation: actorBridgeWithSpec("getSharedLibraryInformation"), + registerEventNotifications: actorBridgeWithSpec("registerEventNotifications"), + unregisterEventNotifications: actorBridgeWithSpec("unregisterEventNotifications"), + setProfilerStatusInterval: actorBridgeWithSpec("setProfilerStatusInterval"), + + /** + * Pipe events from Profiler module to this actor. + */ + _onProfilerEvent: function (eventName, ...data) { + events.emit(this, eventName, ...data); + }, +}); diff --git a/devtools/server/actors/promises.js b/devtools/server/actors/promises.js new file mode 100644 index 0000000000..a9a56219d7 --- /dev/null +++ b/devtools/server/actors/promises.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 protocol = require("devtools/shared/protocol"); +const { promisesSpec } = require("devtools/shared/specs/promises"); +const { expectState, ActorPool } = require("devtools/server/actors/common"); +const { ObjectActor, createValueGrip } = require("devtools/server/actors/object"); +const DevToolsUtils = require("devtools/shared/DevToolsUtils"); +loader.lazyRequireGetter(this, "events", "sdk/event/core"); + +/** + * The Promises Actor provides support for getting the list of live promises and + * observing changes to their settlement state. + */ +var PromisesActor = protocol.ActorClassWithSpec(promisesSpec, { + /** + * @param conn DebuggerServerConnection. + * @param parent TabActor|RootActor + */ + initialize: function (conn, parent) { + protocol.Actor.prototype.initialize.call(this, conn); + + this.conn = conn; + this.parent = parent; + this.state = "detached"; + this._dbg = null; + this._gripDepth = 0; + this._navigationLifetimePool = null; + this._newPromises = null; + this._promisesSettled = null; + + this.objectGrip = this.objectGrip.bind(this); + this._makePromiseEventHandler = this._makePromiseEventHandler.bind(this); + this._onWindowReady = this._onWindowReady.bind(this); + }, + + destroy: function () { + protocol.Actor.prototype.destroy.call(this, this.conn); + + if (this.state === "attached") { + this.detach(); + } + }, + + get dbg() { + if (!this._dbg) { + this._dbg = this.parent.makeDebugger(); + } + return this._dbg; + }, + + /** + * Attach to the PromisesActor. + */ + attach: expectState("detached", function () { + this.dbg.addDebuggees(); + + this._navigationLifetimePool = this._createActorPool(); + this.conn.addActorPool(this._navigationLifetimePool); + + this._newPromises = []; + this._promisesSettled = []; + + this.dbg.findScripts().forEach(s => { + this.parent.sources.createSourceActors(s.source); + }); + + this.dbg.onNewScript = s => { + this.parent.sources.createSourceActors(s.source); + }; + + events.on(this.parent, "window-ready", this._onWindowReady); + + this.state = "attached"; + }, "attaching to the PromisesActor"), + + /** + * Detach from the PromisesActor upon Debugger closing. + */ + detach: expectState("attached", function () { + this.dbg.removeAllDebuggees(); + this.dbg.enabled = false; + this._dbg = null; + this._newPromises = null; + this._promisesSettled = null; + + if (this._navigationLifetimePool) { + this.conn.removeActorPool(this._navigationLifetimePool); + this._navigationLifetimePool = null; + } + + events.off(this.parent, "window-ready", this._onWindowReady); + + this.state = "detached"; + }), + + _createActorPool: function () { + let pool = new ActorPool(this.conn); + pool.objectActors = new WeakMap(); + return pool; + }, + + /** + * Create an ObjectActor for the given Promise object. + * + * @param object promise + * The promise object + * @return object + * An ObjectActor object that wraps the given Promise object + */ + _createObjectActorForPromise: function (promise) { + if (this._navigationLifetimePool.objectActors.has(promise)) { + return this._navigationLifetimePool.objectActors.get(promise); + } + + let actor = new ObjectActor(promise, { + getGripDepth: () => this._gripDepth, + incrementGripDepth: () => this._gripDepth++, + decrementGripDepth: () => this._gripDepth--, + createValueGrip: v => + createValueGrip(v, this._navigationLifetimePool, this.objectGrip), + sources: () => this.parent.sources, + createEnvironmentActor: () => DevToolsUtils.reportException( + "PromisesActor", Error("createEnvironmentActor not yet implemented")), + getGlobalDebugObject: () => DevToolsUtils.reportException( + "PromisesActor", Error("getGlobalDebugObject not yet implemented")), + }); + + this._navigationLifetimePool.addActor(actor); + this._navigationLifetimePool.objectActors.set(promise, actor); + + return actor; + }, + + /** + * Get a grip for the given Promise object. + * + * @param object value + * The Promise object + * @return object + * The grip for the given Promise object + */ + objectGrip: function (value) { + return this._createObjectActorForPromise(value).grip(); + }, + + /** + * Get a list of ObjectActors for all live Promise Objects. + */ + listPromises: function () { + let promises = this.dbg.findObjects({ class: "Promise" }); + + this.dbg.onNewPromise = this._makePromiseEventHandler(this._newPromises, + "new-promises"); + this.dbg.onPromiseSettled = this._makePromiseEventHandler( + this._promisesSettled, "promises-settled"); + + return promises.map(p => this._createObjectActorForPromise(p)); + }, + + /** + * Creates an event handler for onNewPromise that will add the new + * Promise ObjectActor to the array and schedule it to be emitted as a + * batch for the provided event. + * + * @param array array + * The list of Promise ObjectActors to emit + * @param string eventName + * The event name + */ + _makePromiseEventHandler: function (array, eventName) { + return promise => { + let actor = this._createObjectActorForPromise(promise); + let needsScheduling = array.length == 0; + + array.push(actor); + + if (needsScheduling) { + DevToolsUtils.executeSoon(() => { + events.emit(this, eventName, array.splice(0, array.length)); + }); + } + }; + }, + + _onWindowReady: expectState("attached", function ({ isTopLevel }) { + if (!isTopLevel) { + return; + } + + this._navigationLifetimePool.cleanup(); + this.dbg.removeAllDebuggees(); + this.dbg.addDebuggees(); + }) +}); + +exports.PromisesActor = PromisesActor; diff --git a/devtools/server/actors/reflow.js b/devtools/server/actors/reflow.js new file mode 100644 index 0000000000..0ebe00207f --- /dev/null +++ b/devtools/server/actors/reflow.js @@ -0,0 +1,514 @@ +/* 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"; + +/** + * About the types of objects in this file: + * + * - ReflowActor: the actor class used for protocol purposes. + * Mostly empty, just gets an instance of LayoutChangesObserver and forwards + * its "reflows" events to clients. + * + * - LayoutChangesObserver: extends Observable and uses the ReflowObserver, to + * track reflows on the page. + * Used by the LayoutActor, but is also exported on the module, so can be used + * by any other actor that needs it. + * + * - Observable: A utility parent class, meant at being extended by classes that + * need a to observe something on the tabActor's windows. + * + * - Dedicated observers: There's only one of them for now: ReflowObserver which + * listens to reflow events via the docshell, + * These dedicated classes are used by the LayoutChangesObserver. + */ + +const {Ci} = require("chrome"); +const {XPCOMUtils} = require("resource://gre/modules/XPCOMUtils.jsm"); +const protocol = require("devtools/shared/protocol"); +const {method, Arg} = protocol; +const events = require("sdk/event/core"); +const Heritage = require("sdk/core/heritage"); +const EventEmitter = require("devtools/shared/event-emitter"); +const {reflowSpec} = require("devtools/shared/specs/reflow"); + +/** + * The reflow actor tracks reflows and emits events about them. + */ +var ReflowActor = exports.ReflowActor = protocol.ActorClassWithSpec(reflowSpec, { + initialize: function (conn, tabActor) { + protocol.Actor.prototype.initialize.call(this, conn); + + this.tabActor = tabActor; + this._onReflow = this._onReflow.bind(this); + this.observer = getLayoutChangesObserver(tabActor); + this._isStarted = false; + }, + + /** + * The reflow actor is the first (and last) in its hierarchy to use + * protocol.js so it doesn't have a parent protocol actor that takes care of + * its lifetime. So it needs a disconnect method to cleanup. + */ + disconnect: function () { + this.destroy(); + }, + + destroy: function () { + this.stop(); + releaseLayoutChangesObserver(this.tabActor); + this.observer = null; + this.tabActor = null; + + protocol.Actor.prototype.destroy.call(this); + }, + + /** + * Start tracking reflows and sending events to clients about them. + * This is a oneway method, do not expect a response and it won't return a + * promise. + */ + start: function () { + if (!this._isStarted) { + this.observer.on("reflows", this._onReflow); + this._isStarted = true; + } + }, + + /** + * Stop tracking reflows and sending events to clients about them. + * This is a oneway method, do not expect a response and it won't return a + * promise. + */ + stop: function () { + if (this._isStarted) { + this.observer.off("reflows", this._onReflow); + this._isStarted = false; + } + }, + + _onReflow: function (event, reflows) { + if (this._isStarted) { + events.emit(this, "reflows", reflows); + } + } +}); + +/** + * Base class for all sorts of observers that need to listen to events on the + * tabActor's windows. + * @param {TabActor} tabActor + * @param {Function} callback Executed everytime the observer observes something + */ +function Observable(tabActor, callback) { + this.tabActor = tabActor; + this.callback = callback; + + this._onWindowReady = this._onWindowReady.bind(this); + this._onWindowDestroyed = this._onWindowDestroyed.bind(this); + + events.on(this.tabActor, "window-ready", this._onWindowReady); + events.on(this.tabActor, "window-destroyed", this._onWindowDestroyed); +} + +Observable.prototype = { + /** + * Is the observer currently observing + */ + isObserving: false, + + /** + * Stop observing and detroy this observer instance + */ + destroy: function () { + if (this.isDestroyed) { + return; + } + this.isDestroyed = true; + + this.stop(); + + events.off(this.tabActor, "window-ready", this._onWindowReady); + events.off(this.tabActor, "window-destroyed", this._onWindowDestroyed); + + this.callback = null; + this.tabActor = null; + }, + + /** + * Start observing whatever it is this observer is supposed to observe + */ + start: function () { + if (this.isObserving) { + return; + } + this.isObserving = true; + + this._startListeners(this.tabActor.windows); + }, + + /** + * Stop observing + */ + stop: function () { + if (!this.isObserving) { + return; + } + this.isObserving = false; + + if (this.tabActor.attached && this.tabActor.docShell) { + // It's only worth stopping if the tabActor is still attached + this._stopListeners(this.tabActor.windows); + } + }, + + _onWindowReady: function ({window}) { + if (this.isObserving) { + this._startListeners([window]); + } + }, + + _onWindowDestroyed: function ({window}) { + if (this.isObserving) { + this._stopListeners([window]); + } + }, + + _startListeners: function (windows) { + // To be implemented by sub-classes. + }, + + _stopListeners: function (windows) { + // To be implemented by sub-classes. + }, + + /** + * To be called by sub-classes when something has been observed + */ + notifyCallback: function (...args) { + this.isObserving && this.callback && this.callback.apply(null, args); + } +}; + +/** + * The LayouChangesObserver will observe reflows as soon as it is started. + * Some devtools actors may cause reflows and it may be wanted to "hide" these + * reflows from the LayouChangesObserver consumers. + * If this is the case, such actors should require this module and use this + * global function to turn the ignore mode on and off temporarily. + * + * Note that if a node is provided, it will be used to force a sync reflow to + * make sure all reflows which occurred before switching the mode on or off are + * either observed or ignored depending on the current mode. + * + * @param {Boolean} ignore + * @param {DOMNode} syncReflowNode The node to use to force a sync reflow + */ +var gIgnoreLayoutChanges = false; +exports.setIgnoreLayoutChanges = function (ignore, syncReflowNode) { + if (syncReflowNode) { + let forceSyncReflow = syncReflowNode.offsetWidth; + } + gIgnoreLayoutChanges = ignore; +}; + +/** + * The LayoutChangesObserver class is instantiated only once per given tab + * and is used to track reflows and dom and style changes in that tab. + * The LayoutActor uses this class to send reflow events to its clients. + * + * This class isn't exported on the module because it shouldn't be instantiated + * to avoid creating several instances per tabs. + * Use `getLayoutChangesObserver(tabActor)` + * and `releaseLayoutChangesObserver(tabActor)` + * which are exported to get and release instances. + * + * The observer loops every EVENT_BATCHING_DELAY ms and checks if layout changes + * have happened since the last loop iteration. If there are, it sends the + * corresponding events: + * + * - "reflows", with an array of all the reflows that occured, + * - "resizes", with an array of all the resizes that occured, + * + * @param {TabActor} tabActor + */ +function LayoutChangesObserver(tabActor) { + this.tabActor = tabActor; + + this._startEventLoop = this._startEventLoop.bind(this); + this._onReflow = this._onReflow.bind(this); + this._onResize = this._onResize.bind(this); + + // Creating the various observers we're going to need + // For now, just the reflow observer, but later we can add markupMutation, + // styleSheetChanges and styleRuleChanges + this.reflowObserver = new ReflowObserver(this.tabActor, this._onReflow); + this.resizeObserver = new WindowResizeObserver(this.tabActor, this._onResize); + + EventEmitter.decorate(this); +} + +exports.LayoutChangesObserver = LayoutChangesObserver; + +LayoutChangesObserver.prototype = { + /** + * How long does this observer waits before emitting batched events. + * The lower the value, the more event packets will be sent to clients, + * potentially impacting performance. + * The higher the value, the more time we'll wait, this is better for + * performance but has an effect on how soon changes are shown in the toolbox. + */ + EVENT_BATCHING_DELAY: 300, + + /** + * Destroying this instance of LayoutChangesObserver will stop the batched + * events from being sent. + */ + destroy: function () { + this.isObserving = false; + + this.reflowObserver.destroy(); + this.reflows = null; + + this.resizeObserver.destroy(); + this.hasResized = false; + + this.tabActor = null; + }, + + start: function () { + if (this.isObserving) { + return; + } + this.isObserving = true; + + this.reflows = []; + this.hasResized = false; + + this._startEventLoop(); + + this.reflowObserver.start(); + this.resizeObserver.start(); + }, + + stop: function () { + if (!this.isObserving) { + return; + } + this.isObserving = false; + + this._stopEventLoop(); + + this.reflows = []; + this.hasResized = false; + + this.reflowObserver.stop(); + this.resizeObserver.stop(); + }, + + /** + * Start the event loop, which regularly checks if there are any observer + * events to be sent as batched events + * Calls itself in a loop. + */ + _startEventLoop: function () { + // Avoid emitting events if the tabActor has been detached (may happen + // during shutdown) + if (!this.tabActor || !this.tabActor.attached) { + return; + } + + // Send any reflows we have + if (this.reflows && this.reflows.length) { + this.emit("reflows", this.reflows); + this.reflows = []; + } + + // Send any resizes we have + if (this.hasResized) { + this.emit("resize"); + this.hasResized = false; + } + + this.eventLoopTimer = this._setTimeout(this._startEventLoop, + this.EVENT_BATCHING_DELAY); + }, + + _stopEventLoop: function () { + this._clearTimeout(this.eventLoopTimer); + }, + + // Exposing set/clearTimeout here to let tests override them if needed + _setTimeout: function (cb, ms) { + return setTimeout(cb, ms); + }, + _clearTimeout: function (t) { + return clearTimeout(t); + }, + + /** + * Executed whenever a reflow is observed. Only stacks the reflow in the + * reflows array. + * The EVENT_BATCHING_DELAY loop will take care of it later. + * @param {Number} start When the reflow started + * @param {Number} end When the reflow ended + * @param {Boolean} isInterruptible + */ + _onReflow: function (start, end, isInterruptible) { + if (gIgnoreLayoutChanges) { + return; + } + + // XXX: when/if bug 997092 gets fixed, we will be able to know which + // elements have been reflowed, which would be a nice thing to add here. + this.reflows.push({ + start: start, + end: end, + isInterruptible: isInterruptible + }); + }, + + /** + * Executed whenever a resize is observed. Only store a flag saying that a + * resize occured. + * The EVENT_BATCHING_DELAY loop will take care of it later. + */ + _onResize: function () { + if (gIgnoreLayoutChanges) { + return; + } + + this.hasResized = true; + } +}; + +/** + * Get a LayoutChangesObserver instance for a given window. This function makes + * sure there is only one instance per window. + * @param {TabActor} tabActor + * @return {LayoutChangesObserver} + */ +var observedWindows = new Map(); +function getLayoutChangesObserver(tabActor) { + let observerData = observedWindows.get(tabActor); + if (observerData) { + observerData.refCounting ++; + return observerData.observer; + } + + let obs = new LayoutChangesObserver(tabActor); + observedWindows.set(tabActor, { + observer: obs, + // counting references allows to stop the observer when no tabActor owns an + // instance. + refCounting: 1 + }); + obs.start(); + return obs; +} +exports.getLayoutChangesObserver = getLayoutChangesObserver; + +/** + * Release a LayoutChangesObserver instance that was retrieved by + * getLayoutChangesObserver. This is required to ensure the tabActor reference + * is removed and the observer is eventually stopped and destroyed. + * @param {TabActor} tabActor + */ +function releaseLayoutChangesObserver(tabActor) { + let observerData = observedWindows.get(tabActor); + if (!observerData) { + return; + } + + observerData.refCounting --; + if (!observerData.refCounting) { + observerData.observer.destroy(); + observedWindows.delete(tabActor); + } +} +exports.releaseLayoutChangesObserver = releaseLayoutChangesObserver; + +/** + * Reports any reflow that occurs in the tabActor's docshells. + * @extends Observable + * @param {TabActor} tabActor + * @param {Function} callback Executed everytime a reflow occurs + */ +function ReflowObserver(tabActor, callback) { + Observable.call(this, tabActor, callback); +} + +ReflowObserver.prototype = Heritage.extend(Observable.prototype, { + QueryInterface: XPCOMUtils.generateQI([Ci.nsIReflowObserver, + Ci.nsISupportsWeakReference]), + + _startListeners: function (windows) { + for (let window of windows) { + let docshell = window.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIWebNavigation) + .QueryInterface(Ci.nsIDocShell); + docshell.addWeakReflowObserver(this); + } + }, + + _stopListeners: function (windows) { + for (let window of windows) { + try { + let docshell = window.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIWebNavigation) + .QueryInterface(Ci.nsIDocShell); + docshell.removeWeakReflowObserver(this); + } catch (e) { + // Corner cases where a global has already been freed may happen, in + // which case, no need to remove the observer. + } + } + }, + + reflow: function (start, end) { + this.notifyCallback(start, end, false); + }, + + reflowInterruptible: function (start, end) { + this.notifyCallback(start, end, true); + } +}); + +/** + * Reports window resize events on the tabActor's windows. + * @extends Observable + * @param {TabActor} tabActor + * @param {Function} callback Executed everytime a resize occurs + */ +function WindowResizeObserver(tabActor, callback) { + Observable.call(this, tabActor, callback); + this.onResize = this.onResize.bind(this); +} + +WindowResizeObserver.prototype = Heritage.extend(Observable.prototype, { + _startListeners: function () { + this.listenerTarget.addEventListener("resize", this.onResize); + }, + + _stopListeners: function () { + this.listenerTarget.removeEventListener("resize", this.onResize); + }, + + onResize: function () { + this.notifyCallback(); + }, + + get listenerTarget() { + // For the rootActor, return its window. + if (this.tabActor.isRootActor) { + return this.tabActor.window; + } + + // Otherwise, get the tabActor's chromeEventHandler. + return this.tabActor.window.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIWebNavigation) + .QueryInterface(Ci.nsIDocShell) + .chromeEventHandler; + } +}); diff --git a/devtools/server/actors/root.js b/devtools/server/actors/root.js new file mode 100644 index 0000000000..b6f8c0ee4c --- /dev/null +++ b/devtools/server/actors/root.js @@ -0,0 +1,535 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* 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 { Cc, Ci, Cu } = require("chrome"); +const Services = require("Services"); +const { ActorPool, appendExtraActors, createExtraActors } = require("devtools/server/actors/common"); +const { DebuggerServer } = require("devtools/server/main"); + +loader.lazyGetter(this, "ppmm", () => { + return Cc["@mozilla.org/parentprocessmessagemanager;1"].getService(Ci.nsIMessageBroadcaster); +}); + +/* Root actor for the remote debugging protocol. */ + +/** + * Create a remote debugging protocol root actor. + * + * @param aConnection + * The DebuggerServerConnection whose root actor we are constructing. + * + * @param aParameters + * The properties of |aParameters| provide backing objects for the root + * actor's requests; if a given property is omitted from |aParameters|, the + * root actor won't implement the corresponding requests or notifications. + * Supported properties: + * + * - tabList: a live list (see below) of tab actors. If present, the + * new root actor supports the 'listTabs' request, providing the live + * list's elements as its tab actors, and sending 'tabListChanged' + * notifications when the live list's contents change. One actor in + * this list must have a true '.selected' property. + * + * - addonList: a live list (see below) of addon actors. If present, the + * new root actor supports the 'listAddons' request, providing the live + * list's elements as its addon actors, and sending 'addonListchanged' + * notifications when the live list's contents change. + * + * - globalActorFactories: an object |A| describing further actors to + * attach to the 'listTabs' reply. This is the type accumulated by + * DebuggerServer.addGlobalActor. For each own property |P| of |A|, + * the root actor adds a property named |P| to the 'listTabs' + * reply whose value is the name of an actor constructed by + * |A[P]|. + * + * - onShutdown: a function to call when the root actor is disconnected. + * + * Instance properties: + * + * - applicationType: the string the root actor will include as the + * "applicationType" property in the greeting packet. By default, this + * is "browser". + * + * Live lists: + * + * A "live list", as used for the |tabList|, is an object that presents a + * list of actors, and also notifies its clients of changes to the list. A + * live list's interface is two properties: + * + * - getList: a method that returns a promise to the contents of the list. + * + * - onListChanged: a handler called, with no arguments, when the set of + * values the iterator would produce has changed since the last + * time 'iterator' was called. This may only be set to null or a + * callable value (one for which the typeof operator returns + * 'function'). (Note that the live list will not call the + * onListChanged handler until the list has been iterated over + * once; if nobody's seen the list in the first place, nobody + * should care if its contents have changed!) + * + * When the list changes, the list implementation should ensure that any + * actors yielded in previous iterations whose referents (tabs) still exist + * get yielded again in subsequent iterations. If the underlying referent + * is the same, the same actor should be presented for it. + * + * The root actor registers an 'onListChanged' handler on the appropriate + * list when it may need to send the client 'tabListChanged' notifications, + * and is careful to remove the handler whenever it does not need to send + * such notifications (including when it is disconnected). This means that + * live list implementations can use the state of the handler property (set + * or null) to install and remove observers and event listeners. + * + * Note that, as the only way for the root actor to see the members of the + * live list is to begin an iteration over the list, the live list need not + * actually produce any actors until they are reached in the course of + * iteration: alliterative lazy live lists. + */ +function RootActor(aConnection, aParameters) { + this.conn = aConnection; + this._parameters = aParameters; + this._onTabListChanged = this.onTabListChanged.bind(this); + this._onAddonListChanged = this.onAddonListChanged.bind(this); + this._onWorkerListChanged = this.onWorkerListChanged.bind(this); + this._onServiceWorkerRegistrationListChanged = this.onServiceWorkerRegistrationListChanged.bind(this); + this._onProcessListChanged = this.onProcessListChanged.bind(this); + this._extraActors = {}; + + this._globalActorPool = new ActorPool(this.conn); + this.conn.addActorPool(this._globalActorPool); + + this._chromeActor = null; +} + +RootActor.prototype = { + constructor: RootActor, + applicationType: "browser", + + traits: { + sources: true, + // Whether the inspector actor allows modifying outer HTML. + editOuterHTML: true, + // Whether the inspector actor allows modifying innerHTML and inserting + // adjacent HTML. + pasteHTML: true, + // Whether the server-side highlighter actor exists and can be used to + // remotely highlight nodes (see server/actors/highlighters.js) + highlightable: true, + // Which custom highlighter does the server-side highlighter actor supports? + // (see server/actors/highlighters.js) + customHighlighters: true, + // Whether the inspector actor implements the getImageDataFromURL + // method that returns data-uris for image URLs. This is used for image + // tooltips for instance + urlToImageDataResolver: true, + networkMonitor: true, + // Whether the storage inspector actor to inspect cookies, etc. + storageInspector: true, + // Whether storage inspector is read only + storageInspectorReadOnly: true, + // Whether conditional breakpoints are supported + conditionalBreakpoints: true, + // Whether the server supports full source actors (breakpoints on + // eval scripts, etc) + debuggerSourceActors: true, + bulk: true, + // Whether the style rule actor implements the modifySelector method + // that modifies the rule's selector + selectorEditable: true, + // Whether the page style actor implements the addNewRule method that + // adds new rules to the page + addNewRule: true, + // Whether the dom node actor implements the getUniqueSelector method + getUniqueSelector: true, + // Whether the director scripts are supported + directorScripts: true, + // Whether the debugger server supports + // blackboxing/pretty-printing (not supported in Fever Dream yet) + noBlackBoxing: false, + noPrettyPrinting: false, + // Whether the page style actor implements the getUsedFontFaces method + // that returns the font faces used on a node + getUsedFontFaces: true, + // Trait added in Gecko 38, indicating that all features necessary for + // grabbing allocations from the MemoryActor are available for the performance tool + memoryActorAllocations: true, + // Added in Gecko 40, indicating that the backend isn't stupid about + // sending resumption packets on tab navigation. + noNeedToFakeResumptionOnNavigation: true, + // Added in Firefox 40. Indicates that the backend supports registering custom + // commands through the WebConsoleCommands API. + webConsoleCommands: true, + // Whether root actor exposes tab actors + // if allowChromeProcess is true, you can fetch a ChromeActor instance + // to debug chrome and any non-content ressource via getProcess request + // if allocChromeProcess is defined, but not true, it means that root actor + // no longer expose tab actors, but also that getProcess forbids + // exposing actors for security reasons + get allowChromeProcess() { + return DebuggerServer.allowChromeProcess; + }, + // Whether or not `getProfile()` supports specifying a `startTime` + // and `endTime` to filter out samples. Fx40+ + profilerDataFilterable: true, + // Whether or not the MemoryActor's heap snapshot abilities are + // fully equipped to handle heap snapshots for the memory tool. Fx44+ + heapSnapshots: true, + // Whether or not the timeline actor can emit DOMContentLoaded and Load + // markers, currently in use by the network monitor. Fx45+ + documentLoadingMarkers: true + }, + + /** + * Return a 'hello' packet as specified by the Remote Debugging Protocol. + */ + sayHello: function () { + return { + from: this.actorID, + applicationType: this.applicationType, + /* This is not in the spec, but it's used by tests. */ + testConnectionPrefix: this.conn.prefix, + traits: this.traits + }; + }, + + forwardingCancelled: function (prefix) { + return { + from: this.actorID, + type: "forwardingCancelled", + prefix, + }; + }, + + /** + * Disconnects the actor from the browser window. + */ + disconnect: function () { + /* Tell the live lists we aren't watching any more. */ + if (this._parameters.tabList) { + this._parameters.tabList.onListChanged = null; + } + if (this._parameters.addonList) { + this._parameters.addonList.onListChanged = null; + } + if (this._parameters.workerList) { + this._parameters.workerList.onListChanged = null; + } + if (this._parameters.serviceWorkerRegistrationList) { + this._parameters.serviceWorkerRegistrationList.onListChanged = null; + } + if (typeof this._parameters.onShutdown === "function") { + this._parameters.onShutdown(); + } + this._extraActors = null; + this.conn = null; + this._tabActorPool = null; + this._globalActorPool = null; + this._parameters = null; + this._chromeActor = null; + }, + + /* The 'listTabs' request and the 'tabListChanged' notification. */ + + /** + * Handles the listTabs request. The actors will survive until at least + * the next listTabs request. + */ + onListTabs: function () { + let tabList = this._parameters.tabList; + if (!tabList) { + return { from: this.actorID, error: "noTabs", + message: "This root actor has no browser tabs." }; + } + + /* + * Walk the tab list, accumulating the array of tab actors for the + * reply, and moving all the actors to a new ActorPool. We'll + * replace the old tab actor pool with the one we build here, thus + * retiring any actors that didn't get listed again, and preparing any + * new actors to receive packets. + */ + let newActorPool = new ActorPool(this.conn); + let tabActorList = []; + let selected; + return tabList.getList().then((tabActors) => { + for (let tabActor of tabActors) { + if (tabActor.selected) { + selected = tabActorList.length; + } + tabActor.parentID = this.actorID; + newActorPool.addActor(tabActor); + tabActorList.push(tabActor); + } + /* DebuggerServer.addGlobalActor support: create actors. */ + if (!this._globalActorPool) { + this._globalActorPool = new ActorPool(this.conn); + this.conn.addActorPool(this._globalActorPool); + } + this._createExtraActors(this._parameters.globalActorFactories, this._globalActorPool); + /* + * Drop the old actorID -> actor map. Actors that still mattered were + * added to the new map; others will go away. + */ + if (this._tabActorPool) { + this.conn.removeActorPool(this._tabActorPool); + } + this._tabActorPool = newActorPool; + this.conn.addActorPool(this._tabActorPool); + + let reply = { + "from": this.actorID, + "selected": selected || 0, + "tabs": tabActorList.map(actor => actor.form()) + }; + + /* If a root window is accessible, include its URL. */ + if (this.url) { + reply.url = this.url; + } + + /* DebuggerServer.addGlobalActor support: name actors in 'listTabs' reply. */ + this._appendExtraActors(reply); + + /* + * Now that we're actually going to report the contents of tabList to + * the client, we're responsible for letting the client know if it + * changes. + */ + tabList.onListChanged = this._onTabListChanged; + + return reply; + }); + }, + + onGetTab: function (options) { + let tabList = this._parameters.tabList; + if (!tabList) { + return { error: "noTabs", + message: "This root actor has no browser tabs." }; + } + if (!this._tabActorPool) { + this._tabActorPool = new ActorPool(this.conn); + this.conn.addActorPool(this._tabActorPool); + } + return tabList.getTab(options) + .then(tabActor => { + tabActor.parentID = this.actorID; + this._tabActorPool.addActor(tabActor); + + return { tab: tabActor.form() }; + }, error => { + if (error.error) { + // Pipe expected errors as-is to the client + return error; + } else { + return { error: "noTab", + message: "Unexpected error while calling getTab(): " + error }; + } + }); + }, + + onTabListChanged: function () { + this.conn.send({ from: this.actorID, type:"tabListChanged" }); + /* It's a one-shot notification; no need to watch any more. */ + this._parameters.tabList.onListChanged = null; + }, + + onListAddons: function () { + let addonList = this._parameters.addonList; + if (!addonList) { + return { from: this.actorID, error: "noAddons", + message: "This root actor has no browser addons." }; + } + + return addonList.getList().then((addonActors) => { + let addonActorPool = new ActorPool(this.conn); + for (let addonActor of addonActors) { + addonActorPool.addActor(addonActor); + } + + if (this._addonActorPool) { + this.conn.removeActorPool(this._addonActorPool); + } + this._addonActorPool = addonActorPool; + this.conn.addActorPool(this._addonActorPool); + + addonList.onListChanged = this._onAddonListChanged; + + return { + "from": this.actorID, + "addons": addonActors.map(addonActor => addonActor.form()) + }; + }); + }, + + onAddonListChanged: function () { + this.conn.send({ from: this.actorID, type: "addonListChanged" }); + this._parameters.addonList.onListChanged = null; + }, + + onListWorkers: function () { + let workerList = this._parameters.workerList; + if (!workerList) { + return { from: this.actorID, error: "noWorkers", + message: "This root actor has no workers." }; + } + + return workerList.getList().then(actors => { + let pool = new ActorPool(this.conn); + for (let actor of actors) { + pool.addActor(actor); + } + + this.conn.removeActorPool(this._workerActorPool); + this._workerActorPool = pool; + this.conn.addActorPool(this._workerActorPool); + + workerList.onListChanged = this._onWorkerListChanged; + + return { + "from": this.actorID, + "workers": actors.map(actor => actor.form()) + }; + }); + }, + + onWorkerListChanged: function () { + this.conn.send({ from: this.actorID, type: "workerListChanged" }); + this._parameters.workerList.onListChanged = null; + }, + + onListServiceWorkerRegistrations: function () { + let registrationList = this._parameters.serviceWorkerRegistrationList; + if (!registrationList) { + return { from: this.actorID, error: "noServiceWorkerRegistrations", + message: "This root actor has no service worker registrations." }; + } + + return registrationList.getList().then(actors => { + let pool = new ActorPool(this.conn); + for (let actor of actors) { + pool.addActor(actor); + } + + this.conn.removeActorPool(this._serviceWorkerRegistrationActorPool); + this._serviceWorkerRegistrationActorPool = pool; + this.conn.addActorPool(this._serviceWorkerRegistrationActorPool); + + registrationList.onListChanged = this._onServiceWorkerRegistrationListChanged; + + return { + "from": this.actorID, + "registrations": actors.map(actor => actor.form()) + }; + }); + }, + + onServiceWorkerRegistrationListChanged: function () { + this.conn.send({ from: this.actorID, type: "serviceWorkerRegistrationListChanged" }); + this._parameters.serviceWorkerRegistrationList.onListChanged = null; + }, + + onListProcesses: function () { + let { processList } = this._parameters; + if (!processList) { + return { from: this.actorID, error: "noProcesses", + message: "This root actor has no processes." }; + } + processList.onListChanged = this._onProcessListChanged; + return { + processes: processList.getList() + }; + }, + + onProcessListChanged: function () { + this.conn.send({ from: this.actorID, type: "processListChanged" }); + this._parameters.processList.onListChanged = null; + }, + + onGetProcess: function (aRequest) { + if (!DebuggerServer.allowChromeProcess) { + return { error: "forbidden", + message: "You are not allowed to debug chrome." }; + } + if (("id" in aRequest) && typeof (aRequest.id) != "number") { + return { error: "wrongParameter", + message: "getProcess requires a valid `id` attribute." }; + } + // If the request doesn't contains id parameter or id is 0 + // (id == 0, based on onListProcesses implementation) + if ((!("id" in aRequest)) || aRequest.id === 0) { + if (!this._chromeActor) { + // Create a ChromeActor for the parent process + let { ChromeActor } = require("devtools/server/actors/chrome"); + this._chromeActor = new ChromeActor(this.conn); + this._globalActorPool.addActor(this._chromeActor); + } + + return { form: this._chromeActor.form() }; + } else { + let mm = ppmm.getChildAt(aRequest.id); + if (!mm) { + return { error: "noProcess", + message: "There is no process with id '" + aRequest.id + "'." }; + } + return DebuggerServer.connectToContent(this.conn, mm) + .then(form => ({ form })); + } + }, + + /* This is not in the spec, but it's used by tests. */ + onEcho: function (aRequest) { + /* + * Request packets are frozen. Copy aRequest, so that + * DebuggerServerConnection.onPacket can attach a 'from' property. + */ + return Cu.cloneInto(aRequest, {}); + }, + + onProtocolDescription: function () { + return require("devtools/shared/protocol").dumpProtocolSpec(); + }, + + /* Support for DebuggerServer.addGlobalActor. */ + _createExtraActors: createExtraActors, + _appendExtraActors: appendExtraActors, + + /** + * Remove the extra actor (added by DebuggerServer.addGlobalActor or + * DebuggerServer.addTabActor) name |aName|. + */ + removeActorByName: function (aName) { + if (aName in this._extraActors) { + const actor = this._extraActors[aName]; + if (this._globalActorPool.has(actor)) { + this._globalActorPool.removeActor(actor); + } + if (this._tabActorPool) { + // Iterate over TabActor instances to also remove tab actors + // created during listTabs for each document. + this._tabActorPool.forEach(tab => { + tab.removeActorByName(aName); + }); + } + delete this._extraActors[aName]; + } + } +}; + +RootActor.prototype.requestTypes = { + "listTabs": RootActor.prototype.onListTabs, + "getTab": RootActor.prototype.onGetTab, + "listAddons": RootActor.prototype.onListAddons, + "listWorkers": RootActor.prototype.onListWorkers, + "listServiceWorkerRegistrations": RootActor.prototype.onListServiceWorkerRegistrations, + "listProcesses": RootActor.prototype.onListProcesses, + "getProcess": RootActor.prototype.onGetProcess, + "echo": RootActor.prototype.onEcho, + "protocolDescription": RootActor.prototype.onProtocolDescription +}; + +exports.RootActor = RootActor; diff --git a/devtools/server/actors/script.js b/devtools/server/actors/script.js new file mode 100644 index 0000000000..e8e39546c2 --- /dev/null +++ b/devtools/server/actors/script.js @@ -0,0 +1,2360 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2; js-indent-level: 2 -*- */ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* 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 Services = require("Services"); +const { Cc, Ci, Cu, Cr, components, ChromeWorker } = require("chrome"); +const { ActorPool, OriginalLocation, GeneratedLocation } = require("devtools/server/actors/common"); +const { BreakpointActor, setBreakpointAtEntryPoints } = require("devtools/server/actors/breakpoint"); +const { EnvironmentActor } = require("devtools/server/actors/environment"); +const { FrameActor } = require("devtools/server/actors/frame"); +const { ObjectActor, createValueGrip, longStringGrip } = require("devtools/server/actors/object"); +const { SourceActor, getSourceURL } = require("devtools/server/actors/source"); +const { DebuggerServer } = require("devtools/server/main"); +const { ActorClassWithSpec } = require("devtools/shared/protocol"); +const DevToolsUtils = require("devtools/shared/DevToolsUtils"); +const flags = require("devtools/shared/flags"); +const { assert, dumpn, update, fetch } = DevToolsUtils; +const promise = require("promise"); +const xpcInspector = require("xpcInspector"); +const { DevToolsWorker } = require("devtools/shared/worker/worker"); +const object = require("sdk/util/object"); +const { threadSpec } = require("devtools/shared/specs/script"); + +const { defer, resolve, reject, all } = promise; + +loader.lazyGetter(this, "Debugger", () => { + let Debugger = require("Debugger"); + hackDebugger(Debugger); + return Debugger; +}); +loader.lazyRequireGetter(this, "CssLogic", "devtools/server/css-logic", true); +loader.lazyRequireGetter(this, "events", "sdk/event/core"); +loader.lazyRequireGetter(this, "mapURIToAddonID", "devtools/server/actors/utils/map-uri-to-addon-id"); + +/** + * A BreakpointActorMap is a map from locations to instances of BreakpointActor. + */ +function BreakpointActorMap() { + this._size = 0; + this._actors = {}; +} + +BreakpointActorMap.prototype = { + /** + * Return the number of BreakpointActors in this BreakpointActorMap. + * + * @returns Number + * The number of BreakpointActor in this BreakpointActorMap. + */ + get size() { + return this._size; + }, + + /** + * Generate all BreakpointActors that match the given location in + * this BreakpointActorMap. + * + * @param OriginalLocation location + * The location for which matching BreakpointActors should be generated. + */ + findActors: function* (location = new OriginalLocation()) { + // Fast shortcut for when we know we won't find any actors. Surprisingly + // enough, this speeds up refreshing when there are no breakpoints set by + // about 2x! + if (this.size === 0) { + return; + } + + function* findKeys(object, key) { + if (key !== undefined) { + if (key in object) { + yield key; + } + } + else { + for (let key of Object.keys(object)) { + yield key; + } + } + } + + let query = { + sourceActorID: location.originalSourceActor ? location.originalSourceActor.actorID : undefined, + line: location.originalLine, + }; + + // If location contains a line, assume we are searching for a whole line + // breakpoint, and set begin/endColumn accordingly. Otherwise, we are + // searching for all breakpoints, so begin/endColumn should be left unset. + if (location.originalLine) { + query.beginColumn = location.originalColumn ? location.originalColumn : 0; + query.endColumn = location.originalColumn ? location.originalColumn + 1 : Infinity; + } else { + query.beginColumn = location.originalColumn ? query.originalColumn : undefined; + query.endColumn = location.originalColumn ? query.originalColumn + 1 : undefined; + } + + for (let sourceActorID of findKeys(this._actors, query.sourceActorID)) + for (let line of findKeys(this._actors[sourceActorID], query.line)) + for (let beginColumn of findKeys(this._actors[sourceActorID][line], query.beginColumn)) + for (let endColumn of findKeys(this._actors[sourceActorID][line][beginColumn], query.endColumn)) { + yield this._actors[sourceActorID][line][beginColumn][endColumn]; + } + }, + + /** + * Return the BreakpointActor at the given location in this + * BreakpointActorMap. + * + * @param OriginalLocation location + * The location for which the BreakpointActor should be returned. + * + * @returns BreakpointActor actor + * The BreakpointActor at the given location. + */ + getActor: function (originalLocation) { + for (let actor of this.findActors(originalLocation)) { + return actor; + } + + return null; + }, + + /** + * Set the given BreakpointActor to the given location in this + * BreakpointActorMap. + * + * @param OriginalLocation location + * The location to which the given BreakpointActor should be set. + * + * @param BreakpointActor actor + * The BreakpointActor to be set to the given location. + */ + setActor: function (location, actor) { + let { originalSourceActor, originalLine, originalColumn } = location; + + let sourceActorID = originalSourceActor.actorID; + let line = originalLine; + let beginColumn = originalColumn ? originalColumn : 0; + let endColumn = originalColumn ? originalColumn + 1 : Infinity; + + if (!this._actors[sourceActorID]) { + this._actors[sourceActorID] = []; + } + if (!this._actors[sourceActorID][line]) { + this._actors[sourceActorID][line] = []; + } + if (!this._actors[sourceActorID][line][beginColumn]) { + this._actors[sourceActorID][line][beginColumn] = []; + } + if (!this._actors[sourceActorID][line][beginColumn][endColumn]) { + ++this._size; + } + this._actors[sourceActorID][line][beginColumn][endColumn] = actor; + }, + + /** + * Delete the BreakpointActor from the given location in this + * BreakpointActorMap. + * + * @param OriginalLocation location + * The location from which the BreakpointActor should be deleted. + */ + deleteActor: function (location) { + let { originalSourceActor, originalLine, originalColumn } = location; + + let sourceActorID = originalSourceActor.actorID; + let line = originalLine; + let beginColumn = originalColumn ? originalColumn : 0; + let endColumn = originalColumn ? originalColumn + 1 : Infinity; + + if (this._actors[sourceActorID]) { + if (this._actors[sourceActorID][line]) { + if (this._actors[sourceActorID][line][beginColumn]) { + if (this._actors[sourceActorID][line][beginColumn][endColumn]) { + --this._size; + } + delete this._actors[sourceActorID][line][beginColumn][endColumn]; + if (Object.keys(this._actors[sourceActorID][line][beginColumn]).length === 0) { + delete this._actors[sourceActorID][line][beginColumn]; + } + } + if (Object.keys(this._actors[sourceActorID][line]).length === 0) { + delete this._actors[sourceActorID][line]; + } + } + } + } +}; + +exports.BreakpointActorMap = BreakpointActorMap; + +/** + * Keeps track of persistent sources across reloads and ties different + * source instances to the same actor id so that things like + * breakpoints survive reloads. ThreadSources uses this to force the + * same actorID on a SourceActor. + */ +function SourceActorStore() { + // source identifier --> actor id + this._sourceActorIds = Object.create(null); +} + +SourceActorStore.prototype = { + /** + * Lookup an existing actor id that represents this source, if available. + */ + getReusableActorId: function (aSource, aOriginalUrl) { + let url = this.getUniqueKey(aSource, aOriginalUrl); + if (url && url in this._sourceActorIds) { + return this._sourceActorIds[url]; + } + return null; + }, + + /** + * Update a source with an actorID. + */ + setReusableActorId: function (aSource, aOriginalUrl, actorID) { + let url = this.getUniqueKey(aSource, aOriginalUrl); + if (url) { + this._sourceActorIds[url] = actorID; + } + }, + + /** + * Make a unique URL from a source that identifies it across reloads. + */ + getUniqueKey: function (aSource, aOriginalUrl) { + if (aOriginalUrl) { + // Original source from a sourcemap. + return aOriginalUrl; + } + else { + return getSourceURL(aSource); + } + } +}; + +exports.SourceActorStore = SourceActorStore; + +/** + * Manages pushing event loops and automatically pops and exits them in the + * correct order as they are resolved. + * + * @param ThreadActor thread + * The thread actor instance that owns this EventLoopStack. + * @param DebuggerServerConnection connection + * The remote protocol connection associated with this event loop stack. + * @param Object hooks + * An object with the following properties: + * - url: The URL string of the debuggee we are spinning an event loop + * for. + * - preNest: function called before entering a nested event loop + * - postNest: function called after exiting a nested event loop + */ +function EventLoopStack({ thread, connection, hooks }) { + this._hooks = hooks; + this._thread = thread; + this._connection = connection; +} + +EventLoopStack.prototype = { + /** + * The number of nested event loops on the stack. + */ + get size() { + return xpcInspector.eventLoopNestLevel; + }, + + /** + * The URL of the debuggee who pushed the event loop on top of the stack. + */ + get lastPausedUrl() { + let url = null; + if (this.size > 0) { + try { + url = xpcInspector.lastNestRequestor.url; + } catch (e) { + // The tab's URL getter may throw if the tab is destroyed by the time + // this code runs, but we don't really care at this point. + dumpn(e); + } + } + return url; + }, + + /** + * The DebuggerServerConnection of the debugger who pushed the event loop on + * top of the stack + */ + get lastConnection() { + return xpcInspector.lastNestRequestor._connection; + }, + + /** + * Push a new nested event loop onto the stack. + * + * @returns EventLoop + */ + push: function () { + return new EventLoop({ + thread: this._thread, + connection: this._connection, + hooks: this._hooks + }); + } +}; + +/** + * 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. + * @param DebuggerServerConnection connection + * The remote protocol connection associated with this event loop. + * @param Object hooks + * The same hooks object passed into EventLoopStack during its + * initialization. + */ +function EventLoop({ thread, connection, hooks }) { + this._thread = thread; + this._hooks = hooks; + this._connection = connection; + + this.enter = this.enter.bind(this); + this.resolve = this.resolve.bind(this); +} + +EventLoop.prototype = { + entered: false, + resolved: false, + get url() { return this._hooks.url; }, + + /** + * Enter this nested event loop. + */ + enter: function () { + let nestData = this._hooks.preNest + ? this._hooks.preNest() + : null; + + this.entered = true; + xpcInspector.enterNestedEventLoop(this); + + // Keep exiting nested event loops while the last requestor is resolved. + if (xpcInspector.eventLoopNestLevel > 0) { + const { resolved } = xpcInspector.lastNestRequestor; + if (resolved) { + xpcInspector.exitNestedEventLoop(); + } + } + + if (this._hooks.postNest) { + this._hooks.postNest(nestData); + } + }, + + /** + * Resolve 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 resolved yet. + */ + resolve: function () { + if (!this.entered) { + throw new Error("Can't resolve an event loop before it has been entered!"); + } + if (this.resolved) { + throw new Error("Already resolved this nested event loop!"); + } + this.resolved = true; + if (this === xpcInspector.lastNestRequestor) { + xpcInspector.exitNestedEventLoop(); + return true; + } + return false; + }, +}; + +/** + * JSD2 actors. + */ + +/** + * Creates a ThreadActor. + * + * ThreadActors manage a JSInspector object and manage execution/inspection + * of debuggees. + * + * @param aParent object + * This |ThreadActor|'s parent actor. It must implement the following + * properties: + * - url: The URL string of the debuggee. + * - window: The global window object. + * - preNest: Function called before entering a nested event loop. + * - postNest: Function called after exiting a nested event loop. + * - makeDebugger: A function that takes no arguments and instantiates + * a Debugger that manages its globals on its own. + * @param aGlobal object [optional] + * An optional (for content debugging only) reference to the content + * window. + */ +const ThreadActor = ActorClassWithSpec(threadSpec, { + initialize: function (aParent, aGlobal) { + this._state = "detached"; + this._frameActors = []; + this._parent = aParent; + this._dbg = null; + this._gripDepth = 0; + this._threadLifetimePool = null; + this._tabClosed = false; + this._scripts = null; + this._pauseOnDOMEvents = null; + + this._options = { + useSourceMaps: false, + autoBlackBox: false + }; + + this.breakpointActorMap = new BreakpointActorMap(); + this.sourceActorStore = new SourceActorStore(); + + this._debuggerSourcesSeen = null; + + // A map of actorID -> actor for breakpoints created and managed by the + // server. + this._hiddenBreakpoints = new Map(); + + this.global = aGlobal; + + this._allEventsListener = this._allEventsListener.bind(this); + this.onNewGlobal = this.onNewGlobal.bind(this); + this.onSourceEvent = this.onSourceEvent.bind(this); + this.uncaughtExceptionHook = this.uncaughtExceptionHook.bind(this); + this.onDebuggerStatement = this.onDebuggerStatement.bind(this); + this.onNewScript = this.onNewScript.bind(this); + this.objectGrip = this.objectGrip.bind(this); + this.pauseObjectGrip = this.pauseObjectGrip.bind(this); + this._onWindowReady = this._onWindowReady.bind(this); + events.on(this._parent, "window-ready", this._onWindowReady); + // Set a wrappedJSObject property so |this| can be sent via the observer svc + // for the xpcshell harness. + this.wrappedJSObject = this; + }, + + // Used by the ObjectActor to keep track of the depth of grip() calls. + _gripDepth: null, + + get dbg() { + if (!this._dbg) { + this._dbg = this._parent.makeDebugger(); + this._dbg.uncaughtExceptionHook = this.uncaughtExceptionHook; + this._dbg.onDebuggerStatement = this.onDebuggerStatement; + this._dbg.onNewScript = this.onNewScript; + this._dbg.on("newGlobal", this.onNewGlobal); + // Keep the debugger disabled until a client attaches. + this._dbg.enabled = this._state != "detached"; + } + return this._dbg; + }, + + get globalDebugObject() { + if (!this._parent.window) { + return null; + } + return this.dbg.makeGlobalObjectReference(this._parent.window); + }, + + get state() { + return this._state; + }, + + get attached() { + return this.state == "attached" || + this.state == "running" || + this.state == "paused"; + }, + + get threadLifetimePool() { + if (!this._threadLifetimePool) { + this._threadLifetimePool = new ActorPool(this.conn); + this.conn.addActorPool(this._threadLifetimePool); + this._threadLifetimePool.objectActors = new WeakMap(); + } + return this._threadLifetimePool; + }, + + get sources() { + return this._parent.sources; + }, + + get youngestFrame() { + if (this.state != "paused") { + return null; + } + return this.dbg.getNewestFrame(); + }, + + _prettyPrintWorker: null, + get prettyPrintWorker() { + if (!this._prettyPrintWorker) { + this._prettyPrintWorker = new DevToolsWorker( + "resource://devtools/server/actors/pretty-print-worker.js", + { name: "pretty-print", + verbose: flags.wantLogging } + ); + } + return this._prettyPrintWorker; + }, + + /** + * Keep track of all of the nested event loops we use to pause the debuggee + * when we hit a breakpoint/debugger statement/etc in one place so we can + * resolve them when we get resume packets. We have more than one (and keep + * them in a stack) because we can pause within client evals. + */ + _threadPauseEventLoops: null, + _pushThreadPause: function () { + if (!this._threadPauseEventLoops) { + this._threadPauseEventLoops = []; + } + const eventLoop = this._nestedEventLoops.push(); + this._threadPauseEventLoops.push(eventLoop); + eventLoop.enter(); + }, + _popThreadPause: function () { + const eventLoop = this._threadPauseEventLoops.pop(); + assert(eventLoop, "Should have an event loop."); + eventLoop.resolve(); + }, + + /** + * Remove all debuggees and clear out the thread's sources. + */ + clearDebuggees: function () { + if (this._dbg) { + this.dbg.removeAllDebuggees(); + } + this._sources = null; + this._scripts = null; + }, + + /** + * Listener for our |Debugger|'s "newGlobal" event. + */ + onNewGlobal: function (aGlobal) { + // Notify the client. + this.conn.send({ + from: this.actorID, + type: "newGlobal", + // TODO: after bug 801084 lands see if we need to JSONify this. + hostAnnotations: aGlobal.hostAnnotations + }); + }, + + disconnect: function () { + dumpn("in ThreadActor.prototype.disconnect"); + if (this._state == "paused") { + this.onResume(); + } + + // Blow away our source actor ID store because those IDs are only + // valid for this connection. This is ok because we never keep + // things like breakpoints across connections. + this._sourceActorStore = null; + + events.off(this._parent, "window-ready", this._onWindowReady); + this.sources.off("newSource", this.onSourceEvent); + this.sources.off("updatedSource", this.onSourceEvent); + this.clearDebuggees(); + this.conn.removeActorPool(this._threadLifetimePool); + this._threadLifetimePool = null; + + if (this._prettyPrintWorker) { + this._prettyPrintWorker.destroy(); + this._prettyPrintWorker = null; + } + + if (!this._dbg) { + return; + } + this._dbg.enabled = false; + this._dbg = null; + }, + + /** + * Disconnect the debugger and put the actor in the exited state. + */ + exit: function () { + this.disconnect(); + this._state = "exited"; + }, + + // Request handlers + onAttach: function (aRequest) { + if (this.state === "exited") { + return { type: "exited" }; + } + + if (this.state !== "detached") { + return { error: "wrongState", + message: "Current state is " + this.state }; + } + + this._state = "attached"; + this._debuggerSourcesSeen = new WeakSet(); + + Object.assign(this._options, aRequest.options || {}); + this.sources.setOptions(this._options); + this.sources.on("newSource", this.onSourceEvent); + this.sources.on("updatedSource", this.onSourceEvent); + + // Initialize an event loop stack. This can't be done in the constructor, + // because this.conn is not yet initialized by the actor pool at that time. + this._nestedEventLoops = new EventLoopStack({ + hooks: this._parent, + connection: this.conn, + thread: this + }); + + this.dbg.addDebuggees(); + this.dbg.enabled = true; + try { + // Put ourselves in the paused state. + let packet = this._paused(); + if (!packet) { + return { error: "notAttached" }; + } + packet.why = { type: "attached" }; + + // Send the response to the attach request now (rather than + // returning it), because we're going to start a nested event loop + // here. + this.conn.send(packet); + + // Start a nested event loop. + this._pushThreadPause(); + + // We already sent a response to this request, don't send one + // now. + return null; + } catch (e) { + reportError(e); + return { error: "notAttached", message: e.toString() }; + } + }, + + onDetach: function (aRequest) { + this.disconnect(); + this._state = "detached"; + this._debuggerSourcesSeen = null; + + dumpn("ThreadActor.prototype.onDetach: returning 'detached' packet"); + return { + type: "detached" + }; + }, + + onReconfigure: function (aRequest) { + if (this.state == "exited") { + return { error: "wrongState" }; + } + const options = aRequest.options || {}; + + if ("observeAsmJS" in options) { + this.dbg.allowUnobservedAsmJS = !options.observeAsmJS; + } + + Object.assign(this._options, options); + + // Update the global source store + this.sources.setOptions(options); + + return {}; + }, + + /** + * Pause the debuggee, by entering a nested event loop, and return a 'paused' + * packet to the client. + * + * @param Debugger.Frame aFrame + * The newest debuggee frame in the stack. + * @param object aReason + * An object with a 'type' property containing the reason for the pause. + * @param function onPacket + * Hook to modify the packet before it is sent. Feel free to return a + * promise. + */ + _pauseAndRespond: function (aFrame, aReason, onPacket = function (k) { return k; }) { + try { + let packet = this._paused(aFrame); + if (!packet) { + return undefined; + } + packet.why = aReason; + + let generatedLocation = this.sources.getFrameLocation(aFrame); + this.sources.getOriginalLocation(generatedLocation) + .then((originalLocation) => { + if (!originalLocation.originalSourceActor) { + // The only time the source actor will be null is if there + // was a sourcemap and it tried to look up the original + // location but there was no original URL. This is a strange + // scenario so we simply don't pause. + DevToolsUtils.reportException( + "ThreadActor", + new Error("Attempted to pause in a script with a sourcemap but " + + "could not find original location.") + ); + + return undefined; + } + + packet.frame.where = { + source: originalLocation.originalSourceActor.form(), + line: originalLocation.originalLine, + column: originalLocation.originalColumn + }; + resolve(onPacket(packet)) + .then(null, error => { + reportError(error); + return { + error: "unknownError", + message: error.message + "\n" + error.stack + }; + }) + .then(packet => { + this.conn.send(packet); + }); + }); + + this._pushThreadPause(); + } catch (e) { + reportError(e, "Got an exception during TA__pauseAndRespond: "); + } + + // If the browser tab has been closed, terminate the debuggee script + // instead of continuing. Executing JS after the content window is gone is + // a bad idea. + return this._tabClosed ? null : undefined; + }, + + _makeOnEnterFrame: function ({ pauseAndRespond }) { + return aFrame => { + const generatedLocation = this.sources.getFrameLocation(aFrame); + let { originalSourceActor } = this.unsafeSynchronize(this.sources.getOriginalLocation( + generatedLocation)); + let url = originalSourceActor.url; + + return this.sources.isBlackBoxed(url) + ? undefined + : pauseAndRespond(aFrame); + }; + }, + + _makeOnPop: function ({ thread, pauseAndRespond, createValueGrip }) { + return function (aCompletion) { + // onPop is called with 'this' set to the current frame. + + const generatedLocation = thread.sources.getFrameLocation(this); + const { originalSourceActor } = thread.unsafeSynchronize(thread.sources.getOriginalLocation( + generatedLocation)); + const url = originalSourceActor.url; + + if (thread.sources.isBlackBoxed(url)) { + return undefined; + } + + // Note that we're popping this frame; we need to watch for + // subsequent step events on its caller. + this.reportedPop = true; + + return pauseAndRespond(this, aPacket => { + aPacket.why.frameFinished = {}; + if (!aCompletion) { + aPacket.why.frameFinished.terminated = true; + } else if (aCompletion.hasOwnProperty("return")) { + aPacket.why.frameFinished.return = createValueGrip(aCompletion.return); + } else if (aCompletion.hasOwnProperty("yield")) { + aPacket.why.frameFinished.return = createValueGrip(aCompletion.yield); + } else { + aPacket.why.frameFinished.throw = createValueGrip(aCompletion.throw); + } + return aPacket; + }); + }; + }, + + _makeOnStep: function ({ thread, pauseAndRespond, startFrame, + startLocation, steppingType }) { + // Breaking in place: we should always pause. + if (steppingType === "break") { + return function () { + return pauseAndRespond(this); + }; + } + + // Otherwise take what a "step" means into consideration. + return function () { + // onStep is called with 'this' set to the current frame. + + // Only allow stepping stops at entry points for the line, when + // the stepping occurs in a single frame. The "same frame" + // check makes it so a sequence of steps can step out of a frame + // and into subsequent calls in the outer frame. E.g., if there + // is a call "a(b())" and the user steps into b, then this + // condition makes it possible to step out of b and into a. + if (this === startFrame && + !this.script.getOffsetLocation(this.offset).isEntryPoint) { + return undefined; + } + + const generatedLocation = thread.sources.getFrameLocation(this); + const newLocation = thread.unsafeSynchronize(thread.sources.getOriginalLocation( + generatedLocation)); + + // Cases when we should pause because we have executed enough to consider + // a "step" to have occured: + // + // 1.1. We change frames. + // 1.2. We change URLs (can happen without changing frames thanks to + // source mapping). + // 1.3. We change lines. + // + // Cases when we should always continue execution, even if one of the + // above cases is true: + // + // 2.1. We are in a source mapped region, but inside a null mapping + // (doesn't correlate to any region of original source) + // 2.2. The source we are in is black boxed. + + // Cases 2.1 and 2.2 + if (newLocation.originalUrl == null + || thread.sources.isBlackBoxed(newLocation.originalUrl)) { + return undefined; + } + + // Cases 1.1, 1.2 and 1.3 + if (this !== startFrame + || startLocation.originalUrl !== newLocation.originalUrl + || startLocation.originalLine !== newLocation.originalLine) { + return pauseAndRespond(this); + } + + // Otherwise, let execution continue (we haven't executed enough code to + // consider this a "step" yet). + return undefined; + }; + }, + + /** + * Define the JS hook functions for stepping. + */ + _makeSteppingHooks: function (aStartLocation, steppingType) { + // Bind these methods and state because some of the hooks are called + // with 'this' set to the current frame. Rather than repeating the + // binding in each _makeOnX method, just do it once here and pass it + // in to each function. + const steppingHookState = { + pauseAndRespond: (aFrame, onPacket = k=>k) => { + return this._pauseAndRespond(aFrame, { type: "resumeLimit" }, onPacket); + }, + createValueGrip: v => createValueGrip(v, this._pausePool, + this.objectGrip), + thread: this, + startFrame: this.youngestFrame, + startLocation: aStartLocation, + steppingType: steppingType + }; + + return { + onEnterFrame: this._makeOnEnterFrame(steppingHookState), + onPop: this._makeOnPop(steppingHookState), + onStep: this._makeOnStep(steppingHookState) + }; + }, + + /** + * Handle attaching the various stepping hooks we need to attach when we + * receive a resume request with a resumeLimit property. + * + * @param Object aRequest + * The request packet received over the RDP. + * @returns A promise that resolves to true once the hooks are attached, or is + * rejected with an error packet. + */ + _handleResumeLimit: function (aRequest) { + let steppingType = aRequest.resumeLimit.type; + if (["break", "step", "next", "finish"].indexOf(steppingType) == -1) { + return reject({ error: "badParameterType", + message: "Unknown resumeLimit type" }); + } + + const generatedLocation = this.sources.getFrameLocation(this.youngestFrame); + return this.sources.getOriginalLocation(generatedLocation) + .then(originalLocation => { + const { onEnterFrame, onPop, onStep } = this._makeSteppingHooks(originalLocation, + steppingType); + + // Make sure there is still a frame on the stack if we are to continue + // stepping. + let stepFrame = this._getNextStepFrame(this.youngestFrame); + if (stepFrame) { + switch (steppingType) { + case "step": + this.dbg.onEnterFrame = onEnterFrame; + // Fall through. + case "break": + case "next": + if (stepFrame.script) { + stepFrame.onStep = onStep; + } + stepFrame.onPop = onPop; + break; + case "finish": + stepFrame.onPop = onPop; + } + } + + return true; + }); + }, + + /** + * Clear the onStep and onPop hooks from the given frame and all of the frames + * below it. + * + * @param Debugger.Frame aFrame + * The frame we want to clear the stepping hooks from. + */ + _clearSteppingHooks: function (aFrame) { + if (aFrame && aFrame.live) { + while (aFrame) { + aFrame.onStep = undefined; + aFrame.onPop = undefined; + aFrame = aFrame.older; + } + } + }, + + /** + * Listen to the debuggee's DOM events if we received a request to do so. + * + * @param Object aRequest + * The resume request packet received over the RDP. + */ + _maybeListenToEvents: function (aRequest) { + // Break-on-DOMEvents is only supported in content debugging. + let events = aRequest.pauseOnDOMEvents; + if (this.global && events && + (events == "*" || + (Array.isArray(events) && events.length))) { + this._pauseOnDOMEvents = events; + let els = Cc["@mozilla.org/eventlistenerservice;1"] + .getService(Ci.nsIEventListenerService); + els.addListenerForAllEvents(this.global, this._allEventsListener, true); + } + }, + + /** + * If we are tasked with breaking on the load event, we have to add the + * listener early enough. + */ + _onWindowReady: function () { + this._maybeListenToEvents({ + pauseOnDOMEvents: this._pauseOnDOMEvents + }); + }, + + /** + * Handle a protocol request to resume execution of the debuggee. + */ + onResume: function (aRequest) { + if (this._state !== "paused") { + return { + error: "wrongState", + message: "Can't resume when debuggee isn't paused. Current state is '" + + this._state + "'", + state: this._state + }; + } + + // In case of multiple nested event loops (due to multiple debuggers open in + // different tabs or multiple debugger clients connected to the same tab) + // only allow resumption in a LIFO order. + if (this._nestedEventLoops.size && this._nestedEventLoops.lastPausedUrl + && (this._nestedEventLoops.lastPausedUrl !== this._parent.url + || this._nestedEventLoops.lastConnection !== this.conn)) { + return { + error: "wrongOrder", + message: "trying to resume in the wrong order.", + lastPausedUrl: this._nestedEventLoops.lastPausedUrl + }; + } + + let resumeLimitHandled; + if (aRequest && aRequest.resumeLimit) { + resumeLimitHandled = this._handleResumeLimit(aRequest); + } else { + this._clearSteppingHooks(this.youngestFrame); + resumeLimitHandled = resolve(true); + } + + return resumeLimitHandled.then(() => { + if (aRequest) { + this._options.pauseOnExceptions = aRequest.pauseOnExceptions; + this._options.ignoreCaughtExceptions = aRequest.ignoreCaughtExceptions; + this.maybePauseOnExceptions(); + this._maybeListenToEvents(aRequest); + } + + let packet = this._resumed(); + this._popThreadPause(); + // Tell anyone who cares of the resume (as of now, that's the xpcshell + // harness) + if (Services.obs) { + Services.obs.notifyObservers(this, "devtools-thread-resumed", null); + } + return packet; + }, error => { + return error instanceof Error + ? { error: "unknownError", + message: DevToolsUtils.safeErrorString(error) } + // It is a known error, and the promise was rejected with an error + // packet. + : error; + }); + }, + + /** + * Spin up a nested event loop so we can synchronously resolve a promise. + * + * DON'T USE THIS UNLESS YOU ABSOLUTELY MUST! Nested event loops suck: the + * world's state can change out from underneath your feet because JS is no + * longer run-to-completion. + * + * @param aPromise + * The promise we want to resolve. + * @returns The promise's resolution. + */ + unsafeSynchronize: function (aPromise) { + let needNest = true; + let eventLoop; + let returnVal; + + aPromise + .then((aResolvedVal) => { + needNest = false; + returnVal = aResolvedVal; + }) + .then(null, (aError) => { + reportError(aError, "Error inside unsafeSynchronize:"); + }) + .then(() => { + if (eventLoop) { + eventLoop.resolve(); + } + }); + + if (needNest) { + eventLoop = this._nestedEventLoops.push(); + eventLoop.enter(); + } + + return returnVal; + }, + + /** + * Set the debugging hook to pause on exceptions if configured to do so. + */ + maybePauseOnExceptions: function () { + if (this._options.pauseOnExceptions) { + this.dbg.onExceptionUnwind = this.onExceptionUnwind.bind(this); + } + }, + + /** + * A listener that gets called for every event fired on the page, when a list + * of interesting events was provided with the pauseOnDOMEvents property. It + * is used to set server-managed breakpoints on any existing event listeners + * for those events. + * + * @param Event event + * The event that was fired. + */ + _allEventsListener: function (event) { + if (this._pauseOnDOMEvents == "*" || + this._pauseOnDOMEvents.indexOf(event.type) != -1) { + for (let listener of this._getAllEventListeners(event.target)) { + if (event.type == listener.type || this._pauseOnDOMEvents == "*") { + this._breakOnEnter(listener.script); + } + } + } + }, + + /** + * Return an array containing all the event listeners attached to the + * specified event target and its ancestors in the event target chain. + * + * @param EventTarget eventTarget + * The target the event was dispatched on. + * @returns Array + */ + _getAllEventListeners: function (eventTarget) { + let els = Cc["@mozilla.org/eventlistenerservice;1"] + .getService(Ci.nsIEventListenerService); + + let targets = els.getEventTargetChainFor(eventTarget, true); + let listeners = []; + + for (let target of targets) { + let handlers = els.getListenerInfoFor(target); + for (let handler of handlers) { + // Null is returned for all-events handlers, and native event listeners + // don't provide any listenerObject, which makes them not that useful to + // a JS debugger. + if (!handler || !handler.listenerObject || !handler.type) + continue; + // Create a listener-like object suitable for our purposes. + let l = Object.create(null); + l.type = handler.type; + let listener = handler.listenerObject; + let listenerDO = this.globalDebugObject.makeDebuggeeValue(listener); + // If the listener is not callable, assume it is an event handler object. + if (!listenerDO.callable) { + // For some events we don't have permission to access the + // 'handleEvent' property when running in content scope. + if (!listenerDO.unwrap()) { + continue; + } + let heDesc; + while (!heDesc && listenerDO) { + heDesc = listenerDO.getOwnPropertyDescriptor("handleEvent"); + listenerDO = listenerDO.proto; + } + if (heDesc && heDesc.value) { + listenerDO = heDesc.value; + } + } + // When the listener is a bound function, we are actually interested in + // the target function. + while (listenerDO.isBoundFunction) { + listenerDO = listenerDO.boundTargetFunction; + } + l.script = listenerDO.script; + // Chrome listeners won't be converted to debuggee values, since their + // compartment is not added as a debuggee. + if (!l.script) + continue; + listeners.push(l); + } + } + return listeners; + }, + + /** + * Set a breakpoint on the first line of the given script that has an entry + * point. + */ + _breakOnEnter: function (script) { + let offsets = script.getAllOffsets(); + for (let line = 0, n = offsets.length; line < n; line++) { + if (offsets[line]) { + // N.B. Hidden breakpoints do not have an original location, and are not + // stored in the breakpoint actor map. + let actor = new BreakpointActor(this); + this.threadLifetimePool.addActor(actor); + + let scripts = this.dbg.findScripts({ source: script.source, line: line }); + let entryPoints = findEntryPointsForLine(scripts, line); + setBreakpointAtEntryPoints(actor, entryPoints); + this._hiddenBreakpoints.set(actor.actorID, actor); + break; + } + } + }, + + /** + * Helper method that returns the next frame when stepping. + */ + _getNextStepFrame: function (aFrame) { + let stepFrame = aFrame.reportedPop ? aFrame.older : aFrame; + if (!stepFrame || !stepFrame.script) { + stepFrame = null; + } + return stepFrame; + }, + + onClientEvaluate: function (aRequest) { + if (this.state !== "paused") { + return { error: "wrongState", + message: "Debuggee must be paused to evaluate code." }; + } + + let frame = this._requestFrame(aRequest.frame); + if (!frame) { + return { error: "unknownFrame", + message: "Evaluation frame not found" }; + } + + if (!frame.environment) { + return { error: "notDebuggee", + message: "cannot access the environment of this frame." }; + } + + let youngest = this.youngestFrame; + + // Put ourselves back in the running state and inform the client. + let resumedPacket = this._resumed(); + this.conn.send(resumedPacket); + + // Run the expression. + // XXX: test syntax errors + let completion = frame.eval(aRequest.expression); + + // Put ourselves back in the pause state. + let packet = this._paused(youngest); + packet.why = { type: "clientEvaluated", + frameFinished: this.createProtocolCompletionValue(completion) }; + + // Return back to our previous pause's event loop. + return packet; + }, + + onFrames: function (aRequest) { + if (this.state !== "paused") { + return { error: "wrongState", + message: "Stack frames are only available while the debuggee is paused."}; + } + + let start = aRequest.start ? aRequest.start : 0; + let count = aRequest.count; + + // Find the starting frame... + let frame = this.youngestFrame; + let i = 0; + while (frame && (i < start)) { + frame = frame.older; + i++; + } + + // Return request.count frames, or all remaining + // frames if count is not defined. + let promises = []; + for (; frame && (!count || i < (start + count)); i++, frame = frame.older) { + let form = this._createFrameActor(frame).form(); + form.depth = i; + + let promise = this.sources.getOriginalLocation(new GeneratedLocation( + this.sources.createNonSourceMappedActor(frame.script.source), + form.where.line, + form.where.column + )).then((originalLocation) => { + if (!originalLocation.originalSourceActor) { + return null; + } + + let sourceForm = originalLocation.originalSourceActor.form(); + form.where = { + source: sourceForm, + line: originalLocation.originalLine, + column: originalLocation.originalColumn + }; + form.source = sourceForm; + return form; + }); + promises.push(promise); + } + + return all(promises).then(function (frames) { + // Filter null values because sourcemapping may have failed. + return { frames: frames.filter(x => !!x) }; + }); + }, + + onReleaseMany: function (aRequest) { + if (!aRequest.actors) { + return { error: "missingParameter", + message: "no actors were specified" }; + } + + let res; + for (let actorID of aRequest.actors) { + let actor = this.threadLifetimePool.get(actorID); + if (!actor) { + if (!res) { + res = { error: "notReleasable", + message: "Only thread-lifetime actors can be released." }; + } + continue; + } + actor.onRelease(); + } + return res ? res : {}; + }, + + /** + * Get the script and source lists from the debugger. + */ + _discoverSources: function () { + // Only get one script per Debugger.Source. + const sourcesToScripts = new Map(); + const scripts = this.dbg.findScripts(); + + for (let i = 0, len = scripts.length; i < len; i++) { + let s = scripts[i]; + if (s.source) { + sourcesToScripts.set(s.source, s); + } + } + + return all([...sourcesToScripts.values()].map(script => { + return this.sources.createSourceActors(script.source); + })); + }, + + onSources: function (aRequest) { + return this._discoverSources().then(() => { + // No need to flush the new source packets here, as we are sending the + // list of sources out immediately and we don't need to invoke the + // overhead of an RDP packet for every source right now. Let the default + // timeout flush the buffered packets. + + return { + sources: this.sources.iter().map(s => s.form()) + }; + }); + }, + + /** + * Disassociate all breakpoint actors from their scripts and clear the + * breakpoint handlers. This method can be used when the thread actor intends + * to keep the breakpoint store, but needs to clear any actual breakpoints, + * e.g. due to a page navigation. This way the breakpoint actors' script + * caches won't hold on to the Debugger.Script objects leaking memory. + */ + disableAllBreakpoints: function () { + for (let bpActor of this.breakpointActorMap.findActors()) { + bpActor.removeScripts(); + } + }, + + + /** + * Handle a protocol request to pause the debuggee. + */ + onInterrupt: function (aRequest) { + if (this.state == "exited") { + return { type: "exited" }; + } else if (this.state == "paused") { + // TODO: return the actual reason for the existing pause. + return { type: "paused", why: { type: "alreadyPaused" } }; + } else if (this.state != "running") { + return { error: "wrongState", + message: "Received interrupt request in " + this.state + + " state." }; + } + + try { + // If execution should pause just before the next JavaScript bytecode is + // executed, just set an onEnterFrame handler. + if (aRequest.when == "onNext") { + let onEnterFrame = (aFrame) => { + return this._pauseAndRespond(aFrame, { type: "interrupted", onNext: true }); + }; + this.dbg.onEnterFrame = onEnterFrame; + + return { type: "willInterrupt" }; + } + + // If execution should pause immediately, just put ourselves in the paused + // state. + let packet = this._paused(); + if (!packet) { + return { error: "notInterrupted" }; + } + packet.why = { type: "interrupted" }; + + // Send the response to the interrupt request now (rather than + // returning it), because we're going to start a nested event loop + // here. + this.conn.send(packet); + + // Start a nested event loop. + this._pushThreadPause(); + + // We already sent a response to this request, don't send one + // now. + return null; + } catch (e) { + reportError(e); + return { error: "notInterrupted", message: e.toString() }; + } + }, + + /** + * Handle a protocol request to retrieve all the event listeners on the page. + */ + onEventListeners: function (aRequest) { + // This request is only supported in content debugging. + if (!this.global) { + return { + error: "notImplemented", + message: "eventListeners request is only supported in content debugging" + }; + } + + let els = Cc["@mozilla.org/eventlistenerservice;1"] + .getService(Ci.nsIEventListenerService); + + let nodes = this.global.document.getElementsByTagName("*"); + nodes = [this.global].concat([].slice.call(nodes)); + let listeners = []; + + for (let node of nodes) { + let handlers = els.getListenerInfoFor(node); + + for (let handler of handlers) { + // Create a form object for serializing the listener via the protocol. + let listenerForm = Object.create(null); + let listener = handler.listenerObject; + // Native event listeners don't provide any listenerObject or type and + // are not that useful to a JS debugger. + if (!listener || !handler.type) { + continue; + } + + // There will be no tagName if the event listener is set on the window. + let selector = node.tagName ? CssLogic.findCssSelector(node) : "window"; + let nodeDO = this.globalDebugObject.makeDebuggeeValue(node); + listenerForm.node = { + selector: selector, + object: createValueGrip(nodeDO, this._pausePool, this.objectGrip) + }; + listenerForm.type = handler.type; + listenerForm.capturing = handler.capturing; + listenerForm.allowsUntrusted = handler.allowsUntrusted; + listenerForm.inSystemEventGroup = handler.inSystemEventGroup; + let handlerName = "on" + listenerForm.type; + listenerForm.isEventHandler = false; + if (typeof node.hasAttribute !== "undefined") { + listenerForm.isEventHandler = !!node.hasAttribute(handlerName); + } + if (!!node[handlerName]) { + listenerForm.isEventHandler = !!node[handlerName]; + } + // Get the Debugger.Object for the listener object. + let listenerDO = this.globalDebugObject.makeDebuggeeValue(listener); + // If the listener is an object with a 'handleEvent' method, use that. + if (listenerDO.class == "Object" || listenerDO.class == "XULElement") { + // For some events we don't have permission to access the + // 'handleEvent' property when running in content scope. + if (!listenerDO.unwrap()) { + continue; + } + let heDesc; + while (!heDesc && listenerDO) { + heDesc = listenerDO.getOwnPropertyDescriptor("handleEvent"); + listenerDO = listenerDO.proto; + } + if (heDesc && heDesc.value) { + listenerDO = heDesc.value; + } + } + // When the listener is a bound function, we are actually interested in + // the target function. + while (listenerDO.isBoundFunction) { + listenerDO = listenerDO.boundTargetFunction; + } + listenerForm.function = createValueGrip(listenerDO, this._pausePool, + this.objectGrip); + listeners.push(listenerForm); + } + } + return { listeners: listeners }; + }, + + /** + * Return the Debug.Frame for a frame mentioned by the protocol. + */ + _requestFrame: function (aFrameID) { + if (!aFrameID) { + return this.youngestFrame; + } + + if (this._framePool.has(aFrameID)) { + return this._framePool.get(aFrameID).frame; + } + + return undefined; + }, + + _paused: function (aFrame) { + // We don't handle nested pauses correctly. Don't try - if we're + // paused, just continue running whatever code triggered the pause. + // We don't want to actually have nested pauses (although we + // have nested event loops). If code runs in the debuggee during + // a pause, it should cause the actor to resume (dropping + // pause-lifetime actors etc) and then repause when complete. + + if (this.state === "paused") { + return undefined; + } + + // Clear stepping hooks. + this.dbg.onEnterFrame = undefined; + this.dbg.onExceptionUnwind = undefined; + if (aFrame) { + aFrame.onStep = undefined; + aFrame.onPop = undefined; + } + + // Clear DOM event breakpoints. + // XPCShell tests don't use actual DOM windows for globals and cause + // removeListenerForAllEvents to throw. + if (!isWorker && this.global && !this.global.toString().includes("Sandbox")) { + let els = Cc["@mozilla.org/eventlistenerservice;1"] + .getService(Ci.nsIEventListenerService); + els.removeListenerForAllEvents(this.global, this._allEventsListener, true); + for (let [, bp] of this._hiddenBreakpoints) { + bp.delete(); + } + this._hiddenBreakpoints.clear(); + } + + this._state = "paused"; + + // Create the actor pool that will hold the pause actor and its + // children. + assert(!this._pausePool, "No pause pool should exist yet"); + this._pausePool = new ActorPool(this.conn); + this.conn.addActorPool(this._pausePool); + + // Give children of the pause pool a quick link back to the + // thread... + this._pausePool.threadActor = this; + + // Create the pause actor itself... + assert(!this._pauseActor, "No pause actor should exist yet"); + this._pauseActor = new PauseActor(this._pausePool); + this._pausePool.addActor(this._pauseActor); + + // Update the list of frames. + let poppedFrames = this._updateFrames(); + + // Send off the paused packet and spin an event loop. + let packet = { from: this.actorID, + type: "paused", + actor: this._pauseActor.actorID }; + if (aFrame) { + packet.frame = this._createFrameActor(aFrame).form(); + } + + if (poppedFrames) { + packet.poppedFrames = poppedFrames; + } + + return packet; + }, + + _resumed: function () { + this._state = "running"; + + // Drop the actors in the pause actor pool. + this.conn.removeActorPool(this._pausePool); + + this._pausePool = null; + this._pauseActor = null; + + return { from: this.actorID, type: "resumed" }; + }, + + /** + * Expire frame actors for frames that have been popped. + * + * @returns A list of actor IDs whose frames have been popped. + */ + _updateFrames: function () { + let popped = []; + + // Create the actor pool that will hold the still-living frames. + let framePool = new ActorPool(this.conn); + let frameList = []; + + for (let frameActor of this._frameActors) { + if (frameActor.frame.live) { + framePool.addActor(frameActor); + frameList.push(frameActor); + } else { + popped.push(frameActor.actorID); + } + } + + // Remove the old frame actor pool, this will expire + // any actors that weren't added to the new pool. + if (this._framePool) { + this.conn.removeActorPool(this._framePool); + } + + this._frameActors = frameList; + this._framePool = framePool; + this.conn.addActorPool(framePool); + + return popped; + }, + + _createFrameActor: function (aFrame) { + if (aFrame.actor) { + return aFrame.actor; + } + + let actor = new FrameActor(aFrame, this); + this._frameActors.push(actor); + this._framePool.addActor(actor); + aFrame.actor = actor; + + return actor; + }, + + /** + * Create and return an environment actor that corresponds to the provided + * Debugger.Environment. + * @param Debugger.Environment aEnvironment + * The lexical environment we want to extract. + * @param object aPool + * The pool where the newly-created actor will be placed. + * @return The EnvironmentActor for aEnvironment or undefined for host + * functions or functions scoped to a non-debuggee global. + */ + createEnvironmentActor: function (aEnvironment, aPool) { + if (!aEnvironment) { + return undefined; + } + + if (aEnvironment.actor) { + return aEnvironment.actor; + } + + let actor = new EnvironmentActor(aEnvironment, this); + aPool.addActor(actor); + aEnvironment.actor = actor; + + return actor; + }, + + /** + * Return a protocol completion value representing the given + * Debugger-provided completion value. + */ + createProtocolCompletionValue: function (aCompletion) { + let protoValue = {}; + if (aCompletion == null) { + protoValue.terminated = true; + } else if ("return" in aCompletion) { + protoValue.return = createValueGrip(aCompletion.return, + this._pausePool, this.objectGrip); + } else if ("throw" in aCompletion) { + protoValue.throw = createValueGrip(aCompletion.throw, + this._pausePool, this.objectGrip); + } else { + protoValue.return = createValueGrip(aCompletion.yield, + this._pausePool, this.objectGrip); + } + return protoValue; + }, + + /** + * Create a grip for the given debuggee object. + * + * @param aValue Debugger.Object + * The debuggee object value. + * @param aPool ActorPool + * The actor pool where the new object actor will be added. + */ + objectGrip: function (aValue, aPool) { + if (!aPool.objectActors) { + aPool.objectActors = new WeakMap(); + } + + if (aPool.objectActors.has(aValue)) { + return aPool.objectActors.get(aValue).grip(); + } else if (this.threadLifetimePool.objectActors.has(aValue)) { + return this.threadLifetimePool.objectActors.get(aValue).grip(); + } + + let actor = new PauseScopedObjectActor(aValue, { + getGripDepth: () => this._gripDepth, + incrementGripDepth: () => this._gripDepth++, + decrementGripDepth: () => this._gripDepth--, + createValueGrip: v => createValueGrip(v, this._pausePool, + this.pauseObjectGrip), + sources: () => this.sources, + createEnvironmentActor: (env, pool) => + this.createEnvironmentActor(env, pool), + promote: () => this.threadObjectGrip(actor), + isThreadLifetimePool: () => + actor.registeredPool !== this.threadLifetimePool, + getGlobalDebugObject: () => this.globalDebugObject + }); + aPool.addActor(actor); + aPool.objectActors.set(aValue, actor); + return actor.grip(); + }, + + /** + * Create a grip for the given debuggee object with a pause lifetime. + * + * @param aValue Debugger.Object + * The debuggee object value. + */ + pauseObjectGrip: function (aValue) { + if (!this._pausePool) { + throw "Object grip requested while not paused."; + } + + return this.objectGrip(aValue, this._pausePool); + }, + + /** + * Extend the lifetime of the provided object actor to thread lifetime. + * + * @param aActor object + * The object actor. + */ + threadObjectGrip: function (aActor) { + // We want to reuse the existing actor ID, so we just remove it from the + // current pool's weak map and then let pool.addActor do the rest. + aActor.registeredPool.objectActors.delete(aActor.obj); + this.threadLifetimePool.addActor(aActor); + this.threadLifetimePool.objectActors.set(aActor.obj, aActor); + }, + + /** + * Handle a protocol request to promote multiple pause-lifetime grips to + * thread-lifetime grips. + * + * @param aRequest object + * The protocol request object. + */ + onThreadGrips: function (aRequest) { + if (this.state != "paused") { + return { error: "wrongState" }; + } + + if (!aRequest.actors) { + return { error: "missingParameter", + message: "no actors were specified" }; + } + + for (let actorID of aRequest.actors) { + let actor = this._pausePool.get(actorID); + if (actor) { + this.threadObjectGrip(actor); + } + } + return {}; + }, + + /** + * Create a long string grip that is scoped to a pause. + * + * @param aString String + * The string we are creating a grip for. + */ + pauseLongStringGrip: function (aString) { + return longStringGrip(aString, this._pausePool); + }, + + /** + * Create a long string grip that is scoped to a thread. + * + * @param aString String + * The string we are creating a grip for. + */ + threadLongStringGrip: function (aString) { + return longStringGrip(aString, this._threadLifetimePool); + }, + + // JS Debugger API hooks. + + /** + * A function that the engine calls when a call to a debug event hook, + * breakpoint handler, watchpoint handler, or similar function throws some + * exception. + * + * @param aException exception + * The exception that was thrown in the debugger code. + */ + uncaughtExceptionHook: function (aException) { + dumpn("Got an exception: " + aException.message + "\n" + aException.stack); + }, + + /** + * A function that the engine calls when a debugger statement has been + * executed in the specified frame. + * + * @param aFrame Debugger.Frame + * The stack frame that contained the debugger statement. + */ + onDebuggerStatement: function (aFrame) { + // Don't pause if we are currently stepping (in or over) or the frame is + // black-boxed. + const generatedLocation = this.sources.getFrameLocation(aFrame); + const { originalSourceActor } = this.unsafeSynchronize(this.sources.getOriginalLocation( + generatedLocation)); + const url = originalSourceActor ? originalSourceActor.url : null; + + return this.sources.isBlackBoxed(url) || aFrame.onStep + ? undefined + : this._pauseAndRespond(aFrame, { type: "debuggerStatement" }); + }, + + /** + * A function that the engine calls when an exception has been thrown and has + * propagated to the specified frame. + * + * @param aFrame Debugger.Frame + * The youngest remaining stack frame. + * @param aValue object + * The exception that was thrown. + */ + onExceptionUnwind: function (aFrame, aValue) { + let willBeCaught = false; + for (let frame = aFrame; frame != null; frame = frame.older) { + if (frame.script.isInCatchScope(frame.offset)) { + willBeCaught = true; + break; + } + } + + if (willBeCaught && this._options.ignoreCaughtExceptions) { + return undefined; + } + + // NS_ERROR_NO_INTERFACE exceptions are a special case in browser code, + // since they're almost always thrown by QueryInterface functions, and + // handled cleanly by native code. + if (aValue == Cr.NS_ERROR_NO_INTERFACE) { + return undefined; + } + + const generatedLocation = this.sources.getFrameLocation(aFrame); + const { originalSourceActor } = this.unsafeSynchronize(this.sources.getOriginalLocation( + generatedLocation)); + const url = originalSourceActor ? originalSourceActor.url : null; + + if (this.sources.isBlackBoxed(url)) { + return undefined; + } + + try { + let packet = this._paused(aFrame); + if (!packet) { + return undefined; + } + + packet.why = { type: "exception", + exception: createValueGrip(aValue, this._pausePool, + this.objectGrip) + }; + this.conn.send(packet); + + this._pushThreadPause(); + } catch (e) { + reportError(e, "Got an exception during TA_onExceptionUnwind: "); + } + + return undefined; + }, + + /** + * A function that the engine calls when a new script has been loaded into the + * scope of the specified debuggee global. + * + * @param aScript Debugger.Script + * The source script that has been loaded into a debuggee compartment. + * @param aGlobal Debugger.Object + * A Debugger.Object instance whose referent is the global object. + */ + onNewScript: function (aScript, aGlobal) { + this._addSource(aScript.source); + }, + + /** + * A function called when there's a new or updated source from a thread actor's + * sources. Emits `newSource` and `updatedSource` on the tab actor. + * + * @param {String} name + * @param {SourceActor} source + */ + onSourceEvent: function (name, source) { + this.conn.send({ + from: this._parent.actorID, + type: name, + source: source.form() + }); + + // For compatibility and debugger still using `newSource` on the thread client, + // still emit this event here. Clean up in bug 1247084 + if (name === "newSource") { + this.conn.send({ + from: this.actorID, + type: name, + source: source.form() + }); + } + }, + + /** + * Add the provided source to the server cache. + * + * @param aSource Debugger.Source + * The source that will be stored. + * @returns true, if the source was added; false otherwise. + */ + _addSource: function (aSource) { + if (!this.sources.allowSource(aSource) || this._debuggerSourcesSeen.has(aSource)) { + return false; + } + + let sourceActor = this.sources.createNonSourceMappedActor(aSource); + let bpActors = [...this.breakpointActorMap.findActors()]; + + if (this._options.useSourceMaps) { + let promises = []; + + // Go ahead and establish the source actors for this script, which + // fetches sourcemaps if available and sends onNewSource + // notifications. + let sourceActorsCreated = this.sources._createSourceMappedActors(aSource); + + if (bpActors.length) { + // We need to use unsafeSynchronize here because if the page is being reloaded, + // this call will replace the previous set of source actors for this source + // with a new one. If the source actors have not been replaced by the time + // we try to reset the breakpoints below, their location objects will still + // point to the old set of source actors, which point to different + // scripts. + this.unsafeSynchronize(sourceActorsCreated); + } + + for (let _actor of bpActors) { + // XXX bug 1142115: We do async work in here, so we need to create a fresh + // binding because for/of does not yet do that in SpiderMonkey. + let actor = _actor; + + if (actor.isPending) { + promises.push(actor.originalLocation.originalSourceActor._setBreakpoint(actor)); + } else { + promises.push(this.sources.getAllGeneratedLocations(actor.originalLocation) + .then((generatedLocations) => { + if (generatedLocations.length > 0 && + generatedLocations[0].generatedSourceActor.actorID === sourceActor.actorID) { + sourceActor._setBreakpointAtAllGeneratedLocations(actor, generatedLocations); + } + })); + } + } + + if (promises.length > 0) { + this.unsafeSynchronize(promise.all(promises)); + } + } else { + // Bug 1225160: If addSource is called in response to a new script + // notification, and this notification was triggered by loading a JSM from + // chrome code, calling unsafeSynchronize could cause a debuggee timer to + // fire. If this causes the JSM to be loaded a second time, the browser + // will crash, because loading JSMS is not reentrant, and the first load + // has not completed yet. + // + // The root of the problem is that unsafeSynchronize can cause debuggee + // code to run. Unfortunately, fixing that is prohibitively difficult. The + // best we can do at the moment is disable source maps for the browser + // debugger, and carefully avoid the use of unsafeSynchronize in this + // function when source maps are disabled. + for (let actor of bpActors) { + if (actor.isPending) { + actor.originalLocation.originalSourceActor._setBreakpoint(actor); + } else { + actor.originalLocation.originalSourceActor._setBreakpointAtGeneratedLocation( + actor, GeneratedLocation.fromOriginalLocation(actor.originalLocation) + ); + } + } + } + + this._debuggerSourcesSeen.add(aSource); + return true; + }, + + + /** + * Get prototypes and properties of multiple objects. + */ + onPrototypesAndProperties: function (aRequest) { + let result = {}; + for (let actorID of aRequest.actors) { + // This code assumes that there are no lazily loaded actors returned + // by this call. + let actor = this.conn.getActor(actorID); + if (!actor) { + return { from: this.actorID, + error: "noSuchActor" }; + } + let handler = actor.onPrototypeAndProperties; + if (!handler) { + return { from: this.actorID, + error: "unrecognizedPacketType", + message: ('Actor "' + actorID + + '" does not recognize the packet type ' + + '"prototypeAndProperties"') }; + } + result[actorID] = handler.call(actor, {}); + } + return { from: this.actorID, + actors: result }; + } +}); + +ThreadActor.prototype.requestTypes = object.merge(ThreadActor.prototype.requestTypes, { + "attach": ThreadActor.prototype.onAttach, + "detach": ThreadActor.prototype.onDetach, + "reconfigure": ThreadActor.prototype.onReconfigure, + "resume": ThreadActor.prototype.onResume, + "clientEvaluate": ThreadActor.prototype.onClientEvaluate, + "frames": ThreadActor.prototype.onFrames, + "interrupt": ThreadActor.prototype.onInterrupt, + "eventListeners": ThreadActor.prototype.onEventListeners, + "releaseMany": ThreadActor.prototype.onReleaseMany, + "sources": ThreadActor.prototype.onSources, + "threadGrips": ThreadActor.prototype.onThreadGrips, + "prototypesAndProperties": ThreadActor.prototype.onPrototypesAndProperties +}); + +exports.ThreadActor = ThreadActor; + +/** + * Creates a PauseActor. + * + * PauseActors exist for the lifetime of a given debuggee pause. Used to + * scope pause-lifetime grips. + * + * @param ActorPool aPool + * The actor pool created for this pause. + */ +function PauseActor(aPool) +{ + this.pool = aPool; +} + +PauseActor.prototype = { + actorPrefix: "pause" +}; + + +/** + * A base actor for any actors that should only respond receive messages in the + * paused state. Subclasses may expose a `threadActor` which is used to help + * determine when we are in a paused state. Subclasses should set their own + * "constructor" property if they want better error messages. You should never + * instantiate a PauseScopedActor directly, only through subclasses. + */ +function PauseScopedActor() +{ +} + +/** + * A function decorator for creating methods to handle protocol messages that + * should only be received while in the paused state. + * + * @param aMethod Function + * The function we are decorating. + */ +PauseScopedActor.withPaused = function (aMethod) { + return function () { + if (this.isPaused()) { + return aMethod.apply(this, arguments); + } else { + return this._wrongState(); + } + }; +}; + +PauseScopedActor.prototype = { + + /** + * Returns true if we are in the paused state. + */ + isPaused: function () { + // When there is not a ThreadActor available (like in the webconsole) we + // have to be optimistic and assume that we are paused so that we can + // respond to requests. + return this.threadActor ? this.threadActor.state === "paused" : true; + }, + + /** + * Returns the wrongState response packet for this actor. + */ + _wrongState: function () { + return { + error: "wrongState", + message: this.constructor.name + + " actors can only be accessed while the thread is paused." + }; + } +}; + +/** + * Creates a pause-scoped actor for the specified object. + * @see ObjectActor + */ +function PauseScopedObjectActor(obj, hooks) { + ObjectActor.call(this, obj, hooks); + this.hooks.promote = hooks.promote; + this.hooks.isThreadLifetimePool = hooks.isThreadLifetimePool; +} + +PauseScopedObjectActor.prototype = Object.create(PauseScopedActor.prototype); + +Object.assign(PauseScopedObjectActor.prototype, ObjectActor.prototype); + +Object.assign(PauseScopedObjectActor.prototype, { + constructor: PauseScopedObjectActor, + actorPrefix: "pausedobj", + + onOwnPropertyNames: + PauseScopedActor.withPaused(ObjectActor.prototype.onOwnPropertyNames), + + onPrototypeAndProperties: + PauseScopedActor.withPaused(ObjectActor.prototype.onPrototypeAndProperties), + + onPrototype: PauseScopedActor.withPaused(ObjectActor.prototype.onPrototype), + onProperty: PauseScopedActor.withPaused(ObjectActor.prototype.onProperty), + onDecompile: PauseScopedActor.withPaused(ObjectActor.prototype.onDecompile), + + onDisplayString: + PauseScopedActor.withPaused(ObjectActor.prototype.onDisplayString), + + onParameterNames: + PauseScopedActor.withPaused(ObjectActor.prototype.onParameterNames), + + /** + * Handle a protocol request to promote a pause-lifetime grip to a + * thread-lifetime grip. + * + * @param aRequest object + * The protocol request object. + */ + onThreadGrip: PauseScopedActor.withPaused(function (aRequest) { + this.hooks.promote(); + return {}; + }), + + /** + * Handle a protocol request to release a thread-lifetime grip. + * + * @param aRequest object + * The protocol request object. + */ + onRelease: PauseScopedActor.withPaused(function (aRequest) { + if (this.hooks.isThreadLifetimePool()) { + return { error: "notReleasable", + message: "Only thread-lifetime actors can be released." }; + } + + this.release(); + return {}; + }), +}); + +Object.assign(PauseScopedObjectActor.prototype.requestTypes, { + "threadGrip": PauseScopedObjectActor.prototype.onThreadGrip, +}); + +function hackDebugger(Debugger) { + // TODO: Improve native code instead of hacking on top of it + + /** + * Override the toString method in order to get more meaningful script output + * for debugging the debugger. + */ + Debugger.Script.prototype.toString = function () { + let output = ""; + if (this.url) { + output += this.url; + } + if (typeof this.staticLevel != "undefined") { + output += ":L" + this.staticLevel; + } + if (typeof this.startLine != "undefined") { + output += ":" + this.startLine; + if (this.lineCount && this.lineCount > 1) { + output += "-" + (this.startLine + this.lineCount - 1); + } + } + if (typeof this.startLine != "undefined") { + output += ":" + this.startLine; + if (this.lineCount && this.lineCount > 1) { + output += "-" + (this.startLine + this.lineCount - 1); + } + } + if (this.strictMode) { + output += ":strict"; + } + return output; + }; + + /** + * Helper property for quickly getting to the line number a stack frame is + * currently paused at. + */ + Object.defineProperty(Debugger.Frame.prototype, "line", { + configurable: true, + get: function () { + if (this.script) { + return this.script.getOffsetLocation(this.offset).lineNumber; + } else { + return null; + } + } + }); +} + + +/** + * Creates an actor for handling chrome debugging. ChromeDebuggerActor is a + * thin wrapper over ThreadActor, slightly changing some of its behavior. + * + * @param aConnection object + * The DebuggerServerConnection with which this ChromeDebuggerActor + * is associated. (Currently unused, but required to make this + * constructor usable with addGlobalActor.) + * + * @param aParent object + * This actor's parent actor. See ThreadActor for a list of expected + * properties. + */ +function ChromeDebuggerActor(aConnection, aParent) +{ + ThreadActor.prototype.initialize.call(this, aParent); +} + +ChromeDebuggerActor.prototype = Object.create(ThreadActor.prototype); + +Object.assign(ChromeDebuggerActor.prototype, { + constructor: ChromeDebuggerActor, + + // A constant prefix that will be used to form the actor ID by the server. + actorPrefix: "chromeDebugger" +}); + +exports.ChromeDebuggerActor = ChromeDebuggerActor; + +/** + * Creates an actor for handling add-on debugging. AddonThreadActor is + * a thin wrapper over ThreadActor. + * + * @param aConnection object + * The DebuggerServerConnection with which this AddonThreadActor + * is associated. (Currently unused, but required to make this + * constructor usable with addGlobalActor.) + * + * @param aParent object + * This actor's parent actor. See ThreadActor for a list of expected + * properties. + */ +function AddonThreadActor(aConnect, aParent) { + ThreadActor.prototype.initialize.call(this, aParent); +} + +AddonThreadActor.prototype = Object.create(ThreadActor.prototype); + +Object.assign(AddonThreadActor.prototype, { + constructor: AddonThreadActor, + + // A constant prefix that will be used to form the actor ID by the server. + actorPrefix: "addonThread" +}); + +exports.AddonThreadActor = AddonThreadActor; + +// Utility functions. + +/** + * Report the given error in the error console and to stdout. + * + * @param Error aError + * The error object you wish to report. + * @param String aPrefix + * An optional prefix for the reported error message. + */ +var oldReportError = reportError; +reportError = function (aError, aPrefix = "") { + assert(aError instanceof Error, "Must pass Error objects to reportError"); + let msg = aPrefix + aError.message + ":\n" + aError.stack; + oldReportError(msg); + dumpn(msg); +}; + +/** + * Find the scripts which contain offsets that are an entry point to the given + * line. + * + * @param Array scripts + * The set of Debugger.Scripts to consider. + * @param Number line + * The line we are searching for entry points into. + * @returns Array of objects of the form { script, offsets } where: + * - script is a Debugger.Script + * - offsets is an array of offsets that are entry points into the + * given line. + */ +function findEntryPointsForLine(scripts, line) { + const entryPoints = []; + for (let script of scripts) { + const offsets = script.getLineOffsets(line); + if (offsets.length) { + entryPoints.push({ script, offsets }); + } + } + return entryPoints; +} + +/** + * Unwrap a global that is wrapped in a |Debugger.Object|, or if the global has + * become a dead object, return |undefined|. + * + * @param Debugger.Object wrappedGlobal + * The |Debugger.Object| which wraps a global. + * + * @returns {Object|undefined} + * Returns the unwrapped global object or |undefined| if unwrapping + * failed. + */ +exports.unwrapDebuggerObjectGlobal = wrappedGlobal => { + try { + // Because of bug 991399 we sometimes get nuked window references here. We + // just bail out in that case. + // + // Note that addon sandboxes have a DOMWindow as their prototype. So make + // sure that we can touch the prototype too (whatever it is), in case _it_ + // is it a nuked window reference. We force stringification to make sure + // that any dead object proxies make themselves known. + let global = wrappedGlobal.unsafeDereference(); + Object.getPrototypeOf(global) + ""; + return global; + } + catch (e) { + return undefined; + } +}; diff --git a/devtools/server/actors/settings.js b/devtools/server/actors/settings.js new file mode 100644 index 0000000000..179c82aa5f --- /dev/null +++ b/devtools/server/actors/settings.js @@ -0,0 +1,146 @@ +/* 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 {Cc, Ci} = require("chrome"); +const protocol = require("devtools/shared/protocol"); +const {DebuggerServer} = require("devtools/server/main"); +const promise = require("promise"); +const Services = require("Services"); +const { settingsSpec } = require("devtools/shared/specs/settings"); +const { FileUtils} = require("resource://gre/modules/FileUtils.jsm"); +const { NetUtil} = require("resource://gre/modules/NetUtil.jsm"); + +var defaultSettings = {}; +var settingsFile; + +exports.register = function (handle) { + handle.addGlobalActor(SettingsActor, "settingsActor"); +}; + +exports.unregister = function (handle) { +}; + +function getDefaultSettings() { + let chan = NetUtil.newChannel({ + uri: NetUtil.newURI(settingsFile), + loadUsingSystemPrincipal: true}); + let stream = chan.open2(); + // Obtain a converter to read from a UTF-8 encoded input stream. + let converter = Cc["@mozilla.org/intl/scriptableunicodeconverter"] + .createInstance(Ci.nsIScriptableUnicodeConverter); + converter.charset = "UTF-8"; + let rawstr = converter.ConvertToUnicode(NetUtil.readInputStreamToString( + stream, + stream.available()) || ""); + + try { + defaultSettings = JSON.parse(rawstr); + } catch (e) { } + stream.close(); +} + +function loadSettingsFile() { + // Loading resource://app/defaults/settings.json doesn't work because + // settings.json is not in the omnijar. + // So we look for the app dir instead and go from here... + if (settingsFile) { + return; + } + settingsFile = FileUtils.getFile("DefRt", ["settings.json"], false); + if (!settingsFile || (settingsFile && !settingsFile.exists())) { + // On b2g desktop builds the settings.json file is moved in the + // profile directory by the build system. + settingsFile = FileUtils.getFile("ProfD", ["settings.json"], false); + if (!settingsFile || (settingsFile && !settingsFile.exists())) { + console.log("settings.json file does not exist"); + } + } + + if (settingsFile.exists()) { + getDefaultSettings(); + } +} + +var SettingsActor = exports.SettingsActor = protocol.ActorClassWithSpec(settingsSpec, { + _getSettingsService: function () { + let win = Services.wm.getMostRecentWindow(DebuggerServer.chromeWindowType); + return win.navigator.mozSettings; + }, + + getSetting: function (name) { + let deferred = promise.defer(); + let lock = this._getSettingsService().createLock(); + let req = lock.get(name); + req.onsuccess = function () { + deferred.resolve(req.result[name]); + }; + req.onerror = function () { + deferred.reject(req.error); + }; + return deferred.promise; + }, + + setSetting: function (name, value) { + let deferred = promise.defer(); + let data = {}; + data[name] = value; + let lock = this._getSettingsService().createLock(); + let req = lock.set(data); + req.onsuccess = function () { + deferred.resolve(true); + }; + req.onerror = function () { + deferred.reject(req.error); + }; + return deferred.promise; + }, + + _hasUserSetting: function (name, value) { + if (typeof value === "object") { + return JSON.stringify(defaultSettings[name]) !== JSON.stringify(value); + } + return (defaultSettings[name] !== value); + }, + + getAllSettings: function () { + loadSettingsFile(); + let settings = {}; + let self = this; + + let deferred = promise.defer(); + let lock = this._getSettingsService().createLock(); + let req = lock.get("*"); + + req.onsuccess = function () { + for (var name in req.result) { + settings[name] = { + value: req.result[name], + hasUserValue: self._hasUserSetting(name, req.result[name]) + }; + } + deferred.resolve(settings); + }; + req.onfailure = function () { + deferred.reject(req.error); + }; + + return deferred.promise; + }, + + clearUserSetting: function (name) { + loadSettingsFile(); + try { + this.setSetting(name, defaultSettings[name]); + } catch (e) { + console.log(e); + } + } +}); + +// For tests +exports._setDefaultSettings = function (settings) { + defaultSettings = settings || {}; +}; diff --git a/devtools/server/actors/source.js b/devtools/server/actors/source.js new file mode 100644 index 0000000000..e76c14fe87 --- /dev/null +++ b/devtools/server/actors/source.js @@ -0,0 +1,902 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2; js-indent-level: 2 -*- */ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* 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 { Cc, Ci } = require("chrome"); +const Services = require("Services"); +const { BreakpointActor, setBreakpointAtEntryPoints } = require("devtools/server/actors/breakpoint"); +const { OriginalLocation, GeneratedLocation } = require("devtools/server/actors/common"); +const { createValueGrip } = require("devtools/server/actors/object"); +const { ActorClassWithSpec, Arg, RetVal, method } = require("devtools/shared/protocol"); +const DevToolsUtils = require("devtools/shared/DevToolsUtils"); +const { assert, fetch } = DevToolsUtils; +const { joinURI } = require("devtools/shared/path"); +const promise = require("promise"); +const { defer, resolve, reject, all } = promise; +const { sourceSpec } = require("devtools/shared/specs/source"); + +loader.lazyRequireGetter(this, "SourceMapConsumer", "source-map", true); +loader.lazyRequireGetter(this, "SourceMapGenerator", "source-map", true); +loader.lazyRequireGetter(this, "mapURIToAddonID", "devtools/server/actors/utils/map-uri-to-addon-id"); + +function isEvalSource(source) { + let introType = source.introductionType; + // These are all the sources that are essentially eval-ed (either + // by calling eval or passing a string to one of these functions). + return (introType === "eval" || + introType === "Function" || + introType === "eventHandler" || + introType === "setTimeout" || + introType === "setInterval"); +} + +exports.isEvalSource = isEvalSource; + +function getSourceURL(source, window) { + if (isEvalSource(source)) { + // Eval sources have no urls, but they might have a `displayURL` + // created with the sourceURL pragma. If the introduction script + // is a non-eval script, generate an full absolute URL relative to it. + + if (source.displayURL && source.introductionScript && + !isEvalSource(source.introductionScript.source)) { + + if (source.introductionScript.source.url === "debugger eval code") { + if (window) { + // If this is a named eval script created from the console, make it + // relative to the current page. window is only available + // when we care about this. + return joinURI(window.location.href, source.displayURL); + } + } + else { + return joinURI(source.introductionScript.source.url, source.displayURL); + } + } + + return source.displayURL; + } + else if (source.url === "debugger eval code") { + // Treat code evaluated by the console as unnamed eval scripts + return null; + } + return source.url; +} + +exports.getSourceURL = getSourceURL; + +/** + * Resolve a URI back to physical file. + * + * Of course, this works only for URIs pointing to local resources. + * + * @param aURI + * URI to resolve + * @return + * resolved nsIURI + */ +function resolveURIToLocalPath(aURI) { + let resolved; + switch (aURI.scheme) { + case "jar": + case "file": + return aURI; + + case "chrome": + resolved = Cc["@mozilla.org/chrome/chrome-registry;1"]. + getService(Ci.nsIChromeRegistry).convertChromeURL(aURI); + return resolveURIToLocalPath(resolved); + + case "resource": + resolved = Cc["@mozilla.org/network/protocol;1?name=resource"]. + getService(Ci.nsIResProtocolHandler).resolveURI(aURI); + aURI = Services.io.newURI(resolved, null, null); + return resolveURIToLocalPath(aURI); + + default: + return null; + } +} + +/** + * A SourceActor provides information about the source of a script. There + * are two kinds of source actors: ones that represent real source objects, + * and ones that represent non-existant "original" sources when the real + * sources are sourcemapped. When a source is sourcemapped, actors are + * created for both the "generated" and "original" sources, and the client will + * only see the original sources. We separate these because there isn't + * a 1:1 mapping of generated to original sources; one generated source + * may represent N original sources, so we need to create N + 1 separate + * actors. + * + * There are 4 different scenarios for sources that you should + * understand: + * + * - A single non-sourcemapped source that is not inlined in HTML + * (separate JS file, eval'ed code, etc) + * - A single sourcemapped source which creates N original sources + * - An HTML page with multiple inline scripts, which are distinct + * sources, but should be represented as a single source + * - A pretty-printed source (which may or may not be an original + * sourcemapped source), which generates a sourcemap for itself + * + * The complexity of `SourceActor` and `ThreadSources` are to handle + * all of thise cases and hopefully internalize the complexities. + * + * @param Debugger.Source source + * The source object we are representing. + * @param ThreadActor thread + * The current thread actor. + * @param String originalUrl + * Optional. For sourcemapped urls, the original url this is representing. + * @param Debugger.Source generatedSource + * Optional, passed in when aSourceMap is also passed in. The generated + * source object that introduced this source. + * @param Boolean isInlineSource + * Optional. True if this is an inline source from a HTML or XUL page. + * @param String contentType + * Optional. The content type of this source, if immediately available. + */ +let SourceActor = ActorClassWithSpec(sourceSpec, { + typeName: "source", + + initialize: function ({ source, thread, originalUrl, generatedSource, + isInlineSource, contentType }) { + this._threadActor = thread; + this._originalUrl = originalUrl; + this._source = source; + this._generatedSource = generatedSource; + this._contentType = contentType; + this._isInlineSource = isInlineSource; + + this.onSource = this.onSource.bind(this); + this._invertSourceMap = this._invertSourceMap.bind(this); + this._encodeAndSetSourceMapURL = this._encodeAndSetSourceMapURL.bind(this); + this._getSourceText = this._getSourceText.bind(this); + + this._mapSourceToAddon(); + + if (this.threadActor.sources.isPrettyPrinted(this.url)) { + this._init = this.prettyPrint( + this.threadActor.sources.prettyPrintIndent(this.url) + ).then(null, error => { + DevToolsUtils.reportException("SourceActor", error); + }); + } else { + this._init = null; + } + }, + + get isSourceMapped() { + return !!(!this.isInlineSource && ( + this._originalURL || this._generatedSource || + this.threadActor.sources.isPrettyPrinted(this.url) + )); + }, + + get isInlineSource() { + return this._isInlineSource; + }, + + get threadActor() { return this._threadActor; }, + get sources() { return this._threadActor.sources; }, + get dbg() { return this.threadActor.dbg; }, + get source() { return this._source; }, + get generatedSource() { return this._generatedSource; }, + get breakpointActorMap() { return this.threadActor.breakpointActorMap; }, + get url() { + if (this.source) { + return getSourceURL(this.source, this.threadActor._parent.window); + } + return this._originalUrl; + }, + get addonID() { return this._addonID; }, + get addonPath() { return this._addonPath; }, + + get prettyPrintWorker() { + return this.threadActor.prettyPrintWorker; + }, + + form: function () { + let source = this.source || this.generatedSource; + // This might not have a source or a generatedSource because we + // treat HTML pages with inline scripts as a special SourceActor + // that doesn't have either + let introductionUrl = null; + if (source && source.introductionScript) { + introductionUrl = source.introductionScript.source.url; + } + + return { + actor: this.actorID, + generatedUrl: this.generatedSource ? this.generatedSource.url : null, + url: this.url ? this.url.split(" -> ").pop() : null, + addonID: this._addonID, + addonPath: this._addonPath, + isBlackBoxed: this.threadActor.sources.isBlackBoxed(this.url), + isPrettyPrinted: this.threadActor.sources.isPrettyPrinted(this.url), + isSourceMapped: this.isSourceMapped, + sourceMapURL: source ? source.sourceMapURL : null, + introductionUrl: introductionUrl ? introductionUrl.split(" -> ").pop() : null, + introductionType: source ? source.introductionType : null + }; + }, + + disconnect: function () { + if (this.registeredPool && this.registeredPool.sourceActors) { + delete this.registeredPool.sourceActors[this.actorID]; + } + }, + + _mapSourceToAddon: function () { + try { + var nsuri = Services.io.newURI(this.url.split(" -> ").pop(), null, null); + } + catch (e) { + // We can't do anything with an invalid URI + return; + } + + let localURI = resolveURIToLocalPath(nsuri); + if (!localURI) { + return; + } + + let id = mapURIToAddonID(localURI); + if (!id) { + return; + } + this._addonID = id; + + if (localURI instanceof Ci.nsIJARURI) { + // The path in the add-on is easy for jar: uris + this._addonPath = localURI.JAREntry; + } + else if (localURI instanceof Ci.nsIFileURL) { + // For file: uris walk up to find the last directory that is part of the + // add-on + let target = localURI.file; + let path = target.leafName; + + // We can assume that the directory containing the source file is part + // of the add-on + let root = target.parent; + let file = root.parent; + while (file && mapURIToAddonID(Services.io.newFileURI(file))) { + path = root.leafName + "/" + path; + root = file; + file = file.parent; + } + + if (!file) { + const error = new Error("Could not find the root of the add-on for " + this.url); + DevToolsUtils.reportException("SourceActor.prototype._mapSourceToAddon", error); + return; + } + + this._addonPath = path; + } + }, + + _reportLoadSourceError: function (error, map = null) { + try { + DevToolsUtils.reportException("SourceActor", error); + + JSON.stringify(this.form(), null, 4).split(/\n/g) + .forEach(line => console.error("\t", line)); + + if (!map) { + return; + } + + console.error("\t", "source map's sourceRoot =", map.sourceRoot); + + console.error("\t", "source map's sources ="); + map.sources.forEach(s => { + let hasSourceContent = map.sourceContentFor(s, true); + console.error("\t\t", s, "\t", + hasSourceContent ? "has source content" : "no source content"); + }); + + console.error("\t", "source map's sourcesContent ="); + map.sourcesContent.forEach(c => { + if (c.length > 80) { + c = c.slice(0, 77) + "..."; + } + c = c.replace(/\n/g, "\\n"); + console.error("\t\t", c); + }); + } catch (e) { } + }, + + _getSourceText: function () { + let toResolvedContent = t => ({ + content: t, + contentType: this._contentType + }); + + let genSource = this.generatedSource || this.source; + return this.threadActor.sources.fetchSourceMap(genSource).then(map => { + if (map) { + try { + let sourceContent = map.sourceContentFor(this.url); + if (sourceContent) { + return toResolvedContent(sourceContent); + } + } catch (error) { + this._reportLoadSourceError(error, map); + throw error; + } + } + + // Use `source.text` if it exists, is not the "no source" string, and + // the content type of the source is JavaScript or it is synthesized + // wasm. It will be "no source" if the Debugger API wasn't able to load + // the source because sources were discarded + // (javascript.options.discardSystemSource == true). Re-fetch non-JS + // sources to get the contentType from the headers. + if (this.source && + this.source.text !== "[no source]" && + this._contentType && + (this._contentType.indexOf("javascript") !== -1 || + this._contentType === "text/wasm")) { + return toResolvedContent(this.source.text); + } + else { + // Only load the HTML page source from cache (which exists when + // there are inline sources). Otherwise, we can't trust the + // cache because we are most likely here because we are + // fetching the original text for sourcemapped code, and the + // page hasn't requested it before (if it has, it was a + // previous debugging session). + let loadFromCache = this.isInlineSource; + + // Fetch the sources with the same principal as the original document + let win = this.threadActor._parent.window; + let principal, cacheKey; + // On xpcshell, we don't have a window but a Sandbox + if (!isWorker && win instanceof Ci.nsIDOMWindow) { + let webNav = win.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIWebNavigation); + let channel = webNav.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 && + webNav.currentDocumentChannel instanceof Ci.nsICacheInfoChannel) { + cacheKey = webNav.currentDocumentChannel.cacheKey; + assert( + cacheKey, + "Could not fetch the cacheKey from the related document." + ); + } + } + + let sourceFetched = fetch(this.url, { + principal, + cacheKey, + loadFromCache + }); + + // Record the contentType we just learned during fetching + return sourceFetched + .then(result => { + this._contentType = result.contentType; + return result; + }, error => { + this._reportLoadSourceError(error, map); + throw error; + }); + } + }); + }, + + /** + * Get all executable lines from the current source + * @return Array - Executable lines of the current script + **/ + getExecutableLines: function () { + function sortLines(lines) { + // Converting the Set into an array + lines = [...lines]; + lines.sort((a, b) => { + return a - b; + }); + return lines; + } + + if (this.generatedSource) { + return this.threadActor.sources.getSourceMap(this.generatedSource).then(sm => { + let lines = new Set(); + + // Position of executable lines in the generated source + let offsets = this.getExecutableOffsets(this.generatedSource, false); + for (let offset of offsets) { + let {line, source: sourceUrl} = sm.originalPositionFor({ + line: offset.lineNumber, + column: offset.columnNumber + }); + + if (sourceUrl === this.url) { + lines.add(line); + } + } + + return sortLines(lines); + }); + } + + let lines = this.getExecutableOffsets(this.source, true); + return sortLines(lines); + }, + + /** + * Extract all executable offsets from the given script + * @param String url - extract offsets of the script with this url + * @param Boolean onlyLine - will return only the line number + * @return Set - Executable offsets/lines of the script + **/ + getExecutableOffsets: function (source, onlyLine) { + let offsets = new Set(); + for (let s of this.dbg.findScripts({ source })) { + for (let offset of s.getAllColumnOffsets()) { + offsets.add(onlyLine ? offset.lineNumber : offset); + } + } + + return offsets; + }, + + /** + * Handler for the "source" packet. + */ + onSource: function () { + return resolve(this._init) + .then(this._getSourceText) + .then(({ content, contentType }) => { + return { + source: createValueGrip(content, this.threadActor.threadLifetimePool, + this.threadActor.objectGrip), + contentType: contentType + }; + }) + .then(null, aError => { + reportError(aError, "Got an exception during SA_onSource: "); + throw new Error("Could not load the source for " + this.url + ".\n" + + DevToolsUtils.safeErrorString(aError)); + }); + }, + + /** + * Handler for the "prettyPrint" packet. + */ + prettyPrint: function (indent) { + this.threadActor.sources.prettyPrint(this.url, indent); + return this._getSourceText() + .then(this._sendToPrettyPrintWorker(indent)) + .then(this._invertSourceMap) + .then(this._encodeAndSetSourceMapURL) + .then(() => { + // We need to reset `_init` now because we have already done the work of + // pretty printing, and don't want onSource to wait forever for + // initialization to complete. + this._init = null; + }) + .then(this.onSource) + .then(null, error => { + this.disablePrettyPrint(); + throw new Error(DevToolsUtils.safeErrorString(error)); + }); + }, + + /** + * Return a function that sends a request to the pretty print worker, waits on + * the worker's response, and then returns the pretty printed code. + * + * @param Number aIndent + * The number of spaces to indent by the code by, when we send the + * request to the pretty print worker. + * @returns Function + * Returns a function which takes an AST, and returns a promise that + * is resolved with `{ code, mappings }` where `code` is the pretty + * printed code, and `mappings` is an array of source mappings. + */ + _sendToPrettyPrintWorker: function (aIndent) { + return ({ content }) => { + return this.prettyPrintWorker.performTask("pretty-print", { + url: this.url, + indent: aIndent, + source: content + }); + }; + }, + + /** + * Invert a source map. So if a source map maps from a to b, return a new + * source map from b to a. We need to do this because the source map we get + * from _generatePrettyCodeAndMap goes the opposite way we want it to for + * debugging. + * + * Note that the source map is modified in place. + */ + _invertSourceMap: function ({ code, mappings }) { + const generator = new SourceMapGenerator({ file: this.url }); + return DevToolsUtils.yieldingEach(mappings._array, m => { + let mapping = { + generated: { + line: m.originalLine, + column: m.originalColumn + } + }; + if (m.source) { + mapping.source = m.source; + mapping.original = { + line: m.generatedLine, + column: m.generatedColumn + }; + mapping.name = m.name; + } + generator.addMapping(mapping); + }).then(() => { + generator.setSourceContent(this.url, code); + let consumer = SourceMapConsumer.fromSourceMap(generator); + + return { + code: code, + map: consumer + }; + }); + }, + + /** + * Save the source map back to our thread's ThreadSources object so that + * stepping, breakpoints, debugger statements, etc can use it. If we are + * pretty printing a source mapped source, we need to compose the existing + * source map with our new one. + */ + _encodeAndSetSourceMapURL: function ({ map: sm }) { + let source = this.generatedSource || this.source; + let sources = this.threadActor.sources; + + return sources.getSourceMap(source).then(prevMap => { + if (prevMap) { + // Compose the source maps + this._oldSourceMapping = { + url: source.sourceMapURL, + map: prevMap + }; + + prevMap = SourceMapGenerator.fromSourceMap(prevMap); + prevMap.applySourceMap(sm, this.url); + sm = SourceMapConsumer.fromSourceMap(prevMap); + } + + let sources = this.threadActor.sources; + sources.clearSourceMapCache(source.sourceMapURL); + sources.setSourceMapHard(source, null, sm); + }); + }, + + /** + * Handler for the "disablePrettyPrint" packet. + */ + disablePrettyPrint: function () { + let source = this.generatedSource || this.source; + let sources = this.threadActor.sources; + let sm = sources.getSourceMap(source); + + sources.clearSourceMapCache(source.sourceMapURL, { hard: true }); + + if (this._oldSourceMapping) { + sources.setSourceMapHard(source, + this._oldSourceMapping.url, + this._oldSourceMapping.map); + this._oldSourceMapping = null; + } + + this.threadActor.sources.disablePrettyPrint(this.url); + return this.onSource(); + }, + + /** + * Handler for the "blackbox" packet. + */ + blackbox: function () { + this.threadActor.sources.blackBox(this.url); + if (this.threadActor.state == "paused" + && this.threadActor.youngestFrame + && this.threadActor.youngestFrame.script.url == this.url) { + return true; + } + return false; + }, + + /** + * Handler for the "unblackbox" packet. + */ + unblackbox: function () { + this.threadActor.sources.unblackBox(this.url); + }, + + /** + * Handle a request to set a breakpoint. + * + * @param Number line + * Line to break on. + * @param Number column + * Column to break on. + * @param String condition + * A condition which must be true for breakpoint to be hit. + * @param Boolean noSliding + * If true, disables breakpoint sliding. + * + * @returns Promise + * A promise that resolves to a JSON object representing the + * response. + */ + setBreakpoint: function (line, column, condition, noSliding) { + if (this.threadActor.state !== "paused") { + throw { + error: "wrongState", + message: "Cannot set breakpoint while debuggee is running." + }; + } + + let location = new OriginalLocation(this, line, column); + return this._getOrCreateBreakpointActor( + location, + condition, + noSliding + ).then((actor) => { + let response = { + actor: actor.actorID, + isPending: actor.isPending + }; + + let actualLocation = actor.originalLocation; + if (!actualLocation.equals(location)) { + response.actualLocation = actualLocation.toJSON(); + } + + return response; + }); + }, + + /** + * Get or create a BreakpointActor for the given location in the original + * source, and ensure it is set as a breakpoint handler on all scripts that + * match the given location. + * + * @param OriginalLocation originalLocation + * An OriginalLocation representing the location of the breakpoint in + * the original source. + * @param String condition + * A string that is evaluated whenever the breakpoint is hit. If the + * string evaluates to false, the breakpoint is ignored. + * @param Boolean noSliding + * If true, disables breakpoint sliding. + * + * @returns BreakpointActor + * A BreakpointActor representing the breakpoint. + */ + _getOrCreateBreakpointActor: function (originalLocation, condition, noSliding) { + let actor = this.breakpointActorMap.getActor(originalLocation); + if (!actor) { + actor = new BreakpointActor(this.threadActor, originalLocation); + this.threadActor.threadLifetimePool.addActor(actor); + this.breakpointActorMap.setActor(originalLocation, actor); + } + + actor.condition = condition; + + return this._setBreakpoint(actor, noSliding); + }, + + /* + * Ensure the given BreakpointActor is set as a breakpoint handler on all + * scripts that match its location in the original source. + * + * If there are no scripts that match the location of the BreakpointActor, + * we slide its location to the next closest line (for line breakpoints) or + * column (for column breakpoint) that does. + * + * If breakpoint sliding fails, then either there are no scripts that contain + * any code for the given location, or they were all garbage collected before + * the debugger started running. We cannot distinguish between these two + * cases, so we insert the BreakpointActor in the BreakpointActorMap as + * a pending breakpoint. Whenever a new script is introduced, this method is + * called again for each pending breakpoint. + * + * @param BreakpointActor actor + * The BreakpointActor to be set as a breakpoint handler. + * @param Boolean noSliding + * If true, disables breakpoint sliding. + * + * @returns A Promise that resolves to the given BreakpointActor. + */ + _setBreakpoint: function (actor, noSliding) { + const { originalLocation } = actor; + const { originalLine, originalSourceActor } = originalLocation; + + if (!this.isSourceMapped) { + const generatedLocation = GeneratedLocation.fromOriginalLocation(originalLocation); + if (!this._setBreakpointAtGeneratedLocation(actor, generatedLocation) && + !noSliding) { + const query = { line: originalLine }; + // For most cases, we have a real source to query for. The + // only time we don't is for HTML pages. In that case we want + // to query for scripts in an HTML page based on its URL, as + // there could be several sources within an HTML page. + if (this.source) { + query.source = this.source; + } else { + query.url = this.url; + } + const scripts = this.dbg.findScripts(query); + + // Never do breakpoint sliding for column breakpoints. + // Additionally, never do breakpoint sliding if no scripts + // exist on this line. + // + // Sliding can go horribly wrong if we always try to find the + // next line with valid entry points in the entire file. + // Scripts may be completely GCed and we never knew they + // existed, so we end up sliding through whole functions to + // the user's bewilderment. + // + // We can slide reliably if any scripts exist, however, due + // to how scripts are kept alive. A parent Debugger.Script + // keeps all of its children alive, so as long as we have a + // valid script, we can slide through it and know we won't + // slide through any of its child scripts. Additionally, if a + // script gets GCed, that means that all parents scripts are + // GCed as well, and no scripts will exist on those lines + // anymore. We will never slide through a GCed script. + if (originalLocation.originalColumn || scripts.length === 0) { + return promise.resolve(actor); + } + + // Find the script that spans the largest amount of code to + // determine the bounds for sliding. + const largestScript = scripts.reduce((largestScript, script) => { + if (script.lineCount > largestScript.lineCount) { + return script; + } + return largestScript; + }); + const maxLine = largestScript.startLine + largestScript.lineCount - 1; + + let actualLine = originalLine; + for (; actualLine <= maxLine; actualLine++) { + const loc = new GeneratedLocation(this, actualLine); + if (this._setBreakpointAtGeneratedLocation(actor, loc)) { + break; + } + } + + // The above loop should never complete. We only did breakpoint sliding + // because we found scripts on the line we started from, + // which means there must be valid entry points somewhere + // within those scripts. + assert( + actualLine <= maxLine, + "Could not find any entry points to set a breakpoint on, " + + "even though I was told a script existed on the line I started " + + "the search with." + ); + + // Update the actor to use the new location (reusing a + // previous breakpoint if it already exists on that line). + const actualLocation = new OriginalLocation(originalSourceActor, actualLine); + const existingActor = this.breakpointActorMap.getActor(actualLocation); + this.breakpointActorMap.deleteActor(originalLocation); + if (existingActor) { + actor.delete(); + actor = existingActor; + } else { + actor.originalLocation = actualLocation; + this.breakpointActorMap.setActor(actualLocation, actor); + } + } + + return promise.resolve(actor); + } else { + return this.sources.getAllGeneratedLocations(originalLocation).then((generatedLocations) => { + this._setBreakpointAtAllGeneratedLocations( + actor, + generatedLocations + ); + + return actor; + }); + } + }, + + _setBreakpointAtAllGeneratedLocations: function (actor, generatedLocations) { + let success = false; + for (let generatedLocation of generatedLocations) { + if (this._setBreakpointAtGeneratedLocation( + actor, + generatedLocation + )) { + success = true; + } + } + return success; + }, + + /* + * Ensure the given BreakpointActor is set as breakpoint handler on all + * scripts that match the given location in the generated source. + * + * @param BreakpointActor actor + * The BreakpointActor to be set as a breakpoint handler. + * @param GeneratedLocation generatedLocation + * A GeneratedLocation representing the location in the generated + * source for which the given BreakpointActor is to be set as a + * breakpoint handler. + * + * @returns A Boolean that is true if the BreakpointActor was set as a + * breakpoint handler on at least one script, and false otherwise. + */ + _setBreakpointAtGeneratedLocation: function (actor, generatedLocation) { + let { + generatedSourceActor, + generatedLine, + generatedColumn, + generatedLastColumn + } = generatedLocation; + + // Find all scripts that match the given source actor and line + // number. + const query = { line: generatedLine }; + if (generatedSourceActor.source) { + query.source = generatedSourceActor.source; + } else { + query.url = generatedSourceActor.url; + } + let scripts = this.dbg.findScripts(query); + + scripts = scripts.filter((script) => !actor.hasScript(script)); + + // Find all entry points that correspond to the given location. + let entryPoints = []; + if (generatedColumn === undefined) { + // This is a line breakpoint, so we are interested in all offsets + // that correspond to the given line number. + for (let script of scripts) { + let offsets = script.getLineOffsets(generatedLine); + if (offsets.length > 0) { + entryPoints.push({ script, offsets }); + } + } + } else { + // This is a column breakpoint, so we are interested in all column + // offsets that correspond to the given line *and* column number. + for (let script of scripts) { + let columnToOffsetMap = script.getAllColumnOffsets() + .filter(({ lineNumber }) => { + return lineNumber === generatedLine; + }); + for (let { columnNumber: column, offset } of columnToOffsetMap) { + if (column >= generatedColumn && column <= generatedLastColumn) { + entryPoints.push({ script, offsets: [offset] }); + } + } + } + } + + if (entryPoints.length === 0) { + return false; + } + setBreakpointAtEntryPoints(actor, entryPoints); + return true; + } +}); + +exports.SourceActor = SourceActor; diff --git a/devtools/server/actors/storage.js b/devtools/server/actors/storage.js new file mode 100644 index 0000000000..572cd6b684 --- /dev/null +++ b/devtools/server/actors/storage.js @@ -0,0 +1,2542 @@ +/* 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 {Cc, Ci, Cu, CC} = require("chrome"); +const events = require("sdk/event/core"); +const protocol = require("devtools/shared/protocol"); +const {LongStringActor} = require("devtools/server/actors/string"); +const {DebuggerServer} = require("devtools/server/main"); +const Services = require("Services"); +const promise = require("promise"); +const {isWindowIncluded} = require("devtools/shared/layout/utils"); +const specs = require("devtools/shared/specs/storage"); +const { Task } = require("devtools/shared/task"); + +loader.lazyImporter(this, "OS", "resource://gre/modules/osfile.jsm"); +loader.lazyImporter(this, "Sqlite", "resource://gre/modules/Sqlite.jsm"); + +// We give this a funny name to avoid confusion with the global +// indexedDB. +loader.lazyGetter(this, "indexedDBForStorage", () => { + // On xpcshell, we can't instantiate indexedDB without crashing + try { + let sandbox + = Cu.Sandbox(CC("@mozilla.org/systemprincipal;1", "nsIPrincipal")(), + {wantGlobalProperties: ["indexedDB"]}); + return sandbox.indexedDB; + } catch (e) { + return {}; + } +}); + +// Maximum number of cookies/local storage key-value-pairs that can be sent +// over the wire to the client in one request. +const MAX_STORE_OBJECT_COUNT = 50; +// Delay for the batch job that sends the accumulated update packets to the +// client (ms). +const BATCH_DELAY = 200; + +// MAX_COOKIE_EXPIRY should be 2^63-1, but JavaScript can't handle that +// precision. +const MAX_COOKIE_EXPIRY = Math.pow(2, 62); + +// A RegExp for characters that cannot appear in a file/directory name. This is +// used to sanitize the host name for indexed db to lookup whether the file is +// present in <profileDir>/storage/default/ location +var illegalFileNameCharacters = [ + "[", + // Control characters \001 to \036 + "\\x00-\\x24", + // Special characters + "/:*?\\\"<>|\\\\", + "]" +].join(""); +var ILLEGAL_CHAR_REGEX = new RegExp(illegalFileNameCharacters, "g"); + +// Holder for all the registered storage actors. +var storageTypePool = new Map(); + +/** + * An async method equivalent to setTimeout but using Promises + * + * @param {number} time + * The wait time in milliseconds. + */ +function sleep(time) { + let deferred = promise.defer(); + + setTimeout(() => { + deferred.resolve(null); + }, time); + + return deferred.promise; +} + +// Helper methods to create a storage actor. +var StorageActors = {}; + +/** + * Creates a default object with the common methods required by all storage + * actors. + * + * This default object is missing a couple of required methods that should be + * implemented seperately for each actor. They are namely: + * - observe : Method which gets triggered on the notificaiton of the watched + * topic. + * - getNamesForHost : Given a host, get list of all known store names. + * - getValuesForHost : Given a host (and optianally a name) get all known + * store objects. + * - toStoreObject : Given a store object, convert it to the required format + * so that it can be transferred over wire. + * - populateStoresForHost : Given a host, populate the map of all store + * objects for it + * - getFields: Given a subType(optional), get an array of objects containing + * column field info. The info includes, + * "name" is name of colume key. + * "editable" is 1 means editable field; 0 means uneditable. + * + * @param {string} typeName + * The typeName of the actor. + * @param {string} observationTopic + * The topic which this actor listens to via Notification Observers. + */ +StorageActors.defaults = function (typeName, observationTopic) { + return { + typeName: typeName, + + get conn() { + return this.storageActor.conn; + }, + + /** + * Returns a list of currently knwon hosts for the target window. This list + * contains unique hosts from the window + all inner windows. + */ + get hosts() { + let hosts = new Set(); + for (let {location} of this.storageActor.windows) { + hosts.add(this.getHostName(location)); + } + return hosts; + }, + + /** + * Returns all the windows present on the page. Includes main window + inner + * iframe windows. + */ + get windows() { + return this.storageActor.windows; + }, + + /** + * Converts the window.location object into host. + */ + getHostName(location) { + return location.hostname || location.href; + }, + + initialize(storageActor) { + protocol.Actor.prototype.initialize.call(this, null); + + this.storageActor = storageActor; + + this.populateStoresForHosts(); + if (observationTopic) { + Services.obs.addObserver(this, observationTopic, false); + } + this.onWindowReady = this.onWindowReady.bind(this); + this.onWindowDestroyed = this.onWindowDestroyed.bind(this); + events.on(this.storageActor, "window-ready", this.onWindowReady); + events.on(this.storageActor, "window-destroyed", this.onWindowDestroyed); + }, + + destroy() { + if (observationTopic) { + Services.obs.removeObserver(this, observationTopic, false); + } + events.off(this.storageActor, "window-ready", this.onWindowReady); + events.off(this.storageActor, "window-destroyed", this.onWindowDestroyed); + + this.hostVsStores.clear(); + this.storageActor = null; + }, + + getNamesForHost(host) { + return [...this.hostVsStores.get(host).keys()]; + }, + + getValuesForHost(host, name) { + if (name) { + return [this.hostVsStores.get(host).get(name)]; + } + return [...this.hostVsStores.get(host).values()]; + }, + + getObjectsSize(host, names) { + return names.length; + }, + + /** + * When a new window is added to the page. This generally means that a new + * iframe is created, or the current window is completely reloaded. + * + * @param {window} window + * The window which was added. + */ + onWindowReady: Task.async(function* (window) { + let host = this.getHostName(window.location); + if (!this.hostVsStores.has(host)) { + yield this.populateStoresForHost(host, window); + let data = {}; + data[host] = this.getNamesForHost(host); + this.storageActor.update("added", typeName, data); + } + }), + + /** + * When a window is removed from the page. This generally means that an + * iframe was removed, or the current window reload is triggered. + * + * @param {window} window + * The window which was removed. + */ + onWindowDestroyed(window) { + if (!window.location) { + // Nothing can be done if location object is null + return; + } + let host = this.getHostName(window.location); + if (!this.hosts.has(host)) { + this.hostVsStores.delete(host); + let data = {}; + data[host] = []; + this.storageActor.update("deleted", typeName, data); + } + }, + + form(form, detail) { + if (detail === "actorid") { + return this.actorID; + } + + let hosts = {}; + for (let host of this.hosts) { + hosts[host] = []; + } + + return { + actor: this.actorID, + hosts: hosts + }; + }, + + /** + * Populates a map of known hosts vs a map of stores vs value. + */ + populateStoresForHosts() { + this.hostVsStores = new Map(); + for (let host of this.hosts) { + this.populateStoresForHost(host); + } + }, + + /** + * Returns a list of requested store objects. Maximum values returned are + * MAX_STORE_OBJECT_COUNT. This method returns paginated values whose + * starting index and total size can be controlled via the options object + * + * @param {string} host + * The host name for which the store values are required. + * @param {array:string} names + * Array containing the names of required store objects. Empty if all + * items are required. + * @param {object} options + * Additional options for the request containing following + * properties: + * - offset {number} : The begin index of the returned array amongst + * the total values + * - size {number} : The number of values required. + * - sortOn {string} : The values should be sorted on this property. + * - index {string} : In case of indexed db, the IDBIndex to be used + * for fetching the values. + * + * @return {object} An object containing following properties: + * - offset - The actual offset of the returned array. This might + * be different from the requested offset if that was + * invalid + * - total - The total number of entries possible. + * - data - The requested values. + */ + getStoreObjects: Task.async(function* (host, names, options = {}) { + let offset = options.offset || 0; + let size = options.size || MAX_STORE_OBJECT_COUNT; + if (size > MAX_STORE_OBJECT_COUNT) { + size = MAX_STORE_OBJECT_COUNT; + } + let sortOn = options.sortOn || "name"; + + let toReturn = { + offset: offset, + total: 0, + data: [] + }; + + let principal = null; + if (this.typeName === "indexedDB") { + // We only acquire principal when the type of the storage is indexedDB + // because the principal only matters the indexedDB. + let win = this.storageActor.getWindowFromHost(host); + if (win) { + principal = win.document.nodePrincipal; + } + } + + if (names) { + for (let name of names) { + let values = yield this.getValuesForHost(host, name, options, + this.hostVsStores, principal); + + let {result, objectStores} = values; + + if (result && typeof result.objectsSize !== "undefined") { + for (let {key, count} of result.objectsSize) { + this.objectsSize[key] = count; + } + } + + if (result) { + toReturn.data.push(...result.data); + } else if (objectStores) { + toReturn.data.push(...objectStores); + } else { + toReturn.data.push(...values); + } + } + toReturn.total = this.getObjectsSize(host, names, options); + if (offset > toReturn.total) { + // In this case, toReturn.data is an empty array. + toReturn.offset = toReturn.total; + toReturn.data = []; + } else { + toReturn.data = toReturn.data.sort((a, b) => { + return a[sortOn] - b[sortOn]; + }).slice(offset, offset + size).map(a => this.toStoreObject(a)); + } + } else { + let obj = yield this.getValuesForHost(host, undefined, undefined, + this.hostVsStores, principal); + if (obj.dbs) { + obj = obj.dbs; + } + + toReturn.total = obj.length; + if (offset > toReturn.total) { + // In this case, toReturn.data is an empty array. + toReturn.offset = offset = toReturn.total; + toReturn.data = []; + } else { + toReturn.data = obj.sort((a, b) => { + return a[sortOn] - b[sortOn]; + }).slice(offset, offset + size) + .map(object => this.toStoreObject(object)); + } + } + + return toReturn; + }) + }; +}; + +/** + * Creates an actor and its corresponding front and registers it to the Storage + * Actor. + * + * @See StorageActors.defaults() + * + * @param {object} options + * Options required by StorageActors.defaults method which are : + * - typeName {string} + * The typeName of the actor. + * - observationTopic {string} + * The topic which this actor listens to via + * Notification Observers. + * @param {object} overrides + * All the methods which you want to be different from the ones in + * StorageActors.defaults method plus the required ones described there. + */ +StorageActors.createActor = function (options = {}, overrides = {}) { + let actorObject = StorageActors.defaults( + options.typeName, + options.observationTopic || null + ); + for (let key in overrides) { + actorObject[key] = overrides[key]; + } + + let actorSpec = specs.childSpecs[options.typeName]; + let actor = protocol.ActorClassWithSpec(actorSpec, actorObject); + storageTypePool.set(actorObject.typeName, actor); +}; + +/** + * The Cookies actor and front. + */ +StorageActors.createActor({ + typeName: "cookies" +}, { + initialize(storageActor) { + protocol.Actor.prototype.initialize.call(this, null); + + this.storageActor = storageActor; + + this.maybeSetupChildProcess(); + this.populateStoresForHosts(); + this.addCookieObservers(); + + this.onWindowReady = this.onWindowReady.bind(this); + this.onWindowDestroyed = this.onWindowDestroyed.bind(this); + events.on(this.storageActor, "window-ready", this.onWindowReady); + events.on(this.storageActor, "window-destroyed", this.onWindowDestroyed); + }, + + destroy() { + this.hostVsStores.clear(); + + // We need to remove the cookie listeners early in E10S mode so we need to + // use a conditional here to ensure that we only attempt to remove them in + // single process mode. + if (!DebuggerServer.isInChildProcess) { + this.removeCookieObservers(); + } + + events.off(this.storageActor, "window-ready", this.onWindowReady); + events.off(this.storageActor, "window-destroyed", this.onWindowDestroyed); + + this._pendingResponse = this.storageActor = null; + }, + + /** + * Given a cookie object, figure out all the matching hosts from the page that + * the cookie belong to. + */ + getMatchingHosts(cookies) { + if (!cookies.length) { + cookies = [cookies]; + } + let hosts = new Set(); + for (let host of this.hosts) { + for (let cookie of cookies) { + if (this.isCookieAtHost(cookie, host)) { + hosts.add(host); + } + } + } + return [...hosts]; + }, + + /** + * Given a cookie object and a host, figure out if the cookie is valid for + * that host. + */ + isCookieAtHost(cookie, host) { + if (cookie.host == null) { + return host == null; + } + if (cookie.host.startsWith(".")) { + return ("." + host).endsWith(cookie.host); + } + if (cookie.host === "") { + return host.startsWith("file://" + cookie.path); + } + return cookie.host == host; + }, + + toStoreObject(cookie) { + if (!cookie) { + return null; + } + + return { + name: cookie.name, + path: cookie.path || "", + host: cookie.host || "", + + // because expires is in seconds + expires: (cookie.expires || 0) * 1000, + + // because it is in micro seconds + creationTime: cookie.creationTime / 1000, + + // - do - + lastAccessed: cookie.lastAccessed / 1000, + value: new LongStringActor(this.conn, cookie.value || ""), + isDomain: cookie.isDomain, + isSecure: cookie.isSecure, + isHttpOnly: cookie.isHttpOnly + }; + }, + + populateStoresForHost(host) { + this.hostVsStores.set(host, new Map()); + let doc = this.storageActor.document; + + let cookies = this.getCookiesFromHost(host, doc.nodePrincipal + .originAttributes); + + for (let cookie of cookies) { + if (this.isCookieAtHost(cookie, host)) { + this.hostVsStores.get(host).set(cookie.name, cookie); + } + } + }, + + /** + * Notification observer for "cookie-change". + * + * @param subject + * {Cookie|[Array]} A JSON parsed object containing either a single + * cookie representation or an array. Array is only in case of + * a "batch-deleted" action. + * @param {string} topic + * The topic of the notification. + * @param {string} action + * Additional data associated with the notification. Its the type of + * cookie change in the "cookie-change" topic. + */ + onCookieChanged(subject, topic, action) { + if (topic !== "cookie-changed" || + !this.storageActor || + !this.storageActor.windows) { + return null; + } + + let hosts = this.getMatchingHosts(subject); + let data = {}; + + switch (action) { + case "added": + case "changed": + if (hosts.length) { + for (let host of hosts) { + this.hostVsStores.get(host).set(subject.name, subject); + data[host] = [subject.name]; + } + this.storageActor.update(action, "cookies", data); + } + break; + + case "deleted": + if (hosts.length) { + for (let host of hosts) { + this.hostVsStores.get(host).delete(subject.name); + data[host] = [subject.name]; + } + this.storageActor.update("deleted", "cookies", data); + } + break; + + case "batch-deleted": + if (hosts.length) { + for (let host of hosts) { + let stores = []; + for (let cookie of subject) { + this.hostVsStores.get(host).delete(cookie.name); + stores.push(cookie.name); + } + data[host] = stores; + } + this.storageActor.update("deleted", "cookies", data); + } + break; + + case "cleared": + if (hosts.length) { + for (let host of hosts) { + data[host] = []; + } + this.storageActor.update("cleared", "cookies", data); + } + break; + } + return null; + }, + + getFields: Task.async(function* () { + return [ + { name: "name", editable: 1}, + { name: "path", editable: 1}, + { name: "host", editable: 1}, + { name: "expires", editable: 1}, + { name: "lastAccessed", editable: 0}, + { name: "value", editable: 1}, + { name: "isDomain", editable: 0}, + { name: "isSecure", editable: 1}, + { name: "isHttpOnly", editable: 1} + ]; + }), + + /** + * Pass the editItem command from the content to the chrome process. + * + * @param {Object} data + * See editCookie() for format details. + */ + editItem: Task.async(function* (data) { + let doc = this.storageActor.document; + data.originAttributes = doc.nodePrincipal + .originAttributes; + this.editCookie(data); + }), + + removeItem: Task.async(function* (host, name) { + let doc = this.storageActor.document; + this.removeCookie(host, name, doc.nodePrincipal + .originAttributes); + }), + + removeAll: Task.async(function* (host, domain) { + let doc = this.storageActor.document; + this.removeAllCookies(host, domain, doc.nodePrincipal + .originAttributes); + }), + + maybeSetupChildProcess() { + cookieHelpers.onCookieChanged = this.onCookieChanged.bind(this); + + if (!DebuggerServer.isInChildProcess) { + this.getCookiesFromHost = + cookieHelpers.getCookiesFromHost.bind(cookieHelpers); + this.addCookieObservers = + cookieHelpers.addCookieObservers.bind(cookieHelpers); + this.removeCookieObservers = + cookieHelpers.removeCookieObservers.bind(cookieHelpers); + this.editCookie = + cookieHelpers.editCookie.bind(cookieHelpers); + this.removeCookie = + cookieHelpers.removeCookie.bind(cookieHelpers); + this.removeAllCookies = + cookieHelpers.removeAllCookies.bind(cookieHelpers); + return; + } + + const { sendSyncMessage, addMessageListener } = + this.conn.parentMessageManager; + + this.conn.setupInParent({ + module: "devtools/server/actors/storage", + setupParent: "setupParentProcessForCookies" + }); + + this.getCookiesFromHost = + callParentProcess.bind(null, "getCookiesFromHost"); + this.addCookieObservers = + callParentProcess.bind(null, "addCookieObservers"); + this.removeCookieObservers = + callParentProcess.bind(null, "removeCookieObservers"); + this.editCookie = + callParentProcess.bind(null, "editCookie"); + this.removeCookie = + callParentProcess.bind(null, "removeCookie"); + this.removeAllCookies = + callParentProcess.bind(null, "removeAllCookies"); + + addMessageListener("debug:storage-cookie-request-child", + cookieHelpers.handleParentRequest); + + function callParentProcess(methodName, ...args) { + let reply = sendSyncMessage("debug:storage-cookie-request-parent", { + method: methodName, + args: args + }); + + if (reply.length === 0) { + console.error("ERR_DIRECTOR_CHILD_NO_REPLY from " + methodName); + } else if (reply.length > 1) { + console.error("ERR_DIRECTOR_CHILD_MULTIPLE_REPLIES from " + methodName); + } + + let result = reply[0]; + + if (methodName === "getCookiesFromHost") { + return JSON.parse(result); + } + + return result; + } + }, +}); + +var cookieHelpers = { + getCookiesFromHost(host, originAttributes) { + // Local files have no host. + if (host.startsWith("file:///")) { + host = ""; + } + + let cookies = Services.cookies.getCookiesFromHost(host, originAttributes); + let store = []; + + while (cookies.hasMoreElements()) { + let cookie = cookies.getNext().QueryInterface(Ci.nsICookie2); + + store.push(cookie); + } + + return store; + }, + + /** + * Apply the results of a cookie edit. + * + * @param {Object} data + * An object in the following format: + * { + * host: "http://www.mozilla.org", + * field: "value", + * key: "name", + * oldValue: "%7BHello%7D", + * newValue: "%7BHelloo%7D", + * items: { + * name: "optimizelyBuckets", + * path: "/", + * host: ".mozilla.org", + * expires: "Mon, 02 Jun 2025 12:37:37 GMT", + * creationTime: "Tue, 18 Nov 2014 16:21:18 GMT", + * lastAccessed: "Wed, 17 Feb 2016 10:06:23 GMT", + * value: "%7BHelloo%7D", + * isDomain: "true", + * isSecure: "false", + * isHttpOnly: "false" + * } + * } + */ + editCookie(data) { + let {field, oldValue, newValue} = data; + let origName = field === "name" ? oldValue : data.items.name; + let origHost = field === "host" ? oldValue : data.items.host; + let origPath = field === "path" ? oldValue : data.items.path; + let cookie = null; + + let enumerator = Services.cookies.getCookiesFromHost(origHost, data.originAttributes || {}); + while (enumerator.hasMoreElements()) { + let nsiCookie = enumerator.getNext().QueryInterface(Ci.nsICookie2); + if (nsiCookie.name === origName && nsiCookie.host === origHost) { + cookie = { + host: nsiCookie.host, + path: nsiCookie.path, + name: nsiCookie.name, + value: nsiCookie.value, + isSecure: nsiCookie.isSecure, + isHttpOnly: nsiCookie.isHttpOnly, + isSession: nsiCookie.isSession, + expires: nsiCookie.expires, + originAttributes: nsiCookie.originAttributes + }; + break; + } + } + + if (!cookie) { + return; + } + + // If the date is expired set it for 1 minute in the future. + let now = new Date(); + if (!cookie.isSession && (cookie.expires * 1000) <= now) { + let tenSecondsFromNow = (now.getTime() + 10 * 1000) / 1000; + + cookie.expires = tenSecondsFromNow; + } + + switch (field) { + case "isSecure": + case "isHttpOnly": + case "isSession": + newValue = newValue === "true"; + break; + + case "expires": + newValue = Date.parse(newValue) / 1000; + + if (isNaN(newValue)) { + newValue = MAX_COOKIE_EXPIRY; + } + break; + + case "host": + case "name": + case "path": + // Remove the edited cookie. + Services.cookies.remove(origHost, origName, origPath, + false, cookie.originAttributes); + break; + } + + // Apply changes. + cookie[field] = newValue; + + // cookie.isSession is not always set correctly on session cookies so we + // need to trust cookie.expires instead. + cookie.isSession = !cookie.expires; + + // Add the edited cookie. + Services.cookies.add( + cookie.host, + cookie.path, + cookie.name, + cookie.value, + cookie.isSecure, + cookie.isHttpOnly, + cookie.isSession, + cookie.isSession ? MAX_COOKIE_EXPIRY : cookie.expires, + cookie.originAttributes + ); + }, + + _removeCookies(host, opts = {}) { + function hostMatches(cookieHost, matchHost) { + if (cookieHost == null) { + return matchHost == null; + } + if (cookieHost.startsWith(".")) { + return ("." + matchHost).endsWith(cookieHost); + } + return cookieHost == host; + } + + let enumerator = Services.cookies.getCookiesFromHost(host, opts.originAttributes || {}); + while (enumerator.hasMoreElements()) { + let cookie = enumerator.getNext().QueryInterface(Ci.nsICookie2); + if (hostMatches(cookie.host, host) && + (!opts.name || cookie.name === opts.name) && + (!opts.domain || cookie.host === opts.domain)) { + Services.cookies.remove( + cookie.host, + cookie.name, + cookie.path, + false, + cookie.originAttributes + ); + } + } + }, + + removeCookie(host, name, originAttributes) { + if (name !== undefined) { + this._removeCookies(host, { name, originAttributes }); + } + }, + + removeAllCookies(host, domain, originAttributes) { + this._removeCookies(host, { domain, originAttributes }); + }, + + addCookieObservers() { + Services.obs.addObserver(cookieHelpers, "cookie-changed", false); + return null; + }, + + removeCookieObservers() { + Services.obs.removeObserver(cookieHelpers, "cookie-changed", false); + return null; + }, + + observe(subject, topic, data) { + if (!subject) { + return; + } + + switch (topic) { + case "cookie-changed": + if (data === "batch-deleted") { + let cookiesNoInterface = subject.QueryInterface(Ci.nsIArray); + let cookies = []; + + for (let i = 0; i < cookiesNoInterface.length; i++) { + let cookie = cookiesNoInterface.queryElementAt(i, Ci.nsICookie2); + cookies.push(cookie); + } + cookieHelpers.onCookieChanged(cookies, topic, data); + + return; + } + + let cookie = subject.QueryInterface(Ci.nsICookie2); + cookieHelpers.onCookieChanged(cookie, topic, data); + break; + } + }, + + handleParentRequest(msg) { + switch (msg.json.method) { + case "onCookieChanged": + let [cookie, topic, data] = msg.data.args; + cookie = JSON.parse(cookie); + cookieHelpers.onCookieChanged(cookie, topic, data); + break; + } + }, + + handleChildRequest(msg) { + switch (msg.json.method) { + case "getCookiesFromHost": { + let host = msg.data.args[0]; + let originAttributes = msg.data.args[1]; + let cookies = cookieHelpers.getCookiesFromHost(host, originAttributes); + return JSON.stringify(cookies); + } + case "addCookieObservers": { + return cookieHelpers.addCookieObservers(); + } + case "removeCookieObservers": { + return cookieHelpers.removeCookieObservers(); + } + case "editCookie": { + let rowdata = msg.data.args[0]; + return cookieHelpers.editCookie(rowdata); + } + case "removeCookie": { + let host = msg.data.args[0]; + let name = msg.data.args[1]; + let originAttributes = msg.data.args[2]; + return cookieHelpers.removeCookie(host, name, originAttributes); + } + case "removeAllCookies": { + let host = msg.data.args[0]; + let domain = msg.data.args[1]; + let originAttributes = msg.data.args[2]; + return cookieHelpers.removeAllCookies(host, domain, originAttributes); + } + default: + console.error("ERR_DIRECTOR_PARENT_UNKNOWN_METHOD", msg.json.method); + throw new Error("ERR_DIRECTOR_PARENT_UNKNOWN_METHOD"); + } + }, +}; + +/** + * E10S parent/child setup helpers + */ + +exports.setupParentProcessForCookies = function ({ mm, prefix }) { + cookieHelpers.onCookieChanged = + callChildProcess.bind(null, "onCookieChanged"); + + // listen for director-script requests from the child process + setMessageManager(mm); + + function callChildProcess(methodName, ...args) { + if (methodName === "onCookieChanged") { + args[0] = JSON.stringify(args[0]); + } + + try { + mm.sendAsyncMessage("debug:storage-cookie-request-child", { + method: methodName, + args: args + }); + } catch (e) { + // We may receive a NS_ERROR_NOT_INITIALIZED if the target window has + // been closed. This can legitimately happen in between test runs. + } + } + + function setMessageManager(newMM) { + if (mm) { + mm.removeMessageListener("debug:storage-cookie-request-parent", + cookieHelpers.handleChildRequest); + } + mm = newMM; + if (mm) { + mm.addMessageListener("debug:storage-cookie-request-parent", + cookieHelpers.handleChildRequest); + } + } + + return { + onBrowserSwap: setMessageManager, + onDisconnected: () => { + // Although "disconnected-from-child" implies that the child is already + // disconnected this is not the case. The disconnection takes place after + // this method has finished. This gives us chance to clean up items within + // the parent process e.g. observers. + cookieHelpers.removeCookieObservers(); + setMessageManager(null); + } + }; +}; + +/** + * Helper method to create the overriden object required in + * StorageActors.createActor for Local Storage and Session Storage. + * This method exists as both Local Storage and Session Storage have almost + * identical actors. + */ +function getObjectForLocalOrSessionStorage(type) { + return { + getNamesForHost(host) { + let storage = this.hostVsStores.get(host); + return storage ? Object.keys(storage) : []; + }, + + getValuesForHost(host, name) { + let storage = this.hostVsStores.get(host); + if (!storage) { + return []; + } + if (name) { + let value = storage ? storage.getItem(name) : null; + return [{ name, value }]; + } + if (!storage) { + return []; + } + return Object.keys(storage).map(key => ({ + name: key, + value: storage.getItem(key) + })); + }, + + getHostName(location) { + if (!location.host) { + return location.href; + } + return location.protocol + "//" + location.host; + }, + + populateStoresForHost(host, window) { + try { + this.hostVsStores.set(host, window[type]); + } catch (ex) { + console.warn(`Failed to enumerate ${type} for host ${host}: ${ex}`); + } + }, + + populateStoresForHosts() { + this.hostVsStores = new Map(); + for (let window of this.windows) { + this.populateStoresForHost(this.getHostName(window.location), window); + } + }, + + getFields: Task.async(function* () { + return [ + { name: "name", editable: 1}, + { name: "value", editable: 1} + ]; + }), + + /** + * Edit localStorage or sessionStorage fields. + * + * @param {Object} data + * See editCookie() for format details. + */ + editItem: Task.async(function* ({host, field, oldValue, items}) { + let storage = this.hostVsStores.get(host); + if (!storage) { + return; + } + + if (field === "name") { + storage.removeItem(oldValue); + } + + storage.setItem(items.name, items.value); + }), + + removeItem: Task.async(function* (host, name) { + let storage = this.hostVsStores.get(host); + if (!storage) { + return; + } + storage.removeItem(name); + }), + + removeAll: Task.async(function* (host) { + let storage = this.hostVsStores.get(host); + if (!storage) { + return; + } + storage.clear(); + }), + + observe(subject, topic, data) { + if (topic != "dom-storage2-changed" || data != type) { + return null; + } + + let host = this.getSchemaAndHost(subject.url); + + if (!this.hostVsStores.has(host)) { + return null; + } + + let action = "changed"; + if (subject.key == null) { + return this.storageActor.update("cleared", type, [host]); + } else if (subject.oldValue == null) { + action = "added"; + } else if (subject.newValue == null) { + action = "deleted"; + } + let updateData = {}; + updateData[host] = [subject.key]; + return this.storageActor.update(action, type, updateData); + }, + + /** + * Given a url, correctly determine its protocol + hostname part. + */ + getSchemaAndHost(url) { + let uri = Services.io.newURI(url, null, null); + if (!uri.host) { + return uri.spec; + } + return uri.scheme + "://" + uri.hostPort; + }, + + toStoreObject(item) { + if (!item) { + return null; + } + + return { + name: item.name, + value: new LongStringActor(this.conn, item.value || "") + }; + }, + }; +} + +/** + * The Local Storage actor and front. + */ +StorageActors.createActor({ + typeName: "localStorage", + observationTopic: "dom-storage2-changed" +}, getObjectForLocalOrSessionStorage("localStorage")); + +/** + * The Session Storage actor and front. + */ +StorageActors.createActor({ + typeName: "sessionStorage", + observationTopic: "dom-storage2-changed" +}, getObjectForLocalOrSessionStorage("sessionStorage")); + +StorageActors.createActor({ + typeName: "Cache" +}, { + getCachesForHost: Task.async(function* (host) { + let uri = Services.io.newURI(host, null, null); + let principal = + Services.scriptSecurityManager.getNoAppCodebasePrincipal(uri); + + // The first argument tells if you want to get |content| cache or |chrome| + // cache. + // The |content| cache is the cache explicitely named by the web content + // (service worker or web page). + // The |chrome| cache is the cache implicitely cached by the platform, + // hosting the source file of the service worker. + let { CacheStorage } = this.storageActor.window; + let cache = new CacheStorage("content", principal); + return cache; + }), + + preListStores: Task.async(function* () { + for (let host of this.hosts) { + yield this.populateStoresForHost(host); + } + }), + + form(form, detail) { + if (detail === "actorid") { + return this.actorID; + } + + let hosts = {}; + for (let host of this.hosts) { + hosts[host] = this.getNamesForHost(host); + } + + return { + actor: this.actorID, + hosts: hosts + }; + }, + + getNamesForHost(host) { + // UI code expect each name to be a JSON string of an array :/ + return [...this.hostVsStores.get(host).keys()].map(a => { + return JSON.stringify([a]); + }); + }, + + getValuesForHost: Task.async(function* (host, name) { + if (!name) { + return []; + } + // UI is weird and expect a JSON stringified array... and pass it back :/ + name = JSON.parse(name)[0]; + + let cache = this.hostVsStores.get(host).get(name); + let requests = yield cache.keys(); + let results = []; + for (let request of requests) { + let response = yield cache.match(request); + // Unwrap the response to get access to all its properties if the + // response happen to be 'opaque', when it is a Cross Origin Request. + response = response.cloneUnfiltered(); + results.push(yield this.processEntry(request, response)); + } + return results; + }), + + processEntry: Task.async(function* (request, response) { + return { + url: String(request.url), + status: String(response.statusText), + }; + }), + + getFields: Task.async(function* () { + return [ + { name: "url", editable: 0 }, + { name: "status", editable: 0 } + ]; + }), + + getHostName(location) { + if (!location.host) { + return location.href; + } + return location.protocol + "//" + location.host; + }, + + populateStoresForHost: Task.async(function* (host) { + let storeMap = new Map(); + let caches = yield this.getCachesForHost(host); + try { + for (let name of (yield caches.keys())) { + storeMap.set(name, (yield caches.open(name))); + } + } catch (ex) { + console.warn(`Failed to enumerate CacheStorage for host ${host}: ${ex}`); + } + this.hostVsStores.set(host, storeMap); + }), + + /** + * This method is overriden and left blank as for Cache Storage, this + * operation cannot be performed synchronously. Thus, the preListStores + * method exists to do the same task asynchronously. + */ + populateStoresForHosts() { + this.hostVsStores = new Map(); + }, + + /** + * Given a url, correctly determine its protocol + hostname part. + */ + getSchemaAndHost(url) { + let uri = Services.io.newURI(url, null, null); + return uri.scheme + "://" + uri.hostPort; + }, + + toStoreObject(item) { + return item; + }, + + removeItem: Task.async(function* (host, name) { + const cacheMap = this.hostVsStores.get(host); + if (!cacheMap) { + return; + } + + const parsedName = JSON.parse(name); + + if (parsedName.length == 1) { + // Delete the whole Cache object + const [ cacheName ] = parsedName; + cacheMap.delete(cacheName); + const cacheStorage = yield this.getCachesForHost(host); + yield cacheStorage.delete(cacheName); + this.onItemUpdated("deleted", host, [ cacheName ]); + } else if (parsedName.length == 2) { + // Delete one cached request + const [ cacheName, url ] = parsedName; + const cache = cacheMap.get(cacheName); + if (cache) { + yield cache.delete(url); + this.onItemUpdated("deleted", host, [ cacheName, url ]); + } + } + }), + + removeAll: Task.async(function* (host, name) { + const cacheMap = this.hostVsStores.get(host); + if (!cacheMap) { + return; + } + + const parsedName = JSON.parse(name); + + // Only a Cache object is a valid object to clear + if (parsedName.length == 1) { + const [ cacheName ] = parsedName; + const cache = cacheMap.get(cacheName); + if (cache) { + let keys = yield cache.keys(); + yield promise.all(keys.map(key => cache.delete(key))); + this.onItemUpdated("cleared", host, [ cacheName ]); + } + } + }), + + /** + * CacheStorage API doesn't support any notifications, we must fake them + */ + onItemUpdated(action, host, path) { + this.storageActor.update(action, "Cache", { + [host]: [ JSON.stringify(path) ] + }); + }, +}); + +/** + * Code related to the Indexed DB actor and front + */ + +// Metadata holder objects for various components of Indexed DB + +/** + * Meta data object for a particular index in an object store + * + * @param {IDBIndex} index + * The particular index from the object store. + */ +function IndexMetadata(index) { + this._name = index.name; + this._keyPath = index.keyPath; + this._unique = index.unique; + this._multiEntry = index.multiEntry; +} +IndexMetadata.prototype = { + toObject() { + return { + name: this._name, + keyPath: this._keyPath, + unique: this._unique, + multiEntry: this._multiEntry + }; + } +}; + +/** + * Meta data object for a particular object store in a db + * + * @param {IDBObjectStore} objectStore + * The particular object store from the db. + */ +function ObjectStoreMetadata(objectStore) { + this._name = objectStore.name; + this._keyPath = objectStore.keyPath; + this._autoIncrement = objectStore.autoIncrement; + this._indexes = []; + + for (let i = 0; i < objectStore.indexNames.length; i++) { + let index = objectStore.index(objectStore.indexNames[i]); + + let newIndex = { + keypath: index.keyPath, + multiEntry: index.multiEntry, + name: index.name, + objectStore: { + autoIncrement: index.objectStore.autoIncrement, + indexNames: [...index.objectStore.indexNames], + keyPath: index.objectStore.keyPath, + name: index.objectStore.name, + } + }; + + this._indexes.push([newIndex, new IndexMetadata(index)]); + } +} +ObjectStoreMetadata.prototype = { + toObject() { + return { + name: this._name, + keyPath: this._keyPath, + autoIncrement: this._autoIncrement, + indexes: JSON.stringify( + [...this._indexes.values()].map(index => index.toObject()) + ) + }; + } +}; + +/** + * Meta data object for a particular indexed db in a host. + * + * @param {string} origin + * The host associated with this indexed db. + * @param {IDBDatabase} db + * The particular indexed db. + */ +function DatabaseMetadata(origin, db) { + this._origin = origin; + this._name = db.name; + this._version = db.version; + this._objectStores = []; + + if (db.objectStoreNames.length) { + let transaction = db.transaction(db.objectStoreNames, "readonly"); + + for (let i = 0; i < transaction.objectStoreNames.length; i++) { + let objectStore = + transaction.objectStore(transaction.objectStoreNames[i]); + this._objectStores.push([transaction.objectStoreNames[i], + new ObjectStoreMetadata(objectStore)]); + } + } +} +DatabaseMetadata.prototype = { + get objectStores() { + return this._objectStores; + }, + + toObject() { + return { + name: this._name, + origin: this._origin, + version: this._version, + objectStores: this._objectStores.size + }; + } +}; + +StorageActors.createActor({ + typeName: "indexedDB" +}, { + initialize(storageActor) { + protocol.Actor.prototype.initialize.call(this, null); + + this.storageActor = storageActor; + + this.maybeSetupChildProcess(); + + this.objectsSize = {}; + this.storageActor = storageActor; + this.onWindowReady = this.onWindowReady.bind(this); + this.onWindowDestroyed = this.onWindowDestroyed.bind(this); + + events.on(this.storageActor, "window-ready", this.onWindowReady); + events.on(this.storageActor, "window-destroyed", this.onWindowDestroyed); + }, + + destroy() { + this.hostVsStores.clear(); + this.objectsSize = null; + + events.off(this.storageActor, "window-ready", this.onWindowReady); + events.off(this.storageActor, "window-destroyed", this.onWindowDestroyed); + }, + + /** + * Remove an indexedDB database from given host with a given name. + */ + removeDatabase: Task.async(function* (host, name) { + let win = this.storageActor.getWindowFromHost(host); + if (!win) { + return { error: `Window for host ${host} not found` }; + } + + let principal = win.document.nodePrincipal; + return this.removeDB(host, principal, name); + }), + + removeAll: Task.async(function* (host, name) { + let [db, store] = JSON.parse(name); + + let win = this.storageActor.getWindowFromHost(host); + if (!win) { + return; + } + + let principal = win.document.nodePrincipal; + this.clearDBStore(host, principal, db, store); + }), + + removeItem: Task.async(function* (host, name) { + let [db, store, id] = JSON.parse(name); + + let win = this.storageActor.getWindowFromHost(host); + if (!win) { + return; + } + + let principal = win.document.nodePrincipal; + this.removeDBRecord(host, principal, db, store, id); + }), + + getHostName(location) { + if (!location.host) { + return location.href; + } + return location.protocol + "//" + location.host; + }, + + /** + * This method is overriden and left blank as for indexedDB, this operation + * cannot be performed synchronously. Thus, the preListStores method exists to + * do the same task asynchronously. + */ + populateStoresForHosts() {}, + + getNamesForHost(host) { + let names = []; + + for (let [dbName, {objectStores}] of this.hostVsStores.get(host)) { + if (objectStores.size) { + for (let objectStore of objectStores.keys()) { + names.push(JSON.stringify([dbName, objectStore])); + } + } else { + names.push(JSON.stringify([dbName])); + } + } + return names; + }, + + /** + * Returns the total number of entries for various types of requests to + * getStoreObjects for Indexed DB actor. + * + * @param {string} host + * The host for the request. + * @param {array:string} names + * Array of stringified name objects for indexed db actor. + * The request type depends on the length of any parsed entry from this + * array. 0 length refers to request for the whole host. 1 length + * refers to request for a particular db in the host. 2 length refers + * to a particular object store in a db in a host. 3 length refers to + * particular items of an object store in a db in a host. + * @param {object} options + * An options object containing following properties: + * - index {string} The IDBIndex for the object store in the db. + */ + getObjectsSize(host, names, options) { + // In Indexed DB, we are interested in only the first name, as the pattern + // should follow in all entries. + let name = names[0]; + let parsedName = JSON.parse(name); + + if (parsedName.length == 3) { + // This is the case where specific entries from an object store were + // requested + return names.length; + } else if (parsedName.length == 2) { + // This is the case where all entries from an object store are requested. + let index = options.index; + let [db, objectStore] = parsedName; + if (this.objectsSize[host + db + objectStore + index]) { + return this.objectsSize[host + db + objectStore + index]; + } + } else if (parsedName.length == 1) { + // This is the case where details of all object stores in a db are + // requested. + if (this.hostVsStores.has(host) && + this.hostVsStores.get(host).has(parsedName[0])) { + return this.hostVsStores.get(host).get(parsedName[0]).objectStores.size; + } + } else if (!parsedName || !parsedName.length) { + // This is the case were details of all dbs in a host are requested. + if (this.hostVsStores.has(host)) { + return this.hostVsStores.get(host).size; + } + } + return 0; + }, + + /** + * Purpose of this method is same as populateStoresForHosts but this is async. + * This exact same operation cannot be performed in populateStoresForHosts + * method, as that method is called in initialize method of the actor, which + * cannot be asynchronous. + */ + preListStores: Task.async(function* () { + this.hostVsStores = new Map(); + + for (let host of this.hosts) { + yield this.populateStoresForHost(host); + } + }), + + populateStoresForHost: Task.async(function* (host) { + let storeMap = new Map(); + let {names} = yield this.getDBNamesForHost(host); + let win = this.storageActor.getWindowFromHost(host); + if (win) { + let principal = win.document.nodePrincipal; + + for (let name of names) { + let metadata = yield this.getDBMetaData(host, principal, name); + + metadata = indexedDBHelpers.patchMetadataMapsAndProtos(metadata); + storeMap.set(name, metadata); + } + } + + this.hostVsStores.set(host, storeMap); + }), + + /** + * Returns the over-the-wire implementation of the indexed db entity. + */ + toStoreObject(item) { + if (!item) { + return null; + } + + if ("indexes" in item) { + // Object store meta data + return { + objectStore: item.name, + keyPath: item.keyPath, + autoIncrement: item.autoIncrement, + indexes: item.indexes + }; + } + if ("objectStores" in item) { + // DB meta data + return { + db: item.name, + origin: item.origin, + version: item.version, + objectStores: item.objectStores + }; + } + // Indexed db entry + return { + name: item.name, + value: new LongStringActor(this.conn, JSON.stringify(item.value)) + }; + }, + + form(form, detail) { + if (detail === "actorid") { + return this.actorID; + } + + let hosts = {}; + for (let host of this.hosts) { + hosts[host] = this.getNamesForHost(host); + } + + return { + actor: this.actorID, + hosts: hosts + }; + }, + + onItemUpdated(action, host, path) { + // Database was removed, remove it from stores map + if (action === "deleted" && path.length === 1) { + if (this.hostVsStores.has(host)) { + this.hostVsStores.get(host).delete(path[0]); + } + } + + this.storageActor.update(action, "indexedDB", { + [host]: [ JSON.stringify(path) ] + }); + }, + + maybeSetupChildProcess() { + if (!DebuggerServer.isInChildProcess) { + this.backToChild = (func, rv) => rv; + this.getDBMetaData = indexedDBHelpers.getDBMetaData; + this.openWithPrincipal = indexedDBHelpers.openWithPrincipal; + this.getDBNamesForHost = indexedDBHelpers.getDBNamesForHost; + this.getSanitizedHost = indexedDBHelpers.getSanitizedHost; + this.getNameFromDatabaseFile = indexedDBHelpers.getNameFromDatabaseFile; + this.getValuesForHost = indexedDBHelpers.getValuesForHost; + this.getObjectStoreData = indexedDBHelpers.getObjectStoreData; + this.removeDB = indexedDBHelpers.removeDB; + this.removeDBRecord = indexedDBHelpers.removeDBRecord; + this.clearDBStore = indexedDBHelpers.clearDBStore; + return; + } + + const { sendAsyncMessage, addMessageListener } = + this.conn.parentMessageManager; + + this.conn.setupInParent({ + module: "devtools/server/actors/storage", + setupParent: "setupParentProcessForIndexedDB" + }); + + this.getDBMetaData = callParentProcessAsync.bind(null, "getDBMetaData"); + this.getDBNamesForHost = callParentProcessAsync.bind(null, "getDBNamesForHost"); + this.getValuesForHost = callParentProcessAsync.bind(null, "getValuesForHost"); + this.removeDB = callParentProcessAsync.bind(null, "removeDB"); + this.removeDBRecord = callParentProcessAsync.bind(null, "removeDBRecord"); + this.clearDBStore = callParentProcessAsync.bind(null, "clearDBStore"); + + addMessageListener("debug:storage-indexedDB-request-child", msg => { + switch (msg.json.method) { + case "backToChild": { + let [func, rv] = msg.json.args; + let deferred = unresolvedPromises.get(func); + if (deferred) { + unresolvedPromises.delete(func); + deferred.resolve(rv); + } + break; + } + case "onItemUpdated": { + let [action, host, path] = msg.json.args; + this.onItemUpdated(action, host, path); + } + } + }); + + let unresolvedPromises = new Map(); + function callParentProcessAsync(methodName, ...args) { + let deferred = promise.defer(); + + unresolvedPromises.set(methodName, deferred); + + sendAsyncMessage("debug:storage-indexedDB-request-parent", { + method: methodName, + args: args + }); + + return deferred.promise; + } + }, + + getFields: Task.async(function* (subType) { + switch (subType) { + // Detail of database + case "database": + return [ + { name: "objectStore", editable: 0 }, + { name: "keyPath", editable: 0 }, + { name: "autoIncrement", editable: 0 }, + { name: "indexes", editable: 0 }, + ]; + + // Detail of object store + case "object store": + return [ + { name: "name", editable: 0 }, + { name: "value", editable: 0 } + ]; + + // Detail of indexedDB for one origin + default: + return [ + { name: "db", editable: 0 }, + { name: "origin", editable: 0 }, + { name: "version", editable: 0 }, + { name: "objectStores", editable: 0 }, + ]; + } + }) +}); + +var indexedDBHelpers = { + backToChild(...args) { + let mm = Cc["@mozilla.org/globalmessagemanager;1"] + .getService(Ci.nsIMessageListenerManager); + + mm.broadcastAsyncMessage("debug:storage-indexedDB-request-child", { + method: "backToChild", + args: args + }); + }, + + onItemUpdated(action, host, path) { + let mm = Cc["@mozilla.org/globalmessagemanager;1"] + .getService(Ci.nsIMessageListenerManager); + + mm.broadcastAsyncMessage("debug:storage-indexedDB-request-child", { + method: "onItemUpdated", + args: [ action, host, path ] + }); + }, + + /** + * Fetches and stores all the metadata information for the given database + * `name` for the given `host` with its `principal`. The stored metadata + * information is of `DatabaseMetadata` type. + */ + getDBMetaData: Task.async(function* (host, principal, name) { + let request = this.openWithPrincipal(principal, name); + let success = promise.defer(); + + request.onsuccess = event => { + let db = event.target.result; + + let dbData = new DatabaseMetadata(host, db); + db.close(); + + success.resolve(this.backToChild("getDBMetaData", dbData)); + }; + request.onerror = ({target}) => { + console.error( + `Error opening indexeddb database ${name} for host ${host}`, target.error); + success.resolve(this.backToChild("getDBMetaData", null)); + }; + return success.promise; + }), + + /** + * Opens an indexed db connection for the given `principal` and + * database `name`. + */ + openWithPrincipal(principal, name) { + return indexedDBForStorage.openForPrincipal(principal, name); + }, + + removeDB: Task.async(function* (host, principal, name) { + let result = new promise(resolve => { + let request = indexedDBForStorage.deleteForPrincipal(principal, name); + + request.onsuccess = () => { + resolve({}); + this.onItemUpdated("deleted", host, [name]); + }; + + request.onblocked = () => { + console.warn(`Deleting indexedDB database ${name} for host ${host} is blocked`); + resolve({ blocked: true }); + }; + + request.onerror = () => { + let { error } = request; + console.warn( + `Error deleting indexedDB database ${name} for host ${host}: ${error}`); + resolve({ error: error.message }); + }; + + // If the database is blocked repeatedly, the onblocked event will not + // be fired again. To avoid waiting forever, report as blocked if nothing + // else happens after 3 seconds. + setTimeout(() => resolve({ blocked: true }), 3000); + }); + + return this.backToChild("removeDB", yield result); + }), + + removeDBRecord: Task.async(function* (host, principal, dbName, storeName, id) { + let db; + + try { + db = yield new promise((resolve, reject) => { + let request = this.openWithPrincipal(principal, dbName); + request.onsuccess = ev => resolve(ev.target.result); + request.onerror = ev => reject(ev.target.error); + }); + + let transaction = db.transaction(storeName, "readwrite"); + let store = transaction.objectStore(storeName); + + yield new promise((resolve, reject) => { + let request = store.delete(id); + request.onsuccess = () => resolve(); + request.onerror = ev => reject(ev.target.error); + }); + + this.onItemUpdated("deleted", host, [dbName, storeName, id]); + } catch (error) { + let recordPath = [dbName, storeName, id].join("/"); + console.error(`Failed to delete indexedDB record: ${recordPath}: ${error}`); + } + + if (db) { + db.close(); + } + + return this.backToChild("removeDBRecord", null); + }), + + clearDBStore: Task.async(function* (host, principal, dbName, storeName) { + let db; + + try { + db = yield new promise((resolve, reject) => { + let request = this.openWithPrincipal(principal, dbName); + request.onsuccess = ev => resolve(ev.target.result); + request.onerror = ev => reject(ev.target.error); + }); + + let transaction = db.transaction(storeName, "readwrite"); + let store = transaction.objectStore(storeName); + + yield new promise((resolve, reject) => { + let request = store.clear(); + request.onsuccess = () => resolve(); + request.onerror = ev => reject(ev.target.error); + }); + + this.onItemUpdated("cleared", host, [dbName, storeName]); + } catch (error) { + let storePath = [dbName, storeName].join("/"); + console.error(`Failed to clear indexedDB store: ${storePath}: ${error}`); + } + + if (db) { + db.close(); + } + + return this.backToChild("clearDBStore", null); + }), + + /** + * Fetches all the databases and their metadata for the given `host`. + */ + getDBNamesForHost: Task.async(function* (host) { + let sanitizedHost = this.getSanitizedHost(host); + let directory = OS.Path.join(OS.Constants.Path.profileDir, "storage", + "default", sanitizedHost, "idb"); + + let exists = yield OS.File.exists(directory); + if (!exists && host.startsWith("about:")) { + // try for moz-safe-about directory + sanitizedHost = this.getSanitizedHost("moz-safe-" + host); + directory = OS.Path.join(OS.Constants.Path.profileDir, "storage", + "permanent", sanitizedHost, "idb"); + exists = yield OS.File.exists(directory); + } + if (!exists) { + return this.backToChild("getDBNamesForHost", {names: []}); + } + + let names = []; + let dirIterator = new OS.File.DirectoryIterator(directory); + try { + yield dirIterator.forEach(file => { + // Skip directories. + if (file.isDir) { + return null; + } + + // Skip any non-sqlite files. + if (!file.name.endsWith(".sqlite")) { + return null; + } + + return this.getNameFromDatabaseFile(file.path).then(name => { + if (name) { + names.push(name); + } + return null; + }); + }); + } finally { + dirIterator.close(); + } + return this.backToChild("getDBNamesForHost", {names: names}); + }), + + /** + * Removes any illegal characters from the host name to make it a valid file + * name. + */ + getSanitizedHost(host) { + return host.replace(ILLEGAL_CHAR_REGEX, "+"); + }, + + /** + * Retrieves the proper indexed db database name from the provided .sqlite + * file location. + */ + getNameFromDatabaseFile: Task.async(function* (path) { + let connection = null; + let retryCount = 0; + + // Content pages might be having an open transaction for the same indexed db + // which this sqlite file belongs to. In that case, sqlite.openConnection + // will throw. Thus we retey for some time to see if lock is removed. + while (!connection && retryCount++ < 25) { + try { + connection = yield Sqlite.openConnection({ path: path }); + } catch (ex) { + // Continuously retrying is overkill. Waiting for 100ms before next try + yield sleep(100); + } + } + + if (!connection) { + return null; + } + + let rows = yield connection.execute("SELECT name FROM database"); + if (rows.length != 1) { + return null; + } + + let name = rows[0].getResultByName("name"); + + yield connection.close(); + + return name; + }), + + getValuesForHost: Task.async(function* (host, name = "null", options, + hostVsStores, principal) { + name = JSON.parse(name); + if (!name || !name.length) { + // This means that details about the db in this particular host are + // requested. + let dbs = []; + if (hostVsStores.has(host)) { + for (let [, db] of hostVsStores.get(host)) { + db = indexedDBHelpers.patchMetadataMapsAndProtos(db); + dbs.push(db.toObject()); + } + } + return this.backToChild("getValuesForHost", {dbs: dbs}); + } + + let [db2, objectStore, id] = name; + if (!objectStore) { + // This means that details about all the object stores in this db are + // requested. + let objectStores = []; + if (hostVsStores.has(host) && hostVsStores.get(host).has(db2)) { + let db = hostVsStores.get(host).get(db2); + + db = indexedDBHelpers.patchMetadataMapsAndProtos(db); + + let objectStores2 = db.objectStores; + + for (let objectStore2 of objectStores2) { + objectStores.push(objectStore2[1].toObject()); + } + } + return this.backToChild("getValuesForHost", {objectStores: objectStores}); + } + // Get either all entries from the object store, or a particular id + let result = yield this.getObjectStoreData(host, principal, db2, + objectStore, id, options.index, options.size); + return this.backToChild("getValuesForHost", {result: result}); + }), + + /** + * Returns all or requested entries from a particular objectStore from the db + * in the given host. + * + * @param {string} host + * The given host. + * @param {nsIPrincipal} principal + * The principal of the given document. + * @param {string} dbName + * The name of the indexed db from the above host. + * @param {string} objectStore + * The name of the object store from the above db. + * @param {string} id + * id of the requested entry from the above object store. + * null if all entries from the above object store are requested. + * @param {string} index + * name of the IDBIndex to be iterated on while fetching entries. + * null or "name" if no index is to be iterated. + * @param {number} offset + * ofsset of the entries to be fetched. + * @param {number} size + * The intended size of the entries to be fetched. + */ + getObjectStoreData(host, principal, dbName, objectStore, id, index, + offset, size) { + let request = this.openWithPrincipal(principal, dbName); + let success = promise.defer(); + let data = []; + let db; + + if (!size || size > MAX_STORE_OBJECT_COUNT) { + size = MAX_STORE_OBJECT_COUNT; + } + + request.onsuccess = event => { + db = event.target.result; + + let transaction = db.transaction(objectStore, "readonly"); + let source = transaction.objectStore(objectStore); + if (index && index != "name") { + source = source.index(index); + } + + source.count().onsuccess = event2 => { + let objectsSize = []; + let count = event2.target.result; + objectsSize.push({ + key: host + dbName + objectStore + index, + count: count + }); + + if (!offset) { + offset = 0; + } else if (offset > count) { + db.close(); + success.resolve([]); + return; + } + + if (id) { + source.get(id).onsuccess = event3 => { + db.close(); + success.resolve([{name: id, value: event3.target.result}]); + }; + } else { + source.openCursor().onsuccess = event4 => { + let cursor = event4.target.result; + + if (!cursor || data.length >= size) { + db.close(); + success.resolve({ + data: data, + objectsSize: objectsSize + }); + return; + } + if (offset-- <= 0) { + data.push({name: cursor.key, value: cursor.value}); + } + cursor.continue(); + }; + } + }; + }; + request.onerror = () => { + db.close(); + success.resolve([]); + }; + return success.promise; + }, + + /** + * When indexedDB metadata is parsed to and from JSON then the object's + * prototype is dropped and any Maps are changed to arrays of arrays. This + * method is used to repair the prototypes and fix any broken Maps. + */ + patchMetadataMapsAndProtos(metadata) { + let md = Object.create(DatabaseMetadata.prototype); + Object.assign(md, metadata); + + md._objectStores = new Map(metadata._objectStores); + + for (let [name, store] of md._objectStores) { + let obj = Object.create(ObjectStoreMetadata.prototype); + Object.assign(obj, store); + + md._objectStores.set(name, obj); + + if (typeof store._indexes.length !== "undefined") { + obj._indexes = new Map(store._indexes); + } + + for (let [name2, value] of obj._indexes) { + let obj2 = Object.create(IndexMetadata.prototype); + Object.assign(obj2, value); + + obj._indexes.set(name2, obj2); + } + } + + return md; + }, + + handleChildRequest(msg) { + let args = msg.data.args; + + switch (msg.json.method) { + case "getDBMetaData": { + let [host, principal, name] = args; + return indexedDBHelpers.getDBMetaData(host, principal, name); + } + case "getDBNamesForHost": { + let [host] = args; + return indexedDBHelpers.getDBNamesForHost(host); + } + case "getValuesForHost": { + let [host, name, options, hostVsStores, principal] = args; + return indexedDBHelpers.getValuesForHost(host, name, options, + hostVsStores, principal); + } + case "removeDB": { + let [host, principal, name] = args; + return indexedDBHelpers.removeDB(host, principal, name); + } + case "removeDBRecord": { + let [host, principal, db, store, id] = args; + return indexedDBHelpers.removeDBRecord(host, principal, db, store, id); + } + case "clearDBStore": { + let [host, principal, db, store] = args; + return indexedDBHelpers.clearDBStore(host, principal, db, store); + } + default: + console.error("ERR_DIRECTOR_PARENT_UNKNOWN_METHOD", msg.json.method); + throw new Error("ERR_DIRECTOR_PARENT_UNKNOWN_METHOD"); + } + } +}; + +/** + * E10S parent/child setup helpers + */ + +exports.setupParentProcessForIndexedDB = function ({ mm, prefix }) { + // listen for director-script requests from the child process + setMessageManager(mm); + + function setMessageManager(newMM) { + if (mm) { + mm.removeMessageListener("debug:storage-indexedDB-request-parent", + indexedDBHelpers.handleChildRequest); + } + mm = newMM; + if (mm) { + mm.addMessageListener("debug:storage-indexedDB-request-parent", + indexedDBHelpers.handleChildRequest); + } + } + + return { + onBrowserSwap: setMessageManager, + onDisconnected: () => setMessageManager(null), + }; +}; + +/** + * The main Storage Actor. + */ +let StorageActor = protocol.ActorClassWithSpec(specs.storageSpec, { + typeName: "storage", + + get window() { + return this.parentActor.window; + }, + + get document() { + return this.parentActor.window.document; + }, + + get windows() { + return this.childWindowPool; + }, + + initialize(conn, tabActor) { + protocol.Actor.prototype.initialize.call(this, null); + + this.conn = conn; + this.parentActor = tabActor; + + this.childActorPool = new Map(); + this.childWindowPool = new Set(); + + // Fetch all the inner iframe windows in this tab. + this.fetchChildWindows(this.parentActor.docShell); + + // Initialize the registered store types + for (let [store, ActorConstructor] of storageTypePool) { + this.childActorPool.set(store, new ActorConstructor(this)); + } + + // Notifications that help us keep track of newly added windows and windows + // that got removed + Services.obs.addObserver(this, "content-document-global-created", false); + Services.obs.addObserver(this, "inner-window-destroyed", false); + this.onPageChange = this.onPageChange.bind(this); + + let handler = tabActor.chromeEventHandler; + handler.addEventListener("pageshow", this.onPageChange, true); + handler.addEventListener("pagehide", this.onPageChange, true); + + this.destroyed = false; + this.boundUpdate = {}; + }, + + destroy() { + clearTimeout(this.batchTimer); + this.batchTimer = null; + // Remove observers + Services.obs.removeObserver(this, "content-document-global-created", false); + Services.obs.removeObserver(this, "inner-window-destroyed", false); + this.destroyed = true; + if (this.parentActor.browser) { + this.parentActor.browser.removeEventListener( + "pageshow", this.onPageChange, true); + this.parentActor.browser.removeEventListener( + "pagehide", this.onPageChange, true); + } + // Destroy the registered store types + for (let actor of this.childActorPool.values()) { + actor.destroy(); + } + this.childActorPool.clear(); + this.childWindowPool.clear(); + this.childWindowPool = this.childActorPool = this.__poolMap = this.conn = + this.parentActor = this.boundUpdate = this.registeredPool = + this._pendingResponse = null; + }, + + /** + * Given a docshell, recursively find out all the child windows from it. + * + * @param {nsIDocShell} item + * The docshell from which all inner windows need to be extracted. + */ + fetchChildWindows(item) { + let docShell = item.QueryInterface(Ci.nsIDocShell) + .QueryInterface(Ci.nsIDocShellTreeItem); + if (!docShell.contentViewer) { + return null; + } + let window = docShell.contentViewer.DOMDocument.defaultView; + if (window.location.href == "about:blank") { + // Skip out about:blank windows as Gecko creates them multiple times while + // creating any global. + return null; + } + this.childWindowPool.add(window); + for (let i = 0; i < docShell.childCount; i++) { + let child = docShell.getChildAt(i); + this.fetchChildWindows(child); + } + return null; + }, + + isIncludedInTopLevelWindow(window) { + return isWindowIncluded(this.window, window); + }, + + getWindowFromInnerWindowID(innerID) { + innerID = innerID.QueryInterface(Ci.nsISupportsPRUint64).data; + for (let win of this.childWindowPool.values()) { + let id = win.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDOMWindowUtils) + .currentInnerWindowID; + if (id == innerID) { + return win; + } + } + return null; + }, + + getWindowFromHost(host) { + for (let win of this.childWindowPool.values()) { + let origin = win.document + .nodePrincipal + .originNoSuffix; + let url = win.document.URL; + if (origin === host || url === host) { + return win; + } + } + return null; + }, + + /** + * Event handler for any docshell update. This lets us figure out whenever + * any new window is added, or an existing window is removed. + */ + observe(subject, topic) { + if (subject.location && + (!subject.location.href || subject.location.href == "about:blank")) { + return null; + } + + if (topic == "content-document-global-created" && + this.isIncludedInTopLevelWindow(subject)) { + this.childWindowPool.add(subject); + events.emit(this, "window-ready", subject); + } else if (topic == "inner-window-destroyed") { + let window = this.getWindowFromInnerWindowID(subject); + if (window) { + this.childWindowPool.delete(window); + events.emit(this, "window-destroyed", window); + } + } + return null; + }, + + /** + * Called on "pageshow" or "pagehide" event on the chromeEventHandler of + * current tab. + * + * @param {event} The event object passed to the handler. We are using these + * three properties from the event: + * - target {document} The document corresponding to the event. + * - type {string} Name of the event - "pageshow" or "pagehide". + * - persisted {boolean} true if there was no + * "content-document-global-created" notification along + * this event. + */ + onPageChange({target, type, persisted}) { + if (this.destroyed) { + return; + } + + let window = target.defaultView; + + if (type == "pagehide" && this.childWindowPool.delete(window)) { + events.emit(this, "window-destroyed", window); + } else if (type == "pageshow" && persisted && window.location.href && + window.location.href != "about:blank" && + this.isIncludedInTopLevelWindow(window)) { + this.childWindowPool.add(window); + events.emit(this, "window-ready", window); + } + }, + + /** + * Lists the available hosts for all the registered storage types. + * + * @returns {object} An object containing with the following structure: + * - <storageType> : [{ + * actor: <actorId>, + * host: <hostname> + * }] + */ + listStores: Task.async(function* () { + let toReturn = {}; + + for (let [name, value] of this.childActorPool) { + if (value.preListStores) { + yield value.preListStores(); + } + toReturn[name] = value; + } + + return toReturn; + }), + + /** + * This method is called by the registered storage types so as to tell the + * Storage Actor that there are some changes in the stores. Storage Actor then + * notifies the client front about these changes at regular (BATCH_DELAY) + * interval. + * + * @param {string} action + * The type of change. One of "added", "changed" or "deleted" + * @param {string} storeType + * The storage actor in which this change has occurred. + * @param {object} data + * The update object. This object is of the following format: + * - { + * <host1>: [<store_names1>, <store_name2>...], + * <host2>: [<store_names34>...], + * } + * Where host1, host2 are the host in which this change happened and + * [<store_namesX] is an array of the names of the changed store objects. + * Pass an empty array if the host itself was affected: either completely + * removed or cleared. + */ + update(action, storeType, data) { + if (action == "cleared") { + events.emit(this, "stores-cleared", { [storeType]: data }); + return null; + } + + if (this.batchTimer) { + clearTimeout(this.batchTimer); + } + if (!this.boundUpdate[action]) { + this.boundUpdate[action] = {}; + } + if (!this.boundUpdate[action][storeType]) { + this.boundUpdate[action][storeType] = {}; + } + for (let host in data) { + if (!this.boundUpdate[action][storeType][host]) { + this.boundUpdate[action][storeType][host] = []; + } + for (let name of data[host]) { + if (!this.boundUpdate[action][storeType][host].includes(name)) { + this.boundUpdate[action][storeType][host].push(name); + } + } + } + if (action == "added") { + // If the same store name was previously deleted or changed, but now is + // added somehow, dont send the deleted or changed update. + this.removeNamesFromUpdateList("deleted", storeType, data); + this.removeNamesFromUpdateList("changed", storeType, data); + } else if (action == "changed" && this.boundUpdate.added && + this.boundUpdate.added[storeType]) { + // If something got added and changed at the same time, then remove those + // items from changed instead. + this.removeNamesFromUpdateList("changed", storeType, + this.boundUpdate.added[storeType]); + } else if (action == "deleted") { + // If any item got delete, or a host got delete, no point in sending + // added or changed update + this.removeNamesFromUpdateList("added", storeType, data); + this.removeNamesFromUpdateList("changed", storeType, data); + for (let host in data) { + if (data[host].length == 0 && this.boundUpdate.added && + this.boundUpdate.added[storeType] && + this.boundUpdate.added[storeType][host]) { + delete this.boundUpdate.added[storeType][host]; + } + if (data[host].length == 0 && this.boundUpdate.changed && + this.boundUpdate.changed[storeType] && + this.boundUpdate.changed[storeType][host]) { + delete this.boundUpdate.changed[storeType][host]; + } + } + } + + this.batchTimer = setTimeout(() => { + clearTimeout(this.batchTimer); + events.emit(this, "stores-update", this.boundUpdate); + this.boundUpdate = {}; + }, BATCH_DELAY); + + return null; + }, + + /** + * This method removes data from the this.boundUpdate object in the same + * manner like this.update() adds data to it. + * + * @param {string} action + * The type of change. One of "added", "changed" or "deleted" + * @param {string} storeType + * The storage actor for which you want to remove the updates data. + * @param {object} data + * The update object. This object is of the following format: + * - { + * <host1>: [<store_names1>, <store_name2>...], + * <host2>: [<store_names34>...], + * } + * Where host1, host2 are the hosts which you want to remove and + * [<store_namesX] is an array of the names of the store objects. + */ + removeNamesFromUpdateList(action, storeType, data) { + for (let host in data) { + if (this.boundUpdate[action] && this.boundUpdate[action][storeType] && + this.boundUpdate[action][storeType][host]) { + for (let name in data[host]) { + let index = this.boundUpdate[action][storeType][host].indexOf(name); + if (index > -1) { + this.boundUpdate[action][storeType][host].splice(index, 1); + } + } + if (!this.boundUpdate[action][storeType][host].length) { + delete this.boundUpdate[action][storeType][host]; + } + } + } + return null; + } +}); + +exports.StorageActor = StorageActor; diff --git a/devtools/server/actors/string.js b/devtools/server/actors/string.js new file mode 100644 index 0000000000..d90a8b31c2 --- /dev/null +++ b/devtools/server/actors/string.js @@ -0,0 +1,43 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +var {DebuggerServer} = require("devtools/server/main"); + +var promise = require("promise"); + +var protocol = require("devtools/shared/protocol"); +const {longStringSpec} = require("devtools/shared/specs/string"); + +exports.LongStringActor = protocol.ActorClassWithSpec(longStringSpec, { + initialize: function (conn, str) { + protocol.Actor.prototype.initialize.call(this, conn); + this.str = str; + this.short = (this.str.length < DebuggerServer.LONG_STRING_LENGTH); + }, + + destroy: function () { + this.str = null; + protocol.Actor.prototype.destroy.call(this); + }, + + form: function () { + if (this.short) { + return this.str; + } + return { + type: "longString", + actor: this.actorID, + length: this.str.length, + initial: this.str.substring(0, DebuggerServer.LONG_STRING_INITIAL_LENGTH) + }; + }, + + substring: function (start, end) { + return promise.resolve(this.str.substring(start, end)); + }, + + release: function () { } +}); diff --git a/devtools/server/actors/styleeditor.js b/devtools/server/actors/styleeditor.js new file mode 100644 index 0000000000..5793a2baf1 --- /dev/null +++ b/devtools/server/actors/styleeditor.js @@ -0,0 +1,528 @@ +/* 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 {Cc, Ci} = require("chrome"); +const Services = require("Services"); +const {XPCOMUtils} = require("resource://gre/modules/XPCOMUtils.jsm"); +const promise = require("promise"); +const events = require("sdk/event/core"); +const protocol = require("devtools/shared/protocol"); +const {Arg, method, RetVal} = protocol; +const {fetch} = require("devtools/shared/DevToolsUtils"); +const {oldStyleSheetSpec, styleEditorSpec} = require("devtools/shared/specs/styleeditor"); + +loader.lazyGetter(this, "CssLogic", () => require("devtools/shared/inspector/css-logic")); + +var TRANSITION_CLASS = "moz-styleeditor-transitioning"; +var TRANSITION_DURATION_MS = 500; +var TRANSITION_RULE = "\ +:root.moz-styleeditor-transitioning, :root.moz-styleeditor-transitioning * {\ +transition-duration: " + TRANSITION_DURATION_MS + "ms !important; \ +transition-delay: 0ms !important;\ +transition-timing-function: ease-out !important;\ +transition-property: all !important;\ +}"; + +var LOAD_ERROR = "error-load"; + +var OldStyleSheetActor = protocol.ActorClassWithSpec(oldStyleSheetSpec, { + toString: function() { + return "[OldStyleSheetActor " + this.actorID + "]"; + }, + + /** + * Window of target + */ + get window() { + return this._window || this.parentActor.window; + }, + + /** + * Document of target. + */ + get document() { + return this.window.document; + }, + + /** + * URL of underlying stylesheet. + */ + get href() { + return this.rawSheet.href; + }, + + /** + * Retrieve the index (order) of stylesheet in the document. + * + * @return number + */ + get styleSheetIndex() + { + if (this._styleSheetIndex == -1) { + for (let i = 0; i < this.document.styleSheets.length; i++) { + if (this.document.styleSheets[i] == this.rawSheet) { + this._styleSheetIndex = i; + break; + } + } + } + return this._styleSheetIndex; + }, + + initialize: function (aStyleSheet, aParentActor, aWindow) { + protocol.Actor.prototype.initialize.call(this, null); + + this.rawSheet = aStyleSheet; + this.parentActor = aParentActor; + this.conn = this.parentActor.conn; + + this._window = aWindow; + + // text and index are unknown until source load + this.text = null; + this._styleSheetIndex = -1; + + this._transitionRefCount = 0; + + // if this sheet has an @import, then it's rules are loaded async + let ownerNode = this.rawSheet.ownerNode; + if (ownerNode) { + let onSheetLoaded = (event) => { + ownerNode.removeEventListener("load", onSheetLoaded, false); + this._notifyPropertyChanged("ruleCount"); + }; + + ownerNode.addEventListener("load", onSheetLoaded, false); + } + }, + + /** + * Get the current state of the actor + * + * @return {object} + * With properties of the underlying stylesheet, plus 'text', + * 'styleSheetIndex' and 'parentActor' if it's @imported + */ + form: function (detail) { + if (detail === "actorid") { + return this.actorID; + } + + let docHref; + if (this.rawSheet.ownerNode) { + if (this.rawSheet.ownerNode instanceof Ci.nsIDOMHTMLDocument) { + docHref = this.rawSheet.ownerNode.location.href; + } + if (this.rawSheet.ownerNode.ownerDocument) { + docHref = this.rawSheet.ownerNode.ownerDocument.location.href; + } + } + + let form = { + actor: this.actorID, // actorID is set when this actor is added to a pool + href: this.href, + nodeHref: docHref, + disabled: this.rawSheet.disabled, + title: this.rawSheet.title, + system: !CssLogic.isContentStylesheet(this.rawSheet), + styleSheetIndex: this.styleSheetIndex + }; + + try { + form.ruleCount = this.rawSheet.cssRules.length; + } + catch (e) { + // stylesheet had an @import rule that wasn't loaded yet + } + return form; + }, + + /** + * Toggle the disabled property of the style sheet + * + * @return {object} + * 'disabled' - the disabled state after toggling. + */ + toggleDisabled: function () { + this.rawSheet.disabled = !this.rawSheet.disabled; + this._notifyPropertyChanged("disabled"); + + return this.rawSheet.disabled; + }, + + /** + * Send an event notifying that a property of the stylesheet + * has changed. + * + * @param {string} property + * Name of the changed property + */ + _notifyPropertyChanged: function (property) { + events.emit(this, "property-change", property, this.form()[property]); + }, + + /** + * Fetch the source of the style sheet from its URL. Send a "sourceLoad" + * event when it's been fetched. + */ + fetchSource: function () { + this._getText().then((content) => { + events.emit(this, "source-load", this.text); + }); + }, + + /** + * Fetch the text for this stylesheet from the cache or network. Return + * cached text if it's already been fetched. + * + * @return {Promise} + * Promise that resolves with a string text of the stylesheet. + */ + _getText: function () { + if (this.text) { + return promise.resolve(this.text); + } + + if (!this.href) { + // this is an inline <style> sheet + let content = this.rawSheet.ownerNode.textContent; + this.text = content; + return promise.resolve(content); + } + + let options = { + policy: Ci.nsIContentPolicy.TYPE_INTERNAL_STYLESHEET, + window: this.window, + charset: this._getCSSCharset() + }; + + return fetch(this.href, options).then(({ content }) => { + this.text = content; + return content; + }); + }, + + /** + * Get the charset of the stylesheet according to the character set rules + * defined in <http://www.w3.org/TR/CSS2/syndata.html#charset>. + * Note that some of the algorithm is implemented in DevToolsUtils.fetch. + */ + _getCSSCharset: function () + { + let sheet = this.rawSheet; + if (sheet) { + // Do we have a @charset rule in the stylesheet? + // step 2 of syndata.html (without the BOM check). + if (sheet.cssRules) { + let rules = sheet.cssRules; + if (rules.length + && rules.item(0).type == Ci.nsIDOMCSSRule.CHARSET_RULE) { + return rules.item(0).encoding; + } + } + + // step 3: charset attribute of <link> or <style> element, if it exists + if (sheet.ownerNode && sheet.ownerNode.getAttribute) { + let linkCharset = sheet.ownerNode.getAttribute("charset"); + if (linkCharset != null) { + return linkCharset; + } + } + + // step 4 (1 of 2): charset of referring stylesheet. + let parentSheet = sheet.parentStyleSheet; + if (parentSheet && parentSheet.cssRules && + parentSheet.cssRules[0].type == Ci.nsIDOMCSSRule.CHARSET_RULE) { + return parentSheet.cssRules[0].encoding; + } + + // step 4 (2 of 2): charset of referring document. + if (sheet.ownerNode && sheet.ownerNode.ownerDocument.characterSet) { + return sheet.ownerNode.ownerDocument.characterSet; + } + } + + // step 5: default to utf-8. + return "UTF-8"; + }, + + /** + * Update the style sheet in place with new text. + * + * @param {object} request + * 'text' - new text + * 'transition' - whether to do CSS transition for change. + */ + update: function (text, transition) { + DOMUtils.parseStyleSheet(this.rawSheet, text); + + this.text = text; + + this._notifyPropertyChanged("ruleCount"); + + if (transition) { + this._insertTransistionRule(); + } + else { + this._notifyStyleApplied(); + } + }, + + /** + * Insert a catch-all transition rule into the document. Set a timeout + * to remove the rule after a certain time. + */ + _insertTransistionRule: function () { + // Insert the global transition rule + // Use a ref count to make sure we do not add it multiple times.. and remove + // it only when all pending StyleEditor-generated transitions ended. + if (this._transitionRefCount == 0) { + this.rawSheet.insertRule(TRANSITION_RULE, this.rawSheet.cssRules.length); + this.document.documentElement.classList.add(TRANSITION_CLASS); + } + + this._transitionRefCount++; + + // Set up clean up and commit after transition duration (+10% buffer) + // @see _onTransitionEnd + this.window.setTimeout(this._onTransitionEnd.bind(this), + Math.floor(TRANSITION_DURATION_MS * 1.1)); + }, + + /** + * This cleans up class and rule added for transition effect and then + * notifies that the style has been applied. + */ + _onTransitionEnd: function () + { + if (--this._transitionRefCount == 0) { + this.document.documentElement.classList.remove(TRANSITION_CLASS); + this.rawSheet.deleteRule(this.rawSheet.cssRules.length - 1); + } + + events.emit(this, "style-applied"); + } +}); + +exports.OldStyleSheetActor = OldStyleSheetActor; + +/** + * Creates a StyleEditorActor. StyleEditorActor provides remote access to the + * stylesheets of a document. + */ +var StyleEditorActor = exports.StyleEditorActor = protocol.ActorClassWithSpec(styleEditorSpec, { + /** + * The window we work with, taken from the parent actor. + */ + get window() { + return this.parentActor.window; + }, + + /** + * The current content document of the window we work with. + */ + get document() { + return this.window.document; + }, + + form: function () + { + return { actor: this.actorID }; + }, + + initialize: function (conn, tabActor) { + protocol.Actor.prototype.initialize.call(this, null); + + this.parentActor = tabActor; + + // keep a map of sheets-to-actors so we don't create two actors for one sheet + this._sheets = new Map(); + }, + + /** + * Destroy the current StyleEditorActor instance. + */ + destroy: function () + { + this._sheets.clear(); + }, + + /** + * Called by client when target navigates to a new document. + * Adds load listeners to document. + */ + newDocument: function () { + // delete previous document's actors + this._clearStyleSheetActors(); + + // Note: listening for load won't be necessary once + // https://bugzilla.mozilla.org/show_bug.cgi?id=839103 is fixed + if (this.document.readyState == "complete") { + this._onDocumentLoaded(); + } + else { + this.window.addEventListener("load", this._onDocumentLoaded, false); + } + return {}; + }, + + /** + * Event handler for document loaded event. Add actor for each stylesheet + * and send an event notifying of the load + */ + _onDocumentLoaded: function (event) { + if (event) { + this.window.removeEventListener("load", this._onDocumentLoaded, false); + } + + let documents = [this.document]; + var forms = []; + for (let doc of documents) { + let sheetForms = this._addStyleSheets(doc.styleSheets); + forms = forms.concat(sheetForms); + // Recursively handle style sheets of the documents in iframes. + for (let iframe of doc.getElementsByTagName("iframe")) { + documents.push(iframe.contentDocument); + } + } + + events.emit(this, "document-load", forms); + }, + + /** + * Add all the stylesheets to the map and create an actor for each one + * if not already created. Send event that there are new stylesheets. + * + * @param {[DOMStyleSheet]} styleSheets + * Stylesheets to add + * @return {[object]} + * Array of actors for each StyleSheetActor created + */ + _addStyleSheets: function (styleSheets) + { + let sheets = []; + for (let i = 0; i < styleSheets.length; i++) { + let styleSheet = styleSheets[i]; + sheets.push(styleSheet); + + // Get all sheets, including imported ones + let imports = this._getImported(styleSheet); + sheets = sheets.concat(imports); + } + let actors = sheets.map(this._createStyleSheetActor.bind(this)); + + return actors; + }, + + /** + * Create a new actor for a style sheet, if it hasn't already been created. + * + * @param {DOMStyleSheet} styleSheet + * The style sheet to create an actor for. + * @return {StyleSheetActor} + * The actor for this style sheet + */ + _createStyleSheetActor: function (styleSheet) + { + if (this._sheets.has(styleSheet)) { + return this._sheets.get(styleSheet); + } + let actor = new OldStyleSheetActor(styleSheet, this); + + this.manage(actor); + this._sheets.set(styleSheet, actor); + + return actor; + }, + + /** + * Get all the stylesheets @imported from a stylesheet. + * + * @param {DOMStyleSheet} styleSheet + * Style sheet to search + * @return {array} + * All the imported stylesheets + */ + _getImported: function (styleSheet) { + let imported = []; + + for (let i = 0; i < styleSheet.cssRules.length; i++) { + let rule = styleSheet.cssRules[i]; + if (rule.type == Ci.nsIDOMCSSRule.IMPORT_RULE) { + // Associated styleSheet may be null if it has already been seen due to + // duplicate @imports for the same URL. + if (!rule.styleSheet) { + continue; + } + imported.push(rule.styleSheet); + + // recurse imports in this stylesheet as well + imported = imported.concat(this._getImported(rule.styleSheet)); + } + else if (rule.type != Ci.nsIDOMCSSRule.CHARSET_RULE) { + // @import rules must precede all others except @charset + break; + } + } + return imported; + }, + + /** + * Clear all the current stylesheet actors in map. + */ + _clearStyleSheetActors: function () { + for (let actor in this._sheets) { + this.unmanage(this._sheets[actor]); + } + this._sheets.clear(); + }, + + /** + * Create a new style sheet in the document with the given text. + * Return an actor for it. + * + * @param {object} request + * Debugging protocol request object, with 'text property' + * @return {object} + * Object with 'styelSheet' property for form on new actor. + */ + newStyleSheet: function (text) { + let parent = this.document.documentElement; + let style = this.document.createElementNS("http://www.w3.org/1999/xhtml", "style"); + style.setAttribute("type", "text/css"); + + if (text) { + style.appendChild(this.document.createTextNode(text)); + } + parent.appendChild(style); + + let actor = this._createStyleSheetActor(style.sheet); + return actor; + } +}); + +XPCOMUtils.defineLazyGetter(this, "DOMUtils", function () { + return Cc["@mozilla.org/inspector/dom-utils;1"].getService(Ci.inIDOMUtils); +}); + +exports.StyleEditorActor = StyleEditorActor; + +/** + * Normalize multiple relative paths towards the base paths on the right. + */ +function normalize(...aURLs) { + let base = Services.io.newURI(aURLs.pop(), null, null); + let url; + while ((url = aURLs.pop())) { + base = Services.io.newURI(url, null, base); + } + return base.spec; +} + +function dirname(aPath) { + return Services.io.newURI( + ".", null, Services.io.newURI(aPath, null, null)).spec; +} diff --git a/devtools/server/actors/styles.js b/devtools/server/actors/styles.js new file mode 100644 index 0000000000..cdb8128828 --- /dev/null +++ b/devtools/server/actors/styles.js @@ -0,0 +1,1687 @@ +/* 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 {Cc, Ci} = require("chrome"); +const promise = require("promise"); +const protocol = require("devtools/shared/protocol"); +const {LongStringActor} = require("devtools/server/actors/string"); +const {getDefinedGeometryProperties} = require("devtools/server/actors/highlighters/geometry-editor"); +const {parseDeclarations} = require("devtools/shared/css/parsing-utils"); +const {isCssPropertyKnown} = require("devtools/server/actors/css-properties"); +const {Task} = require("devtools/shared/task"); +const events = require("sdk/event/core"); + +// This will also add the "stylesheet" actor type for protocol.js to recognize +const {UPDATE_PRESERVING_RULES, UPDATE_GENERAL} = require("devtools/server/actors/stylesheets"); +const {pageStyleSpec, styleRuleSpec, ELEMENT_STYLE} = require("devtools/shared/specs/styles"); + +loader.lazyGetter(this, "CssLogic", () => require("devtools/server/css-logic").CssLogic); +loader.lazyGetter(this, "SharedCssLogic", () => require("devtools/shared/inspector/css-logic")); +loader.lazyGetter(this, "DOMUtils", () => Cc["@mozilla.org/inspector/dom-utils;1"].getService(Ci.inIDOMUtils)); + +loader.lazyGetter(this, "PSEUDO_ELEMENTS", () => { + return DOMUtils.getCSSPseudoElementNames(); +}); + +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"; +const NORMAL_FONT_WEIGHT = 400; +const BOLD_FONT_WEIGHT = 700; +// Offset (in px) to avoid cutting off text edges of italic fonts. +const FONT_PREVIEW_OFFSET = 4; + +/** + * The PageStyle actor lets the client look at the styles on a page, as + * they are applied to a given node. + */ +var PageStyleActor = protocol.ActorClassWithSpec(pageStyleSpec, { + /** + * Create a PageStyleActor. + * + * @param inspector + * The InspectorActor that owns this PageStyleActor. + * + * @constructor + */ + initialize: function (inspector) { + protocol.Actor.prototype.initialize.call(this, null); + this.inspector = inspector; + if (!this.inspector.walker) { + throw Error("The inspector's WalkerActor must be created before " + + "creating a PageStyleActor."); + } + this.walker = inspector.walker; + this.cssLogic = new CssLogic(DOMUtils.isInheritedProperty); + + // Stores the association of DOM objects -> actors + this.refMap = new Map(); + + // Maps document elements to style elements, used to add new rules. + this.styleElements = new WeakMap(); + + this.onFrameUnload = this.onFrameUnload.bind(this); + this.onStyleSheetAdded = this.onStyleSheetAdded.bind(this); + + events.on(this.inspector.tabActor, "will-navigate", this.onFrameUnload); + events.on(this.inspector.tabActor, "stylesheet-added", this.onStyleSheetAdded); + + this._styleApplied = this._styleApplied.bind(this); + this._watchedSheets = new Set(); + }, + + destroy: function () { + if (!this.walker) { + return; + } + protocol.Actor.prototype.destroy.call(this); + events.off(this.inspector.tabActor, "will-navigate", this.onFrameUnload); + events.off(this.inspector.tabActor, "stylesheet-added", this.onStyleSheetAdded); + this.inspector = null; + this.walker = null; + this.refMap = null; + this.cssLogic = null; + this.styleElements = null; + + for (let sheet of this._watchedSheets) { + sheet.off("style-applied", this._styleApplied); + } + this._watchedSheets.clear(); + }, + + get conn() { + return this.inspector.conn; + }, + + form: function (detail) { + if (detail === "actorid") { + return this.actorID; + } + + return { + actor: this.actorID, + traits: { + // Whether the actor has had bug 1103993 fixed, which means that the + // getApplied method calls cssLogic.highlight(node) to recreate the + // style cache. Clients requesting getApplied from actors that have not + // been fixed must make sure cssLogic.highlight(node) was called before. + getAppliedCreatesStyleCache: true, + // Whether addNewRule accepts the editAuthored argument. + authoredStyles: true + } + }; + }, + + /** + * Called when a style sheet is updated. + */ + _styleApplied: function (kind, styleSheet) { + // No matter what kind of update is done, we need to invalidate + // the keyframe cache. + this.cssLogic.reset(); + if (kind === UPDATE_GENERAL) { + events.emit(this, "stylesheet-updated", styleSheet); + } + }, + + /** + * Return or create a StyleRuleActor for the given item. + * @param item Either a CSSStyleRule or a DOM element. + */ + _styleRef: function (item) { + if (this.refMap.has(item)) { + return this.refMap.get(item); + } + let actor = StyleRuleActor(this, item); + this.manage(actor); + this.refMap.set(item, actor); + + return actor; + }, + + /** + * Update the association between a StyleRuleActor and its + * corresponding item. This is used when a StyleRuleActor updates + * as style sheet and starts using a new rule. + * + * @param oldItem The old association; either a CSSStyleRule or a + * DOM element. + * @param item Either a CSSStyleRule or a DOM element. + * @param actor a StyleRuleActor + */ + updateStyleRef: function (oldItem, item, actor) { + this.refMap.delete(oldItem); + this.refMap.set(item, actor); + }, + + /** + * Return or create a StyleSheetActor for the given nsIDOMCSSStyleSheet. + * @param {DOMStyleSheet} sheet + * The style sheet to create an actor for. + * @return {StyleSheetActor} + * The actor for this style sheet + */ + _sheetRef: function (sheet) { + let tabActor = this.inspector.tabActor; + let actor = tabActor.createStyleSheetActor(sheet); + return actor; + }, + + /** + * Get the computed style for a node. + * + * @param NodeActor node + * @param object options + * `filter`: A string filter that affects the "matched" handling. + * 'user': Include properties from user style sheets. + * 'ua': Include properties from user and user-agent sheets. + * Default value is 'ua' + * `markMatched`: true if you want the 'matched' property to be added + * when a computed property has been modified by a style included + * by `filter`. + * `onlyMatched`: true if unmatched properties shouldn't be included. + * + * @returns a JSON blob with the following form: + * { + * "property-name": { + * value: "property-value", + * priority: "!important" <optional> + * matched: <true if there are matched selectors for this value> + * }, + * ... + * } + */ + getComputed: function (node, options) { + let ret = Object.create(null); + + this.cssLogic.sourceFilter = options.filter || SharedCssLogic.FILTER.UA; + this.cssLogic.highlight(node.rawNode); + let computed = this.cssLogic.computedStyle || []; + + Array.prototype.forEach.call(computed, name => { + ret[name] = { + value: computed.getPropertyValue(name), + priority: computed.getPropertyPriority(name) || undefined + }; + }); + + if (options.markMatched || options.onlyMatched) { + let matched = this.cssLogic.hasMatchedSelectors(Object.keys(ret)); + for (let key in ret) { + if (matched[key]) { + ret[key].matched = options.markMatched ? true : undefined; + } else if (options.onlyMatched) { + delete ret[key]; + } + } + } + + return ret; + }, + + /** + * Get all the fonts from a page. + * + * @param object options + * `includePreviews`: Whether to also return image previews of the fonts. + * `previewText`: The text to display in the previews. + * `previewFontSize`: The font size of the text in the previews. + * + * @returns object + * object with 'fontFaces', a list of fonts that apply to this node. + */ + getAllUsedFontFaces: function (options) { + let windows = this.inspector.tabActor.windows; + let fontsList = []; + for (let win of windows) { + fontsList = [...fontsList, + ...this.getUsedFontFaces(win.document.body, options)]; + } + return fontsList; + }, + + /** + * Get the font faces used in an element. + * + * @param NodeActor node / actual DOM node + * The node to get fonts from. + * @param object options + * `includePreviews`: Whether to also return image previews of the fonts. + * `previewText`: The text to display in the previews. + * `previewFontSize`: The font size of the text in the previews. + * + * @returns object + * object with 'fontFaces', a list of fonts that apply to this node. + */ + getUsedFontFaces: function (node, options) { + // node.rawNode is defined for NodeActor objects + let actualNode = node.rawNode || node; + let contentDocument = actualNode.ownerDocument; + // We don't get fonts for a node, but for a range + let rng = contentDocument.createRange(); + rng.selectNodeContents(actualNode); + let fonts = DOMUtils.getUsedFontFaces(rng); + let fontsArray = []; + + for (let i = 0; i < fonts.length; i++) { + let font = fonts.item(i); + let fontFace = { + name: font.name, + CSSFamilyName: font.CSSFamilyName, + srcIndex: font.srcIndex, + URI: font.URI, + format: font.format, + localName: font.localName, + metadata: font.metadata + }; + + // If this font comes from a @font-face rule + if (font.rule) { + let styleActor = StyleRuleActor(this, font.rule); + this.manage(styleActor); + fontFace.rule = styleActor; + fontFace.ruleText = font.rule.cssText; + } + + // Get the weight and style of this font for the preview and sort order + let weight = NORMAL_FONT_WEIGHT, style = ""; + if (font.rule) { + weight = font.rule.style.getPropertyValue("font-weight") + || NORMAL_FONT_WEIGHT; + if (weight == "bold") { + weight = BOLD_FONT_WEIGHT; + } else if (weight == "normal") { + weight = NORMAL_FONT_WEIGHT; + } + style = font.rule.style.getPropertyValue("font-style") || ""; + } + fontFace.weight = weight; + fontFace.style = style; + + if (options.includePreviews) { + let opts = { + previewText: options.previewText, + previewFontSize: options.previewFontSize, + fontStyle: weight + " " + style, + fillStyle: options.previewFillStyle + }; + let { dataURL, size } = getFontPreviewData(font.CSSFamilyName, + contentDocument, opts); + fontFace.preview = { + data: LongStringActor(this.conn, dataURL), + size: size + }; + } + fontsArray.push(fontFace); + } + + // @font-face fonts at the top, then alphabetically, then by weight + fontsArray.sort(function (a, b) { + return a.weight > b.weight ? 1 : -1; + }); + fontsArray.sort(function (a, b) { + if (a.CSSFamilyName == b.CSSFamilyName) { + return 0; + } + return a.CSSFamilyName > b.CSSFamilyName ? 1 : -1; + }); + fontsArray.sort(function (a, b) { + if ((a.rule && b.rule) || (!a.rule && !b.rule)) { + return 0; + } + return !a.rule && b.rule ? 1 : -1; + }); + + return fontsArray; + }, + + /** + * Get a list of selectors that match a given property for a node. + * + * @param NodeActor node + * @param string property + * @param object options + * `filter`: A string filter that affects the "matched" handling. + * 'user': Include properties from user style sheets. + * 'ua': Include properties from user and user-agent sheets. + * Default value is 'ua' + * + * @returns a JSON object with the following form: + * { + * // An ordered list of rules that apply + * matched: [{ + * rule: <rule actorid>, + * sourceText: <string>, // The source of the selector, relative + * // to the node in question. + * selector: <string>, // the selector ID that matched + * value: <string>, // the value of the property + * status: <int>, + * // The status of the match - high numbers are better placed + * // to provide styling information: + * // 3: Best match, was used. + * // 2: Matched, but was overridden. + * // 1: Rule from a parent matched. + * // 0: Unmatched (never returned in this API) + * }, ...], + * + * // The full form of any domrule referenced. + * rules: [ <domrule>, ... ], // The full form of any domrule referenced + * + * // The full form of any sheets referenced. + * sheets: [ <domsheet>, ... ] + * } + */ + getMatchedSelectors: function (node, property, options) { + this.cssLogic.sourceFilter = options.filter || SharedCssLogic.FILTER.UA; + this.cssLogic.highlight(node.rawNode); + + let rules = new Set(); + let sheets = new Set(); + + let matched = []; + let propInfo = this.cssLogic.getPropertyInfo(property); + for (let selectorInfo of propInfo.matchedSelectors) { + let cssRule = selectorInfo.selector.cssRule; + let domRule = cssRule.sourceElement || cssRule.domRule; + + let rule = this._styleRef(domRule); + rules.add(rule); + + matched.push({ + rule: rule, + sourceText: this.getSelectorSource(selectorInfo, node.rawNode), + selector: selectorInfo.selector.text, + name: selectorInfo.property, + value: selectorInfo.value, + status: selectorInfo.status + }); + } + + this.expandSets(rules, sheets); + + return { + matched: matched, + rules: [...rules], + sheets: [...sheets] + }; + }, + + // Get a selector source for a CssSelectorInfo relative to a given + // node. + getSelectorSource: function (selectorInfo, relativeTo) { + let result = selectorInfo.selector.text; + if (selectorInfo.elementStyle) { + let source = selectorInfo.sourceElement; + if (source === relativeTo) { + result = "this"; + } else { + result = CssLogic.getShortName(source); + } + result += ".style"; + } + return result; + }, + + /** + * Get the set of styles that apply to a given node. + * @param NodeActor node + * @param object options + * `filter`: A string filter that affects the "matched" handling. + * 'user': Include properties from user style sheets. + * 'ua': Include properties from user and user-agent sheets. + * Default value is 'ua' + * `inherited`: Include styles inherited from parent nodes. + * `matchedSelectors`: Include an array of specific selectors that + * caused this rule to match its node. + */ + getApplied: Task.async(function* (node, options) { + if (!node) { + return {entries: [], rules: [], sheets: []}; + } + + this.cssLogic.highlight(node.rawNode); + let entries = []; + entries = entries.concat(this._getAllElementRules(node, undefined, + options)); + + let result = this.getAppliedProps(node, entries, options); + for (let rule of result.rules) { + // See the comment in |form| to understand this. + yield rule.getAuthoredCssText(); + } + return result; + }), + + _hasInheritedProps: function (style) { + return Array.prototype.some.call(style, prop => { + return DOMUtils.isInheritedProperty(prop); + }); + }, + + isPositionEditable: Task.async(function* (node) { + if (!node || node.rawNode.nodeType !== node.rawNode.ELEMENT_NODE) { + return false; + } + + let props = getDefinedGeometryProperties(node.rawNode); + + // Elements with only `width` and `height` are currently not considered + // editable. + return props.has("top") || + props.has("right") || + props.has("left") || + props.has("bottom"); + }), + + /** + * Helper function for getApplied, gets all the rules from a given + * element. See getApplied for documentation on parameters. + * @param NodeActor node + * @param bool inherited + * @param object options + + * @return Array The rules for a given element. Each item in the + * array has the following signature: + * - rule RuleActor + * - isSystem Boolean + * - inherited Boolean + * - pseudoElement String + */ + _getAllElementRules: function (node, inherited, options) { + let {bindingElement, pseudo} = + CssLogic.getBindingElementAndPseudo(node.rawNode); + let rules = []; + + if (!bindingElement || !bindingElement.style) { + return rules; + } + + let elementStyle = this._styleRef(bindingElement); + let showElementStyles = !inherited && !pseudo; + let showInheritedStyles = inherited && + this._hasInheritedProps(bindingElement.style); + + let rule = { + rule: elementStyle, + pseudoElement: null, + isSystem: false, + inherited: false + }; + + // First any inline styles + if (showElementStyles) { + rules.push(rule); + } + + // Now any inherited styles + if (showInheritedStyles) { + rule.inherited = inherited; + rules.push(rule); + } + + // Add normal rules. Typically this is passing in the node passed into the + // function, unless if that node was ::before/::after. In which case, + // it will pass in the parentNode along with "::before"/"::after". + this._getElementRules(bindingElement, pseudo, inherited, options) + .forEach(oneRule => { + // The only case when there would be a pseudo here is + // ::before/::after, and in this case we want to tell the + // view that it belongs to the element (which is a + // _moz_generated_content native anonymous element). + oneRule.pseudoElement = null; + rules.push(oneRule); + }); + + // Now any pseudos. + if (showElementStyles) { + for (let readPseudo of PSEUDO_ELEMENTS) { + this._getElementRules(bindingElement, readPseudo, inherited, options) + .forEach(oneRule => { + rules.push(oneRule); + }); + } + } + + return rules; + }, + + /** + * Helper function for _getAllElementRules, returns the rules from a given + * element. See getApplied for documentation on parameters. + * @param DOMNode node + * @param string pseudo + * @param DOMNode inherited + * @param object options + * + * @returns Array + */ + _getElementRules: function (node, pseudo, inherited, options) { + let domRules = DOMUtils.getCSSStyleRules(node, pseudo); + if (!domRules) { + return []; + } + + let rules = []; + + // getCSSStyleRules returns ordered from least-specific to + // most-specific. + for (let i = domRules.Count() - 1; i >= 0; i--) { + let domRule = domRules.GetElementAt(i); + + let isSystem = !SharedCssLogic.isContentStylesheet(domRule.parentStyleSheet); + + if (isSystem && options.filter != SharedCssLogic.FILTER.UA) { + continue; + } + + if (inherited) { + // Don't include inherited rules if none of its properties + // are inheritable. + let hasInherited = [...domRule.style].some( + prop => DOMUtils.isInheritedProperty(prop) + ); + if (!hasInherited) { + continue; + } + } + + let ruleActor = this._styleRef(domRule); + rules.push({ + rule: ruleActor, + inherited: inherited, + isSystem: isSystem, + pseudoElement: pseudo + }); + } + return rules; + }, + + /** + * Given a node and a CSS rule, walk up the DOM looking for a + * matching element rule. Return an array of all found entries, in + * the form generated by _getAllElementRules. Note that this will + * always return an array of either zero or one element. + * + * @param {NodeActor} node the node + * @param {CSSStyleRule} filterRule the rule to filter for + * @return {Array} array of zero or one elements; if one, the element + * is the entry as returned by _getAllElementRules. + */ + findEntryMatchingRule: function (node, filterRule) { + const options = {matchedSelectors: true, inherited: true}; + let entries = []; + let parent = this.walker.parentNode(node); + while (parent && parent.rawNode.nodeType != Ci.nsIDOMNode.DOCUMENT_NODE) { + entries = entries.concat(this._getAllElementRules(parent, parent, + options)); + parent = this.walker.parentNode(parent); + } + + return entries.filter(entry => entry.rule.rawRule === filterRule); + }, + + /** + * Helper function for getApplied that fetches a set of style properties that + * apply to the given node and associated rules + * @param NodeActor node + * @param object options + * `filter`: A string filter that affects the "matched" handling. + * 'user': Include properties from user style sheets. + * 'ua': Include properties from user and user-agent sheets. + * Default value is 'ua' + * `inherited`: Include styles inherited from parent nodes. + * `matchedSelectors`: Include an array of specific selectors that + * caused this rule to match its node. + * @param array entries + * List of appliedstyle objects that lists the rules that apply to the + * node. If adding a new rule to the stylesheet, only the new rule entry + * is provided and only the style properties that apply to the new + * rule is fetched. + * @returns Object containing the list of rule entries, rule actors and + * stylesheet actors that applies to the given node and its associated + * rules. + */ + getAppliedProps: function (node, entries, options) { + if (options.inherited) { + let parent = this.walker.parentNode(node); + while (parent && parent.rawNode.nodeType != Ci.nsIDOMNode.DOCUMENT_NODE) { + entries = entries.concat(this._getAllElementRules(parent, parent, + options)); + parent = this.walker.parentNode(parent); + } + } + + if (options.matchedSelectors) { + for (let entry of entries) { + if (entry.rule.type === ELEMENT_STYLE) { + continue; + } + + let domRule = entry.rule.rawRule; + let selectors = CssLogic.getSelectors(domRule); + let element = entry.inherited ? entry.inherited.rawNode : node.rawNode; + + let {bindingElement, pseudo} = + CssLogic.getBindingElementAndPseudo(element); + entry.matchedSelectors = []; + for (let i = 0; i < selectors.length; i++) { + if (DOMUtils.selectorMatchesElement(bindingElement, domRule, i, + pseudo)) { + entry.matchedSelectors.push(selectors[i]); + } + } + } + } + + // Add all the keyframes rule associated with the element + let computedStyle = this.cssLogic.computedStyle; + if (computedStyle) { + let animationNames = computedStyle.animationName.split(","); + animationNames = animationNames.map(name => name.trim()); + + if (animationNames) { + // Traverse through all the available keyframes rule and add + // the keyframes rule that matches the computed animation name + for (let keyframesRule of this.cssLogic.keyframesRules) { + if (animationNames.indexOf(keyframesRule.name) > -1) { + for (let rule of keyframesRule.cssRules) { + entries.push({ + rule: this._styleRef(rule), + keyframes: this._styleRef(keyframesRule) + }); + } + } + } + } + } + + let rules = new Set(); + let sheets = new Set(); + entries.forEach(entry => rules.add(entry.rule)); + this.expandSets(rules, sheets); + + return { + entries: entries, + rules: [...rules], + sheets: [...sheets] + }; + }, + + /** + * Expand Sets of rules and sheets to include all parent rules and sheets. + */ + expandSets: function (ruleSet, sheetSet) { + // Sets include new items in their iteration + for (let rule of ruleSet) { + if (rule.rawRule.parentRule) { + let parent = this._styleRef(rule.rawRule.parentRule); + if (!ruleSet.has(parent)) { + ruleSet.add(parent); + } + } + if (rule.rawRule.parentStyleSheet) { + let parent = this._sheetRef(rule.rawRule.parentStyleSheet); + if (!sheetSet.has(parent)) { + sheetSet.add(parent); + } + } + } + + for (let sheet of sheetSet) { + if (sheet.rawSheet.parentStyleSheet) { + let parent = this._sheetRef(sheet.rawSheet.parentStyleSheet); + if (!sheetSet.has(parent)) { + sheetSet.add(parent); + } + } + } + }, + + /** + * Get layout-related information about a node. + * This method returns an object with properties giving information about + * the node's margin, border, padding and content region sizes, as well + * as information about the type of box, its position, z-index, etc... + * @param {NodeActor} node + * @param {Object} options The only available option is autoMargins. + * If set to true, the element's margins will receive an extra check to see + * whether they are set to "auto" (knowing that the computed-style in this + * case would return "0px"). + * The returned object will contain an extra property (autoMargins) listing + * all margins that are set to auto, e.g. {top: "auto", left: "auto"}. + * @return {Object} + */ + getLayout: function (node, options) { + this.cssLogic.highlight(node.rawNode); + + let layout = {}; + + // First, we update the first part of the box model view, with + // the size of the element. + + let clientRect = node.rawNode.getBoundingClientRect(); + layout.width = parseFloat(clientRect.width.toPrecision(6)); + layout.height = parseFloat(clientRect.height.toPrecision(6)); + + // We compute and update the values of margins & co. + let style = CssLogic.getComputedStyle(node.rawNode); + for (let prop of [ + "position", + "margin-top", + "margin-right", + "margin-bottom", + "margin-left", + "padding-top", + "padding-right", + "padding-bottom", + "padding-left", + "border-top-width", + "border-right-width", + "border-bottom-width", + "border-left-width", + "z-index", + "box-sizing", + "display" + ]) { + layout[prop] = style.getPropertyValue(prop); + } + + if (options.autoMargins) { + layout.autoMargins = this.processMargins(this.cssLogic); + } + + for (let i in this.map) { + let property = this.map[i].property; + this.map[i].value = parseFloat(style.getPropertyValue(property)); + } + + return layout; + }, + + /** + * Find 'auto' margin properties. + */ + processMargins: function (cssLogic) { + let margins = {}; + + for (let prop of ["top", "bottom", "left", "right"]) { + let info = cssLogic.getPropertyInfo("margin-" + prop); + let selectors = info.matchedSelectors; + if (selectors && selectors.length > 0 && selectors[0].value == "auto") { + margins[prop] = "auto"; + } + } + + return margins; + }, + + /** + * On page navigation, tidy up remaining objects. + */ + onFrameUnload: function () { + this.styleElements = new WeakMap(); + }, + + /** + * When a stylesheet is added, handle the related StyleSheetActor to listen for changes. + * @param {StyleSheetActor} actor + * The actor for the added stylesheet. + */ + onStyleSheetAdded: function (actor) { + if (!this._watchedSheets.has(actor)) { + this._watchedSheets.add(actor); + actor.on("style-applied", this._styleApplied); + } + }, + + /** + * Helper function to addNewRule to get or create a style tag in the provided + * document. + * + * @param {Document} document + * The document in which the style element should be appended. + * @returns DOMElement of the style tag + */ + getStyleElement: function (document) { + if (!this.styleElements.has(document)) { + let style = document.createElementNS(XHTML_NS, "style"); + style.setAttribute("type", "text/css"); + document.documentElement.appendChild(style); + this.styleElements.set(document, style); + } + + return this.styleElements.get(document); + }, + + /** + * Helper function for adding a new rule and getting its applied style + * properties + * @param NodeActor node + * @param CSSStyleRule rule + * @returns Object containing its applied style properties + */ + getNewAppliedProps: function (node, rule) { + let ruleActor = this._styleRef(rule); + return this.getAppliedProps(node, [{ rule: ruleActor }], + { matchedSelectors: true }); + }, + + /** + * Adds a new rule, and returns the new StyleRuleActor. + * @param {NodeActor} node + * @param {String} pseudoClasses The list of pseudo classes to append to the + * new selector. + * @param {Boolean} editAuthored + * True if the selector should be updated by editing the + * authored text; false if the selector should be updated via + * CSSOM. + * @returns {StyleRuleActor} the new rule + */ + addNewRule: Task.async(function* (node, pseudoClasses, editAuthored = false) { + let style = this.getStyleElement(node.rawNode.ownerDocument); + let sheet = style.sheet; + let cssRules = sheet.cssRules; + let rawNode = node.rawNode; + let classes = [...rawNode.classList]; + + let selector; + if (rawNode.id) { + selector = "#" + CSS.escape(rawNode.id); + } else if (classes.length > 0) { + selector = "." + classes.map(c => CSS.escape(c)).join("."); + } else { + selector = rawNode.localName; + } + + if (pseudoClasses && pseudoClasses.length > 0) { + selector += pseudoClasses.join(""); + } + + let index = sheet.insertRule(selector + " {}", cssRules.length); + + // If inserting the rule succeeded, go ahead and edit the source + // text if requested. + if (editAuthored) { + let sheetActor = this._sheetRef(sheet); + let {str: authoredText} = yield sheetActor.getText(); + authoredText += "\n" + selector + " {\n" + "}"; + yield sheetActor.update(authoredText, false); + } + + return this.getNewAppliedProps(node, sheet.cssRules.item(index)); + }) +}); +exports.PageStyleActor = PageStyleActor; + +/** + * An actor that represents a CSS style object on the protocol. + * + * We slightly flatten the CSSOM for this actor, it represents + * both the CSSRule and CSSStyle objects in one actor. For nodes + * (which have a CSSStyle but no CSSRule) we create a StyleRuleActor + * with a special rule type (100). + */ +var StyleRuleActor = protocol.ActorClassWithSpec(styleRuleSpec, { + initialize: function (pageStyle, item) { + protocol.Actor.prototype.initialize.call(this, null); + this.pageStyle = pageStyle; + this.rawStyle = item.style; + this._parentSheet = null; + this._onStyleApplied = this._onStyleApplied.bind(this); + + if (item instanceof (Ci.nsIDOMCSSRule)) { + this.type = item.type; + this.rawRule = item; + if ((this.type === Ci.nsIDOMCSSRule.STYLE_RULE || + this.type === Ci.nsIDOMCSSRule.KEYFRAME_RULE) && + this.rawRule.parentStyleSheet) { + this.line = DOMUtils.getRelativeRuleLine(this.rawRule); + this.column = DOMUtils.getRuleColumn(this.rawRule); + this._parentSheet = this.rawRule.parentStyleSheet; + this._computeRuleIndex(); + this.sheetActor = this.pageStyle._sheetRef(this._parentSheet); + this.sheetActor.on("style-applied", this._onStyleApplied); + } + } else { + // Fake a rule + this.type = ELEMENT_STYLE; + this.rawNode = item; + this.rawRule = { + style: item.style, + toString: function () { + return "[element rule " + this.style + "]"; + } + }; + } + }, + + get conn() { + return this.pageStyle.conn; + }, + + destroy: function () { + if (!this.rawStyle) { + return; + } + protocol.Actor.prototype.destroy.call(this); + this.rawStyle = null; + this.pageStyle = null; + this.rawNode = null; + this.rawRule = null; + if (this.sheetActor) { + this.sheetActor.off("style-applied", this._onStyleApplied); + } + }, + + // Objects returned by this actor are owned by the PageStyleActor + // to which this rule belongs. + get marshallPool() { + return this.pageStyle; + }, + + // True if this rule supports as-authored styles, meaning that the + // rule text can be rewritten using setRuleText. + get canSetRuleText() { + return this.type === ELEMENT_STYLE || + (this._parentSheet && + // If a rule does not have source, then it has been modified via + // CSSOM; and we should fall back to non-authored editing. + // https://bugzilla.mozilla.org/show_bug.cgi?id=1224121 + this.sheetActor.allRulesHaveSource() && + // Special case about:PreferenceStyleSheet, as it is generated on + // the fly and the URI is not registered with the about:handler + // https://bugzilla.mozilla.org/show_bug.cgi?id=935803#c37 + this._parentSheet.href !== "about:PreferenceStyleSheet"); + }, + + getDocument: function (sheet) { + let document; + + if (sheet.ownerNode instanceof Ci.nsIDOMHTMLDocument) { + document = sheet.ownerNode; + } else { + document = sheet.ownerNode.ownerDocument; + } + + return document; + }, + + toString: function () { + return "[StyleRuleActor for " + this.rawRule + "]"; + }, + + form: function (detail) { + if (detail === "actorid") { + return this.actorID; + } + + let form = { + actor: this.actorID, + type: this.type, + line: this.line || undefined, + column: this.column, + traits: { + // Whether the style rule actor implements the modifySelector2 method + // that allows for unmatched rule to be added + modifySelectorUnmatched: true, + // Whether the style rule actor implements the setRuleText + // method. + canSetRuleText: this.canSetRuleText, + } + }; + + if (this.rawRule.parentRule) { + form.parentRule = + this.pageStyle._styleRef(this.rawRule.parentRule).actorID; + + // CSS rules that we call media rules are STYLE_RULES that are children + // of MEDIA_RULEs. We need to check the parentRule to check if a rule is + // a media rule so we do this here instead of in the switch statement + // below. + if (this.rawRule.parentRule.type === Ci.nsIDOMCSSRule.MEDIA_RULE) { + form.media = []; + for (let i = 0, n = this.rawRule.parentRule.media.length; i < n; i++) { + form.media.push(this.rawRule.parentRule.media.item(i)); + } + } + } + if (this._parentSheet) { + form.parentStyleSheet = + this.pageStyle._sheetRef(this._parentSheet).actorID; + } + + // One tricky thing here is that other methods in this actor must + // ensure that authoredText has been set before |form| is called. + // This has to be treated specially, for now, because we cannot + // synchronously compute the authored text, but |form| also cannot + // return a promise. See bug 1205868. + form.authoredText = this.authoredText; + + switch (this.type) { + case Ci.nsIDOMCSSRule.STYLE_RULE: + form.selectors = CssLogic.getSelectors(this.rawRule); + form.cssText = this.rawStyle.cssText || ""; + break; + case ELEMENT_STYLE: + // Elements don't have a parent stylesheet, and therefore + // don't have an associated URI. Provide a URI for + // those. + let doc = this.rawNode.ownerDocument; + form.href = doc.location ? doc.location.href : ""; + form.cssText = this.rawStyle.cssText || ""; + form.authoredText = this.rawNode.getAttribute("style"); + break; + case Ci.nsIDOMCSSRule.CHARSET_RULE: + form.encoding = this.rawRule.encoding; + break; + case Ci.nsIDOMCSSRule.IMPORT_RULE: + form.href = this.rawRule.href; + break; + case Ci.nsIDOMCSSRule.KEYFRAMES_RULE: + form.cssText = this.rawRule.cssText; + form.name = this.rawRule.name; + break; + case Ci.nsIDOMCSSRule.KEYFRAME_RULE: + form.cssText = this.rawStyle.cssText || ""; + form.keyText = this.rawRule.keyText || ""; + break; + } + + // Parse the text into a list of declarations so the client doesn't have to + // and so that we can safely determine if a declaration is valid rather than + // have the client guess it. + if (form.authoredText || form.cssText) { + let declarations = parseDeclarations(isCssPropertyKnown, + form.authoredText || form.cssText, + true); + form.declarations = declarations.map(decl => { + decl.isValid = DOMUtils.cssPropertyIsValid(decl.name, decl.value); + return decl; + }); + } + + return form; + }, + + /** + * Send an event notifying that the location of the rule has + * changed. + * + * @param {Number} line the new line number + * @param {Number} column the new column number + */ + _notifyLocationChanged: function (line, column) { + events.emit(this, "location-changed", line, column); + }, + + /** + * Compute the index of this actor's raw rule in its parent style + * sheet. The index is a vector where each element is the index of + * a given CSS rule in its parent. A vector is used to support + * nested rules. + */ + _computeRuleIndex: function () { + let rule = this.rawRule; + let result = []; + + while (rule) { + let cssRules; + if (rule.parentRule) { + cssRules = rule.parentRule.cssRules; + } else { + cssRules = rule.parentStyleSheet.cssRules; + } + + let found = false; + for (let i = 0; i < cssRules.length; i++) { + if (rule === cssRules.item(i)) { + found = true; + result.unshift(i); + break; + } + } + + if (!found) { + this._ruleIndex = null; + return; + } + + rule = rule.parentRule; + } + + this._ruleIndex = result; + }, + + /** + * Get the rule corresponding to |this._ruleIndex| from the given + * style sheet. + * + * @param {DOMStyleSheet} sheet + * The style sheet. + * @return {CSSStyleRule} the rule corresponding to + * |this._ruleIndex| + */ + _getRuleFromIndex: function (parentSheet) { + let currentRule = null; + for (let i of this._ruleIndex) { + if (currentRule === null) { + currentRule = parentSheet.cssRules[i]; + } else { + currentRule = currentRule.cssRules.item(i); + } + } + return currentRule; + }, + + /** + * This is attached to the parent style sheet actor's + * "style-applied" event. + */ + _onStyleApplied: function (kind) { + if (kind === UPDATE_GENERAL) { + // A general change means that the rule actors are invalidated, + // so stop listening to events now. + if (this.sheetActor) { + this.sheetActor.off("style-applied", this._onStyleApplied); + } + } else if (this._ruleIndex) { + // The sheet was updated by this actor, in a way that preserves + // the rules. Now, recompute our new rule from the style sheet, + // so that we aren't left with a reference to a dangling rule. + let oldRule = this.rawRule; + this.rawRule = this._getRuleFromIndex(this._parentSheet); + // Also tell the page style so that future calls to _styleRef + // return the same StyleRuleActor. + this.pageStyle.updateStyleRef(oldRule, this.rawRule, this); + let line = DOMUtils.getRelativeRuleLine(this.rawRule); + let column = DOMUtils.getRuleColumn(this.rawRule); + if (line !== this.line || column !== this.column) { + this._notifyLocationChanged(line, column); + } + this.line = line; + this.column = column; + } + }, + + /** + * Return a promise that resolves to the authored form of a rule's + * text, if available. If the authored form is not available, the + * returned promise simply resolves to the empty string. If the + * authored form is available, this also sets |this.authoredText|. + * The authored text will include invalid and otherwise ignored + * properties. + */ + getAuthoredCssText: function () { + if (!this.canSetRuleText || + (this.type !== Ci.nsIDOMCSSRule.STYLE_RULE && + this.type !== Ci.nsIDOMCSSRule.KEYFRAME_RULE)) { + return promise.resolve(""); + } + + if (typeof this.authoredText === "string") { + return promise.resolve(this.authoredText); + } + + let parentStyleSheet = + this.pageStyle._sheetRef(this._parentSheet); + return parentStyleSheet.getText().then((longStr) => { + let cssText = longStr.str; + let {text} = getRuleText(cssText, this.line, this.column); + + // Cache the result on the rule actor to avoid parsing again next time + this.authoredText = text; + return this.authoredText; + }); + }, + + /** + * Set the contents of the rule. This rewrites the rule in the + * stylesheet and causes it to be re-evaluated. + * + * @param {String} newText the new text of the rule + * @returns the rule with updated properties + */ + setRuleText: Task.async(function* (newText) { + if (!this.canSetRuleText) { + throw new Error("invalid call to setRuleText"); + } + + if (this.type === ELEMENT_STYLE) { + // For element style rules, set the node's style attribute. + this.rawNode.setAttribute("style", newText); + } else { + // For stylesheet rules, set the text in the stylesheet. + let parentStyleSheet = this.pageStyle._sheetRef(this._parentSheet); + let {str: cssText} = yield parentStyleSheet.getText(); + + let {offset, text} = getRuleText(cssText, this.line, this.column); + cssText = cssText.substring(0, offset) + newText + + cssText.substring(offset + text.length); + + yield parentStyleSheet.update(cssText, false, UPDATE_PRESERVING_RULES); + } + + this.authoredText = newText; + + return this; + }), + + /** + * Modify a rule's properties. Passed an array of modifications: + * { + * type: "set", + * name: <string>, + * value: <string>, + * priority: <optional string> + * } + * or + * { + * type: "remove", + * name: <string>, + * } + * + * @returns the rule with updated properties + */ + modifyProperties: function (modifications) { + // Use a fresh element for each call to this function to prevent side + // effects that pop up based on property values that were already set on the + // element. + + let document; + if (this.rawNode) { + document = this.rawNode.ownerDocument; + } else { + let parentStyleSheet = this._parentSheet; + while (parentStyleSheet.ownerRule && + parentStyleSheet.ownerRule instanceof Ci.nsIDOMCSSImportRule) { + parentStyleSheet = parentStyleSheet.ownerRule.parentStyleSheet; + } + + document = this.getDocument(parentStyleSheet); + } + + let tempElement = document.createElementNS(XHTML_NS, "div"); + + for (let mod of modifications) { + if (mod.type === "set") { + tempElement.style.setProperty(mod.name, mod.value, mod.priority || ""); + this.rawStyle.setProperty(mod.name, + tempElement.style.getPropertyValue(mod.name), mod.priority || ""); + } else if (mod.type === "remove") { + this.rawStyle.removeProperty(mod.name); + } + } + + return this; + }, + + /** + * Helper function for modifySelector and modifySelector2, inserts the new + * rule with the new selector into the parent style sheet and removes the + * current rule. Returns the newly inserted css rule or null if the rule is + * unsuccessfully inserted to the parent style sheet. + * + * @param {String} value + * The new selector value + * @param {Boolean} editAuthored + * True if the selector should be updated by editing the + * authored text; false if the selector should be updated via + * CSSOM. + * + * @returns {CSSRule} + * The new CSS rule added + */ + _addNewSelector: Task.async(function* (value, editAuthored) { + let rule = this.rawRule; + let parentStyleSheet = this._parentSheet; + + // We know the selector modification is ok, so if the client asked + // for the authored text to be edited, do it now. + if (editAuthored) { + let document = this.getDocument(this._parentSheet); + try { + document.querySelector(value); + } catch (e) { + return null; + } + + let sheetActor = this.pageStyle._sheetRef(parentStyleSheet); + let {str: authoredText} = yield sheetActor.getText(); + let [startOffset, endOffset] = getSelectorOffsets(authoredText, this.line, + this.column); + authoredText = authoredText.substring(0, startOffset) + value + + authoredText.substring(endOffset); + yield sheetActor.update(authoredText, false, UPDATE_PRESERVING_RULES); + } else { + let cssRules = parentStyleSheet.cssRules; + let cssText = rule.cssText; + let selectorText = rule.selectorText; + + for (let i = 0; i < cssRules.length; i++) { + if (rule === cssRules.item(i)) { + try { + // Inserts the new style rule into the current style sheet and + // delete the current rule + let ruleText = cssText.slice(selectorText.length).trim(); + parentStyleSheet.insertRule(value + " " + ruleText, i); + parentStyleSheet.deleteRule(i + 1); + break; + } catch (e) { + // The selector could be invalid, or the rule could fail to insert. + return null; + } + } + } + } + + return this._getRuleFromIndex(parentStyleSheet); + }), + + /** + * Modify the current rule's selector by inserting a new rule with the new + * selector value and removing the current rule. + * + * Note this method was kept for backward compatibility, but unmatched rules + * support was added in FF41. + * + * @param string value + * The new selector value + * @returns boolean + * Returns a boolean if the selector in the stylesheet was modified, + * and false otherwise + */ + modifySelector: Task.async(function* (value) { + if (this.type === ELEMENT_STYLE) { + return false; + } + + let document = this.getDocument(this._parentSheet); + // Extract the selector, and pseudo elements and classes + let [selector] = value.split(/(:{1,2}.+$)/); + let selectorElement; + + try { + selectorElement = document.querySelector(selector); + } catch (e) { + return false; + } + + // Check if the selector is valid and not the same as the original + // selector + if (selectorElement && this.rawRule.selectorText !== value) { + yield this._addNewSelector(value, false); + return true; + } + return false; + }), + + /** + * Modify the current rule's selector by inserting a new rule with the new + * selector value and removing the current rule. + * + * In contrast with the modifySelector method which was used before FF41, + * this method also returns information about the new rule and applied style + * so that consumers can immediately display the new rule, whether or not the + * selector matches the current element without having to refresh the whole + * list. + * + * @param {DOMNode} node + * The current selected element + * @param {String} value + * The new selector value + * @param {Boolean} editAuthored + * True if the selector should be updated by editing the + * authored text; false if the selector should be updated via + * CSSOM. + * @returns {Object} + * Returns an object that contains the applied style properties of the + * new rule and a boolean indicating whether or not the new selector + * matches the current selected element + */ + modifySelector2: function (node, value, editAuthored = false) { + if (this.type === ELEMENT_STYLE || + this.rawRule.selectorText === value) { + return { ruleProps: null, isMatching: true }; + } + + let selectorPromise = this._addNewSelector(value, editAuthored); + + if (editAuthored) { + selectorPromise = selectorPromise.then((newCssRule) => { + if (newCssRule) { + let style = this.pageStyle._styleRef(newCssRule); + // See the comment in |form| to understand this. + return style.getAuthoredCssText().then(() => newCssRule); + } + return newCssRule; + }); + } + + return selectorPromise.then((newCssRule) => { + let ruleProps = null; + let isMatching = false; + + if (newCssRule) { + let ruleEntry = this.pageStyle.findEntryMatchingRule(node, newCssRule); + if (ruleEntry.length === 1) { + ruleProps = + this.pageStyle.getAppliedProps(node, ruleEntry, + { matchedSelectors: true }); + } else { + ruleProps = this.pageStyle.getNewAppliedProps(node, newCssRule); + } + + isMatching = ruleProps.entries.some((ruleProp) => + ruleProp.matchedSelectors.length > 0); + } + + return { ruleProps, isMatching }; + }); + } +}); + +/** + * 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 || {}; + let previewText = options.previewText || FONT_PREVIEW_TEXT; + let previewFontSize = options.previewFontSize || FONT_PREVIEW_FONT_SIZE; + let fillStyle = options.fillStyle || FONT_PREVIEW_FILLSTYLE; + let fontStyle = options.fontStyle || ""; + + let canvas = doc.createElementNS(XHTML_NS, "canvas"); + let ctx = canvas.getContext("2d"); + let fontValue = fontStyle + " " + previewFontSize + "px " + font + ", serif"; + + // Get the correct preview text measurements and set the canvas dimensions + ctx.font = fontValue; + ctx.fillStyle = fillStyle; + let textWidth = ctx.measureText(previewText).width; + + canvas.width = textWidth * 2 + FONT_PREVIEW_OFFSET * 2; + canvas.height = previewFontSize * 3; + + // we have to reset these after changing the canvas size + ctx.font = fontValue; + ctx.fillStyle = fillStyle; + + // Oversample the canvas for better text quality + ctx.textBaseline = "top"; + ctx.scale(2, 2); + ctx.fillText(previewText, + FONT_PREVIEW_OFFSET, + Math.round(previewFontSize / 3)); + + let dataURL = canvas.toDataURL("image/png"); + + return { + dataURL: 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"); + } + + let {offset: textOffset, text} = + getTextAtLineColumn(initialText, line, column); + let lexer = DOMUtils.getCSSLexer(text); + + // Search forward for the opening brace. + while (true) { + let 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) { + let 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; + +/** + * Compute the start and end offsets of a rule's selector text, given + * the CSS text and the line and column at which the rule begins. + * @param {String} initialText + * @param {Number} line (1-indexed) + * @param {Number} column (1-indexed) + * @return {array} An array with two elements: [startOffset, endOffset]. + * The elements mark the bounds in |initialText| of + * the CSS rule's selector. + */ +function getSelectorOffsets(initialText, line, column) { + if (typeof line === "undefined" || typeof column === "undefined") { + throw new Error("Location information is missing"); + } + + let {offset: textOffset, text} = + getTextAtLineColumn(initialText, line, column); + let lexer = DOMUtils.getCSSLexer(text); + + // Search forward for the opening brace. + let endOffset; + while (true) { + let token = lexer.nextToken(); + if (!token) { + break; + } + if (token.tokenType === "symbol" && token.text === "{") { + if (endOffset === undefined) { + break; + } + return [textOffset, textOffset + endOffset]; + } + // Preserve comments and whitespace just before the "{". + if (token.tokenType !== "comment" && token.tokenType !== "whitespace") { + endOffset = token.endOffset; + } + } + + throw new Error("could not find bounds of rule"); +} + +/** + * 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) { + let rx = new RegExp("(?:.*(?:\\r\\n|\\n|\\r|\\f)){" + (line - 1) + "}"); + offset = rx.exec(text)[0].length; + } else { + offset = 0; + } + offset += column - 1; + return {offset: offset, text: text.substr(offset) }; +} + +exports.getTextAtLineColumn = getTextAtLineColumn; diff --git a/devtools/server/actors/stylesheets.js b/devtools/server/actors/stylesheets.js new file mode 100644 index 0000000000..f20634e6cb --- /dev/null +++ b/devtools/server/actors/stylesheets.js @@ -0,0 +1,982 @@ +/* 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 {Cc, Ci} = require("chrome"); +const Services = require("Services"); +const {XPCOMUtils} = require("resource://gre/modules/XPCOMUtils.jsm"); +const promise = require("promise"); +const {Task} = require("devtools/shared/task"); +const events = require("sdk/event/core"); +const protocol = require("devtools/shared/protocol"); +const {LongStringActor} = require("devtools/server/actors/string"); +const {fetch} = require("devtools/shared/DevToolsUtils"); +const {listenOnce} = require("devtools/shared/async-utils"); +const {originalSourceSpec, mediaRuleSpec, styleSheetSpec, + styleSheetsSpec} = require("devtools/shared/specs/stylesheets"); +const {SourceMapConsumer} = require("source-map"); +const { installHelperSheet, + addPseudoClassLock, removePseudoClassLock } = require("devtools/server/actors/highlighters/utils/markup"); + +loader.lazyGetter(this, "CssLogic", () => require("devtools/shared/inspector/css-logic")); + +XPCOMUtils.defineLazyGetter(this, "DOMUtils", function () { + return Cc["@mozilla.org/inspector/dom-utils;1"].getService(Ci.inIDOMUtils); +}); + +var TRANSITION_PSEUDO_CLASS = ":-moz-styleeditor-transitioning"; +var TRANSITION_DURATION_MS = 500; +var TRANSITION_BUFFER_MS = 1000; +var TRANSITION_RULE_SELECTOR = +`:root${TRANSITION_PSEUDO_CLASS}, :root${TRANSITION_PSEUDO_CLASS} *`; +var TRANSITION_RULE = `${TRANSITION_RULE_SELECTOR} { + transition-duration: ${TRANSITION_DURATION_MS}ms !important; + transition-delay: 0ms !important; + transition-timing-function: ease-out !important; + transition-property: all !important; +}`; + +var LOAD_ERROR = "error-load"; + +// 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; +exports.UPDATE_PRESERVING_RULES = UPDATE_PRESERVING_RULES; +const UPDATE_GENERAL = 1; +exports.UPDATE_GENERAL = UPDATE_GENERAL; + +// If the user edits a style sheet, we stash a copy of the edited text +// here, keyed by the style sheet. 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. +let modifiedStyleSheets = new WeakMap(); + +/** + * Actor representing an original source of a style sheet that was specified + * in a source map. + */ +var OriginalSourceActor = protocol.ActorClassWithSpec(originalSourceSpec, { + initialize: function (aUrl, aSourceMap, aParentActor) { + protocol.Actor.prototype.initialize.call(this, null); + + this.url = aUrl; + this.sourceMap = aSourceMap; + this.parentActor = aParentActor; + this.conn = this.parentActor.conn; + + this.text = null; + }, + + form: function () { + return { + actor: this.actorID, // actorID is set when it's added to a pool + url: this.url, + relatedStyleSheet: this.parentActor.form() + }; + }, + + _getText: function () { + if (this.text) { + return promise.resolve(this.text); + } + let content = this.sourceMap.sourceContentFor(this.url); + if (content) { + this.text = content; + return promise.resolve(content); + } + let options = { + policy: Ci.nsIContentPolicy.TYPE_INTERNAL_STYLESHEET, + window: this.window + }; + return fetch(this.url, options).then(({content}) => { + this.text = content; + return content; + }); + }, + + /** + * Protocol method to get the text of this source. + */ + getText: function () { + return this._getText().then((text) => { + return new LongStringActor(this.conn, text || ""); + }); + } +}); + +/** + * A MediaRuleActor lives on the server and provides access to properties + * of a DOM @media rule and emits events when it changes. + */ +var MediaRuleActor = protocol.ActorClassWithSpec(mediaRuleSpec, { + get window() { + return this.parentActor.window; + }, + + get document() { + return this.window.document; + }, + + get matches() { + return this.mql ? this.mql.matches : null; + }, + + initialize: function (aMediaRule, aParentActor) { + protocol.Actor.prototype.initialize.call(this, null); + + this.rawRule = aMediaRule; + this.parentActor = aParentActor; + this.conn = this.parentActor.conn; + + this._matchesChange = this._matchesChange.bind(this); + + this.line = DOMUtils.getRuleLine(aMediaRule); + this.column = DOMUtils.getRuleColumn(aMediaRule); + + try { + this.mql = this.window.matchMedia(aMediaRule.media.mediaText); + } catch (e) { + } + + if (this.mql) { + this.mql.addListener(this._matchesChange); + } + }, + + destroy: function () + { + if (this.mql) { + this.mql.removeListener(this._matchesChange); + } + + protocol.Actor.prototype.destroy.call(this); + }, + + form: function (detail) { + if (detail === "actorid") { + return this.actorID; + } + + let form = { + actor: this.actorID, // actorID is set when this is added to a pool + mediaText: this.rawRule.media.mediaText, + conditionText: this.rawRule.conditionText, + matches: this.matches, + line: this.line, + column: this.column, + parentStyleSheet: this.parentActor.actorID + }; + + return form; + }, + + _matchesChange: function () { + events.emit(this, "matches-change", this.matches); + } +}); + +/** + * A StyleSheetActor represents a stylesheet on the server. + */ +var StyleSheetActor = protocol.ActorClassWithSpec(styleSheetSpec, { + /* List of original sources that generated this stylesheet */ + _originalSources: null, + + toString: function () { + return "[StyleSheetActor " + this.actorID + "]"; + }, + + /** + * Window of target + */ + get window() { + return this._window || this.parentActor.window; + }, + + /** + * Document of target. + */ + get document() { + return this.window.document; + }, + + get ownerNode() { + return this.rawSheet.ownerNode; + }, + + /** + * URL of underlying stylesheet. + */ + get href() { + return this.rawSheet.href; + }, + + /** + * Returns the stylesheet href or the document href if the sheet is inline. + */ + get safeHref() { + let href = this.href; + if (!href) { + if (this.ownerNode instanceof Ci.nsIDOMHTMLDocument) { + href = this.ownerNode.location.href; + } else if (this.ownerNode.ownerDocument && + this.ownerNode.ownerDocument.location) { + href = this.ownerNode.ownerDocument.location.href; + } + } + return href; + }, + + /** + * Retrieve the index (order) of stylesheet in the document. + * + * @return number + */ + get styleSheetIndex() + { + if (this._styleSheetIndex == -1) { + for (let i = 0; i < this.document.styleSheets.length; i++) { + if (this.document.styleSheets[i] == this.rawSheet) { + this._styleSheetIndex = i; + break; + } + } + } + return this._styleSheetIndex; + }, + + destroy: function () { + if (this._transitionTimeout) { + this.window.clearTimeout(this._transitionTimeout); + removePseudoClassLock( + this.document.documentElement, TRANSITION_PSEUDO_CLASS); + } + }, + + /** + * Since StyleSheetActor doesn't have a protocol.js parent actor that take + * care of its lifetime, implementing disconnect is required to cleanup. + */ + disconnect: function () { + this.destroy(); + }, + + initialize: function (aStyleSheet, aParentActor, aWindow) { + protocol.Actor.prototype.initialize.call(this, null); + + this.rawSheet = aStyleSheet; + this.parentActor = aParentActor; + this.conn = this.parentActor.conn; + + this._window = aWindow; + + // text and index are unknown until source load + this.text = null; + this._styleSheetIndex = -1; + }, + + /** + * Test whether all the rules in this sheet have associated source. + * @return {Boolean} true if all the rules have source; false if + * some rule was created via CSSOM. + */ + allRulesHaveSource: function () { + let rules; + try { + rules = this.rawSheet.cssRules; + } catch (e) { + // sheet isn't loaded yet + return true; + } + + for (let i = 0; i < rules.length; i++) { + let rule = rules[i]; + if (DOMUtils.getRelativeRuleLine(rule) === 0) { + return false; + } + } + + return true; + }, + + /** + * Get the raw stylesheet's cssRules once the sheet has been loaded. + * + * @return {Promise} + * Promise that resolves with a CSSRuleList + */ + getCSSRules: function () { + let rules; + try { + rules = this.rawSheet.cssRules; + } + catch (e) { + // sheet isn't loaded yet + } + + if (rules) { + return promise.resolve(rules); + } + + if (!this.ownerNode) { + return promise.resolve([]); + } + + if (this._cssRules) { + return this._cssRules; + } + + let deferred = promise.defer(); + + let onSheetLoaded = (event) => { + this.ownerNode.removeEventListener("load", onSheetLoaded, false); + + deferred.resolve(this.rawSheet.cssRules); + }; + + this.ownerNode.addEventListener("load", onSheetLoaded, false); + + // cache so we don't add many listeners if this is called multiple times. + this._cssRules = deferred.promise; + + return this._cssRules; + }, + + /** + * Get the current state of the actor + * + * @return {object} + * With properties of the underlying stylesheet, plus 'text', + * 'styleSheetIndex' and 'parentActor' if it's @imported + */ + form: function (detail) { + if (detail === "actorid") { + return this.actorID; + } + + let docHref; + if (this.ownerNode) { + if (this.ownerNode instanceof Ci.nsIDOMHTMLDocument) { + docHref = this.ownerNode.location.href; + } + else if (this.ownerNode.ownerDocument && this.ownerNode.ownerDocument.location) { + docHref = this.ownerNode.ownerDocument.location.href; + } + } + + let form = { + actor: this.actorID, // actorID is set when this actor is added to a pool + href: this.href, + nodeHref: docHref, + disabled: this.rawSheet.disabled, + title: this.rawSheet.title, + system: !CssLogic.isContentStylesheet(this.rawSheet), + styleSheetIndex: this.styleSheetIndex + }; + + try { + form.ruleCount = this.rawSheet.cssRules.length; + } + catch (e) { + // stylesheet had an @import rule that wasn't loaded yet + this.getCSSRules().then(() => { + this._notifyPropertyChanged("ruleCount"); + }); + } + return form; + }, + + /** + * Toggle the disabled property of the style sheet + * + * @return {object} + * 'disabled' - the disabled state after toggling. + */ + toggleDisabled: function () { + this.rawSheet.disabled = !this.rawSheet.disabled; + this._notifyPropertyChanged("disabled"); + + return this.rawSheet.disabled; + }, + + /** + * Send an event notifying that a property of the stylesheet + * has changed. + * + * @param {string} property + * Name of the changed property + */ + _notifyPropertyChanged: function (property) { + events.emit(this, "property-change", property, this.form()[property]); + }, + + /** + * Protocol method to get the text of this stylesheet. + */ + getText: function () { + return this._getText().then((text) => { + return new LongStringActor(this.conn, text || ""); + }); + }, + + /** + * Fetch the text for this stylesheet from the cache or network. Return + * cached text if it's already been fetched. + * + * @return {Promise} + * Promise that resolves with a string text of the stylesheet. + */ + _getText: function () { + if (typeof this.text === "string") { + return promise.resolve(this.text); + } + + let cssText = modifiedStyleSheets.get(this.rawSheet); + if (cssText !== undefined) { + this.text = cssText; + return promise.resolve(cssText); + } + + if (!this.href) { + // this is an inline <style> sheet + let content = this.ownerNode.textContent; + this.text = content; + return promise.resolve(content); + } + + let options = { + loadFromCache: true, + policy: Ci.nsIContentPolicy.TYPE_INTERNAL_STYLESHEET, + charset: this._getCSSCharset() + }; + + // 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. + // The default internal stylesheets load from the 'resource:' URL. + // Bug 1287607, 1291321 - 'chrome' and 'file' protocols should also be handled in the + // same way. + if (!/^(chrome|file|resource):\/\//.test(this.href)) { + options.window = this.window; + options.principal = this.document.nodePrincipal; + } + + return fetch(this.href, options).then(({ content }) => { + this.text = content; + return content; + }); + }, + + /** + * Protocol method to get the original source (actors) for this + * stylesheet if it has uses source maps. + */ + getOriginalSources: function () { + if (this._originalSources) { + return promise.resolve(this._originalSources); + } + return this._fetchOriginalSources(); + }, + + /** + * Fetch the original sources (actors) for this style sheet using its + * source map. If they've already been fetched, returns cached array. + * + * @return {Promise} + * Promise that resolves with an array of OriginalSourceActors + */ + _fetchOriginalSources: function () { + this._clearOriginalSources(); + this._originalSources = []; + + return this.getSourceMap().then((sourceMap) => { + if (!sourceMap) { + return null; + } + for (let url of sourceMap.sources) { + let actor = new OriginalSourceActor(url, sourceMap, this); + + this.manage(actor); + this._originalSources.push(actor); + } + return this._originalSources; + }); + }, + + /** + * Get the SourceMapConsumer for this stylesheet's source map, if + * it exists. Saves the consumer for later queries. + * + * @return {Promise} + * A promise that resolves with a SourceMapConsumer, or null. + */ + getSourceMap: function () { + if (this._sourceMap) { + return this._sourceMap; + } + return this._fetchSourceMap(); + }, + + /** + * Fetch the source map for this stylesheet. + * + * @return {Promise} + * A promise that resolves with a SourceMapConsumer, or null. + */ + _fetchSourceMap: function () { + let deferred = promise.defer(); + + this._getText().then(sheetContent => { + let url = this._extractSourceMapUrl(sheetContent); + if (!url) { + // no source map for this stylesheet + deferred.resolve(null); + return; + } + + url = normalize(url, this.safeHref); + let options = { + loadFromCache: false, + policy: Ci.nsIContentPolicy.TYPE_INTERNAL_STYLESHEET, + window: this.window + }; + + let map = fetch(url, options).then(({content}) => { + // Fetching the source map might have failed with a 404 or other. When + // this happens, SourceMapConsumer may fail with a JSON.parse error. + let consumer; + try { + consumer = new SourceMapConsumer(content); + } catch (e) { + deferred.reject(new Error( + `Source map at ${url} not found or invalid`)); + return null; + } + this._setSourceMapRoot(consumer, url, this.safeHref); + this._sourceMap = promise.resolve(consumer); + + deferred.resolve(consumer); + return consumer; + }, deferred.reject); + + this._sourceMap = map; + }, deferred.reject); + + return deferred.promise; + }, + + /** + * Clear and unmanage the original source actors for this stylesheet. + */ + _clearOriginalSources: function () { + for (actor in this._originalSources) { + this.unmanage(actor); + } + this._originalSources = null; + }, + + /** + * Sets the source map's sourceRoot to be relative to the source map url. + */ + _setSourceMapRoot: function (aSourceMap, aAbsSourceMapURL, aScriptURL) { + if (aScriptURL.startsWith("blob:")) { + aScriptURL = aScriptURL.replace("blob:", ""); + } + const base = dirname( + aAbsSourceMapURL.startsWith("data:") + ? aScriptURL + : aAbsSourceMapURL); + aSourceMap.sourceRoot = aSourceMap.sourceRoot + ? normalize(aSourceMap.sourceRoot, base) + : base; + }, + + /** + * Get the source map url specified in the text of a stylesheet. + * + * @param {string} content + * The text of the style sheet. + * @return {string} + * Url of source map. + */ + _extractSourceMapUrl: function (content) { + var matches = /sourceMappingURL\=([^\s\*]*)/.exec(content); + if (matches) { + return matches[1]; + } + return null; + }, + + /** + * Protocol method that gets the location in the original source of a + * line, column pair in this stylesheet, if its source mapped, otherwise + * a promise of the same location. + */ + getOriginalLocation: function (line, column) { + return this.getSourceMap().then((sourceMap) => { + if (sourceMap) { + return sourceMap.originalPositionFor({ line: line, column: column }); + } + return { + fromSourceMap: false, + source: this.href, + line: line, + column: column + }; + }); + }, + + /** + * Protocol method to get the media rules for the stylesheet. + */ + getMediaRules: function () { + return this._getMediaRules(); + }, + + /** + * Get all the @media rules in this stylesheet. + * + * @return {promise} + * A promise that resolves with an array of MediaRuleActors. + */ + _getMediaRules: function () { + return this.getCSSRules().then((rules) => { + let mediaRules = []; + for (let i = 0; i < rules.length; i++) { + let rule = rules[i]; + if (rule.type != Ci.nsIDOMCSSRule.MEDIA_RULE) { + continue; + } + let actor = new MediaRuleActor(rule, this); + this.manage(actor); + + mediaRules.push(actor); + } + return mediaRules; + }); + }, + + /** + * Get the charset of the stylesheet according to the character set rules + * defined in <http://www.w3.org/TR/CSS2/syndata.html#charset>. + * Note that some of the algorithm is implemented in DevToolsUtils.fetch. + */ + _getCSSCharset: function () + { + let sheet = this.rawSheet; + if (sheet) { + // Do we have a @charset rule in the stylesheet? + // step 2 of syndata.html (without the BOM check). + if (sheet.cssRules) { + let rules = sheet.cssRules; + if (rules.length + && rules.item(0).type == Ci.nsIDOMCSSRule.CHARSET_RULE) { + return rules.item(0).encoding; + } + } + + // step 3: charset attribute of <link> or <style> element, if it exists + if (sheet.ownerNode && sheet.ownerNode.getAttribute) { + let linkCharset = sheet.ownerNode.getAttribute("charset"); + if (linkCharset != null) { + return linkCharset; + } + } + + // step 4 (1 of 2): charset of referring stylesheet. + let parentSheet = sheet.parentStyleSheet; + if (parentSheet && parentSheet.cssRules && + parentSheet.cssRules[0].type == Ci.nsIDOMCSSRule.CHARSET_RULE) { + return parentSheet.cssRules[0].encoding; + } + + // step 4 (2 of 2): charset of referring document. + if (sheet.ownerNode && sheet.ownerNode.ownerDocument.characterSet) { + return sheet.ownerNode.ownerDocument.characterSet; + } + } + + // step 5: default to utf-8. + return "UTF-8"; + }, + + /** + * Update the style sheet in place with new text. + * + * @param {object} request + * 'text' - new text + * 'transition' - whether to do CSS transition for change. + * 'kind' - either UPDATE_PRESERVING_RULES or UPDATE_GENERAL + */ + update: function (text, transition, kind = UPDATE_GENERAL) { + DOMUtils.parseStyleSheet(this.rawSheet, text); + + modifiedStyleSheets.set(this.rawSheet, text); + + this.text = text; + + this._notifyPropertyChanged("ruleCount"); + + if (transition) { + this._insertTransistionRule(kind); + } + else { + events.emit(this, "style-applied", kind, this); + } + + this._getMediaRules().then((rules) => { + events.emit(this, "media-rules-changed", rules); + }); + }, + + /** + * Insert a catch-all transition rule into the document. Set a timeout + * to remove the rule after a certain time. + */ + _insertTransistionRule: function (kind) { + addPseudoClassLock(this.document.documentElement, TRANSITION_PSEUDO_CLASS); + + // We always add the rule since we've just reset all the rules + this.rawSheet.insertRule(TRANSITION_RULE, this.rawSheet.cssRules.length); + + // Set up clean up and commit after transition duration (+buffer) + // @see _onTransitionEnd + this.window.clearTimeout(this._transitionTimeout); + this._transitionTimeout = this.window.setTimeout(this._onTransitionEnd.bind(this, kind), + TRANSITION_DURATION_MS + TRANSITION_BUFFER_MS); + }, + + /** + * This cleans up class and rule added for transition effect and then + * notifies that the style has been applied. + */ + _onTransitionEnd: function (kind) + { + this._transitionTimeout = null; + removePseudoClassLock(this.document.documentElement, TRANSITION_PSEUDO_CLASS); + + let index = this.rawSheet.cssRules.length - 1; + let rule = this.rawSheet.cssRules[index]; + if (rule.selectorText == TRANSITION_RULE_SELECTOR) { + this.rawSheet.deleteRule(index); + } + + events.emit(this, "style-applied", kind, this); + } +}); + +exports.StyleSheetActor = StyleSheetActor; + +/** + * Creates a StyleSheetsActor. StyleSheetsActor provides remote access to the + * stylesheets of a document. + */ +var StyleSheetsActor = protocol.ActorClassWithSpec(styleSheetsSpec, { + /** + * The window we work with, taken from the parent actor. + */ + get window() { + return this.parentActor.window; + }, + + /** + * The current content document of the window we work with. + */ + get document() { + return this.window.document; + }, + + form: function () + { + return { actor: this.actorID }; + }, + + initialize: function (conn, tabActor) { + protocol.Actor.prototype.initialize.call(this, null); + + this.parentActor = tabActor; + }, + + /** + * Protocol method for getting a list of StyleSheetActors representing + * all the style sheets in this document. + */ + getStyleSheets: Task.async(function* () { + // Iframe document can change during load (bug 1171919). Track their windows + // instead. + let windows = [this.window]; + let actors = []; + + for (let win of windows) { + let sheets = yield this._addStyleSheets(win); + actors = actors.concat(sheets); + + // Recursively handle style sheets of the documents in iframes. + for (let iframe of win.document.querySelectorAll("iframe, browser, frame")) { + if (iframe.contentDocument && iframe.contentWindow) { + // Sometimes, iframes don't have any document, like the + // one that are over deeply nested (bug 285395) + windows.push(iframe.contentWindow); + } + } + } + return actors; + }), + + /** + * Check if we should be showing this stylesheet. + * + * @param {Document} doc + * Document for which we're checking + * @param {DOMCSSStyleSheet} sheet + * Stylesheet we're interested in + * + * @return boolean + * Whether the stylesheet should be listed. + */ + _shouldListSheet: function (doc, sheet) { + // Special case about:PreferenceStyleSheet, as it is generated on the + // fly and the URI is not registered with the about: handler. + // https://bugzilla.mozilla.org/show_bug.cgi?id=935803#c37 + if (sheet.href && sheet.href.toLowerCase() == "about:preferencestylesheet") { + return false; + } + + return true; + }, + + /** + * Add all the stylesheets for the document in this window to the map and + * create an actor for each one if not already created. + * + * @param {Window} win + * Window for which to add stylesheets + * + * @return {Promise} + * Promise that resolves to an array of StyleSheetActors + */ + _addStyleSheets: function (win) + { + return Task.spawn(function* () { + let doc = win.document; + // readyState can be uninitialized if an iframe has just been created but + // it has not started to load yet. + if (doc.readyState === "loading" || doc.readyState === "uninitialized") { + // Wait for the document to load first. + yield listenOnce(win, "DOMContentLoaded", true); + + // Make sure we have the actual document for this window. If the + // readyState was initially uninitialized, the initial dummy document + // was replaced with the actual document (bug 1171919). + doc = win.document; + } + + let isChrome = Services.scriptSecurityManager.isSystemPrincipal(doc.nodePrincipal); + let styleSheets = isChrome ? DOMUtils.getAllStyleSheets(doc) : doc.styleSheets; + let actors = []; + for (let i = 0; i < styleSheets.length; i++) { + let sheet = styleSheets[i]; + if (!this._shouldListSheet(doc, sheet)) { + continue; + } + + let actor = this.parentActor.createStyleSheetActor(sheet); + actors.push(actor); + + // Get all sheets, including imported ones + let imports = yield this._getImported(doc, actor); + actors = actors.concat(imports); + } + return actors; + }.bind(this)); + }, + + /** + * Get all the stylesheets @imported from a stylesheet. + * + * @param {Document} doc + * The document including the stylesheet + * @param {DOMStyleSheet} styleSheet + * Style sheet to search + * @return {Promise} + * A promise that resolves with an array of StyleSheetActors + */ + _getImported: function (doc, styleSheet) { + return Task.spawn(function* () { + let rules = yield styleSheet.getCSSRules(); + let imported = []; + + for (let i = 0; i < rules.length; i++) { + let rule = rules[i]; + if (rule.type == Ci.nsIDOMCSSRule.IMPORT_RULE) { + // Associated styleSheet may be null if it has already been seen due + // to duplicate @imports for the same URL. + if (!rule.styleSheet || !this._shouldListSheet(doc, rule.styleSheet)) { + continue; + } + let actor = this.parentActor.createStyleSheetActor(rule.styleSheet); + imported.push(actor); + + // recurse imports in this stylesheet as well + let children = yield this._getImported(doc, actor); + imported = imported.concat(children); + } + else if (rule.type != Ci.nsIDOMCSSRule.CHARSET_RULE) { + // @import rules must precede all others except @charset + break; + } + } + + return imported; + }.bind(this)); + }, + + + /** + * Create a new style sheet in the document with the given text. + * Return an actor for it. + * + * @param {object} request + * Debugging protocol request object, with 'text property' + * @return {object} + * Object with 'styelSheet' property for form on new actor. + */ + addStyleSheet: function (text) { + let parent = this.document.documentElement; + let style = this.document.createElementNS("http://www.w3.org/1999/xhtml", "style"); + style.setAttribute("type", "text/css"); + + if (text) { + style.appendChild(this.document.createTextNode(text)); + } + parent.appendChild(style); + + let actor = this.parentActor.createStyleSheetActor(style.sheet); + return actor; + } +}); + +exports.StyleSheetsActor = StyleSheetsActor; + +/** + * Normalize multiple relative paths towards the base paths on the right. + */ +function normalize(...aURLs) { + let base = Services.io.newURI(aURLs.pop(), null, null); + let url; + while ((url = aURLs.pop())) { + base = Services.io.newURI(url, null, base); + } + return base.spec; +} + +function dirname(aPath) { + return Services.io.newURI( + ".", null, Services.io.newURI(aPath, null, null)).spec; +} diff --git a/devtools/server/actors/timeline.js b/devtools/server/actors/timeline.js new file mode 100644 index 0000000000..2214541445 --- /dev/null +++ b/devtools/server/actors/timeline.js @@ -0,0 +1,98 @@ +/* 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"; + +/** + * Many Gecko operations (painting, reflows, restyle, ...) can be tracked + * in real time. A marker is a representation of one operation. A marker + * has a name, start and end timestamps. Markers are stored in docShells. + * + * This actor exposes this tracking mechanism to the devtools protocol. + * Most of the logic is handled in devtools/server/performance/timeline.js + * This just wraps that module up and exposes it via RDP. + * + * For more documentation: + * @see devtools/server/performance/timeline.js + */ + +const protocol = require("devtools/shared/protocol"); +const { Option, RetVal } = protocol; +const { actorBridgeWithSpec } = require("devtools/server/actors/common"); +const { Timeline } = require("devtools/server/performance/timeline"); +const { timelineSpec } = require("devtools/shared/specs/timeline"); +const events = require("sdk/event/core"); + +/** + * The timeline actor pops and forwards timeline markers registered in docshells. + */ +var TimelineActor = exports.TimelineActor = protocol.ActorClassWithSpec(timelineSpec, { + /** + * Initializes this actor with the provided connection and tab actor. + */ + initialize: function (conn, tabActor) { + protocol.Actor.prototype.initialize.call(this, conn); + this.tabActor = tabActor; + this.bridge = new Timeline(tabActor); + + this._onTimelineEvent = this._onTimelineEvent.bind(this); + events.on(this.bridge, "*", this._onTimelineEvent); + }, + + /** + * The timeline actor is the first (and last) in its hierarchy to use + * protocol.js so it doesn't have a parent protocol actor that takes care of + * its lifetime. So it needs a disconnect method to cleanup. + */ + disconnect: function () { + this.destroy(); + }, + + /** + * Destroys this actor, stopping recording first. + */ + destroy: function () { + events.off(this.bridge, "*", this._onTimelineEvent); + this.bridge.destroy(); + this.bridge = null; + this.tabActor = null; + protocol.Actor.prototype.destroy.call(this); + }, + + /** + * Propagate events from the Timeline module over RDP if the event is defined + * here. + */ + _onTimelineEvent: function (eventName, ...args) { + events.emit(this, eventName, ...args); + }, + + isRecording: actorBridgeWithSpec("isRecording", { + request: {}, + response: { + value: RetVal("boolean") + } + }), + + start: actorBridgeWithSpec("start", { + request: { + withMarkers: Option(0, "boolean"), + withTicks: Option(0, "boolean"), + withMemory: Option(0, "boolean"), + withFrames: Option(0, "boolean"), + withGCEvents: Option(0, "boolean"), + withDocLoadingEvents: Option(0, "boolean") + }, + response: { + value: RetVal("number") + } + }), + + stop: actorBridgeWithSpec("stop", { + response: { + // Set as possibly nullable due to the end time possibly being + // undefined during destruction + value: RetVal("nullable:number") + } + }), +}); diff --git a/devtools/server/actors/utils/TabSources.js b/devtools/server/actors/utils/TabSources.js new file mode 100644 index 0000000000..56e862939f --- /dev/null +++ b/devtools/server/actors/utils/TabSources.js @@ -0,0 +1,833 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { Ci, Cu } = require("chrome"); +const DevToolsUtils = require("devtools/shared/DevToolsUtils"); +const { assert, fetch } = DevToolsUtils; +const EventEmitter = require("devtools/shared/event-emitter"); +const { OriginalLocation, GeneratedLocation } = require("devtools/server/actors/common"); +const { resolve } = require("promise"); +const { joinURI } = require("devtools/shared/path"); + +loader.lazyRequireGetter(this, "SourceActor", "devtools/server/actors/source", true); +loader.lazyRequireGetter(this, "isEvalSource", "devtools/server/actors/source", true); +loader.lazyRequireGetter(this, "SourceMapConsumer", "source-map", true); +loader.lazyRequireGetter(this, "SourceMapGenerator", "source-map", true); + +/** + * Manages the sources for a thread. Handles source maps, locations in the + * sources, etc for ThreadActors. + */ +function TabSources(threadActor, allowSourceFn = () => true) { + EventEmitter.decorate(this); + + this._thread = threadActor; + this._useSourceMaps = true; + this._autoBlackBox = true; + this._anonSourceMapId = 1; + this.allowSource = source => { + return !isHiddenSource(source) && allowSourceFn(source); + }; + + this.blackBoxedSources = new Set(); + this.prettyPrintedSources = new Map(); + this.neverAutoBlackBoxSources = new Set(); + + // generated Debugger.Source -> promise of SourceMapConsumer + this._sourceMaps = new Map(); + // sourceMapURL -> promise of SourceMapConsumer + this._sourceMapCache = Object.create(null); + // Debugger.Source -> SourceActor + this._sourceActors = new Map(); + // url -> SourceActor + this._sourceMappedSourceActors = Object.create(null); +} + +/** + * 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$/; + +TabSources.prototype = { + /** + * Update preferences and clear out existing sources + */ + setOptions: function (options) { + let shouldReset = false; + + if ("useSourceMaps" in options) { + shouldReset = true; + this._useSourceMaps = options.useSourceMaps; + } + + if ("autoBlackBox" in options) { + shouldReset = true; + this._autoBlackBox = options.autoBlackBox; + } + + if (shouldReset) { + this.reset(); + } + }, + + /** + * Clear existing sources so they are recreated on the next access. + * + * @param Object opts + * Specify { sourceMaps: true } if you also want to clear + * the source map cache (usually done on reload). + */ + reset: function (opts = {}) { + this._sourceActors = new Map(); + this._sourceMaps = new Map(); + this._sourceMappedSourceActors = Object.create(null); + + if (opts.sourceMaps) { + this._sourceMapCache = Object.create(null); + } + }, + + /** + * Return the source actor representing the `source` (or + * `originalUrl`), creating one if none exists already. May return + * null if the source is disallowed. + * + * @param Debugger.Source source + * The source to make an actor for + * @param String originalUrl + * The original source URL of a sourcemapped source + * @param optional Debguger.Source generatedSource + * The generated source that introduced this source via source map, + * if any. + * @param optional String contentType + * The content type of the source, if immediately available. + * @returns a SourceActor representing the source or null. + */ + source: function ({ source, originalUrl, generatedSource, + isInlineSource, contentType }) { + assert(source || (originalUrl && generatedSource), + "TabSources.prototype.source needs an originalUrl or a source"); + + if (source) { + // If a source is passed, we are creating an actor for a real + // source, which may or may not be sourcemapped. + + if (!this.allowSource(source)) { + return null; + } + + // It's a hack, but inline HTML scripts each have real sources, + // but we want to represent all of them as one source as the + // HTML page. The actor representing this fake HTML source is + // stored in this array, which always has a URL, so check it + // first. + if (source.url in this._sourceMappedSourceActors) { + return this._sourceMappedSourceActors[source.url]; + } + + if (isInlineSource) { + // If it's an inline source, the fake HTML source hasn't been + // created yet (would have returned above), so flip this source + // into a sourcemapped state by giving it an `originalUrl` which + // is the HTML url. + originalUrl = source.url; + source = null; + } + else if (this._sourceActors.has(source)) { + return this._sourceActors.get(source); + } + } + else if (originalUrl) { + // Not all "original" scripts are distinctly separate from the + // generated script. Pretty-printed sources have a sourcemap for + // themselves, so we need to make sure there a real source + // doesn't already exist with this URL. + for (let [source, actor] of this._sourceActors) { + if (source.url === originalUrl) { + return actor; + } + } + + if (originalUrl in this._sourceMappedSourceActors) { + return this._sourceMappedSourceActors[originalUrl]; + } + } + + let actor = new SourceActor({ + thread: this._thread, + source: source, + originalUrl: originalUrl, + generatedSource: generatedSource, + isInlineSource: isInlineSource, + contentType: contentType + }); + + let sourceActorStore = this._thread.sourceActorStore; + var id = sourceActorStore.getReusableActorId(source, originalUrl); + if (id) { + actor.actorID = id; + } + + this._thread.threadLifetimePool.addActor(actor); + sourceActorStore.setReusableActorId(source, originalUrl, actor.actorID); + + if (this._autoBlackBox && + !this.neverAutoBlackBoxSources.has(actor.url) && + this._isMinifiedURL(actor.url)) { + + this.blackBox(actor.url); + this.neverAutoBlackBoxSources.add(actor.url); + } + + if (source) { + this._sourceActors.set(source, actor); + } + else { + this._sourceMappedSourceActors[originalUrl] = actor; + } + + this._emitNewSource(actor); + return actor; + }, + + _emitNewSource: function (actor) { + if (!actor.source) { + // Always notify if we don't have a source because that means + // it's something that has been sourcemapped, or it represents + // the HTML file that contains inline sources. + this.emit("newSource", actor); + } + else { + // If sourcemapping is enabled and a source has sourcemaps, we + // create `SourceActor` instances for both the original and + // generated sources. The source actors for the generated + // sources are only for internal use, however; breakpoints are + // managed by these internal actors. We only want to notify the + // user of the original sources though, so if the actor has a + // `Debugger.Source` instance and a valid source map (meaning + // it's a generated source), don't send the notification. + this.fetchSourceMap(actor.source).then(map => { + if (!map) { + this.emit("newSource", actor); + } + }); + } + }, + + getSourceActor: function (source) { + if (source.url in this._sourceMappedSourceActors) { + return this._sourceMappedSourceActors[source.url]; + } + + if (this._sourceActors.has(source)) { + return this._sourceActors.get(source); + } + + throw new Error("getSource: could not find source actor for " + + (source.url || "source")); + }, + + getSourceActorByURL: function (url) { + if (url) { + for (let [source, actor] of this._sourceActors) { + if (source.url === url) { + return actor; + } + } + + if (url in this._sourceMappedSourceActors) { + return this._sourceMappedSourceActors[url]; + } + } + + throw new Error("getSourceActorByURL: could not find source for " + url); + return null; + }, + + /** + * Returns true if the URL likely points to a minified resource, false + * otherwise. + * + * @param String aURL + * The URL to test. + * @returns Boolean + */ + _isMinifiedURL: function (aURL) { + if (!aURL) { + return false; + } + + try { + let url = new URL(aURL); + let 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(aURL); + } + }, + + /** + * Create a source actor representing this source. This ignores + * source mapping and always returns an actor representing this real + * source. Use `createSourceActors` if you want to respect source maps. + * + * @param Debugger.Source aSource + * The source instance to create an actor for. + * @returns SourceActor + */ + createNonSourceMappedActor: function (aSource) { + // Don't use getSourceURL because we don't want to consider the + // displayURL property if it's an eval source. We only want to + // consider real URLs, otherwise if there is a URL but it's + // invalid the code below will not set the content type, and we + // will later try to fetch the contents of the URL to figure out + // the content type, but it's a made up URL for eval sources. + let url = isEvalSource(aSource) ? null : aSource.url; + let spec = { source: aSource }; + + // XXX bug 915433: We can't rely on Debugger.Source.prototype.text + // if the source is an HTML-embedded <script> tag. Since we don't + // have an API implemented to detect whether this is the case, we + // need to be conservative and only treat valid js files as real + // sources. Otherwise, use the `originalUrl` property to treat it + // as an HTML source that manages multiple inline sources. + + // Assume the source is inline if the element that introduced it is not a + // script element, or does not have a src attribute. + let element = aSource.element ? aSource.element.unsafeDereference() : null; + if (element && (element.tagName !== "SCRIPT" || !element.hasAttribute("src"))) { + spec.isInlineSource = true; + } else if (aSource.introductionType === "wasm") { + // Wasm sources are not JavaScript. Give them their own content-type. + spec.contentType = "text/wasm"; + } else { + if (url) { + // There are a few special URLs that we know are JavaScript: + // inline `javascript:` and code coming from the console + if (url.indexOf("Scratchpad/") === 0 || + url.indexOf("javascript:") === 0 || + url === "debugger eval code") { + spec.contentType = "text/javascript"; + } else { + try { + let pathname = new URL(url).pathname; + let filename = pathname.slice(pathname.lastIndexOf("/") + 1); + let index = filename.lastIndexOf("."); + let extension = index >= 0 ? filename.slice(index + 1) : ""; + if (extension === "xml") { + // XUL inline scripts may not correctly have the + // `source.element` property, so do a blunt check here if + // it's an xml page. + spec.isInlineSource = true; + } + else if (extension === "js") { + spec.contentType = "text/javascript"; + } + } catch (e) { + // This only needs to be here because URL is not yet exposed to + // workers. (BUG 1258892) + const filename = url; + const index = filename.lastIndexOf("."); + const extension = index >= 0 ? filename.slice(index + 1) : ""; + if (extension === "js") { + spec.contentType = "text/javascript"; + } + } + } + } + else { + // Assume the content is javascript if there's no URL + spec.contentType = "text/javascript"; + } + } + + return this.source(spec); + }, + + /** + * This is an internal function that returns a promise of an array + * of source actors representing all the source mapped sources of + * `aSource`, or `null` if the source is not sourcemapped or + * sourcemapping is disabled. Users should call `createSourceActors` + * instead of this. + * + * @param Debugger.Source aSource + * The source instance to create actors for. + * @return Promise of an array of source actors + */ + _createSourceMappedActors: function (aSource) { + if (!this._useSourceMaps || !aSource.sourceMapURL) { + return resolve(null); + } + + return this.fetchSourceMap(aSource) + .then(map => { + if (map) { + return map.sources.map(s => { + return this.source({ originalUrl: s, generatedSource: aSource }); + }).filter(isNotNull); + } + return null; + }); + }, + + /** + * Creates the source actors representing the appropriate sources + * of `aSource`. If sourcemapped, returns actors for all of the original + * sources, otherwise returns a 1-element array with the actor for + * `aSource`. + * + * @param Debugger.Source aSource + * The source instance to create actors for. + * @param Promise of an array of source actors + */ + createSourceActors: function (aSource) { + return this._createSourceMappedActors(aSource).then(actors => { + let actor = this.createNonSourceMappedActor(aSource); + return (actors || [actor]).filter(isNotNull); + }); + }, + + /** + * Return a promise of a SourceMapConsumer for the source map for + * `aSource`; if we already have such a promise extant, return that. + * This will fetch the source map if we don't have a cached object + * and source maps are enabled (see `_fetchSourceMap`). + * + * @param Debugger.Source aSource + * The source instance to get sourcemaps for. + * @return Promise of a SourceMapConsumer + */ + fetchSourceMap: function (aSource) { + if (!this._useSourceMaps) { + return resolve(null); + } + else if (this._sourceMaps.has(aSource)) { + return this._sourceMaps.get(aSource); + } + else if (!aSource || !aSource.sourceMapURL) { + return resolve(null); + } + + let sourceMapURL = aSource.sourceMapURL; + if (aSource.url) { + sourceMapURL = joinURI(aSource.url, sourceMapURL); + } + let result = this._fetchSourceMap(sourceMapURL, aSource.url); + + // The promises in `_sourceMaps` must be the exact same instances + // as returned by `_fetchSourceMap` for `clearSourceMapCache` to + // work. + this._sourceMaps.set(aSource, result); + return result; + }, + + /** + * Return a promise of a SourceMapConsumer for the source map for + * `aSource`. The resolved result may be null if the source does not + * have a source map or source maps are disabled. + */ + getSourceMap: function (aSource) { + return resolve(this._sourceMaps.get(aSource)); + }, + + /** + * Set a SourceMapConsumer for the source map for + * |aSource|. + */ + setSourceMap: function (aSource, aMap) { + this._sourceMaps.set(aSource, resolve(aMap)); + }, + + /** + * Return a promise of a SourceMapConsumer for the source map located at + * |aAbsSourceMapURL|, which must be absolute. If there is already such a + * promise extant, return it. This will not fetch if source maps are + * disabled. + * + * @param string aAbsSourceMapURL + * The source map URL, in absolute form, not relative. + * @param string aScriptURL + * When the source map URL is a data URI, there is no sourceRoot on the + * source map, and the source map's sources are relative, we resolve + * them from aScriptURL. + */ + _fetchSourceMap: function (aAbsSourceMapURL, aSourceURL) { + assert(this._useSourceMaps, + "Cannot fetch sourcemaps if they are disabled"); + + if (this._sourceMapCache[aAbsSourceMapURL]) { + return this._sourceMapCache[aAbsSourceMapURL]; + } + + let fetching = fetch(aAbsSourceMapURL, { loadFromCache: false }) + .then(({ content }) => { + let map = new SourceMapConsumer(content); + this._setSourceMapRoot(map, aAbsSourceMapURL, aSourceURL); + return map; + }) + .then(null, error => { + if (!DevToolsUtils.reportingDisabled) { + DevToolsUtils.reportException("TabSources.prototype._fetchSourceMap", error); + } + return null; + }); + this._sourceMapCache[aAbsSourceMapURL] = fetching; + return fetching; + }, + + /** + * Sets the source map's sourceRoot to be relative to the source map url. + */ + _setSourceMapRoot: function (aSourceMap, aAbsSourceMapURL, aScriptURL) { + // No need to do this fiddling if we won't be fetching any sources over the + // wire. + if (aSourceMap.hasContentsOfAllSources()) { + return; + } + + const base = this._dirname( + aAbsSourceMapURL.indexOf("data:") === 0 + ? aScriptURL + : aAbsSourceMapURL); + aSourceMap.sourceRoot = aSourceMap.sourceRoot + ? joinURI(base, aSourceMap.sourceRoot) + : base; + }, + + _dirname: function (aPath) { + let url = new URL(aPath); + let href = url.href; + return href.slice(0, href.lastIndexOf("/")); + }, + + /** + * Clears the source map cache. Source maps are cached by URL so + * they can be reused across separate Debugger instances (once in + * this cache, they will never be reparsed again). They are + * also cached by Debugger.Source objects for usefulness. By default + * this just removes the Debugger.Source cache, but you can remove + * the lower-level URL cache with the `hard` option. + * + * @param aSourceMapURL string + * The source map URL to uncache + * @param opts object + * An object with the following properties: + * - hard: Also remove the lower-level URL cache, which will + * make us completely forget about the source map. + */ + clearSourceMapCache: function (aSourceMapURL, opts = { hard: false }) { + let oldSm = this._sourceMapCache[aSourceMapURL]; + + if (opts.hard) { + delete this._sourceMapCache[aSourceMapURL]; + } + + if (oldSm) { + // Clear out the current cache so all sources will get the new one + for (let [source, sm] of this._sourceMaps.entries()) { + if (sm === oldSm) { + this._sourceMaps.delete(source); + } + } + } + }, + + /* + * Forcefully change the source map of a source, changing the + * sourceMapURL and installing the source map in the cache. This is + * necessary to expose changes across Debugger instances + * (pretty-printing is the use case). Generate a random url if one + * isn't specified, allowing you to set "anonymous" source maps. + * + * @param aSource Debugger.Source + * The source to change the sourceMapURL property + * @param aUrl string + * The source map URL (optional) + * @param aMap SourceMapConsumer + * The source map instance + */ + setSourceMapHard: function (aSource, aUrl, aMap) { + let url = aUrl; + if (!url) { + // This is a littly hacky, but we want to forcefully set a + // sourcemap regardless of sourcemap settings. We want to + // literally change the sourceMapURL so that all debuggers will + // get this and pretty-printing will Just Work (Debugger.Source + // instances are per-debugger, so we can't key off that). To + // avoid tons of work serializing the sourcemap into a data url, + // just make a fake URL and stick the sourcemap there. + url = "internal://sourcemap" + (this._anonSourceMapId++) + "/"; + } + aSource.sourceMapURL = url; + + // Forcefully set the sourcemap cache. This will be used even if + // sourcemaps are disabled. + this._sourceMapCache[url] = resolve(aMap); + this.emit("updatedSource", this.getSourceActor(aSource)); + }, + + /** + * 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 aFrame + * The frame whose location we are getting. + * @returns Object + * Returns an object of the form { source, line, column } + */ + getFrameLocation: function (aFrame) { + if (!aFrame || !aFrame.script) { + return new GeneratedLocation(); + } + let {lineNumber, columnNumber} = + aFrame.script.getOffsetLocation(aFrame.offset); + return new GeneratedLocation( + this.createNonSourceMappedActor(aFrame.script.source), + lineNumber, + columnNumber + ); + }, + + /** + * Returns a promise of the location in the original source if the source is + * source mapped, otherwise a promise of the same location. This can + * be called with a source from *any* Debugger instance and we make + * sure to that it works properly, reusing source maps if already + * fetched. Use this from any actor that needs sourcemapping. + */ + getOriginalLocation: function (generatedLocation) { + let { + generatedSourceActor, + generatedLine, + generatedColumn + } = generatedLocation; + let source = generatedSourceActor.source; + let url = source ? source.url : generatedSourceActor._originalUrl; + + // In certain scenarios the source map may have not been fetched + // yet (or at least tied to this Debugger.Source instance), so use + // `fetchSourceMap` instead of `getSourceMap`. This allows this + // function to be called from anywere (across debuggers) and it + // should just automatically work. + return this.fetchSourceMap(source).then(map => { + if (map) { + let { + source: originalUrl, + line: originalLine, + column: originalColumn, + name: originalName + } = map.originalPositionFor({ + line: generatedLine, + column: generatedColumn == null ? Infinity : generatedColumn + }); + + // Since the `Debugger.Source` instance may come from a + // different `Debugger` instance (any actor can call this + // method), we can't rely on any of the source discovery + // setup (`_discoverSources`, etc) to have been run yet. So + // we have to assume that the actor may not already exist, + // and we might need to create it, so use `source` and give + // it the required parameters for a sourcemapped source. + return new OriginalLocation( + originalUrl ? this.source({ + originalUrl: originalUrl, + generatedSource: source + }) : null, + originalLine, + originalColumn, + originalName + ); + } + + // No source map + return OriginalLocation.fromGeneratedLocation(generatedLocation); + }); + }, + + getAllGeneratedLocations: function (originalLocation) { + let { + originalSourceActor, + originalLine, + originalColumn + } = originalLocation; + + let source = (originalSourceActor.source || + originalSourceActor.generatedSource); + + return this.fetchSourceMap(source).then((map) => { + if (map) { + map.computeColumnSpans(); + + return map.allGeneratedPositionsFor({ + source: originalSourceActor.url, + line: originalLine, + column: originalColumn + }).map(({ line, column, lastColumn }) => { + return new GeneratedLocation( + this.createNonSourceMappedActor(source), + line, + column, + lastColumn + ); + }); + } + + return [GeneratedLocation.fromOriginalLocation(originalLocation)]; + }); + }, + + + /** + * Returns a promise of the location in the generated source corresponding to + * the original source and line given. + * + * When we pass a script S representing generated code to `sourceMap`, + * above, that returns a promise P. The process of resolving P populates + * the tables this function uses; thus, it won't know that S's original + * source URLs map to S until P is resolved. + */ + getGeneratedLocation: function (originalLocation) { + let { originalSourceActor } = originalLocation; + + // Both original sources and normal sources could have sourcemaps, + // because normal sources can be pretty-printed which generates a + // sourcemap for itself. Check both of the source properties to make it work + // for both kinds of sources. + let source = originalSourceActor.source || originalSourceActor.generatedSource; + + // See comment about `fetchSourceMap` in `getOriginalLocation`. + return this.fetchSourceMap(source).then((map) => { + if (map) { + let { + originalLine, + originalColumn + } = originalLocation; + + let { + line: generatedLine, + column: generatedColumn + } = map.generatedPositionFor({ + source: originalSourceActor.url, + line: originalLine, + column: originalColumn == null ? 0 : originalColumn, + bias: SourceMapConsumer.LEAST_UPPER_BOUND + }); + + return new GeneratedLocation( + this.createNonSourceMappedActor(source), + generatedLine, + generatedColumn + ); + } + + return GeneratedLocation.fromOriginalLocation(originalLocation); + }); + }, + + /** + * Returns true if URL for the given source is black boxed. + * + * @param aURL String + * The URL of the source which we are checking whether it is black + * boxed or not. + */ + isBlackBoxed: function (aURL) { + return this.blackBoxedSources.has(aURL); + }, + + /** + * Add the given source URL to the set of sources that are black boxed. + * + * @param aURL String + * The URL of the source which we are black boxing. + */ + blackBox: function (aURL) { + this.blackBoxedSources.add(aURL); + }, + + /** + * Remove the given source URL to the set of sources that are black boxed. + * + * @param aURL String + * The URL of the source which we are no longer black boxing. + */ + unblackBox: function (aURL) { + this.blackBoxedSources.delete(aURL); + }, + + /** + * Returns true if the given URL is pretty printed. + * + * @param aURL String + * The URL of the source that might be pretty printed. + */ + isPrettyPrinted: function (aURL) { + return this.prettyPrintedSources.has(aURL); + }, + + /** + * Add the given URL to the set of sources that are pretty printed. + * + * @param aURL String + * The URL of the source to be pretty printed. + */ + prettyPrint: function (aURL, aIndent) { + this.prettyPrintedSources.set(aURL, aIndent); + }, + + /** + * Return the indent the given URL was pretty printed by. + */ + prettyPrintIndent: function (aURL) { + return this.prettyPrintedSources.get(aURL); + }, + + /** + * Remove the given URL from the set of sources that are pretty printed. + * + * @param aURL String + * The URL of the source that is no longer pretty printed. + */ + disablePrettyPrint: function (aURL) { + this.prettyPrintedSources.delete(aURL); + }, + + iter: function () { + let actors = Object.keys(this._sourceMappedSourceActors).map(k => { + return this._sourceMappedSourceActors[k]; + }); + for (let actor of this._sourceActors.values()) { + if (!this._sourceMaps.has(actor.source)) { + actors.push(actor); + } + } + return actors; + } +}; + +/* + * Checks if a source should never be displayed to the user because + * it's either internal or we don't support in the UI yet. + */ +function isHiddenSource(aSource) { + // Ignore the internal Function.prototype script + return aSource.text === "() {\n}"; +} + +/** + * Returns true if its argument is not null. + */ +function isNotNull(aThing) { + return aThing !== null; +} + +exports.TabSources = TabSources; +exports.isHiddenSource = isHiddenSource; diff --git a/devtools/server/actors/utils/actor-registry-utils.js b/devtools/server/actors/utils/actor-registry-utils.js new file mode 100644 index 0000000000..5866827e1e --- /dev/null +++ b/devtools/server/actors/utils/actor-registry-utils.js @@ -0,0 +1,78 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* 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 { Cu, CC, Ci, Cc } = require("chrome"); + +const { DebuggerServer } = require("devtools/server/main"); +const promise = require("promise"); + +/** + * Support for actor registration. Main used by ActorRegistryActor + * for dynamic registration of new actors. + * + * @param sourceText {String} Source of the actor implementation + * @param fileName {String} URL of the actor module (for proper stack traces) + * @param options {Object} Configuration object + */ +exports.registerActor = function (sourceText, fileName, options) { + // Register in the current process + exports.registerActorInCurrentProcess(sourceText, fileName, options); + // Register in any child processes + return DebuggerServer.setupInChild({ + module: "devtools/server/actors/utils/actor-registry-utils", + setupChild: "registerActorInCurrentProcess", + args: [sourceText, fileName, options], + waitForEval: true + }); +}; + +exports.registerActorInCurrentProcess = function (sourceText, fileName, options) { + const principal = CC("@mozilla.org/systemprincipal;1", "nsIPrincipal")(); + const sandbox = Cu.Sandbox(principal); + sandbox.exports = {}; + sandbox.require = require; + + Cu.evalInSandbox(sourceText, sandbox, "1.8", fileName, 1); + + let { prefix, constructor, type } = options; + + if (type.global && !DebuggerServer.globalActorFactories.hasOwnProperty(prefix)) { + DebuggerServer.addGlobalActor({ + constructorName: constructor, + constructorFun: sandbox[constructor] + }, prefix); + } + + if (type.tab && !DebuggerServer.tabActorFactories.hasOwnProperty(prefix)) { + DebuggerServer.addTabActor({ + constructorName: constructor, + constructorFun: sandbox[constructor] + }, prefix); + } +}; + +exports.unregisterActor = function (options) { + // Unregister in the current process + exports.unregisterActorInCurrentProcess(options); + // Unregister in any child processes + DebuggerServer.setupInChild({ + module: "devtools/server/actors/utils/actor-registry-utils", + setupChild: "unregisterActorInCurrentProcess", + args: [options] + }); +}; + +exports.unregisterActorInCurrentProcess = function (options) { + if (options.tab) { + DebuggerServer.removeTabActor(options); + } + + if (options.global) { + DebuggerServer.removeGlobalActor(options); + } +}; diff --git a/devtools/server/actors/utils/audionodes.json b/devtools/server/actors/utils/audionodes.json new file mode 100644 index 0000000000..12cc6c34b0 --- /dev/null +++ b/devtools/server/actors/utils/audionodes.json @@ -0,0 +1,113 @@ +{ + "OscillatorNode": { + "source": true, + "properties": { + "type": {}, + "frequency": { + "param": true + }, + "detune": { + "param": true + } + } + }, + "GainNode": { + "properties": { "gain": { "param": true }} + }, + "DelayNode": { + "properties": { "delayTime": { "param": true }} + }, + "AudioBufferSourceNode": { + "source": true, + "properties": { + "buffer": { "Buffer": true }, + "playbackRate": { + "param": true + }, + "loop": {}, + "loopStart": {}, + "loopEnd": {} + } + }, + "ScriptProcessorNode": { + "properties": { "bufferSize": { "readonly": true }} + }, + "PannerNode": { + "properties": { + "panningModel": {}, + "distanceModel": {}, + "refDistance": {}, + "maxDistance": {}, + "rolloffFactor": {}, + "coneInnerAngle": {}, + "coneOuterAngle": {}, + "coneOuterGain": {} + } + }, + "ConvolverNode": { + "properties": { + "buffer": { "Buffer": true }, + "normalize": {} + } + }, + "DynamicsCompressorNode": { + "properties": { + "threshold": { "param": true }, + "knee": { "param": true }, + "ratio": { "param": true }, + "reduction": {}, + "attack": { "param": true }, + "release": { "param": true } + } + }, + "BiquadFilterNode": { + "properties": { + "type": {}, + "frequency": { "param": true }, + "Q": { "param": true }, + "detune": { "param": true }, + "gain": { "param": true } + } + }, + "WaveShaperNode": { + "properties": { + "curve": { "Float32Array": true }, + "oversample": {} + } + }, + "AnalyserNode": { + "properties": { + "fftSize": {}, + "minDecibels": {}, + "maxDecibels": {}, + "smoothingTimeConstant": {}, + "frequencyBinCount": { "readonly": true } + } + }, + "AudioDestinationNode": { + "unbypassable": true + }, + "ChannelSplitterNode": { + "unbypassable": true + }, + "ChannelMergerNode": { + "unbypassable": true + }, + "MediaElementAudioSourceNode": { + "source": true + }, + "MediaStreamAudioSourceNode": { + "source": true + }, + "MediaStreamAudioDestinationNode": { + "unbypassable": true, + "properties": { + "stream": { "MediaStream": true } + } + }, + "StereoPannerNode": { + "properties": { + "pan": { "param": true } + } + } +} diff --git a/devtools/server/actors/utils/automation-timeline.js b/devtools/server/actors/utils/automation-timeline.js new file mode 100644 index 0000000000..a086d90bee --- /dev/null +++ b/devtools/server/actors/utils/automation-timeline.js @@ -0,0 +1,373 @@ +/** + * web-audio-automation-timeline - 1.0.3 + * https://github.com/jsantell/web-audio-automation-timeline + * MIT License, copyright (c) 2014 Jordan Santell + */ +!function(e){if("object"==typeof exports&&"undefined"!=typeof module)module.exports=e();else if("function"==typeof define&&define.amd)define([],e);else{var f;"undefined"!=typeof window?f=window:"undefined"!=typeof global?f=global:"undefined"!=typeof self&&(f=self),f.Timeline=e()}}(function(){var define,module,exports;return (function e(t,n,r){function s(o,u){if(!n[o]){if(!t[o]){var a=typeof require=="function"&&require;if(!u&&a)return a(o,!0);if(i)return i(o,!0);var f=new Error("Cannot find module '"+o+"'");throw f.code="MODULE_NOT_FOUND",f}var l=n[o]={exports:{}};t[o][0].call(l.exports,function(e){var n=t[o][1][e];return s(n?n:e)},l,l.exports,e,t,n,r)}return n[o].exports}var i=typeof require=="function"&&require;for(var o=0;o<r.length;o++)s(r[o]);return s})({1:[function(require,module,exports){ +module.exports = require("./lib/timeline").Timeline; + +},{"./lib/timeline":4}],2:[function(require,module,exports){ +var F = require("./formulas"); + +function TimelineEvent (eventName, value, time, timeConstant, duration) { + this.type = eventName; + this.value = value; + this.time = time; + this.constant = timeConstant || 0; + this.duration = duration || 0; +} +exports.TimelineEvent = TimelineEvent; + + +TimelineEvent.prototype.exponentialApproach = function (lastValue, time) { + return F.exponentialApproach(this.time, lastValue, this.value, this.constant, time); +} + +TimelineEvent.prototype.extractValueFromCurve = function (time) { + return F.extractValueFromCurve(this.time, this.value, this.value.length, this.duration, time); +} + +TimelineEvent.prototype.linearInterpolate = function (next, time) { + return F.linearInterpolate(this.time, this.value, next.time, next.value, time); +} + +TimelineEvent.prototype.exponentialInterpolate = function (next, time) { + return F.exponentialInterpolate(this.time, this.value, next.time, next.value, time); +} + +},{"./formulas":3}],3:[function(require,module,exports){ +var EPSILON = 0.0000000001; + +exports.linearInterpolate = function (t0, v0, t1, v1, t) { + return v0 + (v1 - v0) * ((t - t0) / (t1 - t0)); +}; + +exports.exponentialInterpolate = function (t0, v0, t1, v1, t) { + return v0 * Math.pow(v1 / v0, (t - t0) / (t1 - t0)); +}; + +exports.extractValueFromCurve = function (start, curve, curveLength, duration, t) { + var ratio; + + // If time is after duration, return the last curve value, + // or if ratio is >= 1 + if (t >= start + duration || (ratio = Math.max((t - start) / duration, 0)) >= 1) { + return curve[curveLength - 1]; + } + + return curve[~~(curveLength * ratio)]; +}; + +exports.exponentialApproach = function (t0, v0, v1, timeConstant, t) { + return v1 + (v0 - v1) * Math.exp(-(t - t0) / timeConstant); +}; + +// Since we are going to accumulate error by adding 0.01 multiple times +// in a loop, we want to fuzz the equality check in `getValueAtTime` +exports.fuzzyEqual = function (lhs, rhs) { + return Math.abs(lhs - rhs) < EPSILON; +}; + +exports.EPSILON = EPSILON; + +},{}],4:[function(require,module,exports){ +var TimelineEvent = require("./event").TimelineEvent; +var F = require("./formulas"); + +exports.Timeline = Timeline; + +function Timeline (defaultValue) { + this.events = []; + + this._value = defaultValue || 0; +} + +Timeline.prototype.getEventCount = function () { + return this.events.length; +}; + +Timeline.prototype.value = function () { + return this._value; +}; + +Timeline.prototype.setValue = function (value) { + if (this.events.length === 0) { + this._value = value; + } +}; + +Timeline.prototype.getValue = function () { + if (this.events.length) { + throw new Error("Can only call `getValue` when there are 0 events."); + } + + return this._value; +}; + +Timeline.prototype.getValueAtTime = function (time) { + return this._getValueAtTimeHelper(time); +}; + +Timeline.prototype._getValueAtTimeHelper = function (time) { + var bailOut = false; + var previous = null; + var next = null; + var lastComputedValue = null; // Used for `setTargetAtTime` nodes + var events = this.events; + var e; + + for (var i = 0; !bailOut && i < events.length; i++) { + if (F.fuzzyEqual(time, events[i].time)) { + // Find the last event with the same time as `time` + do { + ++i; + } while (i < events.length && F.fuzzyEqual(time, events[i].time)); + + e = events[i - 1]; + + // `setTargetAtTime` can be handled no matter what their next event is (if they have one) + if (e.type === "setTargetAtTime") { + lastComputedValue = this._lastComputedValue(e); + return e.exponentialApproach(lastComputedValue, time); + } + + // `setValueCurveAtTime` events can be handled no matter what their next event node is + // (if they have one) + if (e.type === "setValueCurveAtTime") { + return e.extractValueFromCurve(time); + } + + // For other event types + return e.value; + } + previous = next; + next = events[i]; + + if (time < events[i].time) { + bailOut = true; + } + } + + // Handle the case where the time is past all of the events + if (!bailOut) { + previous = next; + next = null; + } + + // Just return the default value if we did not find anything + if (!previous && !next) { + return this._value; + } + + // If the requested time is before all of the existing events + if (!previous) { + return this._value; + } + + // `setTargetAtTime` can be handled no matter what their next event is (if they have one) + if (previous.type === "setTargetAtTime") { + lastComputedValue = this._lastComputedValue(previous); + return previous.exponentialApproach(lastComputedValue, time); + } + + // `setValueCurveAtTime` events can be handled no matter what their next event node is + // (if they have one) + if (previous.type === "setValueCurveAtTime") { + return previous.extractValueFromCurve(time); + } + + if (!next) { + if (~["setValueAtTime", "linearRampToValueAtTime", "exponentialRampToValueAtTime"].indexOf(previous.type)) { + return previous.value; + } + if (previous.type === "setValueCurveAtTime") { + return previous.extractValueFromCurve(time); + } + if (previous.type === "setTargetAtTime") { + throw new Error("unreached"); + } + throw new Error("unreached"); + } + + // Finally handle the case where we have both a previous and a next event + // First handle the case where our range ends up in a ramp event + if (next.type === "linearRampToValueAtTime") { + return previous.linearInterpolate(next, time); + } else if (next.type === "exponentialRampToValueAtTime") { + return previous.exponentialInterpolate(next, time); + } + + // Now handle all other cases + if (~["setValueAtTime", "linearRampToValueAtTime", "exponentialRampToValueAtTime"].indexOf(previous.type)) { + // If the next event type is neither linear or exponential ramp, + // the value is constant. + return previous.value; + } + if (previous.type === "setValueCurveAtTime") { + return previous.extractValueFromCurve(time); + } + if (previous.type === "setTargetAtTime") { + throw new Error("unreached"); + } + throw new Error("unreached"); +}; + +Timeline.prototype._insertEvent = function (ev) { + var events = this.events; + + if (ev.type === "setValueCurveAtTime") { + if (!ev.value || !ev.value.length) { + throw new Error("NS_ERROR_DOM_SYNTAX_ERR"); + } + } + + if (ev.type === "setTargetAtTime") { + if (F.fuzzyEqual(ev.constant, 0)) { + throw new Error("NS_ERROR_DOM_SYNTAX_ERR"); + } + } + + // Make sure that non-curve events don't fall within the duration of a + // curve event. + for (var i = 0; i < events.length; i++) { + if (events[i].type === "setValueCurveAtTime" && + events[i].time <= ev.time && + (events[i].time + events[i].duration) >= ev.time) { + throw new Error("NS_ERROR_DOM_SYNTAX_ERR"); + } + } + + // Make sure that curve events don't fall in a range which includes other + // events. + if (ev.type === "setValueCurveAtTime") { + for (var i = 0; i < events.length; i++) { + if (events[i].time > ev.time && + events[i].time < (ev.time + ev.duration)) { + throw new Error("NS_ERROR_DOM_SYNTAX_ERR"); + } + } + } + + // Make sure that invalid values are not used for exponential curves + if (ev.type === "exponentialRampToValueAtTime") { + if (ev.value <= 0) throw new Error("NS_ERROR_DOM_SYNTAX_ERR"); + var prev = this._getPreviousEvent(ev.time); + if (prev) { + if (prev.value <= 0) { + throw new Error("NS_ERROR_DOM_SYNTAX_ERR"); + } + } else { + if (this._value <= 0) { + throw new Error("NS_ERROR_DOM_SYNTAX_ERR"); + } + } + } + + for (var i = 0; i < events.length; i++) { + if (ev.time === events[i].time) { + if (ev.type === events[i].type) { + // If times and types are equal, replace the event; + events[i] = ev; + } else { + // Otherwise, place the element after the last event of another type + do { i++; } + while (i < events.length && ev.type !== events[i].type && ev.time === events[i].time); + events.splice(i, 0, ev); + } + return; + } + // Otherwise, place the event right after the latest existing event + if (ev.time < events[i].time) { + events.splice(i, 0, ev); + return; + } + } + + // If we couldn't find a place for the event, just append it to the list + this.events.push(ev); +}; + +Timeline.prototype._getPreviousEvent = function (time) { + var previous = null, next = null; + var bailOut = false; + var events = this.events; + + for (var i = 0; !bailOut && i < events.length; i++) { + if (time === events[i]) { + do { ++i; } + while (i < events.length && time === events[i].time); + return events[i - 1]; + } + previous = next; + next = events[i]; + if (time < events[i].time) { + bailOut = true; + } + } + + // Handle the case where the time is past all the events + if (!bailOut) { + previous = next; + } + + return previous; +}; + +/** + * Calculates the previous value of the timeline, used for + * `setTargetAtTime` nodes. Takes an event, and returns + * the previous computed value for any sample taken during that + * exponential approach node. + */ +Timeline.prototype._lastComputedValue = function (event) { + // If equal times, return the value for the previous event, before + // the `setTargetAtTime` node. + var lastEvent = this._getPreviousEvent(event.time - F.EPSILON); + + // If no event before the setTargetAtTime event, then return the + // intrinsic value. + if (!lastEvent) { + return this._value; + } + // Otherwise, return the value for the previous event, which should + // always be the last computed value (? I think?) + else { + return lastEvent.value; + } +}; + +Timeline.prototype.setValueAtTime = function (value, startTime) { + this._insertEvent(new TimelineEvent("setValueAtTime", value, startTime)); +}; + +Timeline.prototype.linearRampToValueAtTime = function (value, endTime) { + this._insertEvent(new TimelineEvent("linearRampToValueAtTime", value, endTime)); +}; + +Timeline.prototype.exponentialRampToValueAtTime = function (value, endTime) { + this._insertEvent(new TimelineEvent("exponentialRampToValueAtTime", value, endTime)); +}; + +Timeline.prototype.setTargetAtTime = function (value, startTime, timeConstant) { + this._insertEvent(new TimelineEvent("setTargetAtTime", value, startTime, timeConstant)); +}; + +Timeline.prototype.setValueCurveAtTime = function (value, startTime, duration) { + this._insertEvent(new TimelineEvent("setValueCurveAtTime", value, startTime, null, duration)); +}; + +Timeline.prototype.cancelScheduledValues = function (time) { + for (var i = 0; i < this.events.length; i++) { + if (this.events[i].time >= time) { + this.events = this.events.slice(0, i); + break; + } + } +}; + +Timeline.prototype.cancelAllEvents = function () { + this.events.length = 0; +}; + +},{"./event":2,"./formulas":3}]},{},[1])(1) +});
\ No newline at end of file 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..0b260117d7 --- /dev/null +++ b/devtools/server/actors/utils/css-grid-utils.js @@ -0,0 +1,61 @@ +/* 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 { Cu } = require("chrome"); + +/** + * 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); +} + +/** + * Returns a string representation of the CSS Grid data as returned by + * node.getGridFragments. This is useful to compare grid state at each update and redraw + * the highlighter if needed. It also seralizes the grid fragment data so it can be used + * by protocol.js. + * + * @param {Object} fragments + * Grid fragment object. + * @return {String} representation of the CSS grid fragment data. + */ +function stringifyGridFragments(fragments) { + return JSON.stringify(getStringifiableFragments(fragments)); +} + +function getStringifiableFragment(fragment) { + return { + cols: getStringifiableDimension(fragment.cols), + rows: getStringifiableDimension(fragment.rows) + }; +} + +function getStringifiableDimension(dimension) { + return { + lines: [...dimension.lines].map(getStringifiableLine), + tracks: [...dimension.tracks].map(getStringifiableTrack), + }; +} + +function getStringifiableLine({ breadth, number, start, names }) { + return { breadth, number, start, names }; +} + +function getStringifiableTrack({ breadth, start, state, type }) { + return { breadth, start, state, type }; +} + +exports.getStringifiableFragments = getStringifiableFragments; +exports.stringifyGridFragments = stringifyGridFragments; diff --git a/devtools/server/actors/utils/make-debugger.js b/devtools/server/actors/utils/make-debugger.js new file mode 100644 index 0000000000..9bd43e5674 --- /dev/null +++ b/devtools/server/actors/utils/make-debugger.js @@ -0,0 +1,101 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2; js-indent-level: 2 -*- */ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* 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("devtools/shared/event-emitter"); +const Debugger = require("Debugger"); + +const { reportException } = require("devtools/shared/DevToolsUtils"); + +/** + * 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 may be wrapped in a |Debugger.Object|, or + * unwrapped. + * + * @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. + * + * Events emitted by the returned |Debugger| instance: + * + * - "newGlobal": Emitted when a new global has been added as a + * debuggee. Passes the |Debugger.Object| wrapping the new + * debuggee global to listeners. + * + * 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); + + dbg.allowUnobservedAsmJS = true; + dbg.uncaughtExceptionHook = reportDebuggerHookException; + + dbg.onNewGlobalObject = function (global) { + if (shouldAddNewGlobalAsDebuggee(global)) { + safeAddDebuggee(this, global); + } + }; + + dbg.addDebuggees = function () { + for (let global of findDebuggees(this)) { + safeAddDebuggee(this, global); + } + }; + + return dbg; +}; + +const reportDebuggerHookException = e => reportException("Debugger Hook", e); + +/** + * Add |global| as a debuggee to |dbg|, handling error cases. + */ +function safeAddDebuggee(dbg, global) { + try { + let wrappedGlobal = dbg.addDebuggee(global); + if (wrappedGlobal) { + dbg.emit("newGlobal", wrappedGlobal); + } + } catch (e) { + // Ignoring attempt to add the debugger's compartment as a debuggee. + } +} diff --git a/devtools/server/actors/utils/map-uri-to-addon-id.js b/devtools/server/actors/utils/map-uri-to-addon-id.js new file mode 100644 index 0000000000..6f3316b142 --- /dev/null +++ b/devtools/server/actors/utils/map-uri-to-addon-id.js @@ -0,0 +1,44 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2; js-indent-level: 2 -*- */ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* 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("devtools/shared/DevToolsUtils"); +const Services = require("Services"); + +loader.lazyServiceGetter(this, "AddonPathService", + "@mozilla.org/addon-path-service;1", + "amIAddonPathService"); + +const B2G_ID = "{3c2e2abc-06d4-11e1-ac3b-374f68613e61}"; +const GRAPHENE_ID = "{d1bfe7d9-c01e-4237-998b-7b5f960a4314}"; + +/** + * This is a wrapper around amIAddonPathService.mapURIToAddonID which always returns + * false on B2G and graphene to avoid loading the add-on manager there and + * reports any exceptions rather than throwing so that the caller doesn't have + * to worry about them. + */ +if (!Services.appinfo + || Services.appinfo.processType == Services.appinfo.PROCESS_TYPE_CONTENT + || Services.appinfo.ID === undefined /* XPCShell */ + || Services.appinfo.ID == B2G_ID + || Services.appinfo.ID == GRAPHENE_ID + || !AddonPathService) { + module.exports = function mapURIToAddonId(uri) { + return false; + }; +} else { + module.exports = function mapURIToAddonId(uri) { + try { + return AddonPathService.mapURIToAddonId(uri); + } + catch (e) { + DevToolsUtils.reportException("mapURIToAddonId", e); + return false; + } + }; +} diff --git a/devtools/server/actors/utils/moz.build b/devtools/server/actors/utils/moz.build new file mode 100644 index 0000000000..0dcf40faf6 --- /dev/null +++ b/devtools/server/actors/utils/moz.build @@ -0,0 +1,19 @@ +# -*- 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( + 'actor-registry-utils.js', + 'audionodes.json', + 'automation-timeline.js', + 'css-grid-utils.js', + 'make-debugger.js', + 'map-uri-to-addon-id.js', + 'stack.js', + 'TabSources.js', + 'walker-search.js', + 'webconsole-utils.js', + 'webconsole-worker-utils.js', +) diff --git a/devtools/server/actors/utils/stack.js b/devtools/server/actors/utils/stack.js new file mode 100644 index 0000000000..a6a3d1137a --- /dev/null +++ b/devtools/server/actors/utils/stack.js @@ -0,0 +1,185 @@ +/* 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 {Class} = require("sdk/core/heritage"); + +/** + * 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. + */ +var StackFrameCache = Class({ + /** + * Initialize this object. + */ + initialize: function () { + this._framesToIndices = null; + this._framesToForms = null; + this._lastEventSize = 0; + }, + + /** + * Prepare to accept frames. + */ + initFrames: function () { + 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: function () { + 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: function (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: function (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 (let [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: function () { + const size = this._framesToForms.size; + if (!size || size <= this._lastEventSize) { + return null; + } + + let packet = Array(size - this._lastEventSize).fill(null); + for (let [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: function (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: function (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/walker-search.js b/devtools/server/actors/utils/walker-search.js new file mode 100644 index 0000000000..0955a39197 --- /dev/null +++ b/devtools/server/actors/utils/walker-search.js @@ -0,0 +1,278 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +/** + * 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. + */ + +const {Ci, Cu} = require("chrome"); + +/** + * 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 + */ +function WalkerIndex(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); +} + +WalkerIndex.prototype = { + /** + * Destroy this instance, releasing all data and references + */ + destroy: function () { + this.walker.off("any-mutation", this.clearIndex); + }, + + clearIndex: function () { + 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: function (type, node, value) { + // Add an entry for this value if there isn't one + let entry = this._data.get(value); + if (!entry) { + this._data.set(value, []); + } + + // Add the type/node to the list + this._data.get(value).push({ + type: type, + node: node + }); + }, + + index: function () { + // Handle case where iterating nextNode() with the deepTreeWalker triggers + // a mutation (Bug 1222558) + this.currentlyIndexing = true; + + let documentWalker = this.walker.getDocumentWalker(this.doc); + while (documentWalker.nextNode()) { + let node = documentWalker.currentNode; + + if (node.nodeType === 1) { + // For each element node, we get the tagname and all attributes names + // and values + let localName = node.localName; + 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 (let {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; + +/** + * 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 + */ +function WalkerSearch(walker) { + this.walker = walker; + this.index = new WalkerIndex(this.walker); +} + +WalkerSearch.prototype = { + destroy: function () { + this.index.destroy(); + this.walker = null; + }, + + _addResult: function (node, type, results) { + if (!results.has(node)) { + results.set(node, []); + } + + let matches = results.get(node); + + // Do not add if the exact same result is already in the list + let isKnown = false; + for (let match of matches) { + if (match.type === type) { + isKnown = true; + break; + } + } + + if (!isKnown) { + matches.push({type}); + } + }, + + _searchIndex: function (query, options, results) { + for (let [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.indexOf(entry.type) !== -1; + }).forEach(({node, type}) => { + this._addResult(node, type, results); + }); + } + }, + + _searchSelectors: function (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 + let isSelector = query && query.match(/[ >~.#\[\]]/); + if (options.types.indexOf("selector") === -1 || !isSelector) { + return; + } + + let nodes = this.walker._multiFrameQuerySelectorAll(query); + for (let node of nodes) { + this._addResult(node, "selector", 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 search type) + * - 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: function (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 + let results = new Map(); + + // Search through the indexed data + this._searchIndex(query, options, results); + + // Search with querySelectorAll + this._searchSelectors(query, options, results); + + // Concatenate all results into an Array to return + let resultList = []; + for (let [node, matches] of results) { + for (let {type} of matches) { + resultList.push({ + node: node, + type: type, + }); + + // For now, just do one result per node since the frontend + // doesn't have a way to highlight each result individually + // yet. + break; + } + } + + let documents = this.walker.tabActor.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) { + let indA = documents.indexOf(a.node.ownerDocument); + let 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().indexOf(query.toLowerCase()) !== -1; +}; + +WalkerSearch.ALL_RESULTS_TYPES = ["tag", "text", "attributeName", + "attributeValue", "selector"]; + +exports.WalkerSearch = WalkerSearch; diff --git a/devtools/server/actors/utils/webconsole-utils.js b/devtools/server/actors/utils/webconsole-utils.js new file mode 100644 index 0000000000..597f1ddb3e --- /dev/null +++ b/devtools/server/actors/utils/webconsole-utils.js @@ -0,0 +1,1063 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ft= javascript ts=2 et sw=2 tw=80: */ +/* 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 {Cc, Ci, Cu, components} = require("chrome"); +const {isWindowIncluded} = require("devtools/shared/layout/utils"); +const Services = require("Services"); +const {XPCOMUtils} = require("resource://gre/modules/XPCOMUtils.jsm"); + +// TODO: Bug 842672 - browser/ imports modules from toolkit/. +// Note that these are only used in WebConsoleCommands, see $0 and pprint(). +loader.lazyImporter(this, "VariablesView", "resource://devtools/client/shared/widgets/VariablesView.jsm"); + +XPCOMUtils.defineLazyServiceGetter(this, + "swm", + "@mozilla.org/serviceworkers/manager;1", + "nsIServiceWorkerManager"); + +const CONSOLE_WORKER_IDS = exports.CONSOLE_WORKER_IDS = [ + "SharedWorker", + "ServiceWorker", + "Worker" +]; + +var WebConsoleUtils = { + + /** + * Given a message, return one of CONSOLE_WORKER_IDS if it matches + * one of those. + * + * @return string + */ + getWorkerType: function (message) { + let id = message ? message.innerID : null; + return CONSOLE_WORKER_IDS[CONSOLE_WORKER_IDS.indexOf(id)] || null; + }, + + /** + * Clone an object. + * + * @param object object + * The object you want cloned. + * @param boolean recursive + * Tells if you want to dig deeper into the object, to clone + * recursively. + * @param function [filter] + * Optional, filter function, called for every property. Three + * arguments are passed: key, value and object. Return true if the + * property should be added to the cloned object. Return false to skip + * the property. + * @return object + * The cloned object. + */ + cloneObject: function (object, recursive, filter) { + if (typeof object != "object") { + return object; + } + + let temp; + + if (Array.isArray(object)) { + temp = []; + Array.forEach(object, function (value, index) { + if (!filter || filter(index, value, object)) { + temp.push(recursive ? WebConsoleUtils.cloneObject(value) : value); + } + }); + } else { + temp = {}; + for (let key in object) { + let value = object[key]; + if (object.hasOwnProperty(key) && + (!filter || filter(key, value, object))) { + temp[key] = recursive ? WebConsoleUtils.cloneObject(value) : value; + } + } + } + + return temp; + }, + + /** + * Gets the ID of the inner window of this DOM window. + * + * @param nsIDOMWindow window + * @return integer + * Inner ID for the given window. + */ + getInnerWindowId: function (window) { + return window.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDOMWindowUtils).currentInnerWindowID; + }, + + /** + * Recursively gather a list of inner window ids given a + * top level window. + * + * @param nsIDOMWindow window + * @return Array + * list of inner window ids. + */ + getInnerWindowIDsForFrames: function (window) { + let innerWindowID = this.getInnerWindowId(window); + let ids = [innerWindowID]; + + if (window.frames) { + for (let i = 0; i < window.frames.length; i++) { + let frame = window.frames[i]; + ids = ids.concat(this.getInnerWindowIDsForFrames(frame)); + } + } + + return ids; + }, + + /** + * Get the property descriptor for the given object. + * + * @param object object + * The object that contains the property. + * @param string prop + * The property you want to get the descriptor for. + * @return object + * Property descriptor. + */ + getPropertyDescriptor: function (object, prop) { + let desc = null; + while (object) { + try { + if ((desc = Object.getOwnPropertyDescriptor(object, prop))) { + break; + } + } catch (ex) { + // Native getters throw here. See bug 520882. + // null throws TypeError. + if (ex.name != "NS_ERROR_XPC_BAD_CONVERT_JS" && + ex.name != "NS_ERROR_XPC_BAD_OP_ON_WN_PROTO" && + ex.name != "TypeError") { + throw ex; + } + } + + try { + object = Object.getPrototypeOf(object); + } catch (ex) { + if (ex.name == "TypeError") { + return desc; + } + throw ex; + } + } + return desc; + }, + + /** + * Create a grip for the given value. If the value is an object, + * an object wrapper will be created. + * + * @param mixed value + * The value you want to create a grip for, before sending it to the + * client. + * @param function objectWrapper + * If the value is an object then the objectWrapper function is + * invoked to give us an object grip. See this.getObjectGrip(). + * @return mixed + * The value grip. + */ + createValueGrip: function (value, objectWrapper) { + switch (typeof value) { + case "boolean": + return value; + case "string": + return objectWrapper(value); + case "number": + if (value === Infinity) { + return { type: "Infinity" }; + } else if (value === -Infinity) { + return { type: "-Infinity" }; + } else if (Number.isNaN(value)) { + return { type: "NaN" }; + } else if (!value && 1 / value === -Infinity) { + return { type: "-0" }; + } + return value; + case "undefined": + return { type: "undefined" }; + case "object": + if (value === null) { + return { type: "null" }; + } + // Fall through. + case "function": + return objectWrapper(value); + default: + console.error("Failed to provide a grip for value of " + typeof value + + ": " + value); + return null; + } + }, +}; + +exports.Utils = WebConsoleUtils; + +// The page errors listener + +/** + * The nsIConsoleService listener. This is used to send all of the console + * messages (JavaScript, CSS and more) to the remote Web Console instance. + * + * @constructor + * @param nsIDOMWindow [window] + * Optional - the window object for which we are created. This is used + * for filtering out messages that belong to other windows. + * @param object listener + * The listener object must have one method: + * - onConsoleServiceMessage(). This method is invoked with one argument, + * the nsIConsoleMessage, whenever a relevant message is received. + */ +function ConsoleServiceListener(window, listener) { + this.window = window; + this.listener = listener; +} +exports.ConsoleServiceListener = ConsoleServiceListener; + +ConsoleServiceListener.prototype = +{ + QueryInterface: XPCOMUtils.generateQI([Ci.nsIConsoleListener]), + + /** + * The content window for which we listen to page errors. + * @type nsIDOMWindow + */ + window: null, + + /** + * The listener object which is notified of messages from the console service. + * @type object + */ + listener: null, + + /** + * Initialize the nsIConsoleService listener. + */ + init: function () { + Services.console.registerListener(this); + }, + + /** + * The nsIConsoleService observer. This method takes all the script error + * messages belonging to the current window and sends them to the remote Web + * Console instance. + * + * @param nsIConsoleMessage message + * The message object coming from the nsIConsoleService. + */ + observe: function (message) { + if (!this.listener) { + return; + } + + if (this.window) { + if (!(message instanceof Ci.nsIScriptError) || + !message.outerWindowID || + !this.isCategoryAllowed(message.category)) { + return; + } + + let errorWindow = Services.wm.getOuterWindowWithId(message.outerWindowID); + if (!errorWindow || !isWindowIncluded(this.window, errorWindow)) { + return; + } + } + + this.listener.onConsoleServiceMessage(message); + }, + + /** + * Check if the given message category is allowed to be tracked or not. + * We ignore chrome-originating errors as we only care about content. + * + * @param string category + * The message category you want to check. + * @return boolean + * True if the category is allowed to be logged, false otherwise. + */ + isCategoryAllowed: function (category) { + if (!category) { + return false; + } + + switch (category) { + case "XPConnect JavaScript": + case "component javascript": + case "chrome javascript": + case "chrome registration": + case "XBL": + case "XBL Prototype Handler": + case "XBL Content Sink": + case "xbl javascript": + return false; + } + + return true; + }, + + /** + * Get the cached page errors for the current inner window and its (i)frames. + * + * @param boolean [includePrivate=false] + * Tells if you want to also retrieve messages coming from private + * windows. Defaults to false. + * @return array + * The array of cached messages. Each element is an nsIScriptError or + * an nsIConsoleMessage + */ + getCachedMessages: function (includePrivate = false) { + let errors = Services.console.getMessageArray() || []; + + // if !this.window, we're in a browser console. Still need to filter + // private messages. + if (!this.window) { + return errors.filter((error) => { + if (error instanceof Ci.nsIScriptError) { + if (!includePrivate && error.isFromPrivateWindow) { + return false; + } + } + + return true; + }); + } + + let ids = WebConsoleUtils.getInnerWindowIDsForFrames(this.window); + + return errors.filter((error) => { + if (error instanceof Ci.nsIScriptError) { + if (!includePrivate && error.isFromPrivateWindow) { + return false; + } + if (ids && + (ids.indexOf(error.innerWindowID) == -1 || + !this.isCategoryAllowed(error.category))) { + return false; + } + } else if (ids && ids[0]) { + // If this is not an nsIScriptError and we need to do window-based + // filtering we skip this message. + return false; + } + + return true; + }); + }, + + /** + * Remove the nsIConsoleService listener. + */ + destroy: function () { + Services.console.unregisterListener(this); + this.listener = this.window = null; + }, +}; + +// The window.console API observer + +/** + * The window.console API observer. This allows the window.console API messages + * to be sent to the remote Web Console instance. + * + * @constructor + * @param nsIDOMWindow window + * Optional - the window object for which we are created. This is used + * for filtering out messages that belong to other windows. + * @param object owner + * The owner object must have the following methods: + * - onConsoleAPICall(). This method is invoked with one argument, the + * Console API message that comes from the observer service, whenever + * a relevant console API call is received. + * @param object filteringOptions + * Optional - The filteringOptions that this listener should listen to: + * - addonId: filter console messages based on the addonId. + */ +function ConsoleAPIListener(window, owner, {addonId} = {}) { + this.window = window; + this.owner = owner; + this.addonId = addonId; +} +exports.ConsoleAPIListener = ConsoleAPIListener; + +ConsoleAPIListener.prototype = +{ + QueryInterface: XPCOMUtils.generateQI([Ci.nsIObserver]), + + /** + * The content window for which we listen to window.console API calls. + * @type nsIDOMWindow + */ + window: null, + + /** + * The owner object which is notified of window.console API calls. It must + * have a onConsoleAPICall method which is invoked with one argument: the + * console API call object that comes from the observer service. + * + * @type object + * @see WebConsoleActor + */ + owner: null, + + /** + * The addonId that we listen for. If not null then only messages from this + * console will be returned. + */ + addonId: null, + + /** + * Initialize the window.console API observer. + */ + init: function () { + // Note that the observer is process-wide. We will filter the messages as + // needed, see CAL_observe(). + Services.obs.addObserver(this, "console-api-log-event", false); + }, + + /** + * The console API message observer. When messages are received from the + * observer service we forward them to the remote Web Console instance. + * + * @param object message + * The message object receives from the observer service. + * @param string topic + * The message topic received from the observer service. + */ + observe: function (message, topic) { + if (!this.owner) { + return; + } + + // Here, wrappedJSObject is not a security wrapper but a property defined + // by the XPCOM component which allows us to unwrap the XPCOM interface and + // access the underlying JSObject. + let apiMessage = message.wrappedJSObject; + + if (!this.isMessageRelevant(apiMessage)) { + return; + } + + this.owner.onConsoleAPICall(apiMessage); + }, + + /** + * Given a message, return true if this window should show it and false + * if it should be ignored. + * + * @param message + * The message from the Storage Service + * @return bool + * Do we care about this message? + */ + isMessageRelevant: function (message) { + let workerType = WebConsoleUtils.getWorkerType(message); + + if (this.window && workerType === "ServiceWorker") { + // For messages from Service Workers, message.ID is the + // scope, which can be used to determine whether it's controlling + // a window. + let scope = message.ID; + + if (!swm.shouldReportToWindow(this.window, scope)) { + return false; + } + } + + if (this.window && !workerType) { + let msgWindow = Services.wm.getCurrentInnerWindowWithId(message.innerID); + if (!msgWindow || !isWindowIncluded(this.window, msgWindow)) { + // Not the same window! + return false; + } + } + + if (this.addonId) { + // ConsoleAPI.jsm messages contains a consoleID, (and it is currently + // used in Addon SDK add-ons), the standard 'console' object + // (which is used in regular webpages and in WebExtensions pages) + // contains the originAttributes of the source document principal. + + // Filtering based on the originAttributes used by + // the Console API object. + if (message.originAttributes && + message.originAttributes.addonId == this.addonId) { + return true; + } + + // Filtering based on the old-style consoleID property used by + // the legacy Console JSM module. + if (message.consoleID && message.consoleID == `addon/${this.addonId}`) { + return true; + } + + return false; + } + + return true; + }, + + /** + * Get the cached messages for the current inner window and its (i)frames. + * + * @param boolean [includePrivate=false] + * Tells if you want to also retrieve messages coming from private + * windows. Defaults to false. + * @return array + * The array of cached messages. + */ + getCachedMessages: function (includePrivate = false) { + let messages = []; + let ConsoleAPIStorage = Cc["@mozilla.org/consoleAPI-storage;1"] + .getService(Ci.nsIConsoleAPIStorage); + + // if !this.window, we're in a browser console. Retrieve all events + // for filtering based on privacy. + if (!this.window) { + messages = ConsoleAPIStorage.getEvents(); + } else { + let ids = WebConsoleUtils.getInnerWindowIDsForFrames(this.window); + ids.forEach((id) => { + messages = messages.concat(ConsoleAPIStorage.getEvents(id)); + }); + } + + CONSOLE_WORKER_IDS.forEach((id) => { + messages = messages.concat(ConsoleAPIStorage.getEvents(id)); + }); + + messages = messages.filter(msg => { + return this.isMessageRelevant(msg); + }); + + if (includePrivate) { + return messages; + } + + return messages.filter((m) => !m.private); + }, + + /** + * Destroy the console API listener. + */ + destroy: function () { + Services.obs.removeObserver(this, "console-api-log-event"); + this.window = this.owner = null; + }, +}; + +/** + * WebConsole commands manager. + * + * Defines a set of functions /variables ("commands") that are available from + * the Web Console but not from the web page. + * + */ +var WebConsoleCommands = { + _registeredCommands: new Map(), + _originalCommands: new Map(), + + /** + * @private + * Reserved for built-in commands. To register a command from the code of an + * add-on, see WebConsoleCommands.register instead. + * + * @see WebConsoleCommands.register + */ + _registerOriginal: function (name, command) { + this.register(name, command); + this._originalCommands.set(name, this.getCommand(name)); + }, + + /** + * Register a new command. + * @param {string} name The command name (exemple: "$") + * @param {(function|object)} command The command to register. + * It can be a function so the command is a function (like "$()"), + * or it can also be a property descriptor to describe a getter / value (like + * "$0"). + * + * The command function or the command getter are passed a owner object as + * their first parameter (see the example below). + * + * Note that setters don't work currently and "enumerable" and "configurable" + * are forced to true. + * + * @example + * + * WebConsoleCommands.register("$", function JSTH_$(owner, selector) + * { + * return owner.window.document.querySelector(selector); + * }); + * + * WebConsoleCommands.register("$0", { + * get: function(owner) { + * return owner.makeDebuggeeValue(owner.selectedNode); + * } + * }); + */ + register: function (name, command) { + this._registeredCommands.set(name, command); + }, + + /** + * Unregister a command. + * + * If the command being unregister overrode a built-in command, + * the latter is restored. + * + * @param {string} name The name of the command + */ + unregister: function (name) { + this._registeredCommands.delete(name); + if (this._originalCommands.has(name)) { + this.register(name, this._originalCommands.get(name)); + } + }, + + /** + * Returns a command by its name. + * + * @param {string} name The name of the command. + * + * @return {(function|object)} The command. + */ + getCommand: function (name) { + return this._registeredCommands.get(name); + }, + + /** + * Returns true if a command is registered with the given name. + * + * @param {string} name The name of the command. + * + * @return {boolean} True if the command is registered. + */ + hasCommand: function (name) { + return this._registeredCommands.has(name); + }, +}; + +exports.WebConsoleCommands = WebConsoleCommands; + +/* + * Built-in commands. + * + * A list of helper functions used by Firebug can be found here: + * http://getfirebug.com/wiki/index.php/Command_Line_API + */ + +/** + * Find a node by ID. + * + * @param string id + * The ID of the element you want. + * @return nsIDOMNode or null + * The result of calling document.querySelector(selector). + */ +WebConsoleCommands._registerOriginal("$", function (owner, selector) { + return owner.window.document.querySelector(selector); +}); + +/** + * Find the nodes matching a CSS selector. + * + * @param string selector + * A string that is passed to window.document.querySelectorAll. + * @return nsIDOMNodeList + * Returns the result of document.querySelectorAll(selector). + */ +WebConsoleCommands._registerOriginal("$$", function (owner, selector) { + let nodes = owner.window.document.querySelectorAll(selector); + + // Calling owner.window.Array.from() doesn't work without accessing the + // wrappedJSObject, so just loop through the results instead. + let result = new owner.window.Array(); + for (let i = 0; i < nodes.length; i++) { + result.push(nodes[i]); + } + return result; +}); + +/** + * Returns the result of the last console input evaluation + * + * @return object|undefined + * Returns last console evaluation or undefined + */ +WebConsoleCommands._registerOriginal("$_", { + get: function (owner) { + return owner.consoleActor.getLastConsoleInputEvaluation(); + } +}); + +/** + * Runs an xPath query and returns all matched nodes. + * + * @param string xPath + * xPath search query to execute. + * @param [optional] nsIDOMNode context + * Context to run the xPath query on. Uses window.document if not set. + * @return array of nsIDOMNode + */ +WebConsoleCommands._registerOriginal("$x", function (owner, xPath, context) { + let nodes = new owner.window.Array(); + + // Not waiving Xrays, since we want the original Document.evaluate function, + // instead of anything that's been redefined. + let doc = owner.window.document; + context = context || doc; + + let results = doc.evaluate(xPath, context, null, + Ci.nsIDOMXPathResult.ANY_TYPE, null); + let node; + while ((node = results.iterateNext())) { + nodes.push(node); + } + + return nodes; +}); + +/** + * Returns the currently selected object in the highlighter. + * + * @return Object representing the current selection in the + * Inspector, or null if no selection exists. + */ +WebConsoleCommands._registerOriginal("$0", { + get: function (owner) { + return owner.makeDebuggeeValue(owner.selectedNode); + } +}); + +/** + * Clears the output of the WebConsole. + */ +WebConsoleCommands._registerOriginal("clear", function (owner) { + owner.helperResult = { + type: "clearOutput", + }; +}); + +/** + * Clears the input history of the WebConsole. + */ +WebConsoleCommands._registerOriginal("clearHistory", function (owner) { + owner.helperResult = { + type: "clearHistory", + }; +}); + +/** + * Returns the result of Object.keys(object). + * + * @param object object + * Object to return the property names from. + * @return array of strings + */ +WebConsoleCommands._registerOriginal("keys", function (owner, object) { + // Need to waive Xrays so we can iterate functions and accessor properties + return Cu.cloneInto(Object.keys(Cu.waiveXrays(object)), owner.window); +}); + +/** + * Returns the values of all properties on object. + * + * @param object object + * Object to display the values from. + * @return array of string + */ +WebConsoleCommands._registerOriginal("values", function (owner, object) { + let values = []; + // Need to waive Xrays so we can iterate functions and accessor properties + let waived = Cu.waiveXrays(object); + let names = Object.getOwnPropertyNames(waived); + + for (let name of names) { + values.push(waived[name]); + } + + return Cu.cloneInto(values, owner.window); +}); + +/** + * Opens a help window in MDN. + */ +WebConsoleCommands._registerOriginal("help", function (owner) { + owner.helperResult = { type: "help" }; +}); + +/** + * Change the JS evaluation scope. + * + * @param DOMElement|string|window window + * The window object to use for eval scope. This can be a string that + * is used to perform document.querySelector(), to find the iframe that + * you want to cd() to. A DOMElement can be given as well, the + * .contentWindow property is used. Lastly, you can directly pass + * a window object. If you call cd() with no arguments, the current + * eval scope is cleared back to its default (the top window). + */ +WebConsoleCommands._registerOriginal("cd", function (owner, window) { + if (!window) { + owner.consoleActor.evalWindow = null; + owner.helperResult = { type: "cd" }; + return; + } + + if (typeof window == "string") { + window = owner.window.document.querySelector(window); + } + if (window instanceof Ci.nsIDOMElement && window.contentWindow) { + window = window.contentWindow; + } + if (!(window instanceof Ci.nsIDOMWindow)) { + owner.helperResult = { + type: "error", + message: "cdFunctionInvalidArgument" + }; + return; + } + + owner.consoleActor.evalWindow = window; + owner.helperResult = { type: "cd" }; +}); + +/** + * Inspects the passed object. This is done by opening the PropertyPanel. + * + * @param object object + * Object to inspect. + */ +WebConsoleCommands._registerOriginal("inspect", function (owner, object) { + let dbgObj = owner.makeDebuggeeValue(object); + let grip = owner.createValueGrip(dbgObj); + owner.helperResult = { + type: "inspectObject", + input: owner.evalInput, + object: grip, + }; +}); + +/** + * Prints object to the output. + * + * @param object object + * Object to print to the output. + * @return string + */ +WebConsoleCommands._registerOriginal("pprint", function (owner, object) { + if (object === null || object === undefined || object === true || + object === false) { + owner.helperResult = { + type: "error", + message: "helperFuncUnsupportedTypeError", + }; + return null; + } + + owner.helperResult = { rawOutput: true }; + + if (typeof object == "function") { + return object + "\n"; + } + + let output = []; + + let obj = object; + for (let name in obj) { + let desc = WebConsoleUtils.getPropertyDescriptor(obj, name) || {}; + if (desc.get || desc.set) { + // TODO: Bug 842672 - toolkit/ imports modules from browser/. + let getGrip = VariablesView.getGrip(desc.get); + let setGrip = VariablesView.getGrip(desc.set); + let getString = VariablesView.getString(getGrip); + let setString = VariablesView.getString(setGrip); + output.push(name + ":", " get: " + getString, " set: " + setString); + } else { + let valueGrip = VariablesView.getGrip(obj[name]); + let valueString = VariablesView.getString(valueGrip); + output.push(name + ": " + valueString); + } + } + + return " " + output.join("\n "); +}); + +/** + * Print the String representation of a value to the output, as-is. + * + * @param any value + * A value you want to output as a string. + * @return void + */ +WebConsoleCommands._registerOriginal("print", function (owner, value) { + owner.helperResult = { rawOutput: true }; + if (typeof value === "symbol") { + return Symbol.prototype.toString.call(value); + } + // Waiving Xrays here allows us to see a closer representation of the + // underlying object. This may execute arbitrary content code, but that + // code will run with content privileges, and the result will be rendered + // inert by coercing it to a String. + return String(Cu.waiveXrays(value)); +}); + +/** + * Copy the String representation of a value to the clipboard. + * + * @param any value + * A value you want to copy as a string. + * @return void + */ +WebConsoleCommands._registerOriginal("copy", function (owner, value) { + let payload; + try { + if (value instanceof Ci.nsIDOMElement) { + payload = value.outerHTML; + } else if (typeof value == "string") { + payload = value; + } else { + payload = JSON.stringify(value, null, " "); + } + } catch (ex) { + payload = "/* " + ex + " */"; + } + owner.helperResult = { + type: "copyValueToClipboard", + value: payload, + }; +}); + +/** + * (Internal only) Add the bindings to |owner.sandbox|. + * This is intended to be used by the WebConsole actor only. + * + * @param object owner + * The owning object. + */ +function addWebConsoleCommands(owner) { + if (!owner) { + throw new Error("The owner is required"); + } + for (let [name, command] of WebConsoleCommands._registeredCommands) { + if (typeof command === "function") { + owner.sandbox[name] = command.bind(undefined, owner); + } else if (typeof command === "object") { + let clone = Object.assign({}, command, { + // We force the enumerability and the configurability (so the + // WebConsoleActor can reconfigure the property). + enumerable: true, + configurable: true + }); + + if (typeof command.get === "function") { + clone.get = command.get.bind(undefined, owner); + } + if (typeof command.set === "function") { + clone.set = command.set.bind(undefined, owner); + } + + Object.defineProperty(owner.sandbox, name, clone); + } + } +} + +exports.addWebConsoleCommands = addWebConsoleCommands; + +/** + * A ReflowObserver that listens for reflow events from the page. + * Implements nsIReflowObserver. + * + * @constructor + * @param object window + * The window for which we need to track reflow. + * @param object owner + * The listener owner which needs to implement: + * - onReflowActivity(reflowInfo) + */ + +function ConsoleReflowListener(window, listener) { + this.docshell = window.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIWebNavigation) + .QueryInterface(Ci.nsIDocShell); + this.listener = listener; + this.docshell.addWeakReflowObserver(this); +} + +exports.ConsoleReflowListener = ConsoleReflowListener; + +ConsoleReflowListener.prototype = +{ + QueryInterface: XPCOMUtils.generateQI([Ci.nsIReflowObserver, + Ci.nsISupportsWeakReference]), + docshell: null, + listener: null, + + /** + * Forward reflow event to listener. + * + * @param DOMHighResTimeStamp start + * @param DOMHighResTimeStamp end + * @param boolean interruptible + */ + sendReflow: function (start, end, interruptible) { + let frame = components.stack.caller.caller; + + let filename = frame ? frame.filename : null; + + if (filename) { + // Because filename could be of the form "xxx.js -> xxx.js -> xxx.js", + // we only take the last part. + filename = filename.split(" ").pop(); + } + + this.listener.onReflowActivity({ + interruptible: interruptible, + start: start, + end: end, + sourceURL: filename, + sourceLine: frame ? frame.lineNumber : null, + functionName: frame ? frame.name : null + }); + }, + + /** + * On uninterruptible reflow + * + * @param DOMHighResTimeStamp start + * @param DOMHighResTimeStamp end + */ + reflow: function (start, end) { + this.sendReflow(start, end, false); + }, + + /** + * On interruptible reflow + * + * @param DOMHighResTimeStamp start + * @param DOMHighResTimeStamp end + */ + reflowInterruptible: function (start, end) { + this.sendReflow(start, end, true); + }, + + /** + * Unregister listener. + */ + destroy: function () { + this.docshell.removeWeakReflowObserver(this); + this.listener = this.docshell = null; + }, +}; diff --git a/devtools/server/actors/utils/webconsole-worker-utils.js b/devtools/server/actors/utils/webconsole-worker-utils.js new file mode 100644 index 0000000000..0c11429671 --- /dev/null +++ b/devtools/server/actors/utils/webconsole-worker-utils.js @@ -0,0 +1,20 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ft= javascript ts=2 et sw=2 tw=80: */ +/* 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"; + +// XXXworkers This file is loaded on the server side for worker debugging. +// Since the server is running in the worker thread, it doesn't +// have access to Services / Components. This functionality +// is stubbed out to prevent errors, and will need to implemented +// for Bug 1209353. + +exports.Utils = { L10n: function () {} }; +exports.ConsoleServiceListener = function () {}; +exports.ConsoleAPIListener = function () {}; +exports.addWebConsoleCommands = function () {}; +exports.ConsoleReflowListener = function () {}; +exports.CONSOLE_WORKER_IDS = []; diff --git a/devtools/server/actors/webaudio.js b/devtools/server/actors/webaudio.js new file mode 100644 index 0000000000..d9035a9072 --- /dev/null +++ b/devtools/server/actors/webaudio.js @@ -0,0 +1,856 @@ +/* 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 {Cc, Ci, Cu, Cr} = require("chrome"); + +const Services = require("Services"); + +const events = require("sdk/event/core"); +const promise = require("promise"); +const { on: systemOn, off: systemOff } = require("sdk/system/events"); +const protocol = require("devtools/shared/protocol"); +const { CallWatcherActor } = require("devtools/server/actors/call-watcher"); +const { CallWatcherFront } = require("devtools/shared/fronts/call-watcher"); +const { createValueGrip } = require("devtools/server/actors/object"); +const AutomationTimeline = require("./utils/automation-timeline"); +const { on, once, off, emit } = events; +const { types, method, Arg, Option, RetVal, preEvent } = protocol; +const { + audionodeSpec, + webAudioSpec, + AUTOMATION_METHODS, + NODE_CREATION_METHODS, + NODE_ROUTING_METHODS, +} = require("devtools/shared/specs/webaudio"); +const { WebAudioFront } = require("devtools/shared/fronts/webaudio"); +const AUDIO_NODE_DEFINITION = require("devtools/server/actors/utils/audionodes.json"); +const ENABLE_AUTOMATION = false; +const AUTOMATION_GRANULARITY = 2000; +const AUTOMATION_GRANULARITY_MAX = 6000; + +const AUDIO_GLOBALS = [ + "AudioContext", "AudioNode", "AudioParam" +]; + +/** + * An Audio Node actor allowing communication to a specific audio node in the + * Audio Context graph. + */ +var AudioNodeActor = exports.AudioNodeActor = protocol.ActorClassWithSpec(audionodeSpec, { + form: function (detail) { + if (detail === "actorid") { + return this.actorID; + } + + return { + actor: this.actorID, // actorID is set when this is added to a pool + type: this.type, + source: this.source, + bypassable: this.bypassable, + }; + }, + + /** + * Create the Audio Node actor. + * + * @param DebuggerServerConnection conn + * The server connection. + * @param AudioNode node + * The AudioNode that was created. + */ + initialize: function (conn, node) { + protocol.Actor.prototype.initialize.call(this, conn); + + // Store ChromeOnly property `id` to identify AudioNode, + // rather than storing a strong reference, and store a weak + // ref to underlying node for controlling. + this.nativeID = node.id; + this.node = Cu.getWeakReference(node); + + // Stores the AutomationTimelines for this node's AudioParams. + this.automation = {}; + + try { + this.type = getConstructorName(node); + } catch (e) { + this.type = ""; + } + + this.source = !!AUDIO_NODE_DEFINITION[this.type].source; + this.bypassable = !AUDIO_NODE_DEFINITION[this.type].unbypassable; + + // Create automation timelines for all AudioParams + Object.keys(AUDIO_NODE_DEFINITION[this.type].properties || {}) + .filter(isAudioParam.bind(null, node)) + .forEach(paramName => { + this.automation[paramName] = new AutomationTimeline(node[paramName].defaultValue); + }); + }, + + /** + * Returns the string name of the audio type. + * + * DEPRECATED: Use `audionode.type` instead, left here for legacy reasons. + */ + getType: function () { + return this.type; + }, + + /** + * Returns a boolean indicating if the AudioNode has been "bypassed", + * via `AudioNodeActor#bypass` method. + * + * @return Boolean + */ + isBypassed: function () { + let node = this.node.get(); + if (node === null) { + return false; + } + + // Cast to boolean incase `passThrough` is undefined, + // like for AudioDestinationNode + return !!node.passThrough; + }, + + /** + * Takes a boolean, either enabling or disabling the "passThrough" option + * on an AudioNode. If a node is bypassed, an effects processing node (like gain, biquad), + * will allow the audio stream to pass through the node, unaffected. Returns + * the bypass state of the node. + * + * @param Boolean enable + * Whether the bypass value should be set on or off. + * @return Boolean + */ + bypass: function (enable) { + let node = this.node.get(); + + if (node === null) { + return; + } + + if (this.bypassable) { + node.passThrough = enable; + } + + return this.isBypassed(); + }, + + /** + * Changes a param on the audio node. Responds with either `undefined` + * on success, or a description of the error upon param set failure. + * + * @param String param + * Name of the AudioParam to change. + * @param String value + * Value to change AudioParam to. + */ + setParam: function (param, value) { + let node = this.node.get(); + + if (node === null) { + return CollectedAudioNodeError(); + } + + try { + if (isAudioParam(node, param)) { + node[param].value = value; + this.automation[param].setValue(value); + } + else { + node[param] = value; + } + return undefined; + } catch (e) { + return constructError(e); + } + }, + + /** + * Gets a param on the audio node. + * + * @param String param + * Name of the AudioParam to fetch. + */ + getParam: function (param) { + let node = this.node.get(); + + if (node === null) { + return CollectedAudioNodeError(); + } + + // Check to see if it's an AudioParam -- if so, + // return the `value` property of the parameter. + let value = isAudioParam(node, param) ? node[param].value : node[param]; + + // Return the grip form of the value; at this time, + // there shouldn't be any non-primitives at the moment, other than + // AudioBuffer or Float32Array references and the like, + // so this just formats the value to be displayed in the VariablesView, + // without using real grips and managing via actor pools. + let grip = createValueGrip(value, null, createObjectGrip); + + return grip; + }, + + /** + * Get an object containing key-value pairs of additional attributes + * to be consumed by a front end, like if a property should be read only, + * or is a special type (Float32Array, Buffer, etc.) + * + * @param String param + * Name of the AudioParam whose flags are desired. + */ + getParamFlags: function (param) { + return ((AUDIO_NODE_DEFINITION[this.type] || {}).properties || {})[param]; + }, + + /** + * Get an array of objects each containing a `param` and `value` property, + * corresponding to a property name and current value of the audio node. + */ + getParams: function (param) { + let props = Object.keys(AUDIO_NODE_DEFINITION[this.type].properties || {}); + return props.map(prop => + ({ param: prop, value: this.getParam(prop), flags: this.getParamFlags(prop) })); + }, + + /** + * Connects this audionode to an AudioParam via `node.connect(param)`. + */ + connectParam: function (destActor, paramName, output) { + let srcNode = this.node.get(); + let destNode = destActor.node.get(); + + if (srcNode === null || destNode === null) { + return CollectedAudioNodeError(); + } + + try { + // Connect via the unwrapped node, so we can call the + // patched method that fires the webaudio actor's `connect-param` event. + // Connect directly to the wrapped `destNode`, otherwise + // the patched method thinks this is a new node and won't be + // able to find it in `_nativeToActorID`. + XPCNativeWrapper.unwrap(srcNode).connect(destNode[paramName], output); + } catch (e) { + return constructError(e); + } + }, + + /** + * Connects this audionode to another via `node.connect(dest)`. + */ + connectNode: function (destActor, output, input) { + let srcNode = this.node.get(); + let destNode = destActor.node.get(); + + if (srcNode === null || destNode === null) { + return CollectedAudioNodeError(); + } + + try { + // Connect via the unwrapped node, so we can call the + // patched method that fires the webaudio actor's `connect-node` event. + // Connect directly to the wrapped `destNode`, otherwise + // the patched method thinks this is a new node and won't be + // able to find it in `_nativeToActorID`. + XPCNativeWrapper.unwrap(srcNode).connect(destNode, output, input); + } catch (e) { + return constructError(e); + } + }, + + /** + * Disconnects this audionode from all connections via `node.disconnect()`. + */ + disconnect: function (destActor, output) { + let node = this.node.get(); + + if (node === null) { + return CollectedAudioNodeError(); + } + + try { + // Disconnect via the unwrapped node, so we can call the + // patched method that fires the webaudio actor's `disconnect` event. + XPCNativeWrapper.unwrap(node).disconnect(output); + } catch (e) { + return constructError(e); + } + }, + + getAutomationData: function (paramName) { + let timeline = this.automation[paramName]; + if (!timeline) { + return null; + } + + let events = timeline.events; + let values = []; + let i = 0; + + if (!timeline.events.length) { + return { events, values }; + } + + let firstEvent = events[0]; + let lastEvent = events[timeline.events.length - 1]; + // `setValueCurveAtTime` will have a duration value -- other + // events will have duration of `0`. + let timeDelta = (lastEvent.time + lastEvent.duration) - firstEvent.time; + let scale = timeDelta / AUTOMATION_GRANULARITY; + + for (; i < AUTOMATION_GRANULARITY; i++) { + let delta = firstEvent.time + (i * scale); + let value = timeline.getValueAtTime(delta); + values.push({ delta, value }); + } + + // If the last event is setTargetAtTime, the automation + // doesn't actually begin until the event's time, and exponentially + // approaches the target value. In this case, we add more values + // until we're "close enough" to the target. + if (lastEvent.type === "setTargetAtTime") { + for (; i < AUTOMATION_GRANULARITY_MAX; i++) { + let delta = firstEvent.time + (++i * scale); + let value = timeline.getValueAtTime(delta); + values.push({ delta, value }); + } + } + + return { events, values }; + }, + + /** + * Called via WebAudioActor, registers an automation event + * for the AudioParam called. + * + * @param String paramName + * Name of the AudioParam. + * @param String eventName + * Name of the automation event called. + * @param Array args + * Arguments passed into the automation call. + */ + addAutomationEvent: function (paramName, eventName, args = []) { + let node = this.node.get(); + let timeline = this.automation[paramName]; + + if (node === null) { + return CollectedAudioNodeError(); + } + + if (!timeline || !node[paramName][eventName]) { + return InvalidCommandError(); + } + + try { + // Using the unwrapped node and parameter, the corresponding + // WebAudioActor event will be fired, subsequently calling + // `_recordAutomationEvent`. Some finesse is required to handle + // the cast of TypedArray arguments over the protocol, which is + // taken care of below. The event will cast the argument back + // into an array to be broadcasted from WebAudioActor, but the + // double-casting will only occur when starting from `addAutomationEvent`, + // which is only used in tests. + let param = XPCNativeWrapper.unwrap(node[paramName]); + let contentGlobal = Cu.getGlobalForObject(param); + let contentArgs = Cu.cloneInto(args, contentGlobal); + + // If calling `setValueCurveAtTime`, the first argument + // is a Float32Array, which won't be able to be serialized + // over the protocol. Cast a normal array to a Float32Array here. + if (eventName === "setValueCurveAtTime") { + // Create a Float32Array from the content, seeding with an array + // from the same scope. + let curve = new contentGlobal.Float32Array(contentArgs[0]); + contentArgs[0] = curve; + } + + // Apply the args back from the content scope, which is necessary + // due to the method wrapping changing in bug 1130901 to be exported + // directly to the content scope. + param[eventName].apply(param, contentArgs); + } catch (e) { + return constructError(e); + } + }, + + /** + * Registers the automation event in the AudioNodeActor's + * internal timeline. Called when setting automation via + * `addAutomationEvent`, or from the WebAudioActor's listening + * to the event firing via content. + * + * @param String paramName + * Name of the AudioParam. + * @param String eventName + * Name of the automation event called. + * @param Array args + * Arguments passed into the automation call. + */ + _recordAutomationEvent: function (paramName, eventName, args) { + let timeline = this.automation[paramName]; + timeline[eventName].apply(timeline, args); + } +}); + +/** + * The Web Audio Actor handles simple interaction with an AudioContext + * high-level methods. After instantiating this actor, you'll need to set it + * up by calling setup(). + */ +var WebAudioActor = exports.WebAudioActor = protocol.ActorClassWithSpec(webAudioSpec, { + initialize: function (conn, tabActor) { + protocol.Actor.prototype.initialize.call(this, conn); + this.tabActor = tabActor; + + this._onContentFunctionCall = this._onContentFunctionCall.bind(this); + + // Store ChromeOnly ID (`nativeID` property on AudioNodeActor) mapped + // to the associated actorID, so we don't have to expose `nativeID` + // to the client in any way. + this._nativeToActorID = new Map(); + + this._onDestroyNode = this._onDestroyNode.bind(this); + this._onGlobalDestroyed = this._onGlobalDestroyed.bind(this); + this._onGlobalCreated = this._onGlobalCreated.bind(this); + }, + + destroy: function (conn) { + protocol.Actor.prototype.destroy.call(this, conn); + this.finalize(); + }, + + /** + * Returns definition of all AudioNodes, such as AudioParams, and + * flags. + */ + getDefinition: function () { + return AUDIO_NODE_DEFINITION; + }, + + /** + * Starts waiting for the current tab actor's document global to be + * created, in order to instrument the Canvas context and become + * aware of everything the content does with Web Audio. + * + * See ContentObserver and WebAudioInstrumenter for more details. + */ + setup: function ({ reload }) { + // Used to track when something is happening with the web audio API + // the first time, to ultimately fire `start-context` event + this._firstNodeCreated = false; + + // Clear out stored nativeIDs on reload as we do not want to track + // AudioNodes that are no longer on this document. + this._nativeToActorID.clear(); + + if (this._initialized) { + if (reload) { + this.tabActor.window.location.reload(); + } + return; + } + + this._initialized = true; + + this._callWatcher = new CallWatcherActor(this.conn, this.tabActor); + this._callWatcher.onCall = this._onContentFunctionCall; + this._callWatcher.setup({ + tracedGlobals: AUDIO_GLOBALS, + startRecording: true, + performReload: reload, + holdWeak: true, + storeCalls: false + }); + // Bind to `window-ready` so we can reenable recording on the + // call watcher + on(this.tabActor, "window-ready", this._onGlobalCreated); + // Bind to the `window-destroyed` event so we can unbind events between + // the global destruction and the `finalize` cleanup method on the actor. + on(this.tabActor, "window-destroyed", this._onGlobalDestroyed); + }, + + /** + * Invoked whenever an instrumented function is called, like an AudioContext + * method or an AudioNode method. + */ + _onContentFunctionCall: function (functionCall) { + let { name } = functionCall.details; + + // All Web Audio nodes inherit from AudioNode's prototype, so + // hook into the `connect` and `disconnect` methods + if (WebAudioFront.NODE_ROUTING_METHODS.has(name)) { + this._handleRoutingCall(functionCall); + } + else if (WebAudioFront.NODE_CREATION_METHODS.has(name)) { + this._handleCreationCall(functionCall); + } + else if (ENABLE_AUTOMATION && WebAudioFront.AUTOMATION_METHODS.has(name)) { + this._handleAutomationCall(functionCall); + } + }, + + _handleRoutingCall: function (functionCall) { + let { caller, args, name } = functionCall.details; + let source = caller; + let dest = args[0]; + let isAudioParam = dest ? getConstructorName(dest) === "AudioParam" : false; + + // audionode.connect(param) + if (name === "connect" && isAudioParam) { + this._onConnectParam(source, dest); + } + // audionode.connect(node) + else if (name === "connect") { + this._onConnectNode(source, dest); + } + // audionode.disconnect() + else if (name === "disconnect") { + this._onDisconnectNode(source); + } + }, + + _handleCreationCall: function (functionCall) { + let { caller, result } = functionCall.details; + // Keep track of the first node created, so we can alert + // the front end that an audio context is being used since + // we're not hooking into the constructor itself, just its + // instance's methods. + if (!this._firstNodeCreated) { + // Fire the start-up event if this is the first node created + // and trigger a `create-node` event for the context destination + this._onStartContext(); + this._onCreateNode(caller.destination); + this._firstNodeCreated = true; + } + this._onCreateNode(result); + }, + + _handleAutomationCall: function (functionCall) { + let { caller, name, args } = functionCall.details; + let wrappedParam = new XPCNativeWrapper(caller); + + // Sanitize arguments, as these should all be numbers, + // with the exception of a TypedArray, which needs + // casted to an Array + args = sanitizeAutomationArgs(args); + + let nodeActor = this._getActorByNativeID(wrappedParam._parentID); + nodeActor._recordAutomationEvent(wrappedParam._paramName, name, args); + + this._onAutomationEvent({ + node: nodeActor, + paramName: wrappedParam._paramName, + eventName: name, + args: args + }); + }, + + /** + * Stops listening for document global changes and puts this actor + * to hibernation. This method is called automatically just before the + * actor is destroyed. + */ + finalize: function () { + if (!this._initialized) { + return; + } + this._initialized = false; + systemOff("webaudio-node-demise", this._onDestroyNode); + + off(this.tabActor, "window-destroyed", this._onGlobalDestroyed); + off(this.tabActor, "window-ready", this._onGlobalCreated); + this.tabActor = null; + this._nativeToActorID = null; + this._callWatcher.eraseRecording(); + this._callWatcher.finalize(); + this._callWatcher = null; + }, + + /** + * Helper for constructing an AudioNodeActor, assigning to + * internal weak map, and tracking via `manage` so it is assigned + * an `actorID`. + */ + _constructAudioNode: function (node) { + // Ensure AudioNode is wrapped. + node = new XPCNativeWrapper(node); + + this._instrumentParams(node); + + let actor = new AudioNodeActor(this.conn, node); + this.manage(actor); + this._nativeToActorID.set(node.id, actor.actorID); + return actor; + }, + + /** + * Takes an XrayWrapper node, and attaches the node's `nativeID` + * to the AudioParams as `_parentID`, as well as the the type of param + * as a string on `_paramName`. + */ + _instrumentParams: function (node) { + let type = getConstructorName(node); + Object.keys(AUDIO_NODE_DEFINITION[type].properties || {}) + .filter(isAudioParam.bind(null, node)) + .forEach(paramName => { + let param = node[paramName]; + param._parentID = node.id; + param._paramName = paramName; + }); + }, + + /** + * Takes an AudioNode and returns the stored actor for it. + * In some cases, we won't have an actor stored (for example, + * connecting to an AudioDestinationNode, since it's implicitly + * created), so make a new actor and store that. + */ + _getActorByNativeID: function (nativeID) { + // Ensure we have a Number, rather than a string + // return via notification. + nativeID = ~~nativeID; + + let actorID = this._nativeToActorID.get(nativeID); + let actor = actorID != null ? this.conn.getActor(actorID) : null; + return actor; + }, + + /** + * Called on first audio node creation, signifying audio context usage + */ + _onStartContext: function () { + systemOn("webaudio-node-demise", this._onDestroyNode); + emit(this, "start-context"); + }, + + /** + * Called when one audio node is connected to another. + */ + _onConnectNode: function (source, dest) { + let sourceActor = this._getActorByNativeID(source.id); + let destActor = this._getActorByNativeID(dest.id); + + emit(this, "connect-node", { + source: sourceActor, + dest: destActor + }); + }, + + /** + * Called when an audio node is connected to an audio param. + */ + _onConnectParam: function (source, param) { + let sourceActor = this._getActorByNativeID(source.id); + let destActor = this._getActorByNativeID(param._parentID); + emit(this, "connect-param", { + source: sourceActor, + dest: destActor, + param: param._paramName + }); + }, + + /** + * Called when an audio node is disconnected. + */ + _onDisconnectNode: function (node) { + let actor = this._getActorByNativeID(node.id); + emit(this, "disconnect-node", actor); + }, + + /** + * Called when a parameter changes on an audio node + */ + _onParamChange: function (node, param, value) { + let actor = this._getActorByNativeID(node.id); + emit(this, "param-change", { + source: actor, + param: param, + value: value + }); + }, + + /** + * Called on node creation. + */ + _onCreateNode: function (node) { + let actor = this._constructAudioNode(node); + emit(this, "create-node", actor); + }, + + /** Called when `webaudio-node-demise` is triggered, + * and emits the associated actor to the front if found. + */ + _onDestroyNode: function ({data}) { + // Cast to integer. + let nativeID = ~~data; + + let actor = this._getActorByNativeID(nativeID); + + // If actorID exists, emit; in the case where we get demise + // notifications for a document that no longer exists, + // the mapping should not be found, so we do not emit an event. + if (actor) { + this._nativeToActorID.delete(nativeID); + emit(this, "destroy-node", actor); + } + }, + + /** + * Ensures that the new global has recording on + * so we can proxy the function calls. + */ + _onGlobalCreated: function () { + // Used to track when something is happening with the web audio API + // the first time, to ultimately fire `start-context` event + this._firstNodeCreated = false; + + // Clear out stored nativeIDs on reload as we do not want to track + // AudioNodes that are no longer on this document. + this._nativeToActorID.clear(); + + this._callWatcher.resumeRecording(); + }, + + /** + * Fired when an automation event is added to an AudioNode. + */ + _onAutomationEvent: function ({node, paramName, eventName, args}) { + emit(this, "automation-event", { + node: node, + paramName: paramName, + eventName: eventName, + args: args + }); + }, + + /** + * Called when the underlying ContentObserver fires `global-destroyed` + * so we can cleanup some things between the global being destroyed and + * when the actor's `finalize` method gets called. + */ + _onGlobalDestroyed: function ({id}) { + if (this._callWatcher._tracedWindowId !== id) { + return; + } + + if (this._nativeToActorID) { + this._nativeToActorID.clear(); + } + systemOff("webaudio-node-demise", this._onDestroyNode); + } +}); + +/** + * Determines whether or not property is an AudioParam. + * + * @param AudioNode node + * An AudioNode. + * @param String prop + * Property of `node` to evaluate to see if it's an AudioParam. + * @return Boolean + */ +function isAudioParam(node, prop) { + return !!(node[prop] && /AudioParam/.test(node[prop].toString())); +} + +/** + * Takes an `Error` object and constructs a JSON-able response + * + * @param Error err + * A TypeError, RangeError, etc. + * @return Object + */ +function constructError(err) { + return { + message: err.message, + type: err.constructor.name + }; +} + +/** + * Creates and returns a JSON-able response used to indicate + * attempt to access an AudioNode that has been GC'd. + * + * @return Object + */ +function CollectedAudioNodeError() { + return { + message: "AudioNode has been garbage collected and can no longer be reached.", + type: "UnreachableAudioNode" + }; +} + +function InvalidCommandError() { + return { + message: "The command on AudioNode is invalid.", + type: "InvalidCommand" + }; +} + +/** + * Takes an object and converts it's `toString()` form, like + * "[object OscillatorNode]" or "[object Float32Array]", + * or XrayWrapper objects like "[object XrayWrapper [object Array]]" + * to a string of just the constructor name, like "OscillatorNode", + * or "Float32Array". + */ +function getConstructorName(obj) { + return Object.prototype.toString.call(obj).match(/\[object ([^\[\]]*)\]\]?$/)[1]; +} + +/** + * Create a grip-like object to pass in renderable information + * to the front-end for things like Float32Arrays, AudioBuffers, + * without tracking them in an actor pool. + */ +function createObjectGrip(value) { + return { + type: "object", + preview: { + kind: "ObjectWithText", + text: "" + }, + class: getConstructorName(value) + }; +} + +/** + * Converts all TypedArrays of the array that cannot + * be passed over the wire into a normal Array equivilent. + */ +function sanitizeAutomationArgs(args) { + return args.reduce((newArgs, el) => { + newArgs.push(typeof el === "object" && getConstructorName(el) === "Float32Array" ? castToArray(el) : el); + return newArgs; + }, []); +} + +/** + * Casts TypedArray to a normal array via a + * new scope. + */ +function castToArray(typedArray) { + // The Xray machinery for TypedArrays denies indexed access on the grounds + // that it's slow, and advises callers to do a structured clone instead. + let global = Cu.getGlobalForObject(this); + let safeView = Cu.cloneInto(typedArray.subarray(), global); + return copyInto([], safeView); +} + +/** + * Copies values of an array-like `source` into + * a similarly array-like `dest`. + */ +function copyInto(dest, source) { + for (let i = 0; i < source.length; i++) { + dest[i] = source[i]; + } + return dest; +} diff --git a/devtools/server/actors/webbrowser.js b/devtools/server/actors/webbrowser.js new file mode 100644 index 0000000000..0edcdc1875 --- /dev/null +++ b/devtools/server/actors/webbrowser.js @@ -0,0 +1,2529 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* 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"; + +/* global XPCNativeWrapper */ + +var { Ci, Cu, Cr } = require("chrome"); +var Services = require("Services"); +var { XPCOMUtils } = require("resource://gre/modules/XPCOMUtils.jsm"); +var promise = require("promise"); +var { + ActorPool, createExtraActors, appendExtraActors, GeneratedLocation +} = require("devtools/server/actors/common"); +var { DebuggerServer } = require("devtools/server/main"); +var DevToolsUtils = require("devtools/shared/DevToolsUtils"); +var { assert } = DevToolsUtils; +var { TabSources } = require("./utils/TabSources"); +var makeDebugger = require("./utils/make-debugger"); + +loader.lazyRequireGetter(this, "RootActor", "devtools/server/actors/root", true); +loader.lazyRequireGetter(this, "ThreadActor", "devtools/server/actors/script", true); +loader.lazyRequireGetter(this, "unwrapDebuggerObjectGlobal", "devtools/server/actors/script", true); +loader.lazyRequireGetter(this, "BrowserAddonActor", "devtools/server/actors/addon", true); +loader.lazyRequireGetter(this, "WebExtensionActor", "devtools/server/actors/webextension", true); +loader.lazyRequireGetter(this, "WorkerActorList", "devtools/server/actors/worker", true); +loader.lazyRequireGetter(this, "ServiceWorkerRegistrationActorList", "devtools/server/actors/worker", true); +loader.lazyRequireGetter(this, "ProcessActorList", "devtools/server/actors/process", true); +loader.lazyImporter(this, "AddonManager", "resource://gre/modules/AddonManager.jsm"); +loader.lazyImporter(this, "ExtensionContent", "resource://gre/modules/ExtensionContent.jsm"); + +// Assumptions on events module: +// events needs to be dispatched synchronously, +// by calling the listeners in the order or registration. +loader.lazyRequireGetter(this, "events", "sdk/event/core"); + +loader.lazyRequireGetter(this, "StyleSheetActor", "devtools/server/actors/stylesheets", true); + +function getWindowID(window) { + return window.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDOMWindowUtils) + .currentInnerWindowID; +} + +function getDocShellChromeEventHandler(docShell) { + let handler = docShell.chromeEventHandler; + if (!handler) { + try { + // Toplevel xul window's docshell doesn't have chromeEventHandler + // attribute. The chrome event handler is just the global window object. + handler = docShell.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDOMWindow); + } catch (e) { + // ignore + } + } + return handler; +} + +function getChildDocShells(parentDocShell) { + let docShellsEnum = parentDocShell.getDocShellEnumerator( + Ci.nsIDocShellTreeItem.typeAll, + Ci.nsIDocShell.ENUMERATE_FORWARDS + ); + + let docShells = []; + while (docShellsEnum.hasMoreElements()) { + let docShell = docShellsEnum.getNext(); + docShell.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIWebProgress); + docShells.push(docShell); + } + return docShells; +} + +exports.getChildDocShells = getChildDocShells; + +/** + * Browser-specific actors. + */ + +function getInnerId(window) { + return window.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDOMWindowUtils).currentInnerWindowID; +} + +/** + * Yield all windows of type |windowType|, from the oldest window to the + * youngest, using nsIWindowMediator::getEnumerator. We're usually + * interested in "navigator:browser" windows. + */ +function* allAppShellDOMWindows(windowType) { + let e = Services.wm.getEnumerator(windowType); + while (e.hasMoreElements()) { + yield e.getNext(); + } +} + +exports.allAppShellDOMWindows = allAppShellDOMWindows; + +/** + * Retrieve the window type of the top-level window |window|. + */ +function appShellDOMWindowType(window) { + /* This is what nsIWindowMediator's enumerator checks. */ + return window.document.documentElement.getAttribute("windowtype"); +} + +/** + * Send Debugger:Shutdown events to all "navigator:browser" windows. + */ +function sendShutdownEvent() { + for (let win of allAppShellDOMWindows(DebuggerServer.chromeWindowType)) { + let evt = win.document.createEvent("Event"); + evt.initEvent("Debugger:Shutdown", true, false); + win.document.documentElement.dispatchEvent(evt); + } +} + +exports.sendShutdownEvent = sendShutdownEvent; + +/** + * Construct a root actor appropriate for use in a server running in a + * browser. The returned root actor: + * - respects the factories registered with DebuggerServer.addGlobalActor, + * - uses a BrowserTabList to supply tab actors, + * - sends all navigator:browser window documents a Debugger:Shutdown event + * when it exits. + * + * * @param connection DebuggerServerConnection + * The conection to the client. + */ +function createRootActor(connection) { + return new RootActor(connection, { + tabList: new BrowserTabList(connection), + addonList: new BrowserAddonList(connection), + workerList: new WorkerActorList(connection, {}), + serviceWorkerRegistrationList: + new ServiceWorkerRegistrationActorList(connection), + processList: new ProcessActorList(), + globalActorFactories: DebuggerServer.globalActorFactories, + onShutdown: sendShutdownEvent + }); +} + +/** + * A live list of BrowserTabActors representing the current browser tabs, + * to be provided to the root actor to answer 'listTabs' requests. + * + * This object also takes care of listening for TabClose events and + * onCloseWindow notifications, and exiting the BrowserTabActors concerned. + * + * (See the documentation for RootActor for the definition of the "live + * list" interface.) + * + * @param connection DebuggerServerConnection + * The connection in which this list's tab actors may participate. + * + * Some notes: + * + * This constructor is specific to the desktop browser environment; it + * maintains the tab list by tracking XUL windows and their XUL documents' + * "tabbrowser", "tab", and "browser" elements. What's entailed in maintaining + * an accurate list of open tabs in this context? + * + * - Opening and closing XUL windows: + * + * An nsIWindowMediatorListener is notified when new XUL windows (i.e., desktop + * windows) are opened and closed. It is not notified of individual content + * browser tabs coming and going within such a XUL window. That seems + * reasonable enough; it's concerned with XUL windows, not tab elements in the + * window's XUL document. + * + * However, even if we attach TabOpen and TabClose event listeners to each XUL + * window as soon as it is created: + * + * - we do not receive a TabOpen event for the initial empty tab of a new XUL + * window; and + * + * - we do not receive TabClose events for the tabs of a XUL window that has + * been closed. + * + * This means that TabOpen and TabClose events alone are not sufficient to + * maintain an accurate list of live tabs and mark tab actors as closed + * promptly. Our nsIWindowMediatorListener onCloseWindow handler must find and + * exit all actors for tabs that were in the closing window. + * + * Since this is a bit hairy, we don't make each individual attached tab actor + * responsible for noticing when it has been closed; we watch for that, and + * promise to call each actor's 'exit' method when it's closed, regardless of + * how we learn the news. + * + * - nsIWindowMediator locks + * + * nsIWindowMediator holds a lock protecting its list of top-level windows + * while it calls nsIWindowMediatorListener methods. nsIWindowMediator's + * GetEnumerator method also tries to acquire that lock. Thus, enumerating + * windows from within a listener method deadlocks (bug 873589). Rah. One + * can sometimes work around this by leaving the enumeration for a later + * tick. + * + * - Dragging tabs between windows: + * + * When a tab is dragged from one desktop window to another, we receive a + * TabOpen event for the new tab, and a TabClose event for the old tab; tab XUL + * elements do not really move from one document to the other (although their + * linked browser's content window objects do). + * + * However, while we could thus assume that each tab stays with the XUL window + * it belonged to when it was created, I'm not sure this is behavior one should + * rely upon. When a XUL window is closed, we take the less efficient, more + * conservative approach of simply searching the entire table for actors that + * belong to the closing XUL window, rather than trying to somehow track which + * XUL window each tab belongs to. + * + * - Title changes: + * + * For tabs living in the child process, we listen for DOMTitleChange message + * via the top-level window's message manager. Doing this also allows listening + * for title changes on Fennec. + * But as these messages aren't sent for tabs loaded in the parent process, + * we also listen for TabAttrModified event, which is fired only on Firefox + * desktop. + */ +function BrowserTabList(connection) { + this._connection = connection; + + /* + * The XUL document of a tabbed browser window has "tab" elements, whose + * 'linkedBrowser' JavaScript properties are "browser" elements; those + * browsers' 'contentWindow' properties are wrappers on the tabs' content + * window objects. + * + * This map's keys are "browser" XUL elements; it maps each browser element + * to the tab actor we've created for its content window, if we've created + * one. This map serves several roles: + * + * - During iteration, we use it to find actors we've created previously. + * + * - On a TabClose event, we use it to find the tab's actor and exit it. + * + * - When the onCloseWindow handler is called, we iterate over it to find all + * tabs belonging to the closing XUL window, and exit them. + * + * - When it's empty, and the onListChanged hook is null, we know we can + * stop listening for events and notifications. + * + * We listen for TabClose events and onCloseWindow notifications in order to + * send onListChanged notifications, but also to tell actors when their + * referent has gone away and remove entries for dead browsers from this map. + * If that code is working properly, neither this map nor the actors in it + * should ever hold dead tabs alive. + */ + this._actorByBrowser = new Map(); + + /* The current onListChanged handler, or null. */ + this._onListChanged = null; + + /* + * True if we've been iterated over since we last called our onListChanged + * hook. + */ + this._mustNotify = false; + + /* True if we're testing, and should throw if consistency checks fail. */ + this._testing = false; +} + +BrowserTabList.prototype.constructor = BrowserTabList; + +/** + * Get the selected browser for the given navigator:browser window. + * @private + * @param window nsIChromeWindow + * The navigator:browser window for which you want the selected browser. + * @return nsIDOMElement|null + * The currently selected xul:browser element, if any. Note that the + * browser window might not be loaded yet - the function will return + * |null| in such cases. + */ +BrowserTabList.prototype._getSelectedBrowser = function (window) { + return window.gBrowser ? window.gBrowser.selectedBrowser : null; +}; + +/** + * Produces an iterable (in this case a generator) to enumerate all available + * browser tabs. + */ +BrowserTabList.prototype._getBrowsers = function* () { + // Iterate over all navigator:browser XUL windows. + for (let win of allAppShellDOMWindows(DebuggerServer.chromeWindowType)) { + // For each tab in this XUL window, ensure that we have an actor for + // it, reusing existing actors where possible. We actually iterate + // over 'browser' XUL elements, and BrowserTabActor uses + // browser.contentWindow as the debuggee global. + for (let browser of this._getChildren(win)) { + yield browser; + } + } +}; + +BrowserTabList.prototype._getChildren = function (window) { + if (!window.gBrowser) { + return []; + } + let { gBrowser } = window; + if (!gBrowser.browsers) { + return []; + } + return gBrowser.browsers.filter(browser => { + // Filter tabs that are closing. listTabs calls made right after TabClose + // events still list tabs in process of being closed. + let tab = gBrowser.getTabForBrowser(browser); + return !tab.closing; + }); +}; + +BrowserTabList.prototype.getList = function () { + let topXULWindow = Services.wm.getMostRecentWindow( + DebuggerServer.chromeWindowType); + let selectedBrowser = null; + if (topXULWindow) { + selectedBrowser = this._getSelectedBrowser(topXULWindow); + } + + // As a sanity check, make sure all the actors presently in our map get + // picked up when we iterate over all windows' tabs. + let initialMapSize = this._actorByBrowser.size; + this._foundCount = 0; + + // To avoid mysterious behavior if tabs are closed or opened mid-iteration, + // we update the map first, and then make a second pass over it to yield + // the actors. Thus, the sequence yielded is always a snapshot of the + // actors that were live when we began the iteration. + + let actorPromises = []; + + for (let browser of this._getBrowsers()) { + let selected = browser === selectedBrowser; + actorPromises.push( + this._getActorForBrowser(browser) + .then(actor => { + // Set the 'selected' properties on all actors correctly. + actor.selected = selected; + return actor; + }) + ); + } + + if (this._testing && initialMapSize !== this._foundCount) { + throw new Error("_actorByBrowser map contained actors for dead tabs"); + } + + this._mustNotify = true; + this._checkListening(); + + return promise.all(actorPromises); +}; + +BrowserTabList.prototype._getActorForBrowser = function (browser) { + // Do we have an existing actor for this browser? If not, create one. + let actor = this._actorByBrowser.get(browser); + if (actor) { + this._foundCount++; + return actor.update(); + } + + actor = new BrowserTabActor(this._connection, browser); + this._actorByBrowser.set(browser, actor); + this._checkListening(); + return actor.connect(); +}; + +BrowserTabList.prototype.getTab = function ({ outerWindowID, tabId }) { + if (typeof outerWindowID == "number") { + // First look for in-process frames with this ID + let window = Services.wm.getOuterWindowWithId(outerWindowID); + // Safety check to prevent debugging top level window via getTab + if (window instanceof Ci.nsIDOMChromeWindow) { + return promise.reject({ + error: "forbidden", + message: "Window with outerWindowID '" + outerWindowID + "' is chrome" + }); + } + if (window) { + let iframe = window.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDOMWindowUtils) + .containerElement; + if (iframe) { + return this._getActorForBrowser(iframe); + } + } + // Then also look on registered <xul:browsers> when using outerWindowID for + // OOP tabs + for (let browser of this._getBrowsers()) { + if (browser.outerWindowID == outerWindowID) { + return this._getActorForBrowser(browser); + } + } + return promise.reject({ + error: "noTab", + message: "Unable to find tab with outerWindowID '" + outerWindowID + "'" + }); + } else if (typeof tabId == "number") { + // Tabs OOP + for (let browser of this._getBrowsers()) { + if (browser.frameLoader.tabParent && + browser.frameLoader.tabParent.tabId === tabId) { + return this._getActorForBrowser(browser); + } + } + return promise.reject({ + error: "noTab", + message: "Unable to find tab with tabId '" + tabId + "'" + }); + } + + let topXULWindow = Services.wm.getMostRecentWindow( + DebuggerServer.chromeWindowType); + if (topXULWindow) { + let selectedBrowser = this._getSelectedBrowser(topXULWindow); + return this._getActorForBrowser(selectedBrowser); + } + return promise.reject({ + error: "noTab", + message: "Unable to find any selected browser" + }); +}; + +Object.defineProperty(BrowserTabList.prototype, "onListChanged", { + enumerable: true, + configurable: true, + get() { + return this._onListChanged; + }, + set(v) { + if (v !== null && typeof v !== "function") { + throw new Error( + "onListChanged property may only be set to 'null' or a function"); + } + this._onListChanged = v; + this._checkListening(); + } +}); + +/** + * The set of tabs has changed somehow. Call our onListChanged handler, if + * one is set, and if we haven't already called it since the last iteration. + */ +BrowserTabList.prototype._notifyListChanged = function () { + if (!this._onListChanged) { + return; + } + if (this._mustNotify) { + this._onListChanged(); + this._mustNotify = false; + } +}; + +/** + * Exit |actor|, belonging to |browser|, and notify the onListChanged + * handle if needed. + */ +BrowserTabList.prototype._handleActorClose = function (actor, browser) { + if (this._testing) { + if (this._actorByBrowser.get(browser) !== actor) { + throw new Error("BrowserTabActor not stored in map under given browser"); + } + if (actor.browser !== browser) { + throw new Error("actor's browser and map key don't match"); + } + } + + this._actorByBrowser.delete(browser); + actor.exit(); + + this._notifyListChanged(); + this._checkListening(); +}; + +/** + * Make sure we are listening or not listening for activity elsewhere in + * the browser, as appropriate. Other than setting up newly created XUL + * windows, all listener / observer connection and disconnection should + * happen here. + */ +BrowserTabList.prototype._checkListening = function () { + /* + * If we have an onListChanged handler that we haven't sent an announcement + * to since the last iteration, we need to watch for tab creation as well as + * change of the currently selected tab and tab title changes of tabs in + * parent process via TabAttrModified (tabs oop uses DOMTitleChanges). + * + * Oddly, we don't need to watch for 'close' events here. If our actor list + * is empty, then either it was empty the last time we iterated, and no + * close events are possible, or it was not empty the last time we + * iterated, but all the actors have since been closed, and we must have + * sent a notification already when they closed. + */ + this._listenForEventsIf(this._onListChanged && this._mustNotify, + "_listeningForTabOpen", + ["TabOpen", "TabSelect", "TabAttrModified"]); + + /* If we have live actors, we need to be ready to mark them dead. */ + this._listenForEventsIf(this._actorByBrowser.size > 0, + "_listeningForTabClose", + ["TabClose", "TabRemotenessChange"]); + + /* + * We must listen to the window mediator in either case, since that's the + * only way to find out about tabs that come and go when top-level windows + * are opened and closed. + */ + this._listenToMediatorIf((this._onListChanged && this._mustNotify) || + (this._actorByBrowser.size > 0)); + + /* + * We also listen for title changed from the child process. + * This allows listening for title changes from Fennec and OOP tabs in Fx. + */ + this._listenForMessagesIf(this._onListChanged && this._mustNotify, + "_listeningForTitleChange", + ["DOMTitleChanged"]); +}; + +/* + * Add or remove event listeners for all XUL windows. + * + * @param shouldListen boolean + * True if we should add event handlers; false if we should remove them. + * @param guard string + * The name of a guard property of 'this', indicating whether we're + * already listening for those events. + * @param eventNames array of strings + * An array of event names. + */ +BrowserTabList.prototype._listenForEventsIf = + function (shouldListen, guard, eventNames) { + if (!shouldListen !== !this[guard]) { + let op = shouldListen ? "addEventListener" : "removeEventListener"; + for (let win of allAppShellDOMWindows(DebuggerServer.chromeWindowType)) { + for (let name of eventNames) { + win[op](name, this, false); + } + } + this[guard] = shouldListen; + } + }; + +/* + * Add or remove message listeners for all XUL windows. + * + * @param aShouldListen boolean + * True if we should add message listeners; false if we should remove them. + * @param aGuard string + * The name of a guard property of 'this', indicating whether we're + * already listening for those messages. + * @param aMessageNames array of strings + * An array of message names. + */ +BrowserTabList.prototype._listenForMessagesIf = + function (shouldListen, guard, messageNames) { + if (!shouldListen !== !this[guard]) { + let op = shouldListen ? "addMessageListener" : "removeMessageListener"; + for (let win of allAppShellDOMWindows(DebuggerServer.chromeWindowType)) { + for (let name of messageNames) { + win.messageManager[op](name, this); + } + } + this[guard] = shouldListen; + } + }; + +/** + * Implement nsIMessageListener. + */ +BrowserTabList.prototype.receiveMessage = DevToolsUtils.makeInfallible( + function (message) { + let browser = message.target; + switch (message.name) { + case "DOMTitleChanged": { + let actor = this._actorByBrowser.get(browser); + if (actor) { + this._notifyListChanged(); + this._checkListening(); + } + break; + } + } + }); + +/** + * Implement nsIDOMEventListener. + */ +BrowserTabList.prototype.handleEvent = +DevToolsUtils.makeInfallible(function (event) { + let browser = event.target.linkedBrowser; + switch (event.type) { + case "TabOpen": + case "TabSelect": { + /* Don't create a new actor; iterate will take care of that. Just notify. */ + this._notifyListChanged(); + this._checkListening(); + break; + } + case "TabClose": { + let actor = this._actorByBrowser.get(browser); + if (actor) { + this._handleActorClose(actor, browser); + } + break; + } + case "TabRemotenessChange": { + // We have to remove the cached actor as we have to create a new instance. + let actor = this._actorByBrowser.get(browser); + if (actor) { + this._actorByBrowser.delete(browser); + // Don't create a new actor; iterate will take care of that. Just notify. + this._notifyListChanged(); + this._checkListening(); + } + break; + } + case "TabAttrModified": { + // Remote <browser> title changes are handled via DOMTitleChange message + // TabAttrModified is only here for browsers in parent process which + // don't send this message. + if (browser.isRemoteBrowser) { + break; + } + let actor = this._actorByBrowser.get(browser); + if (actor) { + // TabAttrModified is fired in various cases, here only care about title + // changes + if (event.detail.changed.includes("label")) { + this._notifyListChanged(); + this._checkListening(); + } + } + break; + } + } +}, "BrowserTabList.prototype.handleEvent"); + +/* + * If |shouldListen| is true, ensure we've registered a listener with the + * window mediator. Otherwise, ensure we haven't registered a listener. + */ +BrowserTabList.prototype._listenToMediatorIf = function (shouldListen) { + if (!shouldListen !== !this._listeningToMediator) { + let op = shouldListen ? "addListener" : "removeListener"; + Services.wm[op](this); + this._listeningToMediator = shouldListen; + } +}; + +/** + * nsIWindowMediatorListener implementation. + * + * See _onTabClosed for explanation of why we needn't actually tweak any + * actors or tables here. + * + * An nsIWindowMediatorListener's methods get passed all sorts of windows; we + * only care about the tab containers. Those have 'getBrowser' methods. + */ +BrowserTabList.prototype.onWindowTitleChange = () => { }; + +BrowserTabList.prototype.onOpenWindow = +DevToolsUtils.makeInfallible(function (window) { + let handleLoad = DevToolsUtils.makeInfallible(() => { + /* We don't want any further load events from this window. */ + window.removeEventListener("load", handleLoad, false); + + if (appShellDOMWindowType(window) !== DebuggerServer.chromeWindowType) { + return; + } + + // Listen for future tab activity. + if (this._listeningForTabOpen) { + window.addEventListener("TabOpen", this, false); + window.addEventListener("TabSelect", this, false); + window.addEventListener("TabAttrModified", this, false); + } + if (this._listeningForTabClose) { + window.addEventListener("TabClose", this, false); + window.addEventListener("TabRemotenessChange", this, false); + } + if (this._listeningForTitleChange) { + window.messageManager.addMessageListener("DOMTitleChanged", this); + } + + // As explained above, we will not receive a TabOpen event for this + // document's initial tab, so we must notify our client of the new tab + // this will have. + this._notifyListChanged(); + }); + + /* + * You can hardly do anything at all with a XUL window at this point; it + * doesn't even have its document yet. Wait until its document has + * loaded, and then see what we've got. This also avoids + * nsIWindowMediator enumeration from within listeners (bug 873589). + */ + window = window.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDOMWindow); + + window.addEventListener("load", handleLoad, false); +}, "BrowserTabList.prototype.onOpenWindow"); + +BrowserTabList.prototype.onCloseWindow = +DevToolsUtils.makeInfallible(function (window) { + window = window.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDOMWindow); + + if (appShellDOMWindowType(window) !== DebuggerServer.chromeWindowType) { + return; + } + + /* + * nsIWindowMediator deadlocks if you call its GetEnumerator method from + * a nsIWindowMediatorListener's onCloseWindow hook (bug 873589), so + * handle the close in a different tick. + */ + Services.tm.currentThread.dispatch(DevToolsUtils.makeInfallible(() => { + /* + * Scan the entire map for actors representing tabs that were in this + * top-level window, and exit them. + */ + for (let [browser, actor] of this._actorByBrowser) { + /* The browser document of a closed window has no default view. */ + if (!browser.ownerDocument.defaultView) { + this._handleActorClose(actor, browser); + } + } + }, "BrowserTabList.prototype.onCloseWindow's delayed body"), 0); +}, "BrowserTabList.prototype.onCloseWindow"); + +exports.BrowserTabList = BrowserTabList; + +/** + * Creates a TabActor whose main goal is to manage lifetime and + * expose the tab actors being registered via DebuggerServer.registerModule. + * But also track the lifetime of the document being tracked. + * + * ### Main requests: + * + * `attach`/`detach` requests: + * - start/stop document watching: + * Starts watching for new documents and emits `tabNavigated` and + * `frameUpdate` over RDP. + * - retrieve the thread actor: + * Instantiates a ThreadActor that can be later attached to in order to + * debug JS sources in the document. + * `switchToFrame`: + * Change the targeted document of the whole TabActor, and its child tab actors + * to an iframe or back to its original document. + * + * Most of the TabActor properties (like `chromeEventHandler` or `docShells`) + * are meant to be used by the various child tab actors. + * + * ### RDP events: + * + * - `tabNavigated`: + * Sent when the tab is about to navigate or has just navigated to + * a different document. + * This event contains the following attributes: + * * url (string) The new URI being loaded. + * * nativeConsoleAPI (boolean) `false` if the console API of the page has + * been overridden (e.g. by Firebug), + * `true` if the Gecko implementation is used. + * * state (string) `start` if we just start requesting the new URL, + * `stop` if the new URL is done loading. + * * isFrameSwitching (boolean) Indicates the event is dispatched when + * switching the TabActor context to + * a different frame. When we switch to + * an iframe, there is no document load. + * The targeted document is most likely + * going to be already done loading. + * * title (string) The document title being loaded. + * (sent only on state=stop) + * + * - `frameUpdate`: + * Sent when there was a change in the child frames contained in the document + * or when the tab's context was switched to another frame. + * This event can have four different forms depending on the type of change: + * * One or many frames are updated: + * { frames: [{ id, url, title, parentID }, ...] } + * * One frame got destroyed: + * { frames: [{ id, destroy: true }]} + * * All frames got destroyed: + * { destroyAll: true } + * * We switched the context of the TabActor to a specific frame: + * { selected: #id } + * + * ### Internal, non-rdp events: + * Various events are also dispatched on the TabActor itself that are not + * related to RDP, so, not sent to the client. They all relate to the documents + * tracked by the TabActor (its main targeted document, but also any of its + * iframes). + * - will-navigate + * This event fires once navigation starts. + * All pending user prompts are dealt with, + * but it is fired before the first request starts. + * - navigate + * This event is fired once the document's readyState is "complete". + * - window-ready + * This event is fired on three distinct scenarios: + * * When a new Window object is crafted, equivalent of `DOMWindowCreated`. + * It is dispatched before any page script is executed. + * * We will have already received a window-ready event for this window + * when it was created, but we received a window-destroyed event when + * it was frozen into the bfcache, and now the user navigated back to + * this page, so it's now live again and we should resume handling it. + * * For each existing document, when an `attach` request is received. + * At this point scripts in the page will be already loaded. + * - window-destroyed + * This event is fired in two cases: + * * When the window object is destroyed, i.e. when the related document + * is garbage collected. This can happen when the tab is closed or the + * iframe is removed from the DOM. + * It is equivalent of `inner-window-destroyed` event. + * * When the page goes into the bfcache and gets frozen. + * The equivalent of `pagehide`. + * - changed-toplevel-document + * This event fires when we switch the TabActor targeted document + * to one of its iframes, or back to its original top document. + * It is dispatched between window-destroyed and window-ready. + * - stylesheet-added + * This event is fired when a StyleSheetActor is created. + * It contains the following attribute : + * * actor (StyleSheetActor) The created actor. + * + * Note that *all* these events are dispatched in the following order + * when we switch the context of the TabActor to a given iframe: + * - will-navigate + * - window-destroyed + * - changed-toplevel-document + * - window-ready + * - navigate + * + * This class is subclassed by ContentActor and others. + * Subclasses are expected to implement a getter for the docShell property. + * + * @param connection DebuggerServerConnection + * The conection to the client. + */ +function TabActor(connection) { + this.conn = connection; + this._tabActorPool = null; + // A map of actor names to actor instances provided by extensions. + this._extraActors = {}; + this._exited = false; + this._sources = null; + + // Map of DOM stylesheets to StyleSheetActors + this._styleSheetActors = new Map(); + + this._shouldAddNewGlobalAsDebuggee = + this._shouldAddNewGlobalAsDebuggee.bind(this); + + this.makeDebugger = makeDebugger.bind(null, { + findDebuggees: () => { + return this.windows.concat(this.webextensionsContentScriptGlobals); + }, + shouldAddNewGlobalAsDebuggee: this._shouldAddNewGlobalAsDebuggee + }); + + // Flag eventually overloaded by sub classes in order to watch new docshells + // Used by the ChromeActor to list all frames in the Browser Toolbox + this.listenForNewDocShells = false; + + this.traits = { + reconfigure: true, + // Supports frame listing via `listFrames` request and `frameUpdate` events + // as well as frame switching via `switchToFrame` request + frames: true, + // Do not require to send reconfigure request to reset the document state + // to what it was before using the TabActor + noTabReconfigureOnClose: true + }; + + this._workerActorList = null; + this._workerActorPool = null; + this._onWorkerActorListChanged = this._onWorkerActorListChanged.bind(this); +} + +// XXX (bug 710213): TabActor attach/detach/exit/disconnect is a +// *complete* mess, needs to be rethought asap. + +TabActor.prototype = { + traits: null, + + // Optional console API listener options (e.g. used by the WebExtensionActor to + // filter console messages by addonID), set to an empty (no options) object by default. + consoleAPIListenerOptions: {}, + + // Optional TabSources filter function (e.g. used by the WebExtensionActor to filter + // sources by addonID), allow all sources by default. + _allowSource() { + return true; + }, + + get exited() { + return this._exited; + }, + + get attached() { + return !!this._attached; + }, + + _tabPool: null, + get tabActorPool() { + return this._tabPool; + }, + + _contextPool: null, + get contextActorPool() { + return this._contextPool; + }, + + // A constant prefix that will be used to form the actor ID by the server. + actorPrefix: "tab", + + /** + * An object on which listen for DOMWindowCreated and pageshow events. + */ + get chromeEventHandler() { + return getDocShellChromeEventHandler(this.docShell); + }, + + /** + * Getter for the nsIMessageManager associated to the tab. + */ + get messageManager() { + try { + return this.docShell + .QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIContentFrameMessageManager); + } catch (e) { + return null; + } + }, + + /** + * Getter for the tab's doc shell. + */ + get docShell() { + throw new Error( + "The docShell getter should be implemented by a subclass of TabActor"); + }, + + /** + * Getter for the list of all docshell in this tabActor + * @return {Array} + */ + get docShells() { + return getChildDocShells(this.docShell); + }, + + /** + * Getter for the tab content's DOM window. + */ + get window() { + // On xpcshell, there is no document + if (this.docShell) { + return this.docShell + .QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDOMWindow); + } + return null; + }, + + get outerWindowID() { + if (this.window) { + return this.window.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDOMWindowUtils) + .outerWindowID; + } + return null; + }, + + /** + * Getter for the WebExtensions ContentScript globals related to the + * current tab content's DOM window. + */ + get webextensionsContentScriptGlobals() { + // Ignore xpcshell runtime which spawn TabActors without a window. + if (this.window) { + return ExtensionContent.getContentScriptGlobalsForWindow(this.window); + } + + return []; + }, + + /** + * Getter for the list of all content DOM windows in this tabActor + * @return {Array} + */ + get windows() { + return this.docShells.map(docShell => { + return docShell.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDOMWindow); + }); + }, + + /** + * Getter for the original docShell the tabActor got attached to in the first + * place. + * Note that your actor should normally *not* rely on this top level docShell + * if you want it to show information relative to the iframe that's currently + * being inspected in the toolbox. + */ + get originalDocShell() { + if (!this._originalWindow) { + return this.docShell; + } + + return this._originalWindow.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIWebNavigation) + .QueryInterface(Ci.nsIDocShell); + }, + + /** + * Getter for the original window the tabActor got attached to in the first + * place. + * Note that your actor should normally *not* rely on this top level window if + * you want it to show information relative to the iframe that's currently + * being inspected in the toolbox. + */ + get originalWindow() { + return this._originalWindow || this.window; + }, + + /** + * Getter for the nsIWebProgress for watching this window. + */ + get webProgress() { + return this.docShell + .QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIWebProgress); + }, + + /** + * Getter for the nsIWebNavigation for the tab. + */ + get webNavigation() { + return this.docShell + .QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIWebNavigation); + }, + + /** + * Getter for the tab's document. + */ + get contentDocument() { + return this.webNavigation.document; + }, + + /** + * Getter for the tab title. + * @return string + * Tab title. + */ + get title() { + return this.contentDocument.contentTitle; + }, + + /** + * Getter for the tab URL. + * @return string + * Tab URL. + */ + get url() { + if (this.webNavigation.currentURI) { + return this.webNavigation.currentURI.spec; + } + // Abrupt closing of the browser window may leave callbacks without a + // currentURI. + return null; + }, + + get sources() { + if (!this._sources) { + this._sources = new TabSources(this.threadActor, this._allowSource); + } + return this._sources; + }, + + /** + * This is called by BrowserTabList.getList for existing tab actors prior to + * calling |form| below. It can be used to do any async work that may be + * needed to assemble the form. + */ + update() { + return promise.resolve(this); + }, + + form() { + assert(!this.exited, + "form() shouldn't be called on exited browser actor."); + assert(this.actorID, + "tab should have an actorID."); + + let response = { + actor: this.actorID + }; + + // We may try to access window while the document is closing, then + // accessing window throws. Also on xpcshell we are using tabactor even if + // there is no valid document. + if (this.docShell && !this.docShell.isBeingDestroyed()) { + response.title = this.title; + response.url = this.url; + response.outerWindowID = this.outerWindowID; + } + + // Always use the same ActorPool, so existing actor instances + // (created in createExtraActors) are not lost. + if (!this._tabActorPool) { + this._tabActorPool = new ActorPool(this.conn); + this.conn.addActorPool(this._tabActorPool); + } + + // Walk over tab actor factories and make sure they are all + // instantiated and added into the ActorPool. Note that some + // factories can be added dynamically by extensions. + this._createExtraActors(DebuggerServer.tabActorFactories, + this._tabActorPool); + + this._appendExtraActors(response); + return response; + }, + + /** + * Called when the actor is removed from the connection. + */ + disconnect() { + this.exit(); + }, + + /** + * Called by the root actor when the underlying tab is closed. + */ + exit() { + if (this.exited) { + return; + } + + // Tell the thread actor that the tab is closed, so that it may terminate + // instead of resuming the debuggee script. + if (this._attached) { + this.threadActor._tabClosed = true; + } + + this._detach(); + + Object.defineProperty(this, "docShell", { + value: null, + configurable: true + }); + + this._extraActors = null; + + this._exited = true; + }, + + /** + * Return true if the given global is associated with this tab and should be + * added as a debuggee, false otherwise. + */ + _shouldAddNewGlobalAsDebuggee(wrappedGlobal) { + if (wrappedGlobal.hostAnnotations && + wrappedGlobal.hostAnnotations.type == "document" && + wrappedGlobal.hostAnnotations.element === this.window) { + return true; + } + + let global = unwrapDebuggerObjectGlobal(wrappedGlobal); + if (!global) { + return false; + } + + // Check if the global is a sdk page-mod sandbox. + let metadata = {}; + let id = ""; + try { + id = getInnerId(this.window); + metadata = Cu.getSandboxMetadata(global); + } catch (e) { + // ignore + } + if (metadata + && metadata["inner-window-id"] + && metadata["inner-window-id"] == id) { + return true; + } + + return false; + }, + + /* Support for DebuggerServer.addTabActor. */ + _createExtraActors: createExtraActors, + _appendExtraActors: appendExtraActors, + + /** + * Does the actual work of attaching to a tab. + */ + _attach() { + if (this._attached) { + return; + } + + // Create a pool for tab-lifetime actors. + assert(!this._tabPool, "Shouldn't have a tab pool if we weren't attached."); + this._tabPool = new ActorPool(this.conn); + this.conn.addActorPool(this._tabPool); + + // ... and a pool for context-lifetime actors. + this._pushContext(); + + // on xpcshell, there is no document + if (this.window) { + this._progressListener = new DebuggerProgressListener(this); + + // Save references to the original document we attached to + this._originalWindow = this.window; + + // Ensure replying to attach() request first + // before notifying about new docshells. + DevToolsUtils.executeSoon(() => this._watchDocshells()); + } + + this._attached = true; + }, + + _watchDocshells() { + // In child processes, we watch all docshells living in the process. + if (this.listenForNewDocShells) { + Services.obs.addObserver(this, "webnavigation-create", false); + } + Services.obs.addObserver(this, "webnavigation-destroy", false); + + // We watch for all child docshells under the current document, + this._progressListener.watch(this.docShell); + + // And list all already existing ones. + this._updateChildDocShells(); + }, + + onSwitchToFrame(request) { + let windowId = request.windowId; + let win; + + try { + win = Services.wm.getOuterWindowWithId(windowId); + } catch (e) { + // ignore + } + if (!win) { + return { error: "noWindow", + message: "The related docshell is destroyed or not found" }; + } else if (win == this.window) { + return {}; + } + + // Reply first before changing the document + DevToolsUtils.executeSoon(() => this._changeTopLevelDocument(win)); + + return {}; + }, + + onListFrames(request) { + let windows = this._docShellsToWindows(this.docShells); + return { frames: windows }; + }, + + onListWorkers(request) { + if (!this.attached) { + return { error: "wrongState" }; + } + + if (this._workerActorList === null) { + this._workerActorList = new WorkerActorList(this.conn, { + type: Ci.nsIWorkerDebugger.TYPE_DEDICATED, + window: this.window + }); + } + + return this._workerActorList.getList().then((actors) => { + let pool = new ActorPool(this.conn); + for (let actor of actors) { + pool.addActor(actor); + } + + this.conn.removeActorPool(this._workerActorPool); + this._workerActorPool = pool; + this.conn.addActorPool(this._workerActorPool); + + this._workerActorList.onListChanged = this._onWorkerActorListChanged; + + return { + "from": this.actorID, + "workers": actors.map((actor) => actor.form()) + }; + }); + }, + + _onWorkerActorListChanged() { + this._workerActorList.onListChanged = null; + this.conn.sendActorEvent(this.actorID, "workerListChanged"); + }, + + observe(subject, topic, data) { + // Ignore any event that comes before/after the tab actor is attached + // That typically happens during firefox shutdown. + if (!this.attached) { + return; + } + if (topic == "webnavigation-create") { + subject.QueryInterface(Ci.nsIDocShell); + this._onDocShellCreated(subject); + } else if (topic == "webnavigation-destroy") { + this._onDocShellDestroy(subject); + } + }, + + _onDocShellCreated(docShell) { + // (chrome-)webnavigation-create is fired very early during docshell + // construction. In new root docshells within child processes, involving + // TabChild, this event is from within this call: + // http://hg.mozilla.org/mozilla-central/annotate/74d7fb43bb44/dom/ipc/TabChild.cpp#l912 + // whereas the chromeEventHandler (and most likely other stuff) is set + // later: + // http://hg.mozilla.org/mozilla-central/annotate/74d7fb43bb44/dom/ipc/TabChild.cpp#l944 + // So wait a tick before watching it: + DevToolsUtils.executeSoon(() => { + // Bug 1142752: sometimes, the docshell appears to be immediately + // destroyed, bailout early to prevent random exceptions. + if (docShell.isBeingDestroyed()) { + return; + } + + // In child processes, we have new root docshells, + // let's watch them and all their child docshells. + if (this._isRootDocShell(docShell)) { + this._progressListener.watch(docShell); + } + this._notifyDocShellsUpdate([docShell]); + }); + }, + + _onDocShellDestroy(docShell) { + let webProgress = docShell.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIWebProgress); + this._notifyDocShellDestroy(webProgress); + }, + + _isRootDocShell(docShell) { + // Should report as root docshell: + // - New top level window's docshells, when using ChromeActor against a + // process. It allows tracking iframes of the newly opened windows + // like Browser console or new browser windows. + // - MozActivities or window.open frames on B2G, where a new root docshell + // is spawn in the child process of the app. + return !docShell.parent; + }, + + // Convert docShell list to windows objects list being sent to the client + _docShellsToWindows(docshells) { + return docshells.map(docShell => { + let webProgress = docShell.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIWebProgress); + let window = webProgress.DOMWindow; + let id = window.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDOMWindowUtils) + .outerWindowID; + let parentID = undefined; + // Ignore the parent of the original document on non-e10s firefox, + // as we get the xul window as parent and don't care about it. + if (window.parent && window != this._originalWindow) { + parentID = window.parent + .QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDOMWindowUtils) + .outerWindowID; + } + + // Collect the addonID from the document origin attributes. + let addonID = window.document.nodePrincipal.originAttributes.addonId; + + return { + id, + parentID, + addonID, + url: window.location.href, + title: window.document.title, + }; + }); + }, + + _notifyDocShellsUpdate(docshells) { + let windows = this._docShellsToWindows(docshells); + + // Do not send the `frameUpdate` event if the windows array is empty. + if (windows.length == 0) { + return; + } + + this.conn.send({ + from: this.actorID, + type: "frameUpdate", + frames: windows + }); + }, + + _updateChildDocShells() { + this._notifyDocShellsUpdate(this.docShells); + }, + + _notifyDocShellDestroy(webProgress) { + webProgress = webProgress.QueryInterface(Ci.nsIWebProgress); + let id = webProgress.DOMWindow + .QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDOMWindowUtils) + .outerWindowID; + this.conn.send({ + from: this.actorID, + type: "frameUpdate", + frames: [{ + id, + destroy: true + }] + }); + + // Stop watching this docshell (the unwatch() method will check if we + // started watching it before). + webProgress.QueryInterface(Ci.nsIDocShell); + this._progressListener.unwatch(webProgress); + + if (webProgress.DOMWindow == this._originalWindow) { + // If the original top level document we connected to is removed, + // we try to switch to any other top level document + let rootDocShells = this.docShells + .filter(d => { + return d != this.docShell && + this._isRootDocShell(d); + }); + if (rootDocShells.length > 0) { + let newRoot = rootDocShells[0]; + this._originalWindow = newRoot.DOMWindow; + this._changeTopLevelDocument(this._originalWindow); + } else { + // If for some reason (typically during Firefox shutdown), the original + // document is destroyed, and there is no other top level docshell, + // we detach the tab actor to unregister all listeners and prevent any + // exception + this.exit(); + } + return; + } + + // If the currently targeted context is destroyed, + // and we aren't on the top-level document, + // we have to switch to the top-level one. + if (webProgress.DOMWindow == this.window && + this.window != this._originalWindow) { + this._changeTopLevelDocument(this._originalWindow); + } + }, + + _notifyDocShellDestroyAll() { + this.conn.send({ + from: this.actorID, + type: "frameUpdate", + destroyAll: true + }); + }, + + /** + * Creates a thread actor and a pool for context-lifetime actors. It then sets + * up the content window for debugging. + */ + _pushContext() { + assert(!this._contextPool, "Can't push multiple contexts"); + + this._contextPool = new ActorPool(this.conn); + this.conn.addActorPool(this._contextPool); + + this.threadActor = new ThreadActor(this, this.window); + this._contextPool.addActor(this.threadActor); + }, + + /** + * Exits the current thread actor and removes the context-lifetime actor pool. + * The content window is no longer being debugged after this call. + */ + _popContext() { + assert(!!this._contextPool, "No context to pop."); + + this.conn.removeActorPool(this._contextPool); + this._contextPool = null; + this.threadActor.exit(); + this.threadActor = null; + this._sources = null; + }, + + /** + * Does the actual work of detaching from a tab. + * + * @returns false if the tab wasn't attached or true of detaching succeeds. + */ + _detach() { + if (!this.attached) { + return false; + } + + // Check for docShell availability, as it can be already gone + // during Firefox shutdown. + if (this.docShell) { + this._progressListener.unwatch(this.docShell); + this._restoreDocumentSettings(); + } + if (this._progressListener) { + this._progressListener.destroy(); + this._progressListener = null; + this._originalWindow = null; + + // Removes the observers being set in _watchDocShells + if (this.listenForNewDocShells) { + Services.obs.removeObserver(this, "webnavigation-create"); + } + Services.obs.removeObserver(this, "webnavigation-destroy"); + } + + this._popContext(); + + // Shut down actors that belong to this tab's pool. + for (let sheetActor of this._styleSheetActors.values()) { + this._tabPool.removeActor(sheetActor); + } + this._styleSheetActors.clear(); + this.conn.removeActorPool(this._tabPool); + this._tabPool = null; + if (this._tabActorPool) { + this.conn.removeActorPool(this._tabActorPool); + this._tabActorPool = null; + } + + // Make sure that no more workerListChanged notifications are sent. + if (this._workerActorList !== null) { + this._workerActorList.onListChanged = null; + this._workerActorList = null; + } + + if (this._workerActorPool !== null) { + this.conn.removeActorPool(this._workerActorPool); + this._workerActorPool = null; + } + + this._attached = false; + + this.conn.send({ from: this.actorID, + type: "tabDetached" }); + + return true; + }, + + // Protocol Request Handlers + + onAttach(request) { + if (this.exited) { + return { type: "exited" }; + } + + this._attach(); + + return { + type: "tabAttached", + threadActor: this.threadActor.actorID, + cacheDisabled: this._getCacheDisabled(), + javascriptEnabled: this._getJavascriptEnabled(), + traits: this.traits, + }; + }, + + onDetach(request) { + if (!this._detach()) { + return { error: "wrongState" }; + } + + return { type: "detached" }; + }, + + /** + * Bring the tab's window to front. + */ + onFocus() { + if (this.window) { + this.window.focus(); + } + return {}; + }, + + /** + * Reload the page in this tab. + */ + onReload(request) { + let force = request && request.options && request.options.force; + // Wait a tick so that the response packet can be dispatched before the + // subsequent navigation event packet. + Services.tm.currentThread.dispatch(DevToolsUtils.makeInfallible(() => { + // This won't work while the browser is shutting down and we don't really + // care. + if (Services.startup.shuttingDown) { + return; + } + this.webNavigation.reload(force ? + Ci.nsIWebNavigation.LOAD_FLAGS_BYPASS_CACHE : + Ci.nsIWebNavigation.LOAD_FLAGS_NONE); + }, "TabActor.prototype.onReload's delayed body"), 0); + return {}; + }, + + /** + * Navigate this tab to a new location + */ + onNavigateTo(request) { + // Wait a tick so that the response packet can be dispatched before the + // subsequent navigation event packet. + Services.tm.currentThread.dispatch(DevToolsUtils.makeInfallible(() => { + this.window.location = request.url; + }, "TabActor.prototype.onNavigateTo's delayed body"), 0); + return {}; + }, + + /** + * Reconfigure options. + */ + onReconfigure(request) { + let options = request.options || {}; + + if (!this.docShell) { + // The tab is already closed. + return {}; + } + this._toggleDevToolsSettings(options); + + return {}; + }, + + /** + * Handle logic to enable/disable JS/cache/Service Worker testing. + */ + _toggleDevToolsSettings(options) { + // Wait a tick so that the response packet can be dispatched before the + // subsequent navigation event packet. + let reload = false; + + if (typeof options.javascriptEnabled !== "undefined" && + options.javascriptEnabled !== this._getJavascriptEnabled()) { + this._setJavascriptEnabled(options.javascriptEnabled); + reload = true; + } + if (typeof options.cacheDisabled !== "undefined" && + options.cacheDisabled !== this._getCacheDisabled()) { + this._setCacheDisabled(options.cacheDisabled); + } + if ((typeof options.serviceWorkersTestingEnabled !== "undefined") && + (options.serviceWorkersTestingEnabled !== + this._getServiceWorkersTestingEnabled())) { + this._setServiceWorkersTestingEnabled( + options.serviceWorkersTestingEnabled + ); + } + + // Reload if: + // - there's an explicit `performReload` flag and it's true + // - there's no `performReload` flag, but it makes sense to do so + let hasExplicitReloadFlag = "performReload" in options; + if ((hasExplicitReloadFlag && options.performReload) || + (!hasExplicitReloadFlag && reload)) { + this.onReload(); + } + }, + + /** + * Opposite of the _toggleDevToolsSettings method, that reset document state + * when closing the toolbox. + */ + _restoreDocumentSettings() { + this._restoreJavascript(); + this._setCacheDisabled(false); + this._setServiceWorkersTestingEnabled(false); + }, + + /** + * Disable or enable the cache via docShell. + */ + _setCacheDisabled(disabled) { + let enable = Ci.nsIRequest.LOAD_NORMAL; + let disable = Ci.nsIRequest.LOAD_BYPASS_CACHE | + Ci.nsIRequest.INHIBIT_CACHING; + + this.docShell.defaultLoadFlags = disabled ? disable : enable; + }, + + /** + * Disable or enable JS via docShell. + */ + _wasJavascriptEnabled: null, + _setJavascriptEnabled(allow) { + if (this._wasJavascriptEnabled === null) { + this._wasJavascriptEnabled = this.docShell.allowJavascript; + } + this.docShell.allowJavascript = allow; + }, + + /** + * Restore JS state, before the actor modified it. + */ + _restoreJavascript() { + if (this._wasJavascriptEnabled !== null) { + this._setJavascriptEnabled(this._wasJavascriptEnabled); + this._wasJavascriptEnabled = null; + } + }, + + /** + * Return JS allowed status. + */ + _getJavascriptEnabled() { + if (!this.docShell) { + // The tab is already closed. + return null; + } + + return this.docShell.allowJavascript; + }, + + /** + * Disable or enable the service workers testing features. + */ + _setServiceWorkersTestingEnabled(enabled) { + let windowUtils = this.window.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDOMWindowUtils); + windowUtils.serviceWorkersTestingEnabled = enabled; + }, + + /** + * Return cache allowed status. + */ + _getCacheDisabled() { + if (!this.docShell) { + // The tab is already closed. + return null; + } + + let disable = Ci.nsIRequest.LOAD_BYPASS_CACHE | + Ci.nsIRequest.INHIBIT_CACHING; + return this.docShell.defaultLoadFlags === disable; + }, + + /** + * Return service workers testing allowed status. + */ + _getServiceWorkersTestingEnabled() { + if (!this.docShell) { + // The tab is already closed. + return null; + } + + let windowUtils = this.window.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDOMWindowUtils); + return windowUtils.serviceWorkersTestingEnabled; + }, + + /** + * Prepare to enter a nested event loop by disabling debuggee events. + */ + preNest() { + if (!this.window) { + // The tab is already closed. + return; + } + let windowUtils = this.window + .QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDOMWindowUtils); + windowUtils.suppressEventHandling(true); + windowUtils.suspendTimeouts(); + }, + + /** + * Prepare to exit a nested event loop by enabling debuggee events. + */ + postNest(nestData) { + if (!this.window) { + // The tab is already closed. + return; + } + let windowUtils = this.window + .QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDOMWindowUtils); + windowUtils.resumeTimeouts(); + windowUtils.suppressEventHandling(false); + }, + + _changeTopLevelDocument(window) { + // Fake a will-navigate on the previous document + // to let a chance to unregister it + this._willNavigate(this.window, window.location.href, null, true); + + this._windowDestroyed(this.window, null, true); + + // Immediately change the window as this window, if in process of unload + // may already be non working on the next cycle and start throwing + this._setWindow(window); + + DevToolsUtils.executeSoon(() => { + // Then fake window-ready and navigate on the given document + this._windowReady(window, true); + DevToolsUtils.executeSoon(() => { + this._navigate(window, true); + }); + }); + }, + + _setWindow(window) { + let docShell = window.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIWebNavigation) + .QueryInterface(Ci.nsIDocShell); + // Here is the very important call where we switch the currently + // targeted context (it will indirectly update this.window and + // many other attributes defined from docShell). + Object.defineProperty(this, "docShell", { + value: docShell, + enumerable: true, + configurable: true + }); + events.emit(this, "changed-toplevel-document"); + this.conn.send({ + from: this.actorID, + type: "frameUpdate", + selected: this.outerWindowID + }); + }, + + /** + * Handle location changes, by clearing the previous debuggees and enabling + * debugging, which may have been disabled temporarily by the + * DebuggerProgressListener. + */ + _windowReady(window, isFrameSwitching = false) { + let isTopLevel = window == this.window; + + // We just reset iframe list on WillNavigate, so we now list all existing + // frames when we load a new document in the original window + if (window == this._originalWindow && !isFrameSwitching) { + this._updateChildDocShells(); + } + + events.emit(this, "window-ready", { + window: window, + isTopLevel: isTopLevel, + id: getWindowID(window) + }); + + // TODO bug 997119: move that code to ThreadActor by listening to + // window-ready + let threadActor = this.threadActor; + if (isTopLevel && threadActor.state != "detached") { + this.sources.reset({ sourceMaps: true }); + threadActor.clearDebuggees(); + threadActor.dbg.enabled = true; + threadActor.maybePauseOnExceptions(); + // Update the global no matter if the debugger is on or off, + // otherwise the global will be wrong when enabled later. + threadActor.global = window; + } + + // Refresh the debuggee list when a new window object appears (top window or + // iframe). + if (threadActor.attached) { + threadActor.dbg.addDebuggees(); + } + }, + + _windowDestroyed(window, id = null, isFrozen = false) { + events.emit(this, "window-destroyed", { + window: window, + isTopLevel: window == this.window, + id: id || getWindowID(window), + isFrozen: isFrozen + }); + }, + + /** + * Start notifying server and client about a new document + * being loaded in the currently targeted context. + */ + _willNavigate(window, newURI, request, isFrameSwitching = false) { + let isTopLevel = window == this.window; + let reset = false; + + if (window == this._originalWindow && !isFrameSwitching) { + // Clear the iframe list if the original top-level document changes. + this._notifyDocShellDestroyAll(); + + // If the top level document changes and we are targeting + // an iframe, we need to reset to the upcoming new top level document. + // But for this will-navigate event, we will dispatch on the old window. + // (The inspector codebase expect to receive will-navigate for the + // currently displayed document in order to cleanup the markup view) + if (this.window != this._originalWindow) { + reset = true; + window = this.window; + isTopLevel = true; + } + } + + // will-navigate event needs to be dispatched synchronously, + // by calling the listeners in the order or registration. + // This event fires once navigation starts, + // (all pending user prompts are dealt with), + // but before the first request starts. + events.emit(this, "will-navigate", { + window: window, + isTopLevel: isTopLevel, + newURI: newURI, + request: request + }); + + // We don't do anything for inner frames in TabActor. + // (we will only update thread actor on window-ready) + if (!isTopLevel) { + return; + } + + // Proceed normally only if the debuggee is not paused. + // TODO bug 997119: move that code to ThreadActor by listening to + // will-navigate + let threadActor = this.threadActor; + if (threadActor.state == "paused") { + this.conn.send( + threadActor.unsafeSynchronize(Promise.resolve(threadActor.onResume()))); + threadActor.dbg.enabled = false; + } + threadActor.disableAllBreakpoints(); + + this.conn.send({ + from: this.actorID, + type: "tabNavigated", + url: newURI, + nativeConsoleAPI: true, + state: "start", + isFrameSwitching: isFrameSwitching + }); + + if (reset) { + this._setWindow(this._originalWindow); + } + }, + + /** + * Notify server and client about a new document done loading in the current + * targeted context. + */ + _navigate(window, isFrameSwitching = false) { + let isTopLevel = window == this.window; + + // navigate event needs to be dispatched synchronously, + // by calling the listeners in the order or registration. + // This event is fired once the document is loaded, + // after the load event, it's document ready-state is 'complete'. + events.emit(this, "navigate", { + window: window, + isTopLevel: isTopLevel + }); + + // We don't do anything for inner frames in TabActor. + // (we will only update thread actor on window-ready) + if (!isTopLevel) { + return; + } + + // TODO bug 997119: move that code to ThreadActor by listening to navigate + let threadActor = this.threadActor; + if (threadActor.state == "running") { + threadActor.dbg.enabled = true; + } + + this.conn.send({ + from: this.actorID, + type: "tabNavigated", + url: this.url, + title: this.title, + nativeConsoleAPI: this.hasNativeConsoleAPI(this.window), + state: "stop", + isFrameSwitching: isFrameSwitching + }); + }, + + /** + * Tells if the window.console object is native or overwritten by script in + * the page. + * + * @param nsIDOMWindow window + * The window object you want to check. + * @return boolean + * True if the window.console object is native, or false otherwise. + */ + hasNativeConsoleAPI(window) { + let isNative = false; + try { + // We are very explicitly examining the "console" property of + // the non-Xrayed object here. + let console = window.wrappedJSObject.console; + isNative = new XPCNativeWrapper(console).IS_NATIVE_CONSOLE; + } catch (ex) { + // ignore + } + return isNative; + }, + + /** + * Create or return the StyleSheetActor for a style sheet. This method + * is here because the Style Editor and Inspector share style sheet actors. + * + * @param DOMStyleSheet styleSheet + * The style sheet to create an actor for. + * @return StyleSheetActor actor + * The actor for this style sheet. + * + */ + createStyleSheetActor(styleSheet) { + if (this._styleSheetActors.has(styleSheet)) { + return this._styleSheetActors.get(styleSheet); + } + let actor = new StyleSheetActor(styleSheet, this); + this._styleSheetActors.set(styleSheet, actor); + + this._tabPool.addActor(actor); + events.emit(this, "stylesheet-added", actor); + + return actor; + }, + + removeActorByName(name) { + if (name in this._extraActors) { + const actor = this._extraActors[name]; + if (this._tabActorPool.has(actor)) { + this._tabActorPool.removeActor(actor); + } + delete this._extraActors[name]; + } + }, + + /** + * Takes a packet containing a url, line and column and returns + * the updated url, line and column based on the current source mapping + * (source mapped files, pretty prints). + * + * @param {String} request.url + * @param {Number} request.line + * @param {Number?} request.column + * @return {Promise<Object>} + */ + onResolveLocation(request) { + let { url, line } = request; + let column = request.column || 0; + const scripts = this.threadActor.dbg.findScripts({ url }); + + if (!scripts[0] || !scripts[0].source) { + return promise.resolve({ + from: this.actorID, + type: "resolveLocation", + error: "SOURCE_NOT_FOUND" + }); + } + const source = scripts[0].source; + const generatedActor = this.sources.createNonSourceMappedActor(source); + let generatedLocation = new GeneratedLocation( + generatedActor, line, column); + return this.sources.getOriginalLocation(generatedLocation).then(loc => { + // If no map found, return this packet + if (loc.originalLine == null) { + return { + type: "resolveLocation", + error: "MAP_NOT_FOUND" + }; + } + + loc = loc.toJSON(); + return { + from: this.actorID, + url: loc.source.url, + column: loc.column, + line: loc.line + }; + }); + }, +}; + +/** + * The request types this actor can handle. + */ +TabActor.prototype.requestTypes = { + "attach": TabActor.prototype.onAttach, + "detach": TabActor.prototype.onDetach, + "focus": TabActor.prototype.onFocus, + "reload": TabActor.prototype.onReload, + "navigateTo": TabActor.prototype.onNavigateTo, + "reconfigure": TabActor.prototype.onReconfigure, + "switchToFrame": TabActor.prototype.onSwitchToFrame, + "listFrames": TabActor.prototype.onListFrames, + "listWorkers": TabActor.prototype.onListWorkers, + "resolveLocation": TabActor.prototype.onResolveLocation +}; + +exports.TabActor = TabActor; + +/** + * Creates a tab actor for handling requests to a single browser frame. + * Both <xul:browser> and <iframe mozbrowser> are supported. + * This actor is a shim that connects to a ContentActor in a remote browser process. + * All RDP packets get forwarded using the message manager. + * + * @param connection The main RDP connection. + * @param browser <xul:browser> or <iframe mozbrowser> element to connect to. + */ +function BrowserTabActor(connection, browser) { + this._conn = connection; + this._browser = browser; + this._form = null; +} + +BrowserTabActor.prototype = { + connect() { + let onDestroy = () => { + this._form = null; + }; + let connect = DebuggerServer.connectToChild(this._conn, this._browser, onDestroy); + return connect.then(form => { + this._form = form; + return this; + }); + }, + + get _tabbrowser() { + if (typeof this._browser.getTabBrowser == "function") { + return this._browser.getTabBrowser(); + } + return null; + }, + + get _mm() { + // Get messageManager from XUL browser (which might be a specialized tunnel for RDM) + // or else fallback to asking the frameLoader itself. + return this._browser.messageManager || + this._browser.frameLoader.messageManager; + }, + + update() { + // If the child happens to be crashed/close/detach, it won't have _form set, + // so only request form update if some code is still listening on the other + // side. + if (this._form) { + let deferred = promise.defer(); + let onFormUpdate = msg => { + // There may be more than just one childtab.js up and running + if (this._form.actor != msg.json.actor) { + return; + } + this._mm.removeMessageListener("debug:form", onFormUpdate); + this._form = msg.json; + deferred.resolve(this); + }; + this._mm.addMessageListener("debug:form", onFormUpdate); + this._mm.sendAsyncMessage("debug:form"); + return deferred.promise; + } + + return this.connect(); + }, + + /** + * If we don't have a title from the content side because it's a zombie tab, try to find + * it on the chrome side. + */ + get title() { + // On Fennec, we can check the session store data for zombie tabs + if (this._browser.__SS_restore) { + let sessionStore = this._browser.__SS_data; + // Get the last selected entry + let entry = sessionStore.entries[sessionStore.index - 1]; + return entry.title; + } + // If contentTitle is empty (e.g. on a not-yet-restored tab), but there is a + // tabbrowser (i.e. desktop Firefox, but not Fennec), we can use the label + // as the title. + if (this._tabbrowser) { + let tab = this._tabbrowser.getTabForBrowser(this._browser); + if (tab) { + return tab.label; + } + } + return ""; + }, + + /** + * If we don't have a url from the content side because it's a zombie tab, try to find + * it on the chrome side. + */ + get url() { + // On Fennec, we can check the session store data for zombie tabs + if (this._browser.__SS_restore) { + let sessionStore = this._browser.__SS_data; + // Get the last selected entry + let entry = sessionStore.entries[sessionStore.index - 1]; + return entry.url; + } + return null; + }, + + form() { + let form = Object.assign({}, this._form); + // In some cases, the title and url fields might be empty. Zombie tabs (not yet + // restored) are a good example. In such cases, try to look up values for these + // fields using other data in the parent process. + if (!form.title) { + form.title = this.title; + } + if (!form.url) { + form.url = this.url; + } + return form; + }, + + exit() { + this._browser = null; + }, +}; + +exports.BrowserTabActor = BrowserTabActor; + +function BrowserAddonList(connection) { + this._connection = connection; + this._actorByAddonId = new Map(); + this._onListChanged = null; +} + +BrowserAddonList.prototype.getList = function () { + let deferred = promise.defer(); + AddonManager.getAllAddons((addons) => { + for (let addon of addons) { + let actor = this._actorByAddonId.get(addon.id); + if (!actor) { + if (addon.isWebExtension) { + actor = new WebExtensionActor(this._connection, addon); + } else { + actor = new BrowserAddonActor(this._connection, addon); + } + + this._actorByAddonId.set(addon.id, actor); + } + } + deferred.resolve([...this._actorByAddonId].map(([_, actor]) => actor)); + }); + return deferred.promise; +}; + +Object.defineProperty(BrowserAddonList.prototype, "onListChanged", { + enumerable: true, + configurable: true, + get() { + return this._onListChanged; + }, + set(v) { + if (v !== null && typeof v != "function") { + throw new Error( + "onListChanged property may only be set to 'null' or a function"); + } + this._onListChanged = v; + this._adjustListener(); + } +}); + +BrowserAddonList.prototype.onInstalled = function (addon) { + this._notifyListChanged(); + this._adjustListener(); +}; + +BrowserAddonList.prototype.onUninstalled = function (addon) { + this._actorByAddonId.delete(addon.id); + this._notifyListChanged(); + this._adjustListener(); +}; + +BrowserAddonList.prototype._notifyListChanged = function () { + if (this._onListChanged) { + this._onListChanged(); + } +}; + +BrowserAddonList.prototype._adjustListener = function () { + if (this._onListChanged) { + // As long as the callback exists, we need to listen for changes + // so we can notify about add-on changes. + AddonManager.addAddonListener(this); + } else if (this._actorByAddonId.size === 0) { + // When the callback does not exist, we only need to keep listening + // if the actor cache will need adjusting when add-ons change. + AddonManager.removeAddonListener(this); + } +}; + +exports.BrowserAddonList = BrowserAddonList; + +/** + * The DebuggerProgressListener object is an nsIWebProgressListener which + * handles onStateChange events for the inspected browser. If the user tries to + * navigate away from a paused page, the listener makes sure that the debuggee + * is resumed before the navigation begins. + * + * @param TabActor aTabActor + * The tab actor associated with this listener. + */ +function DebuggerProgressListener(tabActor) { + this._tabActor = tabActor; + this._onWindowCreated = this.onWindowCreated.bind(this); + this._onWindowHidden = this.onWindowHidden.bind(this); + + // Watch for windows destroyed (global observer that will need filtering) + Services.obs.addObserver(this, "inner-window-destroyed", false); + + // XXX: for now we maintain the list of windows we know about in this instance + // so that we can discriminate windows we care about when observing + // inner-window-destroyed events. Bug 1016952 would remove the need for this. + this._knownWindowIDs = new Map(); + + this._watchedDocShells = new WeakSet(); +} + +DebuggerProgressListener.prototype = { + QueryInterface: XPCOMUtils.generateQI([ + Ci.nsIWebProgressListener, + Ci.nsISupportsWeakReference, + Ci.nsISupports, + ]), + + destroy() { + Services.obs.removeObserver(this, "inner-window-destroyed", false); + this._knownWindowIDs.clear(); + this._knownWindowIDs = null; + }, + + watch(docShell) { + // Add the docshell to the watched set. We're actually adding the window, + // because docShell objects are not wrappercached and would be rejected + // by the WeakSet. + let docShellWindow = docShell.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDOMWindow); + this._watchedDocShells.add(docShellWindow); + + let webProgress = docShell.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIWebProgress); + webProgress.addProgressListener(this, + Ci.nsIWebProgress.NOTIFY_STATUS | + Ci.nsIWebProgress.NOTIFY_STATE_WINDOW | + Ci.nsIWebProgress.NOTIFY_STATE_DOCUMENT); + + let handler = getDocShellChromeEventHandler(docShell); + handler.addEventListener("DOMWindowCreated", this._onWindowCreated, true); + handler.addEventListener("pageshow", this._onWindowCreated, true); + handler.addEventListener("pagehide", this._onWindowHidden, true); + + // Dispatch the _windowReady event on the tabActor for pre-existing windows + for (let win of this._getWindowsInDocShell(docShell)) { + this._tabActor._windowReady(win); + this._knownWindowIDs.set(getWindowID(win), win); + } + }, + + unwatch(docShell) { + let docShellWindow = docShell.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDOMWindow); + if (!this._watchedDocShells.has(docShellWindow)) { + return; + } + + let webProgress = docShell.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIWebProgress); + // During process shutdown, the docshell may already be cleaned up and throw + try { + webProgress.removeProgressListener(this); + } catch (e) { + // ignore + } + + let handler = getDocShellChromeEventHandler(docShell); + handler.removeEventListener("DOMWindowCreated", + this._onWindowCreated, true); + handler.removeEventListener("pageshow", this._onWindowCreated, true); + handler.removeEventListener("pagehide", this._onWindowHidden, true); + + for (let win of this._getWindowsInDocShell(docShell)) { + this._knownWindowIDs.delete(getWindowID(win)); + } + }, + + _getWindowsInDocShell(docShell) { + return getChildDocShells(docShell).map(d => { + return d.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDOMWindow); + }); + }, + + onWindowCreated: DevToolsUtils.makeInfallible(function (evt) { + if (!this._tabActor.attached) { + return; + } + + // pageshow events for non-persisted pages have already been handled by a + // prior DOMWindowCreated event. For persisted pages, act as if the window + // had just been created since it's been unfrozen from bfcache. + if (evt.type == "pageshow" && !evt.persisted) { + return; + } + + let window = evt.target.defaultView; + this._tabActor._windowReady(window); + + if (evt.type !== "pageshow") { + this._knownWindowIDs.set(getWindowID(window), window); + } + }, "DebuggerProgressListener.prototype.onWindowCreated"), + + onWindowHidden: DevToolsUtils.makeInfallible(function (evt) { + if (!this._tabActor.attached) { + return; + } + + // Only act as if the window has been destroyed if the 'pagehide' event + // was sent for a persisted window (persisted is set when the page is put + // and frozen in the bfcache). If the page isn't persisted, the observer's + // inner-window-destroyed event will handle it. + if (!evt.persisted) { + return; + } + + let window = evt.target.defaultView; + this._tabActor._windowDestroyed(window, null, true); + }, "DebuggerProgressListener.prototype.onWindowHidden"), + + observe: DevToolsUtils.makeInfallible(function (subject, topic) { + if (!this._tabActor.attached) { + return; + } + + // Because this observer will be called for all inner-window-destroyed in + // the application, we need to filter out events for windows we are not + // watching + let innerID = subject.QueryInterface(Ci.nsISupportsPRUint64).data; + let window = this._knownWindowIDs.get(innerID); + if (window) { + this._knownWindowIDs.delete(innerID); + this._tabActor._windowDestroyed(window, innerID); + } + }, "DebuggerProgressListener.prototype.observe"), + + onStateChange: + DevToolsUtils.makeInfallible(function (progress, request, flag, status) { + if (!this._tabActor.attached) { + return; + } + + let isStart = flag & Ci.nsIWebProgressListener.STATE_START; + let isStop = flag & Ci.nsIWebProgressListener.STATE_STOP; + let isDocument = flag & Ci.nsIWebProgressListener.STATE_IS_DOCUMENT; + let isWindow = flag & Ci.nsIWebProgressListener.STATE_IS_WINDOW; + + // Catch any iframe location change + if (isDocument && isStop) { + // Watch document stop to ensure having the new iframe url. + progress.QueryInterface(Ci.nsIDocShell); + this._tabActor._notifyDocShellsUpdate([progress]); + } + + let window = progress.DOMWindow; + if (isDocument && isStart) { + // One of the earliest events that tells us a new URI + // is being loaded in this window. + let newURI = request instanceof Ci.nsIChannel ? request.URI.spec : null; + this._tabActor._willNavigate(window, newURI, request); + } + if (isWindow && isStop) { + // Don't dispatch "navigate" event just yet when there is a redirect to + // about:neterror page. + if (request.status != Cr.NS_OK) { + // Instead, listen for DOMContentLoaded as about:neterror is loaded + // with LOAD_BACKGROUND flags and never dispatches load event. + // That may be the same reason why there is no onStateChange event + // for about:neterror loads. + let handler = getDocShellChromeEventHandler(progress); + let onLoad = evt => { + // Ignore events from iframes + if (evt.target == window.document) { + handler.removeEventListener("DOMContentLoaded", onLoad, true); + this._tabActor._navigate(window); + } + }; + handler.addEventListener("DOMContentLoaded", onLoad, true); + } else { + // Somewhat equivalent of load event. + // (window.document.readyState == complete) + this._tabActor._navigate(window); + } + } + }, "DebuggerProgressListener.prototype.onStateChange") +}; + +exports.register = function (handle) { + handle.setRootActor(createRootActor); +}; + +exports.unregister = function (handle) { + handle.setRootActor(null); +}; diff --git a/devtools/server/actors/webconsole.js b/devtools/server/actors/webconsole.js new file mode 100644 index 0000000000..9712ff32d8 --- /dev/null +++ b/devtools/server/actors/webconsole.js @@ -0,0 +1,2346 @@ +/* -*- js-indent-level: 2; indent-tabs-mode: nil -*- */ +/* vim: set ts=2 et sw=2 tw=80: */ +/* 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 Services = require("Services"); +const { Cc, Ci, Cu } = require("chrome"); +const { DebuggerServer, ActorPool } = require("devtools/server/main"); +const { EnvironmentActor } = require("devtools/server/actors/environment"); +const { ThreadActor } = require("devtools/server/actors/script"); +const { ObjectActor, LongStringActor, createValueGrip, stringIsLong } = require("devtools/server/actors/object"); +const DevToolsUtils = require("devtools/shared/DevToolsUtils"); +const ErrorDocs = require("devtools/server/actors/errordocs"); + +loader.lazyRequireGetter(this, "NetworkMonitor", "devtools/shared/webconsole/network-monitor", true); +loader.lazyRequireGetter(this, "NetworkMonitorChild", "devtools/shared/webconsole/network-monitor", true); +loader.lazyRequireGetter(this, "ConsoleProgressListener", "devtools/shared/webconsole/network-monitor", true); +loader.lazyRequireGetter(this, "StackTraceCollector", "devtools/shared/webconsole/network-monitor", true); +loader.lazyRequireGetter(this, "events", "sdk/event/core"); +loader.lazyRequireGetter(this, "ServerLoggingListener", "devtools/shared/webconsole/server-logger", true); +loader.lazyRequireGetter(this, "JSPropertyProvider", "devtools/shared/webconsole/js-property-provider", true); +loader.lazyRequireGetter(this, "Parser", "resource://devtools/shared/Parser.jsm", true); +loader.lazyRequireGetter(this, "NetUtil", "resource://gre/modules/NetUtil.jsm", true); + +for (let name of ["WebConsoleUtils", "ConsoleServiceListener", + "ConsoleAPIListener", "addWebConsoleCommands", + "ConsoleReflowListener", "CONSOLE_WORKER_IDS"]) { + Object.defineProperty(this, name, { + get: function (prop) { + if (prop == "WebConsoleUtils") { + prop = "Utils"; + } + if (isWorker) { + return require("devtools/server/actors/utils/webconsole-worker-utils")[prop]; + } else { + return require("devtools/server/actors/utils/webconsole-utils")[prop]; + } + }.bind(null, name), + configurable: true, + enumerable: true + }); +} + +/** + * The WebConsoleActor implements capabilities needed for the Web Console + * feature. + * + * @constructor + * @param object aConnection + * The connection to the client, DebuggerServerConnection. + * @param object [aParentActor] + * Optional, the parent actor. + */ +function WebConsoleActor(aConnection, aParentActor) +{ + this.conn = aConnection; + this.parentActor = aParentActor; + + this._actorPool = new ActorPool(this.conn); + this.conn.addActorPool(this._actorPool); + + this._prefs = {}; + + this.dbg = this.parentActor.makeDebugger(); + + this._netEvents = new Map(); + this._gripDepth = 0; + this._listeners = new Set(); + this._lastConsoleInputEvaluation = undefined; + + this.objectGrip = this.objectGrip.bind(this); + this._onWillNavigate = this._onWillNavigate.bind(this); + this._onChangedToplevelDocument = this._onChangedToplevelDocument.bind(this); + events.on(this.parentActor, "changed-toplevel-document", this._onChangedToplevelDocument); + this._onObserverNotification = this._onObserverNotification.bind(this); + if (this.parentActor.isRootActor) { + Services.obs.addObserver(this._onObserverNotification, + "last-pb-context-exited", false); + } + + this.traits = { + customNetworkRequest: !this._parentIsContentActor, + evaluateJSAsync: true, + transferredResponseSize: true, + selectedObjectActor: true, // 44+ + }; +} + +WebConsoleActor.prototype = +{ + /** + * Debugger instance. + * + * @see jsdebugger.jsm + */ + dbg: null, + + /** + * This is used by the ObjectActor to keep track of the depth of grip() calls. + * @private + * @type number + */ + _gripDepth: null, + + /** + * Actor pool for all of the actors we send to the client. + * @private + * @type object + * @see ActorPool + */ + _actorPool: null, + + /** + * Web Console-related preferences. + * @private + * @type object + */ + _prefs: null, + + /** + * Holds a map between nsIChannel objects and NetworkEventActors for requests + * created with sendHTTPRequest. + * + * @private + * @type Map + */ + _netEvents: null, + + /** + * Holds a set of all currently registered listeners. + * + * @private + * @type Set + */ + _listeners: null, + + /** + * The debugger server connection instance. + * @type object + */ + conn: null, + + /** + * List of supported features by the console actor. + * @type object + */ + traits: null, + + /** + * Boolean getter that tells if the parent actor is a ContentActor. + * + * @private + * @type boolean + */ + get _parentIsContentActor() { + return "ContentActor" in DebuggerServer && + this.parentActor instanceof DebuggerServer.ContentActor; + }, + + /** + * The window or sandbox we work with. + * Note that even if it is named `window` it refers to the current + * global we are debugging, which can be a Sandbox for addons + * or browser content toolbox. + * + * @type nsIDOMWindow or Sandbox + */ + get window() { + if (this.parentActor.isRootActor) { + return this._getWindowForBrowserConsole(); + } + return this.parentActor.window; + }, + + /** + * Get a window to use for the browser console. + * + * @private + * @return nsIDOMWindow + * The window to use, or null if no window could be found. + */ + _getWindowForBrowserConsole: function WCA__getWindowForBrowserConsole() + { + // Check if our last used chrome window is still live. + let window = this._lastChromeWindow && this._lastChromeWindow.get(); + // If not, look for a new one. + if (!window || window.closed) { + window = this.parentActor.window; + if (!window) { + // Try to find the Browser Console window to use instead. + window = Services.wm.getMostRecentWindow("devtools:webconsole"); + // We prefer the normal chrome window over the console window, + // so we'll look for those windows in order to replace our reference. + let onChromeWindowOpened = () => { + // We'll look for this window when someone next requests window() + Services.obs.removeObserver(onChromeWindowOpened, "domwindowopened"); + this._lastChromeWindow = null; + }; + Services.obs.addObserver(onChromeWindowOpened, "domwindowopened", false); + } + + this._handleNewWindow(window); + } + + return window; + }, + + /** + * Store a newly found window on the actor to be used in the future. + * + * @private + * @param nsIDOMWindow window + * The window to store on the actor (can be null). + */ + _handleNewWindow: function WCA__handleNewWindow(window) + { + if (window) { + if (this._hadChromeWindow) { + Services.console.logStringMessage('Webconsole context has changed'); + } + this._lastChromeWindow = Cu.getWeakReference(window); + this._hadChromeWindow = true; + } else { + this._lastChromeWindow = null; + } + }, + + /** + * Whether we've been using a window before. + * + * @private + * @type boolean + */ + _hadChromeWindow: false, + + /** + * A weak reference to the last chrome window we used to work with. + * + * @private + * @type nsIWeakReference + */ + _lastChromeWindow: null, + + // The evalWindow is used at the scope for JS evaluation. + _evalWindow: null, + get evalWindow() { + return this._evalWindow || this.window; + }, + + set evalWindow(aWindow) { + this._evalWindow = aWindow; + + if (!this._progressListenerActive) { + events.on(this.parentActor, "will-navigate", this._onWillNavigate); + this._progressListenerActive = true; + } + }, + + /** + * Flag used to track if we are listening for events from the progress + * listener of the tab actor. We use the progress listener to clear + * this.evalWindow on page navigation. + * + * @private + * @type boolean + */ + _progressListenerActive: false, + + /** + * The ConsoleServiceListener instance. + * @type object + */ + consoleServiceListener: null, + + /** + * The ConsoleAPIListener instance. + */ + consoleAPIListener: null, + + /** + * The NetworkMonitor instance. + */ + networkMonitor: null, + + /** + * The NetworkMonitor instance living in the same (child) process. + */ + networkMonitorChild: null, + + /** + * The ConsoleProgressListener instance. + */ + consoleProgressListener: null, + + /** + * The ConsoleReflowListener instance. + */ + consoleReflowListener: null, + + /** + * The Web Console Commands names cache. + * @private + * @type array + */ + _webConsoleCommandsCache: null, + + actorPrefix: "console", + + get globalDebugObject() { + return this.parentActor.threadActor.globalDebugObject; + }, + + grip: function WCA_grip() + { + return { actor: this.actorID }; + }, + + hasNativeConsoleAPI: function WCA_hasNativeConsoleAPI(aWindow) { + let isNative = false; + try { + // We are very explicitly examining the "console" property of + // the non-Xrayed object here. + let console = aWindow.wrappedJSObject.console; + isNative = new XPCNativeWrapper(console).IS_NATIVE_CONSOLE + } + catch (ex) { } + return isNative; + }, + + _findProtoChain: ThreadActor.prototype._findProtoChain, + _removeFromProtoChain: ThreadActor.prototype._removeFromProtoChain, + + /** + * Destroy the current WebConsoleActor instance. + */ + disconnect: function WCA_disconnect() + { + if (this.consoleServiceListener) { + this.consoleServiceListener.destroy(); + this.consoleServiceListener = null; + } + if (this.consoleAPIListener) { + this.consoleAPIListener.destroy(); + this.consoleAPIListener = null; + } + if (this.networkMonitor) { + this.networkMonitor.destroy(); + this.networkMonitor = null; + } + if (this.networkMonitorChild) { + this.networkMonitorChild.destroy(); + this.networkMonitorChild = null; + } + if (this.stackTraceCollector) { + this.stackTraceCollector.destroy(); + this.stackTraceCollector = null; + } + if (this.consoleProgressListener) { + this.consoleProgressListener.destroy(); + this.consoleProgressListener = null; + } + if (this.consoleReflowListener) { + this.consoleReflowListener.destroy(); + this.consoleReflowListener = null; + } + if (this.serverLoggingListener) { + this.serverLoggingListener.destroy(); + this.serverLoggingListener = null; + } + + events.off(this.parentActor, "changed-toplevel-document", + this._onChangedToplevelDocument); + + this.conn.removeActorPool(this._actorPool); + + if (this.parentActor.isRootActor) { + Services.obs.removeObserver(this._onObserverNotification, + "last-pb-context-exited"); + } + + this._actorPool = null; + this._webConsoleCommandsCache = null; + this._lastConsoleInputEvaluation = null; + this._evalWindow = null; + this._netEvents.clear(); + this.dbg.enabled = false; + this.dbg = null; + this.conn = null; + }, + + /** + * Create and return an environment actor that corresponds to the provided + * Debugger.Environment. This is a straightforward clone of the ThreadActor's + * method except that it stores the environment actor in the web console + * actor's pool. + * + * @param Debugger.Environment aEnvironment + * The lexical environment we want to extract. + * @return The EnvironmentActor for aEnvironment or undefined for host + * functions or functions scoped to a non-debuggee global. + */ + createEnvironmentActor: function WCA_createEnvironmentActor(aEnvironment) { + if (!aEnvironment) { + return undefined; + } + + if (aEnvironment.actor) { + return aEnvironment.actor; + } + + let actor = new EnvironmentActor(aEnvironment, this); + this._actorPool.addActor(actor); + aEnvironment.actor = actor; + + return actor; + }, + + /** + * Create a grip for the given value. + * + * @param mixed aValue + * @return object + */ + createValueGrip: function WCA_createValueGrip(aValue) + { + return createValueGrip(aValue, this._actorPool, this.objectGrip); + }, + + /** + * Make a debuggee value for the given value. + * + * @param mixed aValue + * The value you want to get a debuggee value for. + * @param boolean aUseObjectGlobal + * If |true| the object global is determined and added as a debuggee, + * otherwise |this.window| is used when makeDebuggeeValue() is invoked. + * @return object + * Debuggee value for |aValue|. + */ + makeDebuggeeValue: function WCA_makeDebuggeeValue(aValue, aUseObjectGlobal) + { + if (aUseObjectGlobal && typeof aValue == "object") { + try { + let global = Cu.getGlobalForObject(aValue); + let dbgGlobal = this.dbg.makeGlobalObjectReference(global); + return dbgGlobal.makeDebuggeeValue(aValue); + } + catch (ex) { + // The above can throw an exception if aValue is not an actual object + // or 'Object in compartment marked as invisible to Debugger' + } + } + let dbgGlobal = this.dbg.makeGlobalObjectReference(this.window); + return dbgGlobal.makeDebuggeeValue(aValue); + }, + + /** + * Create a grip for the given object. + * + * @param object aObject + * The object you want. + * @param object aPool + * An ActorPool where the new actor instance is added. + * @param object + * The object grip. + */ + objectGrip: function WCA_objectGrip(aObject, aPool) + { + let actor = new ObjectActor(aObject, { + getGripDepth: () => this._gripDepth, + incrementGripDepth: () => this._gripDepth++, + decrementGripDepth: () => this._gripDepth--, + createValueGrip: v => this.createValueGrip(v), + sources: () => DevToolsUtils.reportException("WebConsoleActor", + Error("sources not yet implemented")), + createEnvironmentActor: (env) => this.createEnvironmentActor(env), + getGlobalDebugObject: () => this.globalDebugObject + }); + aPool.addActor(actor); + return actor.grip(); + }, + + /** + * Create a grip for the given string. + * + * @param string aString + * The string you want to create the grip for. + * @param object aPool + * An ActorPool where the new actor instance is added. + * @return object + * A LongStringActor object that wraps the given string. + */ + longStringGrip: function WCA_longStringGrip(aString, aPool) + { + let actor = new LongStringActor(aString); + aPool.addActor(actor); + return actor.grip(); + }, + + /** + * Create a long string grip if needed for the given string. + * + * @private + * @param string aString + * The string you want to create a long string grip for. + * @return string|object + * A string is returned if |aString| is not a long string. + * A LongStringActor grip is returned if |aString| is a long string. + */ + _createStringGrip: function NEA__createStringGrip(aString) + { + if (aString && stringIsLong(aString)) { + return this.longStringGrip(aString, this._actorPool); + } + return aString; + }, + + /** + * Get an object actor by its ID. + * + * @param string aActorID + * @return object + */ + getActorByID: function WCA_getActorByID(aActorID) + { + return this._actorPool.get(aActorID); + }, + + /** + * Release an actor. + * + * @param object aActor + * The actor instance you want to release. + */ + releaseActor: function WCA_releaseActor(aActor) + { + this._actorPool.removeActor(aActor.actorID); + }, + + /** + * Returns the latest web console input evaluation. + * This is undefined if no evaluations have been completed. + * + * @return object + */ + getLastConsoleInputEvaluation: function WCU_getLastConsoleInputEvaluation() + { + return this._lastConsoleInputEvaluation; + }, + + // Request handlers for known packet types. + + /** + * Handler for the "startListeners" request. + * + * @param object aRequest + * The JSON request object received from the Web Console client. + * @return object + * The response object which holds the startedListeners array. + */ + onStartListeners: function WCA_onStartListeners(aRequest) + { + // XXXworkers: Not handling the Console API yet for workers (Bug 1209353). + if (isWorker) { + aRequest.listeners = []; + } + + let startedListeners = []; + let window = !this.parentActor.isRootActor ? this.window : null; + let appId = null; + let messageManager = null; + + if (this._parentIsContentActor) { + appId = this.parentActor.docShell.appId; + messageManager = this.parentActor.messageManager; + } + + while (aRequest.listeners.length > 0) { + let listener = aRequest.listeners.shift(); + switch (listener) { + case "PageError": + if (!this.consoleServiceListener) { + this.consoleServiceListener = + new ConsoleServiceListener(window, this); + this.consoleServiceListener.init(); + } + startedListeners.push(listener); + break; + case "ConsoleAPI": + if (!this.consoleAPIListener) { + // Create the consoleAPIListener (and apply the filtering options defined + // in the parent actor). + this.consoleAPIListener = + new ConsoleAPIListener(window, this, + this.parentActor.consoleAPIListenerOptions); + this.consoleAPIListener.init(); + } + startedListeners.push(listener); + break; + case "NetworkActivity": + if (!this.networkMonitor) { + // Create a StackTraceCollector that's going to be shared both by the + // NetworkMonitorChild (getting messages about requests from parent) and + // by the NetworkMonitor that directly watches service workers requests. + this.stackTraceCollector = new StackTraceCollector({ window, appId }); + this.stackTraceCollector.init(); + + let processBoundary = Services.appinfo.processType != + Ci.nsIXULRuntime.PROCESS_TYPE_DEFAULT; + if ((appId || messageManager) && processBoundary) { + // Start a network monitor in the parent process to listen to + // most requests than happen in parent + this.networkMonitor = + new NetworkMonitorChild(appId, this.parentActor.outerWindowID, + messageManager, this.conn, this); + this.networkMonitor.init(); + // Spawn also one in the child to listen to service workers + this.networkMonitorChild = new NetworkMonitor({ window }, this); + this.networkMonitorChild.init(); + } else { + this.networkMonitor = new NetworkMonitor({ window }, this); + this.networkMonitor.init(); + } + } + startedListeners.push(listener); + break; + case "FileActivity": + if (this.window instanceof Ci.nsIDOMWindow) { + if (!this.consoleProgressListener) { + this.consoleProgressListener = + new ConsoleProgressListener(this.window, this); + } + this.consoleProgressListener.startMonitor(this.consoleProgressListener. + MONITOR_FILE_ACTIVITY); + startedListeners.push(listener); + } + break; + case "ReflowActivity": + if (!this.consoleReflowListener) { + this.consoleReflowListener = + new ConsoleReflowListener(this.window, this); + } + startedListeners.push(listener); + break; + case "ServerLogging": + if (!this.serverLoggingListener) { + this.serverLoggingListener = + new ServerLoggingListener(this.window, this); + } + startedListeners.push(listener); + break; + } + } + + // Update the live list of running listeners + startedListeners.forEach(this._listeners.add, this._listeners); + + return { + startedListeners: startedListeners, + nativeConsoleAPI: this.hasNativeConsoleAPI(this.window), + traits: this.traits, + }; + }, + + /** + * Handler for the "stopListeners" request. + * + * @param object aRequest + * The JSON request object received from the Web Console client. + * @return object + * The response packet to send to the client: holds the + * stoppedListeners array. + */ + onStopListeners: function WCA_onStopListeners(aRequest) + { + let stoppedListeners = []; + + // If no specific listeners are requested to be detached, we stop all + // listeners. + let toDetach = aRequest.listeners || + ["PageError", "ConsoleAPI", "NetworkActivity", + "FileActivity", "ServerLogging"]; + + while (toDetach.length > 0) { + let listener = toDetach.shift(); + switch (listener) { + case "PageError": + if (this.consoleServiceListener) { + this.consoleServiceListener.destroy(); + this.consoleServiceListener = null; + } + stoppedListeners.push(listener); + break; + case "ConsoleAPI": + if (this.consoleAPIListener) { + this.consoleAPIListener.destroy(); + this.consoleAPIListener = null; + } + stoppedListeners.push(listener); + break; + case "NetworkActivity": + if (this.networkMonitor) { + this.networkMonitor.destroy(); + this.networkMonitor = null; + } + if (this.networkMonitorChild) { + this.networkMonitorChild.destroy(); + this.networkMonitorChild = null; + } + if (this.stackTraceCollector) { + this.stackTraceCollector.destroy(); + this.stackTraceCollector = null; + } + stoppedListeners.push(listener); + break; + case "FileActivity": + if (this.consoleProgressListener) { + this.consoleProgressListener.stopMonitor(this.consoleProgressListener. + MONITOR_FILE_ACTIVITY); + this.consoleProgressListener = null; + } + stoppedListeners.push(listener); + break; + case "ReflowActivity": + if (this.consoleReflowListener) { + this.consoleReflowListener.destroy(); + this.consoleReflowListener = null; + } + stoppedListeners.push(listener); + break; + case "ServerLogging": + if (this.serverLoggingListener) { + this.serverLoggingListener.destroy(); + this.serverLoggingListener = null; + } + stoppedListeners.push(listener); + break; + } + } + + // Update the live list of running listeners + stoppedListeners.forEach(this._listeners.delete, this._listeners); + + return { stoppedListeners: stoppedListeners }; + }, + + /** + * Handler for the "getCachedMessages" request. This method sends the cached + * error messages and the window.console API calls to the client. + * + * @param object aRequest + * The JSON request object received from the Web Console client. + * @return object + * The response packet to send to the client: it holds the cached + * messages array. + */ + onGetCachedMessages: function WCA_onGetCachedMessages(aRequest) + { + let types = aRequest.messageTypes; + if (!types) { + return { + error: "missingParameter", + message: "The messageTypes parameter is missing.", + }; + } + + let messages = []; + + while (types.length > 0) { + let type = types.shift(); + switch (type) { + case "ConsoleAPI": { + if (!this.consoleAPIListener) { + break; + } + + // See `window` definition. It isn't always a DOM Window. + let requestStartTime = this.window && this.window.performance ? + this.window.performance.timing.requestStart : 0; + + let cache = this.consoleAPIListener + .getCachedMessages(!this.parentActor.isRootActor); + cache.forEach((aMessage) => { + // Filter out messages that came from a ServiceWorker but happened + // before the page was requested. + if (aMessage.innerID === "ServiceWorker" && + requestStartTime > aMessage.timeStamp) { + return; + } + + let message = this.prepareConsoleMessageForRemote(aMessage); + message._type = type; + messages.push(message); + }); + break; + } + case "PageError": { + if (!this.consoleServiceListener) { + break; + } + let cache = this.consoleServiceListener + .getCachedMessages(!this.parentActor.isRootActor); + cache.forEach((aMessage) => { + let message = null; + if (aMessage instanceof Ci.nsIScriptError) { + message = this.preparePageErrorForRemote(aMessage); + message._type = type; + } + else { + message = { + _type: "LogMessage", + message: this._createStringGrip(aMessage.message), + timeStamp: aMessage.timeStamp, + }; + } + messages.push(message); + }); + break; + } + } + } + + return { + from: this.actorID, + messages: messages, + }; + }, + + /** + * Handler for the "evaluateJSAsync" request. This method evaluates the given + * JavaScript string and sends back a packet with a unique ID. + * The result will be returned later as an unsolicited `evaluationResult`, + * that can be associated back to this request via the `resultID` field. + * + * @param object aRequest + * The JSON request object received from the Web Console client. + * @return object + * The response packet to send to with the unique id in the + * `resultID` field. + */ + onEvaluateJSAsync: function WCA_onEvaluateJSAsync(aRequest) + { + // We want to be able to run console commands without waiting + // for the first to return (see Bug 1088861). + + // First, send a response packet with the id only. + let resultID = Date.now(); + this.conn.send({ + from: this.actorID, + resultID: resultID + }); + + // Then, execute the script that may pause. + let response = this.onEvaluateJS(aRequest); + response.resultID = resultID; + + // Finally, send an unsolicited evaluationResult packet with + // the normal return value + this.conn.sendActorEvent(this.actorID, "evaluationResult", response); + }, + + /** + * Handler for the "evaluateJS" request. This method evaluates the given + * JavaScript string and sends back the result. + * + * @param object aRequest + * The JSON request object received from the Web Console client. + * @return object + * The evaluation response packet. + */ + onEvaluateJS: function WCA_onEvaluateJS(aRequest) + { + let input = aRequest.text; + let timestamp = Date.now(); + + let evalOptions = { + bindObjectActor: aRequest.bindObjectActor, + frameActor: aRequest.frameActor, + url: aRequest.url, + selectedNodeActor: aRequest.selectedNodeActor, + selectedObjectActor: aRequest.selectedObjectActor, + }; + + let evalInfo = this.evalWithDebugger(input, evalOptions); + let evalResult = evalInfo.result; + let helperResult = evalInfo.helperResult; + + let result, errorDocURL, errorMessage, errorGrip = null, frame = null; + if (evalResult) { + if ("return" in evalResult) { + result = evalResult.return; + } else if ("yield" in evalResult) { + result = evalResult.yield; + } else if ("throw" in evalResult) { + let error = evalResult.throw; + + errorGrip = this.createValueGrip(error); + + errorMessage = String(error); + if (typeof error === "object" && error !== null) { + try { + errorMessage = DevToolsUtils.callPropertyOnObject(error, "toString"); + } catch (e) { + // If the debuggee is not allowed to access the "toString" property + // of the error object, calling this property from the debuggee's + // compartment will fail. The debugger should show the error object + // as it is seen by the debuggee, so this behavior is correct. + // + // Unfortunately, we have at least one test that assumes calling the + // "toString" property of an error object will succeed if the + // debugger is allowed to access it, regardless of whether the + // debuggee is allowed to access it or not. + // + // To accomodate these tests, if calling the "toString" property + // from the debuggee compartment fails, we rewrap the error object + // in the debugger's compartment, and then call the "toString" + // property from there. + if (typeof error.unsafeDereference === "function") { + errorMessage = error.unsafeDereference().toString(); + } + } + } + + // It is possible that we won't have permission to unwrap an + // object and retrieve its errorMessageName. + try { + errorDocURL = ErrorDocs.GetURL(error); + } catch (ex) {} + + try { + let line = error.errorLineNumber; + let column = error.errorColumnNumber; + + if (typeof line === "number" && typeof column === "number") { + // Set frame only if we have line/column numbers. + frame = { + source: "debugger eval code", + line, + column + }; + } + } catch (ex) {} + } + } + + // If a value is encountered that the debugger server doesn't support yet, + // the console should remain functional. + let resultGrip; + try { + resultGrip = this.createValueGrip(result); + } catch (e) { + errorMessage = e; + } + + this._lastConsoleInputEvaluation = result; + + return { + from: this.actorID, + input: input, + result: resultGrip, + timestamp: timestamp, + exception: errorGrip, + exceptionMessage: this._createStringGrip(errorMessage), + exceptionDocURL: errorDocURL, + frame, + helperResult: helperResult, + }; + }, + + /** + * The Autocomplete request handler. + * + * @param object aRequest + * The request message - what input to autocomplete. + * @return object + * The response message - matched properties. + */ + onAutocomplete: function WCA_onAutocomplete(aRequest) + { + let frameActorId = aRequest.frameActor; + let dbgObject = null; + let environment = null; + let hadDebuggee = false; + + // This is the case of the paused debugger + if (frameActorId) { + let frameActor = this.conn.getActor(frameActorId); + try { + // Need to try/catch since accessing frame.environment + // can throw "Debugger.Frame is not live" + let frame = frameActor.frame; + environment = frame.environment; + } catch (e) { + DevToolsUtils.reportException("onAutocomplete", + Error("The frame actor was not found: " + frameActorId)); + } + } + // This is the general case (non-paused debugger) + else { + hadDebuggee = this.dbg.hasDebuggee(this.evalWindow); + dbgObject = this.dbg.addDebuggee(this.evalWindow); + } + + let result = JSPropertyProvider(dbgObject, environment, aRequest.text, + aRequest.cursor, frameActorId) || {}; + + if (!hadDebuggee && dbgObject) { + this.dbg.removeDebuggee(this.evalWindow); + } + + let matches = result.matches || []; + let reqText = aRequest.text.substr(0, aRequest.cursor); + + // We consider '$' as alphanumerc because it is used in the names of some + // helper functions. + let lastNonAlphaIsDot = /[.][a-zA-Z0-9$]*$/.test(reqText); + if (!lastNonAlphaIsDot) { + if (!this._webConsoleCommandsCache) { + let helpers = { + sandbox: Object.create(null) + }; + addWebConsoleCommands(helpers); + this._webConsoleCommandsCache = + Object.getOwnPropertyNames(helpers.sandbox); + } + matches = matches.concat(this._webConsoleCommandsCache + .filter(n => n.startsWith(result.matchProp))); + } + + return { + from: this.actorID, + matches: matches.sort(), + matchProp: result.matchProp, + }; + }, + + /** + * The "clearMessagesCache" request handler. + */ + onClearMessagesCache: function WCA_onClearMessagesCache() + { + // TODO: Bug 717611 - Web Console clear button does not clear cached errors + let windowId = !this.parentActor.isRootActor ? + WebConsoleUtils.getInnerWindowId(this.window) : null; + let ConsoleAPIStorage = Cc["@mozilla.org/consoleAPI-storage;1"] + .getService(Ci.nsIConsoleAPIStorage); + ConsoleAPIStorage.clearEvents(windowId); + + CONSOLE_WORKER_IDS.forEach((aId) => { + ConsoleAPIStorage.clearEvents(aId); + }); + + if (this.parentActor.isRootActor) { + Services.console.logStringMessage(null); // for the Error Console + Services.console.reset(); + } + return {}; + }, + + /** + * The "getPreferences" request handler. + * + * @param object aRequest + * The request message - which preferences need to be retrieved. + * @return object + * The response message - a { key: value } object map. + */ + onGetPreferences: function WCA_onGetPreferences(aRequest) + { + let prefs = Object.create(null); + for (let key of aRequest.preferences) { + prefs[key] = this._prefs[key]; + } + return { preferences: prefs }; + }, + + /** + * The "setPreferences" request handler. + * + * @param object aRequest + * The request message - which preferences need to be updated. + */ + onSetPreferences: function WCA_onSetPreferences(aRequest) + { + for (let key in aRequest.preferences) { + this._prefs[key] = aRequest.preferences[key]; + + if (this.networkMonitor) { + if (key == "NetworkMonitor.saveRequestAndResponseBodies") { + this.networkMonitor.saveRequestAndResponseBodies = this._prefs[key]; + if (this.networkMonitorChild) { + this.networkMonitorChild.saveRequestAndResponseBodies = + this._prefs[key]; + } + } else if (key == "NetworkMonitor.throttleData") { + this.networkMonitor.throttleData = this._prefs[key]; + if (this.networkMonitorChild) { + this.networkMonitorChild.throttleData = this._prefs[key]; + } + } + } + } + return { updated: Object.keys(aRequest.preferences) }; + }, + + // End of request handlers. + + /** + * Create an object with the API we expose to the Web Console during + * JavaScript evaluation. + * This object inherits properties and methods from the Web Console actor. + * + * @private + * @param object aDebuggerGlobal + * A Debugger.Object that wraps a content global. This is used for the + * Web Console Commands. + * @return object + * The same object as |this|, but with an added |sandbox| property. + * The sandbox holds methods and properties that can be used as + * bindings during JS evaluation. + */ + _getWebConsoleCommands: function (aDebuggerGlobal) + { + let helpers = { + window: this.evalWindow, + chromeWindow: this.chromeWindow.bind(this), + makeDebuggeeValue: aDebuggerGlobal.makeDebuggeeValue.bind(aDebuggerGlobal), + createValueGrip: this.createValueGrip.bind(this), + sandbox: Object.create(null), + helperResult: null, + consoleActor: this, + }; + addWebConsoleCommands(helpers); + + let evalWindow = this.evalWindow; + function maybeExport(obj, name) { + if (typeof obj[name] != "function") { + return; + } + + // By default, chrome-implemented functions that are exposed to content + // refuse to accept arguments that are cross-origin for the caller. This + // is generally the safe thing, but causes problems for certain console + // helpers like cd(), where we users sometimes want to pass a cross-origin + // window. To circumvent this restriction, we use exportFunction along + // with a special option designed for this purpose. See bug 1051224. + obj[name] = + Cu.exportFunction(obj[name], evalWindow, { allowCrossOriginArguments: true }); + } + for (let name in helpers.sandbox) { + let desc = Object.getOwnPropertyDescriptor(helpers.sandbox, name); + + // Workers don't have access to Cu so won't be able to exportFunction. + if (!isWorker) { + maybeExport(desc, "get"); + maybeExport(desc, "set"); + maybeExport(desc, "value"); + } + if (desc.value) { + // Make sure the helpers can be used during eval. + desc.value = aDebuggerGlobal.makeDebuggeeValue(desc.value); + } + Object.defineProperty(helpers.sandbox, name, desc); + } + return helpers; + }, + + /** + * Evaluates a string using the debugger API. + * + * To allow the variables view to update properties from the Web Console we + * provide the "bindObjectActor" mechanism: the Web Console tells the + * ObjectActor ID for which it desires to evaluate an expression. The + * Debugger.Object pointed at by the actor ID is bound such that it is + * available during expression evaluation (executeInGlobalWithBindings()). + * + * Example: + * _self['foobar'] = 'test' + * where |_self| refers to the desired object. + * + * The |frameActor| property allows the Web Console client to provide the + * frame actor ID, such that the expression can be evaluated in the + * user-selected stack frame. + * + * For the above to work we need the debugger and the Web Console to share + * a connection, otherwise the Web Console actor will not find the frame + * actor. + * + * The Debugger.Frame comes from the jsdebugger's Debugger instance, which + * is different from the Web Console's Debugger instance. This means that + * for evaluation to work, we need to create a new instance for the Web + * Console Commands helpers - they need to be Debugger.Objects coming from the + * jsdebugger's Debugger instance. + * + * When |bindObjectActor| is used objects can come from different iframes, + * from different domains. To avoid permission-related errors when objects + * come from a different window, we also determine the object's own global, + * such that evaluation happens in the context of that global. This means that + * evaluation will happen in the object's iframe, rather than the top level + * window. + * + * @param string aString + * String to evaluate. + * @param object [aOptions] + * Options for evaluation: + * - bindObjectActor: the ObjectActor ID to use for evaluation. + * |evalWithBindings()| will be called with one additional binding: + * |_self| which will point to the Debugger.Object of the given + * ObjectActor. + * - selectedObjectActor: Like bindObjectActor, but executes with the + * top level window as the global. + * - frameActor: the FrameActor ID to use for evaluation. The given + * debugger frame is used for evaluation, instead of the global window. + * - selectedNodeActor: the NodeActor ID of the currently selected node + * in the Inspector (or null, if there is no selection). This is used + * for helper functions that make reference to the currently selected + * node, like $0. + * - url: the url to evaluate the script as. Defaults to + * "debugger eval code". + * @return object + * An object that holds the following properties: + * - dbg: the debugger where the string was evaluated. + * - frame: (optional) the frame where the string was evaluated. + * - window: the Debugger.Object for the global where the string was + * evaluated. + * - result: the result of the evaluation. + * - helperResult: any result coming from a Web Console commands + * function. + */ + evalWithDebugger: function WCA_evalWithDebugger(aString, aOptions = {}) + { + let trimmedString = aString.trim(); + // The help function needs to be easy to guess, so we make the () optional. + if (trimmedString == "help" || trimmedString == "?") { + aString = "help()"; + } + + // Add easter egg for console.mihai(). + if (trimmedString == "console.mihai()" || trimmedString == "console.mihai();") { + aString = "\"http://incompleteness.me/blog/2015/02/09/console-dot-mihai/\""; + } + + // Find the Debugger.Frame of the given FrameActor. + let frame = null, frameActor = null; + if (aOptions.frameActor) { + frameActor = this.conn.getActor(aOptions.frameActor); + if (frameActor) { + frame = frameActor.frame; + } + else { + DevToolsUtils.reportException("evalWithDebugger", + Error("The frame actor was not found: " + aOptions.frameActor)); + } + } + + // If we've been given a frame actor in whose scope we should evaluate the + // expression, be sure to use that frame's Debugger (that is, the JavaScript + // debugger's Debugger) for the whole operation, not the console's Debugger. + // (One Debugger will treat a different Debugger's Debugger.Object instances + // as ordinary objects, not as references to be followed, so mixing + // debuggers causes strange behaviors.) + let dbg = frame ? frameActor.threadActor.dbg : this.dbg; + let dbgWindow = dbg.makeGlobalObjectReference(this.evalWindow); + + // If we have an object to bind to |_self|, create a Debugger.Object + // referring to that object, belonging to dbg. + let bindSelf = null; + if (aOptions.bindObjectActor || aOptions.selectedObjectActor) { + let objActor = this.getActorByID(aOptions.bindObjectActor || + aOptions.selectedObjectActor); + if (objActor) { + let jsObj = objActor.obj.unsafeDereference(); + // If we use the makeDebuggeeValue method of jsObj's own global, then + // we'll get a D.O that sees jsObj as viewed from its own compartment - + // that is, without wrappers. The evalWithBindings call will then wrap + // jsObj appropriately for the evaluation compartment. + let global = Cu.getGlobalForObject(jsObj); + let _dbgWindow = dbg.makeGlobalObjectReference(global); + bindSelf = dbgWindow.makeDebuggeeValue(jsObj); + + if (aOptions.bindObjectActor) { + dbgWindow = _dbgWindow; + } + } + } + + // Get the Web Console commands for the given debugger window. + let helpers = this._getWebConsoleCommands(dbgWindow); + let bindings = helpers.sandbox; + if (bindSelf) { + bindings._self = bindSelf; + } + + if (aOptions.selectedNodeActor) { + let actor = this.conn.getActor(aOptions.selectedNodeActor); + if (actor) { + helpers.selectedNode = actor.rawNode; + } + } + + // Check if the Debugger.Frame or Debugger.Object for the global include + // $ or $$. We will not overwrite these functions with the Web Console + // commands. + let found$ = false, found$$ = false; + if (frame) { + let env = frame.environment; + if (env) { + found$ = !!env.find("$"); + found$$ = !!env.find("$$"); + } + } + else { + found$ = !!dbgWindow.getOwnPropertyDescriptor("$"); + found$$ = !!dbgWindow.getOwnPropertyDescriptor("$$"); + } + + let $ = null, $$ = null; + if (found$) { + $ = bindings.$; + delete bindings.$; + } + if (found$$) { + $$ = bindings.$$; + delete bindings.$$; + } + + // Ready to evaluate the string. + helpers.evalInput = aString; + + let evalOptions; + if (typeof aOptions.url == "string") { + evalOptions = { url: aOptions.url }; + } + + // If the debugger object is changed from the last evaluation, + // adopt this._lastConsoleInputEvaluation value in the new debugger, + // to prevents "Debugger.Object belongs to a different Debugger" exceptions + // related to the $_ bindings. + if (this._lastConsoleInputEvaluation && + this._lastConsoleInputEvaluation.global !== dbgWindow) { + this._lastConsoleInputEvaluation = dbg.adoptDebuggeeValue( + this._lastConsoleInputEvaluation + ); + } + + let result; + + if (frame) { + result = frame.evalWithBindings(aString, bindings, evalOptions); + } + else { + result = dbgWindow.executeInGlobalWithBindings(aString, bindings, evalOptions); + // Attempt to initialize any declarations found in the evaluated string + // since they may now be stuck in an "initializing" state due to the + // error. Already-initialized bindings will be ignored. + if ("throw" in result) { + let ast; + // Parse errors will raise an exception. We can/should ignore the error + // since it's already being handled elsewhere and we are only interested + // in initializing bindings. + try { + ast = Parser.reflectionAPI.parse(aString); + } catch (ex) { + ast = {"body": []}; + } + for (let line of ast.body) { + // Only let and const declarations put bindings into an + // "initializing" state. + if (!(line.kind == "let" || line.kind == "const")) + continue; + + let identifiers = []; + for (let decl of line.declarations) { + switch (decl.id.type) { + case "Identifier": + // let foo = bar; + identifiers.push(decl.id.name); + break; + case "ArrayPattern": + // let [foo, bar] = [1, 2]; + // let [foo=99, bar] = [1, 2]; + for (let e of decl.id.elements) { + if (e.type == "Identifier") { + identifiers.push(e.name); + } else if (e.type == "AssignmentExpression") { + identifiers.push(e.left.name); + } + } + break; + case "ObjectPattern": + // let {bilbo, my} = {bilbo: "baggins", my: "precious"}; + // let {blah: foo} = {blah: yabba()} + // let {blah: foo=99} = {blah: yabba()} + for (let prop of decl.id.properties) { + // key + if (prop.key.type == "Identifier") + identifiers.push(prop.key.name); + // value + if (prop.value.type == "Identifier") { + identifiers.push(prop.value.name); + } else if (prop.value.type == "AssignmentExpression") { + identifiers.push(prop.value.left.name); + } + } + break; + } + } + + for (let name of identifiers) + dbgWindow.forceLexicalInitializationByName(name); + } + } + } + + let helperResult = helpers.helperResult; + delete helpers.evalInput; + delete helpers.helperResult; + delete helpers.selectedNode; + + if ($) { + bindings.$ = $; + } + if ($$) { + bindings.$$ = $$; + } + + if (bindings._self) { + delete bindings._self; + } + + return { + result: result, + helperResult: helperResult, + dbg: dbg, + frame: frame, + window: dbgWindow, + }; + }, + + // Event handlers for various listeners. + + /** + * Handler for messages received from the ConsoleServiceListener. This method + * sends the nsIConsoleMessage to the remote Web Console client. + * + * @param nsIConsoleMessage aMessage + * The message we need to send to the client. + */ + onConsoleServiceMessage: function WCA_onConsoleServiceMessage(aMessage) + { + let packet; + if (aMessage instanceof Ci.nsIScriptError) { + packet = { + from: this.actorID, + type: "pageError", + pageError: this.preparePageErrorForRemote(aMessage), + }; + } + else { + packet = { + from: this.actorID, + type: "logMessage", + message: this._createStringGrip(aMessage.message), + timeStamp: aMessage.timeStamp, + }; + } + this.conn.send(packet); + }, + + /** + * Prepare an nsIScriptError to be sent to the client. + * + * @param nsIScriptError aPageError + * The page error we need to send to the client. + * @return object + * The object you can send to the remote client. + */ + preparePageErrorForRemote: function WCA_preparePageErrorForRemote(aPageError) + { + let stack = null; + // Convert stack objects to the JSON attributes expected by client code + if (aPageError.stack) { + stack = []; + let s = aPageError.stack; + while (s !== null) { + stack.push({ + filename: s.source, + lineNumber: s.line, + columnNumber: s.column, + functionName: s.functionDisplayName + }); + s = s.parent; + } + } + let lineText = aPageError.sourceLine; + if (lineText && lineText.length > DebuggerServer.LONG_STRING_INITIAL_LENGTH) { + lineText = lineText.substr(0, DebuggerServer.LONG_STRING_INITIAL_LENGTH); + } + + return { + errorMessage: this._createStringGrip(aPageError.errorMessage), + errorMessageName: aPageError.errorMessageName, + exceptionDocURL: ErrorDocs.GetURL(aPageError), + sourceName: aPageError.sourceName, + lineText: lineText, + lineNumber: aPageError.lineNumber, + columnNumber: aPageError.columnNumber, + category: aPageError.category, + timeStamp: aPageError.timeStamp, + warning: !!(aPageError.flags & aPageError.warningFlag), + error: !!(aPageError.flags & aPageError.errorFlag), + exception: !!(aPageError.flags & aPageError.exceptionFlag), + strict: !!(aPageError.flags & aPageError.strictFlag), + info: !!(aPageError.flags & aPageError.infoFlag), + private: aPageError.isFromPrivateWindow, + stacktrace: stack + }; + }, + + /** + * Handler for window.console API calls received from the ConsoleAPIListener. + * This method sends the object to the remote Web Console client. + * + * @see ConsoleAPIListener + * @param object aMessage + * The console API call we need to send to the remote client. + */ + onConsoleAPICall: function WCA_onConsoleAPICall(aMessage) + { + let packet = { + from: this.actorID, + type: "consoleAPICall", + message: this.prepareConsoleMessageForRemote(aMessage), + }; + this.conn.send(packet); + }, + + /** + * Handler for network events. This method is invoked when a new network event + * is about to be recorded. + * + * @see NetworkEventActor + * @see NetworkMonitor from webconsole/utils.js + * + * @param object aEvent + * The initial network request event information. + * @return object + * A new NetworkEventActor is returned. This is used for tracking the + * network request and response. + */ + onNetworkEvent: function WCA_onNetworkEvent(aEvent) + { + let actor = this.getNetworkEventActor(aEvent.channelId); + actor.init(aEvent); + + let packet = { + from: this.actorID, + type: "networkEvent", + eventActor: actor.grip() + }; + + this.conn.send(packet); + + return actor; + }, + + /** + * Get the NetworkEventActor for a nsIHttpChannel, if it exists, + * otherwise create a new one. + * + * @param string channelId + * The id of the channel for the network event. + * @return object + * The NetworkEventActor for the given channel. + */ + getNetworkEventActor: function WCA_getNetworkEventActor(channelId) { + let actor = this._netEvents.get(channelId); + if (actor) { + // delete from map as we should only need to do this check once + this._netEvents.delete(channelId); + return actor; + } + + actor = new NetworkEventActor(this); + this._actorPool.addActor(actor); + return actor; + }, + + /** + * Send a new HTTP request from the target's window. + * + * @param object message + * Object with 'request' - the HTTP request details. + */ + onSendHTTPRequest(message) { + let { url, method, headers, body } = message.request; + + // Set the loadingNode and loadGroup to the target document - otherwise the + // request won't show up in the opened netmonitor. + let doc = this.window.document; + + let channel = NetUtil.newChannel({ + uri: NetUtil.newURI(url), + loadingNode: doc, + securityFlags: Ci.nsILoadInfo.SEC_ALLOW_CROSS_ORIGIN_DATA_IS_NULL, + contentPolicyType: Ci.nsIContentPolicy.TYPE_OTHER + }); + + channel.QueryInterface(Ci.nsIHttpChannel); + + channel.loadGroup = doc.documentLoadGroup; + channel.loadFlags |= Ci.nsIRequest.LOAD_BYPASS_CACHE | + Ci.nsIRequest.INHIBIT_CACHING | + Ci.nsIRequest.LOAD_ANONYMOUS; + + channel.requestMethod = method; + + for (let {name, value} of headers) { + channel.setRequestHeader(name, value, false); + } + + if (body) { + channel.QueryInterface(Ci.nsIUploadChannel2); + let bodyStream = Cc["@mozilla.org/io/string-input-stream;1"] + .createInstance(Ci.nsIStringInputStream); + bodyStream.setData(body, body.length); + channel.explicitSetUploadStream(bodyStream, null, -1, method, false); + } + + NetUtil.asyncFetch(channel, () => {}); + + let actor = this.getNetworkEventActor(channel.channelId); + + // map channel to actor so we can associate future events with it + this._netEvents.set(channel.channelId, actor); + + return { + from: this.actorID, + eventActor: actor.grip() + }; + }, + + /** + * Handler for file activity. This method sends the file request information + * to the remote Web Console client. + * + * @see ConsoleProgressListener + * @param string aFileURI + * The requested file URI. + */ + onFileActivity: function WCA_onFileActivity(aFileURI) + { + let packet = { + from: this.actorID, + type: "fileActivity", + uri: aFileURI, + }; + this.conn.send(packet); + }, + + /** + * Handler for reflow activity. This method forwards reflow events to the + * remote Web Console client. + * + * @see ConsoleReflowListener + * @param Object aReflowInfo + */ + onReflowActivity: function WCA_onReflowActivity(aReflowInfo) + { + let packet = { + from: this.actorID, + type: "reflowActivity", + interruptible: aReflowInfo.interruptible, + start: aReflowInfo.start, + end: aReflowInfo.end, + sourceURL: aReflowInfo.sourceURL, + sourceLine: aReflowInfo.sourceLine, + functionName: aReflowInfo.functionName + }; + + this.conn.send(packet); + }, + + /** + * Handler for server logging. This method forwards log events to the + * remote Web Console client. + * + * @see ServerLoggingListener + * @param object aMessage + * The console API call on the server we need to send to the remote client. + */ + onServerLogCall: function WCA_onServerLogCall(aMessage) + { + // Clone all data into the content scope (that's where + // passed arguments comes from). + let msg = Cu.cloneInto(aMessage, this.window); + + // All arguments within the message need to be converted into + // debuggees to properly send it to the client side. + // Use the default target: this.window as the global object + // since that's the correct scope for data in the message. + // The 'false' argument passed into prepareConsoleMessageForRemote() + // ensures that makeDebuggeeValue uses content debuggee. + // See also: + // * makeDebuggeeValue() + // * prepareConsoleMessageForRemote() + msg = this.prepareConsoleMessageForRemote(msg, false); + + let packet = { + from: this.actorID, + type: "serverLogCall", + message: msg, + }; + + this.conn.send(packet); + }, + + // End of event handlers for various listeners. + + /** + * Prepare a message from the console API to be sent to the remote Web Console + * instance. + * + * @param object aMessage + * The original message received from console-api-log-event. + * @param boolean aUseObjectGlobal + * If |true| the object global is determined and added as a debuggee, + * otherwise |this.window| is used when makeDebuggeeValue() is invoked. + * @return object + * The object that can be sent to the remote client. + */ + prepareConsoleMessageForRemote: + function WCA_prepareConsoleMessageForRemote(aMessage, aUseObjectGlobal = true) + { + let result = WebConsoleUtils.cloneObject(aMessage); + + result.workerType = WebConsoleUtils.getWorkerType(result) || "none"; + + delete result.wrappedJSObject; + delete result.ID; + delete result.innerID; + delete result.consoleID; + + result.arguments = Array.map(aMessage.arguments || [], (aObj) => { + let dbgObj = this.makeDebuggeeValue(aObj, aUseObjectGlobal); + return this.createValueGrip(dbgObj); + }); + + result.styles = Array.map(aMessage.styles || [], (aString) => { + return this.createValueGrip(aString); + }); + + result.category = aMessage.category || "webdev"; + + return result; + }, + + /** + * Find the XUL window that owns the content window. + * + * @return Window + * The XUL window that owns the content window. + */ + chromeWindow: function WCA_chromeWindow() + { + let window = null; + try { + window = this.window.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIWebNavigation).QueryInterface(Ci.nsIDocShell) + .chromeEventHandler.ownerDocument.defaultView; + } + catch (ex) { + // The above can fail because chromeEventHandler is not available for all + // kinds of |this.window|. + } + + return window; + }, + + /** + * Notification observer for the "last-pb-context-exited" topic. + * + * @private + * @param object aSubject + * Notification subject - in this case it is the inner window ID that + * was destroyed. + * @param string aTopic + * Notification topic. + */ + _onObserverNotification: function WCA__onObserverNotification(aSubject, aTopic) + { + switch (aTopic) { + case "last-pb-context-exited": + this.conn.send({ + from: this.actorID, + type: "lastPrivateContextExited", + }); + break; + } + }, + + /** + * The "will-navigate" progress listener. This is used to clear the current + * eval scope. + */ + _onWillNavigate: function WCA__onWillNavigate({ window, isTopLevel }) + { + if (isTopLevel) { + this._evalWindow = null; + events.off(this.parentActor, "will-navigate", this._onWillNavigate); + this._progressListenerActive = false; + } + }, + + /** + * This listener is called when we switch to another frame, + * mostly to unregister previous listeners and start listening on the new document. + */ + _onChangedToplevelDocument: function WCA__onChangedToplevelDocument() + { + // Convert the Set to an Array + let listeners = [...this._listeners]; + + // Unregister existing listener on the previous document + // (pass a copy of the array as it will shift from it) + this.onStopListeners({listeners: listeners.slice()}); + + // This method is called after this.window is changed, + // so we register new listener on this new window + this.onStartListeners({listeners: listeners}); + + // Also reset the cached top level chrome window being targeted + this._lastChromeWindow = null; + }, +}; + +WebConsoleActor.prototype.requestTypes = +{ + startListeners: WebConsoleActor.prototype.onStartListeners, + stopListeners: WebConsoleActor.prototype.onStopListeners, + getCachedMessages: WebConsoleActor.prototype.onGetCachedMessages, + evaluateJS: WebConsoleActor.prototype.onEvaluateJS, + evaluateJSAsync: WebConsoleActor.prototype.onEvaluateJSAsync, + autocomplete: WebConsoleActor.prototype.onAutocomplete, + clearMessagesCache: WebConsoleActor.prototype.onClearMessagesCache, + getPreferences: WebConsoleActor.prototype.onGetPreferences, + setPreferences: WebConsoleActor.prototype.onSetPreferences, + sendHTTPRequest: WebConsoleActor.prototype.onSendHTTPRequest +}; + +exports.WebConsoleActor = WebConsoleActor; + +/** + * Creates an actor for a network event. + * + * @constructor + * @param object webConsoleActor + * The parent WebConsoleActor instance for this object. + */ +function NetworkEventActor(webConsoleActor) { + this.parent = webConsoleActor; + this.conn = this.parent.conn; + + this._request = { + method: null, + url: null, + httpVersion: null, + headers: [], + cookies: [], + headersSize: null, + postData: {}, + }; + + this._response = { + headers: [], + cookies: [], + content: {}, + }; + + this._timings = {}; + + // Keep track of LongStringActors owned by this NetworkEventActor. + this._longStringActors = new Set(); +} + +NetworkEventActor.prototype = +{ + _request: null, + _response: null, + _timings: null, + _longStringActors: null, + + actorPrefix: "netEvent", + + /** + * Returns a grip for this actor for returning in a protocol message. + */ + grip: function NEA_grip() + { + return { + actor: this.actorID, + startedDateTime: this._startedDateTime, + timeStamp: Date.parse(this._startedDateTime), + url: this._request.url, + method: this._request.method, + isXHR: this._isXHR, + cause: this._cause, + fromCache: this._fromCache, + fromServiceWorker: this._fromServiceWorker, + private: this._private, + }; + }, + + /** + * Releases this actor from the pool. + */ + release: function NEA_release() + { + for (let grip of this._longStringActors) { + let actor = this.parent.getActorByID(grip.actor); + if (actor) { + this.parent.releaseActor(actor); + } + } + this._longStringActors = new Set(); + + if (this.channel) { + this.parent._netEvents.delete(this.channel); + } + this.parent.releaseActor(this); + }, + + /** + * Handle a protocol request to release a grip. + */ + onRelease: function NEA_onRelease() + { + this.release(); + return {}; + }, + + /** + * Set the properties of this actor based on it's corresponding + * network event. + * + * @param object aNetworkEvent + * The network event associated with this actor. + */ + init: function NEA_init(aNetworkEvent) + { + this._startedDateTime = aNetworkEvent.startedDateTime; + this._isXHR = aNetworkEvent.isXHR; + this._cause = aNetworkEvent.cause; + this._fromCache = aNetworkEvent.fromCache; + this._fromServiceWorker = aNetworkEvent.fromServiceWorker; + + for (let prop of ["method", "url", "httpVersion", "headersSize"]) { + this._request[prop] = aNetworkEvent[prop]; + } + + this._discardRequestBody = aNetworkEvent.discardRequestBody; + this._discardResponseBody = aNetworkEvent.discardResponseBody; + this._private = aNetworkEvent.private; + }, + + /** + * The "getRequestHeaders" packet type handler. + * + * @return object + * The response packet - network request headers. + */ + onGetRequestHeaders: function NEA_onGetRequestHeaders() + { + return { + from: this.actorID, + headers: this._request.headers, + headersSize: this._request.headersSize, + rawHeaders: this._request.rawHeaders, + }; + }, + + /** + * The "getRequestCookies" packet type handler. + * + * @return object + * The response packet - network request cookies. + */ + onGetRequestCookies: function NEA_onGetRequestCookies() + { + return { + from: this.actorID, + cookies: this._request.cookies, + }; + }, + + /** + * The "getRequestPostData" packet type handler. + * + * @return object + * The response packet - network POST data. + */ + onGetRequestPostData: function NEA_onGetRequestPostData() + { + return { + from: this.actorID, + postData: this._request.postData, + postDataDiscarded: this._discardRequestBody, + }; + }, + + /** + * The "getSecurityInfo" packet type handler. + * + * @return object + * The response packet - connection security information. + */ + onGetSecurityInfo: function NEA_onGetSecurityInfo() + { + return { + from: this.actorID, + securityInfo: this._securityInfo, + }; + }, + + /** + * The "getResponseHeaders" packet type handler. + * + * @return object + * The response packet - network response headers. + */ + onGetResponseHeaders: function NEA_onGetResponseHeaders() + { + return { + from: this.actorID, + headers: this._response.headers, + headersSize: this._response.headersSize, + rawHeaders: this._response.rawHeaders, + }; + }, + + /** + * The "getResponseCookies" packet type handler. + * + * @return object + * The response packet - network response cookies. + */ + onGetResponseCookies: function NEA_onGetResponseCookies() + { + return { + from: this.actorID, + cookies: this._response.cookies, + }; + }, + + /** + * The "getResponseContent" packet type handler. + * + * @return object + * The response packet - network response content. + */ + onGetResponseContent: function NEA_onGetResponseContent() + { + return { + from: this.actorID, + content: this._response.content, + contentDiscarded: this._discardResponseBody, + }; + }, + + /** + * The "getEventTimings" packet type handler. + * + * @return object + * The response packet - network event timings. + */ + onGetEventTimings: function NEA_onGetEventTimings() + { + return { + from: this.actorID, + timings: this._timings, + totalTime: this._totalTime + }; + }, + + /** **************************************************************** + * Listeners for new network event data coming from NetworkMonitor. + ******************************************************************/ + + /** + * Add network request headers. + * + * @param array aHeaders + * The request headers array. + * @param string aRawHeaders + * The raw headers source. + */ + addRequestHeaders: function NEA_addRequestHeaders(aHeaders, aRawHeaders) + { + this._request.headers = aHeaders; + this._prepareHeaders(aHeaders); + + var rawHeaders = this.parent._createStringGrip(aRawHeaders); + if (typeof rawHeaders == "object") { + this._longStringActors.add(rawHeaders); + } + this._request.rawHeaders = rawHeaders; + + let packet = { + from: this.actorID, + type: "networkEventUpdate", + updateType: "requestHeaders", + headers: aHeaders.length, + headersSize: this._request.headersSize, + }; + + this.conn.send(packet); + }, + + /** + * Add network request cookies. + * + * @param array aCookies + * The request cookies array. + */ + addRequestCookies: function NEA_addRequestCookies(aCookies) + { + this._request.cookies = aCookies; + this._prepareHeaders(aCookies); + + let packet = { + from: this.actorID, + type: "networkEventUpdate", + updateType: "requestCookies", + cookies: aCookies.length, + }; + + this.conn.send(packet); + }, + + /** + * Add network request POST data. + * + * @param object aPostData + * The request POST data. + */ + addRequestPostData: function NEA_addRequestPostData(aPostData) + { + this._request.postData = aPostData; + aPostData.text = this.parent._createStringGrip(aPostData.text); + if (typeof aPostData.text == "object") { + this._longStringActors.add(aPostData.text); + } + + let packet = { + from: this.actorID, + type: "networkEventUpdate", + updateType: "requestPostData", + dataSize: aPostData.text.length, + discardRequestBody: this._discardRequestBody, + }; + + this.conn.send(packet); + }, + + /** + * Add the initial network response information. + * + * @param object aInfo + * The response information. + * @param string aRawHeaders + * The raw headers source. + */ + addResponseStart: function NEA_addResponseStart(aInfo, aRawHeaders) + { + var rawHeaders = this.parent._createStringGrip(aRawHeaders); + if (typeof rawHeaders == "object") { + this._longStringActors.add(rawHeaders); + } + this._response.rawHeaders = rawHeaders; + + this._response.httpVersion = aInfo.httpVersion; + this._response.status = aInfo.status; + this._response.statusText = aInfo.statusText; + this._response.headersSize = aInfo.headersSize; + this._discardResponseBody = aInfo.discardResponseBody; + + let packet = { + from: this.actorID, + type: "networkEventUpdate", + updateType: "responseStart", + response: aInfo + }; + + this.conn.send(packet); + }, + + /** + * Add connection security information. + * + * @param object info + * The object containing security information. + */ + addSecurityInfo: function NEA_addSecurityInfo(info) + { + this._securityInfo = info; + + let packet = { + from: this.actorID, + type: "networkEventUpdate", + updateType: "securityInfo", + state: info.state, + }; + + this.conn.send(packet); + }, + + /** + * Add network response headers. + * + * @param array aHeaders + * The response headers array. + */ + addResponseHeaders: function NEA_addResponseHeaders(aHeaders) + { + this._response.headers = aHeaders; + this._prepareHeaders(aHeaders); + + let packet = { + from: this.actorID, + type: "networkEventUpdate", + updateType: "responseHeaders", + headers: aHeaders.length, + headersSize: this._response.headersSize, + }; + + this.conn.send(packet); + }, + + /** + * Add network response cookies. + * + * @param array aCookies + * The response cookies array. + */ + addResponseCookies: function NEA_addResponseCookies(aCookies) + { + this._response.cookies = aCookies; + this._prepareHeaders(aCookies); + + let packet = { + from: this.actorID, + type: "networkEventUpdate", + updateType: "responseCookies", + cookies: aCookies.length, + }; + + this.conn.send(packet); + }, + + /** + * Add network response content. + * + * @param object aContent + * The response content. + * @param boolean aDiscardedResponseBody + * Tells if the response content was recorded or not. + */ + addResponseContent: + function NEA_addResponseContent(aContent, aDiscardedResponseBody) + { + this._response.content = aContent; + aContent.text = this.parent._createStringGrip(aContent.text); + if (typeof aContent.text == "object") { + this._longStringActors.add(aContent.text); + } + + let packet = { + from: this.actorID, + type: "networkEventUpdate", + updateType: "responseContent", + mimeType: aContent.mimeType, + contentSize: aContent.size, + encoding: aContent.encoding, + transferredSize: aContent.transferredSize, + discardResponseBody: aDiscardedResponseBody, + }; + + this.conn.send(packet); + }, + + /** + * Add network event timing information. + * + * @param number aTotal + * The total time of the network event. + * @param object aTimings + * Timing details about the network event. + */ + addEventTimings: function NEA_addEventTimings(aTotal, aTimings) + { + this._totalTime = aTotal; + this._timings = aTimings; + + let packet = { + from: this.actorID, + type: "networkEventUpdate", + updateType: "eventTimings", + totalTime: aTotal + }; + + this.conn.send(packet); + }, + + /** + * Prepare the headers array to be sent to the client by using the + * LongStringActor for the header values, when needed. + * + * @private + * @param array aHeaders + */ + _prepareHeaders: function NEA__prepareHeaders(aHeaders) + { + for (let header of aHeaders) { + header.value = this.parent._createStringGrip(header.value); + if (typeof header.value == "object") { + this._longStringActors.add(header.value); + } + } + }, +}; + +NetworkEventActor.prototype.requestTypes = +{ + "release": NetworkEventActor.prototype.onRelease, + "getRequestHeaders": NetworkEventActor.prototype.onGetRequestHeaders, + "getRequestCookies": NetworkEventActor.prototype.onGetRequestCookies, + "getRequestPostData": NetworkEventActor.prototype.onGetRequestPostData, + "getResponseHeaders": NetworkEventActor.prototype.onGetResponseHeaders, + "getResponseCookies": NetworkEventActor.prototype.onGetResponseCookies, + "getResponseContent": NetworkEventActor.prototype.onGetResponseContent, + "getEventTimings": NetworkEventActor.prototype.onGetEventTimings, + "getSecurityInfo": NetworkEventActor.prototype.onGetSecurityInfo, +}; diff --git a/devtools/server/actors/webextension.js b/devtools/server/actors/webextension.js new file mode 100644 index 0000000000..0e83fc999b --- /dev/null +++ b/devtools/server/actors/webextension.js @@ -0,0 +1,333 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { Ci, Cu } = require("chrome"); +const Services = require("Services"); +const { ChromeActor } = require("./chrome"); +const makeDebugger = require("./utils/make-debugger"); + +var DevToolsUtils = require("devtools/shared/DevToolsUtils"); +var { assert } = DevToolsUtils; + +loader.lazyRequireGetter(this, "mapURIToAddonID", "devtools/server/actors/utils/map-uri-to-addon-id"); +loader.lazyRequireGetter(this, "unwrapDebuggerObjectGlobal", "devtools/server/actors/script", true); + +loader.lazyImporter(this, "AddonManager", "resource://gre/modules/AddonManager.jsm"); +loader.lazyImporter(this, "XPIProvider", "resource://gre/modules/addons/XPIProvider.jsm"); + +const FALLBACK_DOC_MESSAGE = "Your addon does not have any document opened yet."; + +/** + * Creates a TabActor for debugging all the contexts associated to a target WebExtensions + * add-on. + * Most of the implementation is inherited from ChromeActor (which inherits most of its + * implementation from TabActor). + * WebExtensionActor is a child of RootActor, it can be retrieved via + * RootActor.listAddons request. + * WebExtensionActor exposes all tab actors via its form() request, like TabActor. + * + * History lecture: + * The add-on actors used to not inherit TabActor because of the different way the + * add-on APIs where exposed to the add-on itself, and for this reason the Addon Debugger + * has only a sub-set of the feature available in the Tab or in the Browser Toolbox. + * In a WebExtensions add-on all the provided contexts (background and popup pages etc.), + * besides the Content Scripts which run in the content process, hooked to an existent + * tab, by creating a new WebExtensionActor which inherits from ChromeActor, we can + * provide a full features Addon Toolbox (which is basically like a BrowserToolbox which + * filters the visible sources and frames to the one that are related to the target + * add-on). + * + * @param conn DebuggerServerConnection + * The connection to the client. + * @param addon AddonWrapper + * The target addon. + */ +function WebExtensionActor(conn, addon) { + ChromeActor.call(this, conn); + + this.id = addon.id; + this.addon = addon; + + // Bind the _allowSource helper to this, it is used in the + // TabActor to lazily create the TabSources instance. + this._allowSource = this._allowSource.bind(this); + + // Set the consoleAPIListener filtering options + // (retrieved and used in the related webconsole child actor). + this.consoleAPIListenerOptions = { + addonId: addon.id, + }; + + // This creates a Debugger instance for debugging all the add-on globals. + this.makeDebugger = makeDebugger.bind(null, { + findDebuggees: dbg => { + return dbg.findAllGlobals().filter(this._shouldAddNewGlobalAsDebuggee); + }, + shouldAddNewGlobalAsDebuggee: this._shouldAddNewGlobalAsDebuggee.bind(this), + }); + + // Discover the preferred debug global for the target addon + this.preferredTargetWindow = null; + this._findAddonPreferredTargetWindow(); + + AddonManager.addAddonListener(this); +} +exports.WebExtensionActor = WebExtensionActor; + +WebExtensionActor.prototype = Object.create(ChromeActor.prototype); + +WebExtensionActor.prototype.actorPrefix = "webExtension"; +WebExtensionActor.prototype.constructor = WebExtensionActor; + +// NOTE: This is needed to catch in the webextension webconsole all the +// errors raised by the WebExtension internals that are not currently +// associated with any window. +WebExtensionActor.prototype.isRootActor = true; + +WebExtensionActor.prototype.form = function () { + assert(this.actorID, "addon should have an actorID."); + + let baseForm = ChromeActor.prototype.form.call(this); + + return Object.assign(baseForm, { + actor: this.actorID, + id: this.id, + name: this.addon.name, + url: this.addon.sourceURI ? this.addon.sourceURI.spec : undefined, + iconURL: this.addon.iconURL, + debuggable: this.addon.isDebuggable, + temporarilyInstalled: this.addon.temporarilyInstalled, + isWebExtension: this.addon.isWebExtension, + }); +}; + +WebExtensionActor.prototype._attach = function () { + // NOTE: we need to be sure that `this.window` can return a + // window before calling the ChromeActor.onAttach, or the TabActor + // will not be subscribed to the child doc shell updates. + + // If a preferredTargetWindow exists, set it as the target for this actor + // when the client request to attach this actor. + if (this.preferredTargetWindow) { + this._setWindow(this.preferredTargetWindow); + } else { + this._createFallbackWindow(); + } + + // Call ChromeActor's _attach to listen for any new/destroyed chrome docshell + ChromeActor.prototype._attach.apply(this); +}; + +WebExtensionActor.prototype._detach = function () { + this._destroyFallbackWindow(); + + // Call ChromeActor's _detach to unsubscribe new/destroyed chrome docshell listeners. + ChromeActor.prototype._detach.apply(this); +}; + +/** + * Called when the actor is removed from the connection. + */ +WebExtensionActor.prototype.exit = function () { + AddonManager.removeAddonListener(this); + + this.preferredTargetWindow = null; + this.addon = null; + this.id = null; + + return ChromeActor.prototype.exit.apply(this); +}; + +// Addon Specific Remote Debugging requestTypes and methods. + +/** + * Reloads the addon. + */ +WebExtensionActor.prototype.onReload = function () { + return this.addon.reload() + .then(() => { + // send an empty response + return {}; + }); +}; + +/** + * Set the preferred global for the add-on (called from the AddonManager). + */ +WebExtensionActor.prototype.setOptions = function (addonOptions) { + if ("global" in addonOptions) { + // Set the proposed debug global as the preferred target window + // (the actor will eventually set it as the target once it is attached) + this.preferredTargetWindow = addonOptions.global; + } +}; + +// AddonManagerListener callbacks. + +WebExtensionActor.prototype.onInstalled = function (addon) { + if (addon.id != this.id) { + return; + } + + // Update the AddonManager's addon object on reload/update. + this.addon = addon; +}; + +WebExtensionActor.prototype.onUninstalled = function (addon) { + if (addon != this.addon) { + return; + } + + this.exit(); +}; + +WebExtensionActor.prototype.onPropertyChanged = function (addon, changedPropNames) { + if (addon != this.addon) { + return; + } + + // Refresh the preferred debug global on disabled/reloaded/upgraded addon. + if (changedPropNames.includes("debugGlobal")) { + this._findAddonPreferredTargetWindow(); + } +}; + +// Private helpers + +WebExtensionActor.prototype._createFallbackWindow = function () { + if (this.fallbackWindow) { + // Skip if there is already an existent fallback window. + return; + } + + // Create an empty hidden window as a fallback (e.g. the background page could be + // not defined for the target add-on or not yet when the actor instance has been + // created). + this.fallbackWebNav = Services.appShell.createWindowlessBrowser(true); + this.fallbackWebNav.loadURI( + `data:text/html;charset=utf-8,${FALLBACK_DOC_MESSAGE}`, + 0, null, null, null + ); + + this.fallbackDocShell = this.fallbackWebNav + .QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDocShell); + + Object.defineProperty(this, "docShell", { + value: this.fallbackDocShell, + configurable: true + }); + + // Save the reference to the fallback DOMWindow + this.fallbackWindow = this.fallbackDocShell.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDOMWindow); +}; + +WebExtensionActor.prototype._destroyFallbackWindow = function () { + if (this.fallbackWebNav) { + // Explicitly close the fallback windowless browser to prevent it to leak + // (and to prevent it to freeze devtools xpcshell tests). + this.fallbackWebNav.loadURI("about:blank", 0, null, null, null); + this.fallbackWebNav.close(); + + this.fallbackWebNav = null; + this.fallbackWindow = null; + } +}; + +/** + * Discover the preferred debug global and switch to it if the addon has been attached. + */ +WebExtensionActor.prototype._findAddonPreferredTargetWindow = function () { + return new Promise(resolve => { + let activeAddon = XPIProvider.activeAddons.get(this.id); + + if (!activeAddon) { + // The addon is not active, the background page is going to be destroyed, + // navigate to the fallback window (if it already exists). + resolve(null); + } else { + AddonManager.getAddonByInstanceID(activeAddon.instanceID) + .then(privateWrapper => { + let targetWindow = privateWrapper.getDebugGlobal(); + + // Do not use the preferred global if it is not a DOMWindow as expected. + if (!(targetWindow instanceof Ci.nsIDOMWindow)) { + targetWindow = null; + } + + resolve(targetWindow); + }); + } + }).then(preferredTargetWindow => { + this.preferredTargetWindow = preferredTargetWindow; + + if (!preferredTargetWindow) { + // Create a fallback window if no preferred target window has been found. + this._createFallbackWindow(); + } else if (this.attached) { + // Change the top level document if the actor is already attached. + this._changeTopLevelDocument(preferredTargetWindow); + } + }); +}; + +/** + * Return an array of the json details related to an array/iterator of docShells. + */ +WebExtensionActor.prototype._docShellsToWindows = function (docshells) { + return ChromeActor.prototype._docShellsToWindows.call(this, docshells) + .filter(windowDetails => { + // filter the docShells based on the addon id + return windowDetails.addonID == this.id; + }); +}; + +/** + * Return true if the given source is associated with this addon and should be + * added to the visible sources (retrieved and used by the webbrowser actor module). + */ +WebExtensionActor.prototype._allowSource = function (source) { + try { + let uri = Services.io.newURI(source.url, null, null); + let addonID = mapURIToAddonID(uri); + + return addonID == this.id; + } catch (e) { + return false; + } +}; + +/** + * Return true if the given global is associated with this addon and should be + * added as a debuggee, false otherwise. + */ +WebExtensionActor.prototype._shouldAddNewGlobalAsDebuggee = function (newGlobal) { + const global = unwrapDebuggerObjectGlobal(newGlobal); + + if (global instanceof Ci.nsIDOMWindow) { + return global.document.nodePrincipal.originAttributes.addonId == this.id; + } + + try { + // This will fail for non-Sandbox objects, hence the try-catch block. + let metadata = Cu.getSandboxMetadata(global); + if (metadata) { + return metadata.addonID === this.id; + } + } catch (e) { + // Unable to retrieve the sandbox metadata. + } + + return false; +}; + +/** + * Override WebExtensionActor requestTypes: + * - redefined `reload`, which should reload the target addon + * (instead of the entire browser as the regular ChromeActor does). + */ +WebExtensionActor.prototype.requestTypes.reload = WebExtensionActor.prototype.onReload; diff --git a/devtools/server/actors/webgl.js b/devtools/server/actors/webgl.js new file mode 100644 index 0000000000..1374486470 --- /dev/null +++ b/devtools/server/actors/webgl.js @@ -0,0 +1,1322 @@ +/* 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 {Cc, Ci, Cu, Cr} = require("chrome"); +const events = require("sdk/event/core"); +const promise = require("promise"); +const protocol = require("devtools/shared/protocol"); +const { ContentObserver } = require("devtools/shared/content-observer"); +const { on, once, off, emit } = events; +const { method, Arg, Option, RetVal } = protocol; +const { + shaderSpec, + programSpec, + webGLSpec, +} = require("devtools/shared/specs/webgl"); + +const WEBGL_CONTEXT_NAMES = ["webgl", "experimental-webgl", "moz-webgl"]; + +// These traits are bit masks. Make sure they're powers of 2. +const PROGRAM_DEFAULT_TRAITS = 0; +const PROGRAM_BLACKBOX_TRAIT = 1; +const PROGRAM_HIGHLIGHT_TRAIT = 2; + +/** + * A WebGL Shader contributing to building a WebGL Program. + * You can either retrieve, or compile the source of a shader, which will + * automatically inflict the necessary changes to the WebGL state. + */ +var ShaderActor = protocol.ActorClassWithSpec(shaderSpec, { + /** + * Create the shader actor. + * + * @param DebuggerServerConnection conn + * The server connection. + * @param WebGLProgram program + * The WebGL program being linked. + * @param WebGLShader shader + * The cooresponding vertex or fragment shader. + * @param WebGLProxy proxy + * The proxy methods for the WebGL context owning this shader. + */ + initialize: function (conn, program, shader, proxy) { + protocol.Actor.prototype.initialize.call(this, conn); + this.program = program; + this.shader = shader; + this.text = proxy.getShaderSource(shader); + this.linkedProxy = proxy; + }, + + /** + * Gets the source code for this shader. + */ + getText: function () { + return this.text; + }, + + /** + * Sets and compiles new source code for this shader. + */ + compile: function (text) { + // Get the shader and corresponding program to change via the WebGL proxy. + let { linkedProxy: proxy, shader, program } = this; + + // Get the new shader source to inject. + let oldText = this.text; + let newText = text; + + // Overwrite the shader's source. + let error = proxy.compileShader(program, shader, this.text = newText); + + // If something went wrong, revert to the previous shader. + if (error.compile || error.link) { + proxy.compileShader(program, shader, this.text = oldText); + return error; + } + return undefined; + } +}); + +/** + * A WebGL program is composed (at the moment, analogue to OpenGL ES 2.0) + * of two shaders: a vertex shader and a fragment shader. + */ +var ProgramActor = protocol.ActorClassWithSpec(programSpec, { + /** + * Create the program actor. + * + * @param DebuggerServerConnection conn + * The server connection. + * @param WebGLProgram program + * The WebGL program being linked. + * @param WebGLShader[] shaders + * The WebGL program's cooresponding vertex and fragment shaders. + * @param WebGLCache cache + * The state storage for the WebGL context owning this program. + * @param WebGLProxy proxy + * The proxy methods for the WebGL context owning this program. + */ + initialize: function (conn, [program, shaders, cache, proxy]) { + protocol.Actor.prototype.initialize.call(this, conn); + this._shaderActorsCache = { vertex: null, fragment: null }; + this.program = program; + this.shaders = shaders; + this.linkedCache = cache; + this.linkedProxy = proxy; + }, + + get ownerWindow() { + return this.linkedCache.ownerWindow; + }, + + get ownerContext() { + return this.linkedCache.ownerContext; + }, + + /** + * Gets the vertex shader linked to this program. This method guarantees + * a single actor instance per shader. + */ + getVertexShader: function () { + return this._getShaderActor("vertex"); + }, + + /** + * Gets the fragment shader linked to this program. This method guarantees + * a single actor instance per shader. + */ + getFragmentShader: function () { + return this._getShaderActor("fragment"); + }, + + /** + * Highlights any geometry rendered using this program. + */ + highlight: function (tint) { + this.linkedProxy.highlightTint = tint; + this.linkedCache.setProgramTrait(this.program, PROGRAM_HIGHLIGHT_TRAIT); + }, + + /** + * Allows geometry to be rendered normally using this program. + */ + unhighlight: function () { + this.linkedCache.unsetProgramTrait(this.program, PROGRAM_HIGHLIGHT_TRAIT); + }, + + /** + * Prevents any geometry from being rendered using this program. + */ + blackbox: function () { + this.linkedCache.setProgramTrait(this.program, PROGRAM_BLACKBOX_TRAIT); + }, + + /** + * Allows geometry to be rendered using this program. + */ + unblackbox: function () { + this.linkedCache.unsetProgramTrait(this.program, PROGRAM_BLACKBOX_TRAIT); + }, + + /** + * Returns a cached ShaderActor instance based on the required shader type. + * + * @param string type + * Either "vertex" or "fragment". + * @return ShaderActor + * The respective shader actor instance. + */ + _getShaderActor: function (type) { + if (this._shaderActorsCache[type]) { + return this._shaderActorsCache[type]; + } + let proxy = this.linkedProxy; + let shader = proxy.getShaderOfType(this.shaders, type); + let shaderActor = new ShaderActor(this.conn, this.program, shader, proxy); + return this._shaderActorsCache[type] = shaderActor; + } +}); + +/** + * The WebGL Actor handles simple interaction with a WebGL context via a few + * high-level methods. After instantiating this actor, you'll need to set it + * up by calling setup(). + */ +var WebGLActor = exports.WebGLActor = protocol.ActorClassWithSpec(webGLSpec, { + initialize: function (conn, tabActor) { + protocol.Actor.prototype.initialize.call(this, conn); + this.tabActor = tabActor; + this._onGlobalCreated = this._onGlobalCreated.bind(this); + this._onGlobalDestroyed = this._onGlobalDestroyed.bind(this); + this._onProgramLinked = this._onProgramLinked.bind(this); + }, + destroy: function (conn) { + protocol.Actor.prototype.destroy.call(this, conn); + this.finalize(); + }, + + /** + * Starts waiting for the current tab actor's document global to be + * created, in order to instrument the Canvas context and become + * aware of everything the content does WebGL-wise. + * + * See ContentObserver and WebGLInstrumenter for more details. + */ + setup: function ({ reload }) { + if (this._initialized) { + return; + } + this._initialized = true; + + this._programActorsCache = []; + this._webglObserver = new WebGLObserver(); + + on(this.tabActor, "window-ready", this._onGlobalCreated); + on(this.tabActor, "window-destroyed", this._onGlobalDestroyed); + on(this._webglObserver, "program-linked", this._onProgramLinked); + + if (reload) { + this.tabActor.window.location.reload(); + } + }, + + /** + * Stops listening for document global changes and puts this actor + * to hibernation. This method is called automatically just before the + * actor is destroyed. + */ + finalize: function () { + if (!this._initialized) { + return; + } + this._initialized = false; + + off(this.tabActor, "window-ready", this._onGlobalCreated); + off(this.tabActor, "window-destroyed", this._onGlobalDestroyed); + off(this._webglObserver, "program-linked", this._onProgramLinked); + + this._programActorsCache = null; + this._contentObserver = null; + this._webglObserver = null; + }, + + /** + * Gets an array of cached program actors for the current tab actor's window. + * This is useful for dealing with bfcache, when no new programs are linked. + */ + getPrograms: function () { + let id = ContentObserver.GetInnerWindowID(this.tabActor.window); + return this._programActorsCache.filter(e => e.ownerWindow == id); + }, + + /** + * Waits for one frame via `requestAnimationFrame` on the tab actor's window. + * Used in tests. + */ + waitForFrame: function () { + let deferred = promise.defer(); + this.tabActor.window.requestAnimationFrame(deferred.resolve); + return deferred.promise; + }, + + /** + * Gets a pixel's RGBA value from a context specified by selector + * and the coordinates of the pixel in question. + * Currently only used in tests. + * + * @param string selector + * A string selector to select the canvas in question from the DOM. + * @param Object position + * An object with an `x` and `y` property indicating coordinates of the pixel being inspected. + * @return Object + * An object containing `r`, `g`, `b`, and `a` properties of the pixel. + */ + getPixel: function ({ selector, position }) { + let { x, y } = position; + let canvas = this.tabActor.window.document.querySelector(selector); + let context = XPCNativeWrapper.unwrap(canvas.getContext("webgl")); + let { proxy } = this._webglObserver.for(context); + let height = canvas.height; + + let buffer = new this.tabActor.window.Uint8Array(4); + buffer = XPCNativeWrapper.unwrap(buffer); + + proxy.readPixels(x, height - y - 1, 1, 1, context.RGBA, context.UNSIGNED_BYTE, buffer); + + return { r: buffer[0], g: buffer[1], b: buffer[2], a: buffer[3] }; + }, + + /** + * Gets an array of all cached program actors belonging to all windows. + * This should only be used for tests. + */ + _getAllPrograms: function () { + return this._programActorsCache; + }, + + + /** + * Invoked whenever the current tab actor's document global is created. + */ + _onGlobalCreated: function ({id, window, isTopLevel}) { + if (isTopLevel) { + WebGLInstrumenter.handle(window, this._webglObserver); + events.emit(this, "global-created", id); + } + }, + + /** + * Invoked whenever the current tab actor's inner window is destroyed. + */ + _onGlobalDestroyed: function ({id, isTopLevel, isFrozen}) { + if (isTopLevel && !isFrozen) { + removeFromArray(this._programActorsCache, e => e.ownerWindow == id); + this._webglObserver.unregisterContextsForWindow(id); + events.emit(this, "global-destroyed", id); + } + }, + + /** + * Invoked whenever an observed WebGL context links a program. + */ + _onProgramLinked: function (...args) { + let programActor = new ProgramActor(this.conn, args); + this._programActorsCache.push(programActor); + events.emit(this, "program-linked", programActor); + } +}); + +/** + * Instruments a HTMLCanvasElement with the appropriate inspection methods. + */ +var WebGLInstrumenter = { + /** + * Overrides the getContext method in the HTMLCanvasElement prototype. + * + * @param nsIDOMWindow window + * The window to perform the instrumentation in. + * @param WebGLObserver observer + * The observer watching function calls in the context. + */ + handle: function (window, observer) { + let self = this; + + let id = ContentObserver.GetInnerWindowID(window); + let canvasElem = XPCNativeWrapper.unwrap(window.HTMLCanvasElement); + let canvasPrototype = canvasElem.prototype; + let originalGetContext = canvasPrototype.getContext; + + /** + * Returns a drawing context on the canvas, or null if the context ID is + * not supported. This override creates an observer for the targeted context + * type and instruments specific functions in the targeted context instance. + */ + canvasPrototype.getContext = function (name, options) { + // Make sure a context was able to be created. + let context = originalGetContext.call(this, name, options); + if (!context) { + return context; + } + // Make sure a WebGL (not a 2D) context will be instrumented. + if (WEBGL_CONTEXT_NAMES.indexOf(name) == -1) { + return context; + } + // Repeated calls to 'getContext' return the same instance, no need to + // instrument everything again. + if (observer.for(context)) { + return context; + } + + // Create a separate state storage for this context. + observer.registerContextForWindow(id, context); + + // Link our observer to the new WebGL context methods. + for (let { timing, callback, functions } of self._methods) { + for (let func of functions) { + self._instrument(observer, context, func, callback, timing); + } + } + + // Return the decorated context back to the content consumer, which + // will continue using it normally. + return context; + }; + }, + + /** + * Overrides a specific method in a HTMLCanvasElement context. + * + * @param WebGLObserver observer + * The observer watching function calls in the context. + * @param WebGLRenderingContext context + * The targeted WebGL context instance. + * @param string funcName + * The function to override. + * @param array callbackName [optional] + * The two callback function names in the observer, corresponding to + * the "before" and "after" invocation times. If unspecified, they will + * default to the name of the function to override. + * @param number timing [optional] + * When to issue the callback in relation to the actual context + * function call. Availalble values are -1 for "before" (default) + * 1 for "after" and 0 for "before and after". + */ + _instrument: function (observer, context, funcName, callbackName = [], timing = -1) { + let { cache, proxy } = observer.for(context); + let originalFunc = context[funcName]; + let beforeFuncName = callbackName[0] || funcName; + let afterFuncName = callbackName[1] || callbackName[0] || funcName; + + context[funcName] = function (...glArgs) { + if (timing <= 0 && !observer.suppressHandlers) { + let glBreak = observer[beforeFuncName](glArgs, cache, proxy); + if (glBreak) return undefined; + } + + // Invoking .apply on an unxrayed content function doesn't work, because + // the arguments array is inaccessible to it. Get Xrays back. + let glResult = Cu.waiveXrays(Cu.unwaiveXrays(originalFunc).apply(this, glArgs)); + + if (timing >= 0 && !observer.suppressHandlers) { + let glBreak = observer[afterFuncName](glArgs, glResult, cache, proxy); + if (glBreak) return undefined; + } + + return glResult; + }; + }, + + /** + * Override mappings for WebGL methods. + */ + _methods: [{ + timing: 1, // after + functions: [ + "linkProgram", "getAttribLocation", "getUniformLocation" + ] + }, { + timing: -1, // before + callback: [ + "toggleVertexAttribArray" + ], + functions: [ + "enableVertexAttribArray", "disableVertexAttribArray" + ] + }, { + timing: -1, // before + callback: [ + "attribute_" + ], + functions: [ + "vertexAttrib1f", "vertexAttrib2f", "vertexAttrib3f", "vertexAttrib4f", + "vertexAttrib1fv", "vertexAttrib2fv", "vertexAttrib3fv", "vertexAttrib4fv", + "vertexAttribPointer" + ] + }, { + timing: -1, // before + callback: [ + "uniform_" + ], + functions: [ + "uniform1i", "uniform2i", "uniform3i", "uniform4i", + "uniform1f", "uniform2f", "uniform3f", "uniform4f", + "uniform1iv", "uniform2iv", "uniform3iv", "uniform4iv", + "uniform1fv", "uniform2fv", "uniform3fv", "uniform4fv", + "uniformMatrix2fv", "uniformMatrix3fv", "uniformMatrix4fv" + ] + }, { + timing: -1, // before + functions: [ + "useProgram", "enable", "disable", "blendColor", + "blendEquation", "blendEquationSeparate", + "blendFunc", "blendFuncSeparate" + ] + }, { + timing: 0, // before and after + callback: [ + "beforeDraw_", "afterDraw_" + ], + functions: [ + "drawArrays", "drawElements" + ] + }] + // TODO: It'd be a good idea to handle other functions as well: + // - getActiveUniform + // - getUniform + // - getActiveAttrib + // - getVertexAttrib +}; + +/** + * An observer that captures a WebGL context's method calls. + */ +function WebGLObserver() { + this._contexts = new Map(); +} + +WebGLObserver.prototype = { + _contexts: null, + + /** + * Creates a WebGLCache and a WebGLProxy for the specified window and context. + * + * @param number id + * The id of the window containing the WebGL context. + * @param WebGLRenderingContext context + * The WebGL context used in the cache and proxy instances. + */ + registerContextForWindow: function (id, context) { + let cache = new WebGLCache(id, context); + let proxy = new WebGLProxy(id, context, cache, this); + cache.refreshState(proxy); + + this._contexts.set(context, { + ownerWindow: id, + cache: cache, + proxy: proxy + }); + }, + + /** + * Removes all WebGLCache and WebGLProxy instances for a particular window. + * + * @param number id + * The id of the window containing the WebGL context. + */ + unregisterContextsForWindow: function (id) { + removeFromMap(this._contexts, e => e.ownerWindow == id); + }, + + /** + * Gets the WebGLCache and WebGLProxy instances for a particular context. + * + * @param WebGLRenderingContext context + * The WebGL context used in the cache and proxy instances. + * @return object + * An object containing the corresponding { cache, proxy } instances. + */ + for: function (context) { + return this._contexts.get(context); + }, + + /** + * Set this flag to true to stop observing any context function calls. + */ + suppressHandlers: false, + + /** + * Called immediately *after* 'linkProgram' is requested in the context. + * + * @param array glArgs + * Overridable arguments with which the function is called. + * @param void glResult + * The returned value of the original function call. + * @param WebGLCache cache + * The state storage for the WebGL context initiating this call. + * @param WebGLProxy proxy + * The proxy methods for the WebGL context initiating this call. + */ + linkProgram: function (glArgs, glResult, cache, proxy) { + let program = glArgs[0]; + let shaders = proxy.getAttachedShaders(program); + cache.addProgram(program, PROGRAM_DEFAULT_TRAITS); + emit(this, "program-linked", program, shaders, cache, proxy); + }, + + /** + * Called immediately *after* 'getAttribLocation' is requested in the context. + * + * @param array glArgs + * Overridable arguments with which the function is called. + * @param GLint glResult + * The returned value of the original function call. + * @param WebGLCache cache + * The state storage for the WebGL context initiating this call. + */ + getAttribLocation: function (glArgs, glResult, cache) { + // Make sure the attribute's value is legal before caching. + if (glResult < 0) { + return; + } + let [program, name] = glArgs; + cache.addAttribute(program, name, glResult); + }, + + /** + * Called immediately *after* 'getUniformLocation' is requested in the context. + * + * @param array glArgs + * Overridable arguments with which the function is called. + * @param WebGLUniformLocation glResult + * The returned value of the original function call. + * @param WebGLCache cache + * The state storage for the WebGL context initiating this call. + */ + getUniformLocation: function (glArgs, glResult, cache) { + // Make sure the uniform's value is legal before caching. + if (!glResult) { + return; + } + let [program, name] = glArgs; + cache.addUniform(program, name, glResult); + }, + + /** + * Called immediately *before* 'enableVertexAttribArray' or + * 'disableVertexAttribArray'is requested in the context. + * + * @param array glArgs + * Overridable arguments with which the function is called. + * @param WebGLCache cache + * The state storage for the WebGL context initiating this call. + */ + toggleVertexAttribArray: function (glArgs, cache) { + glArgs[0] = cache.getCurrentAttributeLocation(glArgs[0]); + return glArgs[0] < 0; // Return true to break original function call. + }, + + /** + * Called immediately *before* 'attribute_' is requested in the context. + * + * @param array glArgs + * Overridable arguments with which the function is called. + * @param WebGLCache cache + * The state storage for the WebGL context initiating this call. + */ + attribute_: function (glArgs, cache) { + glArgs[0] = cache.getCurrentAttributeLocation(glArgs[0]); + return glArgs[0] < 0; // Return true to break original function call. + }, + + /** + * Called immediately *before* 'uniform_' is requested in the context. + * + * @param array glArgs + * Overridable arguments with which the function is called. + * @param WebGLCache cache + * The state storage for the WebGL context initiating this call. + */ + uniform_: function (glArgs, cache) { + glArgs[0] = cache.getCurrentUniformLocation(glArgs[0]); + return !glArgs[0]; // Return true to break original function call. + }, + + /** + * Called immediately *before* 'useProgram' is requested in the context. + * + * @param array glArgs + * Overridable arguments with which the function is called. + * @param WebGLCache cache + * The state storage for the WebGL context initiating this call. + */ + useProgram: function (glArgs, cache) { + // Manually keeping a cache and not using gl.getParameter(CURRENT_PROGRAM) + // because gl.get* functions are slow as potatoes. + cache.currentProgram = glArgs[0]; + }, + + /** + * Called immediately *before* 'enable' is requested in the context. + * + * @param array glArgs + * Overridable arguments with which the function is called. + * @param WebGLCache cache + * The state storage for the WebGL context initiating this call. + */ + enable: function (glArgs, cache) { + cache.currentState[glArgs[0]] = true; + }, + + /** + * Called immediately *before* 'disable' is requested in the context. + * + * @param array glArgs + * Overridable arguments with which the function is called. + * @param WebGLCache cache + * The state storage for the WebGL context initiating this call. + */ + disable: function (glArgs, cache) { + cache.currentState[glArgs[0]] = false; + }, + + /** + * Called immediately *before* 'blendColor' is requested in the context. + * + * @param array glArgs + * Overridable arguments with which the function is called. + * @param WebGLCache cache + * The state storage for the WebGL context initiating this call. + */ + blendColor: function (glArgs, cache) { + let blendColor = cache.currentState.blendColor; + blendColor[0] = glArgs[0]; + blendColor[1] = glArgs[1]; + blendColor[2] = glArgs[2]; + blendColor[3] = glArgs[3]; + }, + + /** + * Called immediately *before* 'blendEquation' is requested in the context. + * + * @param array glArgs + * Overridable arguments with which the function is called. + * @param WebGLCache cache + * The state storage for the WebGL context initiating this call. + */ + blendEquation: function (glArgs, cache) { + let state = cache.currentState; + state.blendEquationRgb = state.blendEquationAlpha = glArgs[0]; + }, + + /** + * Called immediately *before* 'blendEquationSeparate' is requested in the context. + * + * @param array glArgs + * Overridable arguments with which the function is called. + * @param WebGLCache cache + * The state storage for the WebGL context initiating this call. + */ + blendEquationSeparate: function (glArgs, cache) { + let state = cache.currentState; + state.blendEquationRgb = glArgs[0]; + state.blendEquationAlpha = glArgs[1]; + }, + + /** + * Called immediately *before* 'blendFunc' is requested in the context. + * + * @param array glArgs + * Overridable arguments with which the function is called. + * @param WebGLCache cache + * The state storage for the WebGL context initiating this call. + */ + blendFunc: function (glArgs, cache) { + let state = cache.currentState; + state.blendSrcRgb = state.blendSrcAlpha = glArgs[0]; + state.blendDstRgb = state.blendDstAlpha = glArgs[1]; + }, + + /** + * Called immediately *before* 'blendFuncSeparate' is requested in the context. + * + * @param array glArgs + * Overridable arguments with which the function is called. + * @param WebGLCache cache + * The state storage for the WebGL context initiating this call. + */ + blendFuncSeparate: function (glArgs, cache) { + let state = cache.currentState; + state.blendSrcRgb = glArgs[0]; + state.blendDstRgb = glArgs[1]; + state.blendSrcAlpha = glArgs[2]; + state.blendDstAlpha = glArgs[3]; + }, + + /** + * Called immediately *before* 'drawArrays' or 'drawElements' is requested + * in the context. + * + * @param array glArgs + * Overridable arguments with which the function is called. + * @param WebGLCache cache + * The state storage for the WebGL context initiating this call. + * @param WebGLProxy proxy + * The proxy methods for the WebGL context initiating this call. + */ + beforeDraw_: function (glArgs, cache, proxy) { + let traits = cache.currentProgramTraits; + + // Handle program blackboxing. + if (traits & PROGRAM_BLACKBOX_TRAIT) { + return true; // Return true to break original function call. + } + // Handle program highlighting. + if (traits & PROGRAM_HIGHLIGHT_TRAIT) { + proxy.enableHighlighting(); + } + + return false; + }, + + /** + * Called immediately *after* 'drawArrays' or 'drawElements' is requested + * in the context. + * + * @param array glArgs + * Overridable arguments with which the function is called. + * @param void glResult + * The returned value of the original function call. + * @param WebGLCache cache + * The state storage for the WebGL context initiating this call. + * @param WebGLProxy proxy + * The proxy methods for the WebGL context initiating this call. + */ + afterDraw_: function (glArgs, glResult, cache, proxy) { + let traits = cache.currentProgramTraits; + + // Handle program highlighting. + if (traits & PROGRAM_HIGHLIGHT_TRAIT) { + proxy.disableHighlighting(); + } + } +}; + +/** + * A mechanism for storing a single WebGL context's state, programs, shaders, + * attributes or uniforms. + * + * @param number id + * The id of the window containing the WebGL context. + * @param WebGLRenderingContext context + * The WebGL context for which the state is stored. + */ +function WebGLCache(id, context) { + this._id = id; + this._gl = context; + this._programs = new Map(); + this.currentState = {}; +} + +WebGLCache.prototype = { + _id: 0, + _gl: null, + _programs: null, + _currentProgramInfo: null, + _currentAttributesMap: null, + _currentUniformsMap: null, + + get ownerWindow() { + return this._id; + }, + + get ownerContext() { + return this._gl; + }, + + /** + * A collection of flags or properties representing the context's state. + * Implemented as an object hash and not a Map instance because keys are + * always either strings or numbers. + */ + currentState: null, + + /** + * Populates the current state with values retrieved from the context. + * + * @param WebGLProxy proxy + * The proxy methods for the WebGL context owning the state. + */ + refreshState: function (proxy) { + let gl = this._gl; + let s = this.currentState; + + // Populate only with the necessary parameters. Not all default WebGL + // state values are required. + s[gl.BLEND] = proxy.isEnabled("BLEND"); + s.blendColor = proxy.getParameter("BLEND_COLOR"); + s.blendEquationRgb = proxy.getParameter("BLEND_EQUATION_RGB"); + s.blendEquationAlpha = proxy.getParameter("BLEND_EQUATION_ALPHA"); + s.blendSrcRgb = proxy.getParameter("BLEND_SRC_RGB"); + s.blendSrcAlpha = proxy.getParameter("BLEND_SRC_ALPHA"); + s.blendDstRgb = proxy.getParameter("BLEND_DST_RGB"); + s.blendDstAlpha = proxy.getParameter("BLEND_DST_ALPHA"); + }, + + /** + * Adds a program to the cache. + * + * @param WebGLProgram program + * The shader for which the traits are to be cached. + * @param number traits + * A default properties mask set for the program. + */ + addProgram: function (program, traits) { + this._programs.set(program, { + traits: traits, + attributes: [], // keys are GLints (numbers) + uniforms: new Map() // keys are WebGLUniformLocations (objects) + }); + }, + + /** + * Adds a specific trait to a program. The effect of such properties is + * determined by the consumer of this cache. + * + * @param WebGLProgram program + * The program to add the trait to. + * @param number trait + * The property added to the program. + */ + setProgramTrait: function (program, trait) { + this._programs.get(program).traits |= trait; + }, + + /** + * Removes a specific trait from a program. + * + * @param WebGLProgram program + * The program to remove the trait from. + * @param number trait + * The property removed from the program. + */ + unsetProgramTrait: function (program, trait) { + this._programs.get(program).traits &= ~trait; + }, + + /** + * Sets the currently used program in the context. + * @param WebGLProgram program + */ + set currentProgram(program) { + let programInfo = this._programs.get(program); + if (programInfo == null) { + return; + } + this._currentProgramInfo = programInfo; + this._currentAttributesMap = programInfo.attributes; + this._currentUniformsMap = programInfo.uniforms; + }, + + /** + * Gets the traits for the currently used program. + * @return number + */ + get currentProgramTraits() { + return this._currentProgramInfo.traits; + }, + + /** + * Adds an attribute to the cache. + * + * @param WebGLProgram program + * The program for which the attribute is bound. + * @param string name + * The attribute name. + * @param GLint value + * The attribute value. + */ + addAttribute: function (program, name, value) { + this._programs.get(program).attributes[value] = { + name: name, + value: value + }; + }, + + /** + * Adds a uniform to the cache. + * + * @param WebGLProgram program + * The program for which the uniform is bound. + * @param string name + * The uniform name. + * @param WebGLUniformLocation value + * The uniform value. + */ + addUniform: function (program, name, value) { + this._programs.get(program).uniforms.set(new XPCNativeWrapper(value), { + name: name, + value: value + }); + }, + + /** + * Updates the attribute locations for a specific program. + * This is necessary, for example, when the shader is relinked and all the + * attribute locations become obsolete. + * + * @param WebGLProgram program + * The program for which the attributes need updating. + */ + updateAttributesForProgram: function (program) { + let attributes = this._programs.get(program).attributes; + for (let attribute of attributes) { + attribute.value = this._gl.getAttribLocation(program, attribute.name); + } + }, + + /** + * Updates the uniform locations for a specific program. + * This is necessary, for example, when the shader is relinked and all the + * uniform locations become obsolete. + * + * @param WebGLProgram program + * The program for which the uniforms need updating. + */ + updateUniformsForProgram: function (program) { + let uniforms = this._programs.get(program).uniforms; + for (let [, uniform] of uniforms) { + uniform.value = this._gl.getUniformLocation(program, uniform.name); + } + }, + + /** + * Gets the actual attribute location in a specific program. + * When relinked, all the attribute locations become obsolete and are updated + * in the cache. This method returns the (current) real attribute location. + * + * @param GLint initialValue + * The initial attribute value. + * @return GLint + * The current attribute value, or the initial value if it's already + * up to date with its corresponding program. + */ + getCurrentAttributeLocation: function (initialValue) { + let attributes = this._currentAttributesMap; + let currentInfo = attributes ? attributes[initialValue] : null; + return currentInfo ? currentInfo.value : initialValue; + }, + + /** + * Gets the actual uniform location in a specific program. + * When relinked, all the uniform locations become obsolete and are updated + * in the cache. This method returns the (current) real uniform location. + * + * @param WebGLUniformLocation initialValue + * The initial uniform value. + * @return WebGLUniformLocation + * The current uniform value, or the initial value if it's already + * up to date with its corresponding program. + */ + getCurrentUniformLocation: function (initialValue) { + let uniforms = this._currentUniformsMap; + let currentInfo = uniforms ? uniforms.get(initialValue) : null; + return currentInfo ? currentInfo.value : initialValue; + } +}; + +/** + * A mechanism for injecting or qureying state into/from a single WebGL context. + * + * Any interaction with a WebGL context should go through this proxy. + * Otherwise, the corresponding observer would register the calls as coming + * from content, which is usually not desirable. Infinite call stacks are bad. + * + * @param number id + * The id of the window containing the WebGL context. + * @param WebGLRenderingContext context + * The WebGL context used for the proxy methods. + * @param WebGLCache cache + * The state storage for the corresponding context. + * @param WebGLObserver observer + * The observer watching function calls in the corresponding context. + */ +function WebGLProxy(id, context, cache, observer) { + this._id = id; + this._gl = context; + this._cache = cache; + this._observer = observer; + + let exports = [ + "isEnabled", + "getParameter", + "getAttachedShaders", + "getShaderSource", + "getShaderOfType", + "compileShader", + "enableHighlighting", + "disableHighlighting", + "readPixels" + ]; + exports.forEach(e => this[e] = (...args) => this._call(e, args)); +} + +WebGLProxy.prototype = { + _id: 0, + _gl: null, + _cache: null, + _observer: null, + + get ownerWindow() { + return this._id; + }, + get ownerContext() { + return this._gl; + }, + + /** + * Test whether a WebGL capability is enabled. + * + * @param string name + * The WebGL capability name, for example "BLEND". + * @return boolean + * True if enabled, false otherwise. + */ + _isEnabled: function (name) { + return this._gl.isEnabled(this._gl[name]); + }, + + /** + * Returns the value for the specified WebGL parameter name. + * + * @param string name + * The WebGL parameter name, for example "BLEND_COLOR". + * @return any + * The corresponding parameter's value. + */ + _getParameter: function (name) { + return this._gl.getParameter(this._gl[name]); + }, + + /** + * Returns the renderbuffer property value for the specified WebGL parameter. + * If no renderbuffer binding is available, null is returned. + * + * @param string name + * The WebGL parameter name, for example "BLEND_COLOR". + * @return any + * The corresponding parameter's value. + */ + _getRenderbufferParameter: function (name) { + if (!this._getParameter("RENDERBUFFER_BINDING")) { + return null; + } + let gl = this._gl; + return gl.getRenderbufferParameter(gl.RENDERBUFFER, gl[name]); + }, + + /** + * Returns the framebuffer property value for the specified WebGL parameter. + * If no framebuffer binding is available, null is returned. + * + * @param string type + * The framebuffer object attachment point, for example "COLOR_ATTACHMENT0". + * @param string name + * The WebGL parameter name, for example "FRAMEBUFFER_ATTACHMENT_OBJECT_NAME". + * If unspecified, defaults to "FRAMEBUFFER_ATTACHMENT_OBJECT_TYPE". + * @return any + * The corresponding parameter's value. + */ + _getFramebufferAttachmentParameter: function (type, name = "FRAMEBUFFER_ATTACHMENT_OBJECT_TYPE") { + if (!this._getParameter("FRAMEBUFFER_BINDING")) { + return null; + } + let gl = this._gl; + return gl.getFramebufferAttachmentParameter(gl.FRAMEBUFFER, gl[type], gl[name]); + }, + + /** + * Returns the shader objects attached to a program object. + * + * @param WebGLProgram program + * The program for which to retrieve the attached shaders. + * @return array + * The attached vertex and fragment shaders. + */ + _getAttachedShaders: function (program) { + return this._gl.getAttachedShaders(program); + }, + + /** + * Returns the source code string from a shader object. + * + * @param WebGLShader shader + * The shader for which to retrieve the source code. + * @return string + * The shader's source code. + */ + _getShaderSource: function (shader) { + return this._gl.getShaderSource(shader); + }, + + /** + * Finds a shader of the specified type in a list. + * + * @param WebGLShader[] shaders + * The shaders for which to check the type. + * @param string type + * Either "vertex" or "fragment". + * @return WebGLShader | null + * The shader of the specified type, or null if nothing is found. + */ + _getShaderOfType: function (shaders, type) { + let gl = this._gl; + let shaderTypeEnum = { + vertex: gl.VERTEX_SHADER, + fragment: gl.FRAGMENT_SHADER + }[type]; + + for (let shader of shaders) { + if (gl.getShaderParameter(shader, gl.SHADER_TYPE) == shaderTypeEnum) { + return shader; + } + } + return null; + }, + + /** + * Changes a shader's source code and relinks the respective program. + * + * @param WebGLProgram program + * The program who's linked shader is to be modified. + * @param WebGLShader shader + * The shader to be modified. + * @param string text + * The new shader source code. + * @return object + * An object containing the compilation and linking status. + */ + _compileShader: function (program, shader, text) { + let gl = this._gl; + gl.shaderSource(shader, text); + gl.compileShader(shader); + gl.linkProgram(program); + + let error = { compile: "", link: "" }; + + if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { + error.compile = gl.getShaderInfoLog(shader); + } + if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { + error.link = gl.getShaderInfoLog(shader); + } + + this._cache.updateAttributesForProgram(program); + this._cache.updateUniformsForProgram(program); + + return error; + }, + + /** + * Enables color blending based on the geometry highlight tint. + */ + _enableHighlighting: function () { + let gl = this._gl; + + // Avoid changing the blending params when "rendering to texture". + + // Check drawing to a custom framebuffer bound to the default renderbuffer. + let hasFramebuffer = this._getParameter("FRAMEBUFFER_BINDING"); + let hasRenderbuffer = this._getParameter("RENDERBUFFER_BINDING"); + if (hasFramebuffer && !hasRenderbuffer) { + return; + } + + // Check drawing to a depth or stencil component of the framebuffer. + let writesDepth = this._getFramebufferAttachmentParameter("DEPTH_ATTACHMENT"); + let writesStencil = this._getFramebufferAttachmentParameter("STENCIL_ATTACHMENT"); + if (writesDepth || writesStencil) { + return; + } + + // Non-premultiplied alpha blending based on a predefined constant color. + // Simply using gl.colorMask won't work, because we want non-tinted colors + // to be drawn as black, not ignored. + gl.enable(gl.BLEND); + gl.blendColor.apply(gl, this.highlightTint); + gl.blendEquation(gl.FUNC_ADD); + gl.blendFunc(gl.CONSTANT_COLOR, gl.ONE_MINUS_SRC_ALPHA, gl.CONSTANT_COLOR, gl.ZERO); + this.wasHighlighting = true; + }, + + /** + * Disables color blending based on the geometry highlight tint, by + * reverting the corresponding params back to their original values. + */ + _disableHighlighting: function () { + let gl = this._gl; + let s = this._cache.currentState; + + gl[s[gl.BLEND] ? "enable" : "disable"](gl.BLEND); + gl.blendColor.apply(gl, s.blendColor); + gl.blendEquationSeparate(s.blendEquationRgb, s.blendEquationAlpha); + gl.blendFuncSeparate(s.blendSrcRgb, s.blendDstRgb, s.blendSrcAlpha, s.blendDstAlpha); + }, + + /** + * Returns the pixel values at the position specified on the canvas. + */ + _readPixels: function (x, y, w, h, format, type, buffer) { + this._gl.readPixels(x, y, w, h, format, type, buffer); + }, + + /** + * The color tint used for highlighting geometry. + * @see _enableHighlighting and _disableHighlighting. + */ + highlightTint: [0, 0, 0, 0], + + /** + * Executes a function in this object. + * + * This method makes sure that any handlers in the context observer are + * suppressed, hence stopping observing any context function calls. + * + * @param string funcName + * The function to call. + * @param array args + * An array of arguments. + * @return any + * The called function result. + */ + _call: function (funcName, args) { + let prevState = this._observer.suppressHandlers; + + this._observer.suppressHandlers = true; + let result = this["_" + funcName].apply(this, args); + this._observer.suppressHandlers = prevState; + + return result; + } +}; + +// Utility functions. + +function removeFromMap(map, predicate) { + for (let [key, value] of map) { + if (predicate(value)) { + map.delete(key); + } + } +} + +function removeFromArray(array, predicate) { + for (let i = 0; i < array.length;) { + if (predicate(array[i])) { + array.splice(i, 1); + } else { + i++; + } + } +} diff --git a/devtools/server/actors/worker.js b/devtools/server/actors/worker.js new file mode 100644 index 0000000000..1937229d52 --- /dev/null +++ b/devtools/server/actors/worker.js @@ -0,0 +1,611 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { Ci } = require("chrome"); +const { DebuggerServer } = require("devtools/server/main"); +const Services = require("Services"); +const { XPCOMUtils } = require("resource://gre/modules/XPCOMUtils.jsm"); +const protocol = require("devtools/shared/protocol"); +const { Arg, method, RetVal } = protocol; +const { + workerSpec, + pushSubscriptionSpec, + serviceWorkerRegistrationSpec, + serviceWorkerSpec, +} = require("devtools/shared/specs/worker"); + +loader.lazyRequireGetter(this, "ChromeUtils"); +loader.lazyRequireGetter(this, "events", "sdk/event/core"); + +XPCOMUtils.defineLazyServiceGetter( + this, "wdm", + "@mozilla.org/dom/workers/workerdebuggermanager;1", + "nsIWorkerDebuggerManager" +); + +XPCOMUtils.defineLazyServiceGetter( + this, "swm", + "@mozilla.org/serviceworkers/manager;1", + "nsIServiceWorkerManager" +); + +XPCOMUtils.defineLazyServiceGetter( + this, "PushService", + "@mozilla.org/push/Service;1", + "nsIPushService" +); + +function matchWorkerDebugger(dbg, options) { + if ("type" in options && dbg.type !== options.type) { + return false; + } + if ("window" in options) { + let window = dbg.window; + while (window !== null && window.parent !== window) { + window = window.parent; + } + + if (window !== options.window) { + return false; + } + } + + return true; +} + +let WorkerActor = protocol.ActorClassWithSpec(workerSpec, { + initialize(conn, dbg) { + protocol.Actor.prototype.initialize.call(this, conn); + this._dbg = dbg; + this._attached = false; + this._threadActor = null; + this._transport = null; + }, + + form(detail) { + if (detail === "actorid") { + return this.actorID; + } + let form = { + actor: this.actorID, + consoleActor: this._consoleActor, + url: this._dbg.url, + type: this._dbg.type + }; + if (this._dbg.type === Ci.nsIWorkerDebugger.TYPE_SERVICE) { + let registration = this._getServiceWorkerRegistrationInfo(); + form.scope = registration.scope; + } + return form; + }, + + attach() { + if (this._dbg.isClosed) { + return { error: "closed" }; + } + + if (!this._attached) { + // Automatically disable their internal timeout that shut them down + // Should be refactored by having actors specific to service workers + if (this._dbg.type == Ci.nsIWorkerDebugger.TYPE_SERVICE) { + let worker = this._getServiceWorkerInfo(); + if (worker) { + worker.attachDebugger(); + } + } + this._dbg.addListener(this); + this._attached = true; + } + + return { + type: "attached", + url: this._dbg.url + }; + }, + + detach() { + if (!this._attached) { + return { error: "wrongState" }; + } + + this._detach(); + + return { type: "detached" }; + }, + + destroy() { + protocol.Actor.prototype.destroy.call(this); + if (this._attached) { + this._detach(); + } + }, + + disconnect() { + this.destroy(); + }, + + connect(options) { + if (!this._attached) { + return { error: "wrongState" }; + } + + if (this._threadActor !== null) { + return { + type: "connected", + threadActor: this._threadActor + }; + } + + return DebuggerServer.connectToWorker( + this.conn, this._dbg, this.actorID, options + ).then(({ threadActor, transport, consoleActor }) => { + this._threadActor = threadActor; + this._transport = transport; + this._consoleActor = consoleActor; + + return { + type: "connected", + threadActor: this._threadActor, + consoleActor: this._consoleActor + }; + }, (error) => { + return { error: error.toString() }; + }); + }, + + push() { + if (this._dbg.type !== Ci.nsIWorkerDebugger.TYPE_SERVICE) { + return { error: "wrongType" }; + } + let registration = this._getServiceWorkerRegistrationInfo(); + let originAttributes = ChromeUtils.originAttributesToSuffix( + this._dbg.principal.originAttributes); + swm.sendPushEvent(originAttributes, registration.scope); + return { type: "pushed" }; + }, + + onClose() { + if (this._attached) { + this._detach(); + } + + this.conn.sendActorEvent(this.actorID, "close"); + }, + + onError(filename, lineno, message) { + reportError("ERROR:" + filename + ":" + lineno + ":" + message + "\n"); + }, + + _getServiceWorkerRegistrationInfo() { + return swm.getRegistrationByPrincipal(this._dbg.principal, this._dbg.url); + }, + + _getServiceWorkerInfo() { + let registration = this._getServiceWorkerRegistrationInfo(); + return registration.getWorkerByID(this._dbg.serviceWorkerID); + }, + + _detach() { + if (this._threadActor !== null) { + this._transport.close(); + this._transport = null; + this._threadActor = null; + } + + // If the worker is already destroyed, nsIWorkerDebugger.type throws + // (_dbg.closed appears to be false when it throws) + let type; + try { + type = this._dbg.type; + } catch (e) {} + + if (type == Ci.nsIWorkerDebugger.TYPE_SERVICE) { + let worker = this._getServiceWorkerInfo(); + if (worker) { + worker.detachDebugger(); + } + } + + this._dbg.removeListener(this); + this._attached = false; + } +}); + +exports.WorkerActor = WorkerActor; + +function WorkerActorList(conn, options) { + this._conn = conn; + this._options = options; + this._actors = new Map(); + this._onListChanged = null; + this._mustNotify = false; + this.onRegister = this.onRegister.bind(this); + this.onUnregister = this.onUnregister.bind(this); +} + +WorkerActorList.prototype = { + getList() { + // Create a set of debuggers. + let dbgs = new Set(); + let e = wdm.getWorkerDebuggerEnumerator(); + while (e.hasMoreElements()) { + let dbg = e.getNext().QueryInterface(Ci.nsIWorkerDebugger); + if (matchWorkerDebugger(dbg, this._options)) { + dbgs.add(dbg); + } + } + + // Delete each actor for which we don't have a debugger. + for (let [dbg, ] of this._actors) { + if (!dbgs.has(dbg)) { + this._actors.delete(dbg); + } + } + + // Create an actor for each debugger for which we don't have one. + for (let dbg of dbgs) { + if (!this._actors.has(dbg)) { + this._actors.set(dbg, new WorkerActor(this._conn, dbg)); + } + } + + let actors = []; + for (let [, actor] of this._actors) { + actors.push(actor); + } + + if (!this._mustNotify) { + if (this._onListChanged !== null) { + wdm.addListener(this); + } + this._mustNotify = true; + } + + return Promise.resolve(actors); + }, + + get onListChanged() { + return this._onListChanged; + }, + + set onListChanged(onListChanged) { + if (typeof onListChanged !== "function" && onListChanged !== null) { + throw new Error("onListChanged must be either a function or null."); + } + if (onListChanged === this._onListChanged) { + return; + } + + if (this._mustNotify) { + if (this._onListChanged === null && onListChanged !== null) { + wdm.addListener(this); + } + if (this._onListChanged !== null && onListChanged === null) { + wdm.removeListener(this); + } + } + this._onListChanged = onListChanged; + }, + + _notifyListChanged() { + this._onListChanged(); + + if (this._onListChanged !== null) { + wdm.removeListener(this); + } + this._mustNotify = false; + }, + + onRegister(dbg) { + if (matchWorkerDebugger(dbg, this._options)) { + this._notifyListChanged(); + } + }, + + onUnregister(dbg) { + if (matchWorkerDebugger(dbg, this._options)) { + this._notifyListChanged(); + } + } +}; + +exports.WorkerActorList = WorkerActorList; + +let PushSubscriptionActor = protocol.ActorClassWithSpec(pushSubscriptionSpec, { + initialize(conn, subscription) { + protocol.Actor.prototype.initialize.call(this, conn); + this._subscription = subscription; + }, + + form(detail) { + if (detail === "actorid") { + return this.actorID; + } + let subscription = this._subscription; + return { + actor: this.actorID, + endpoint: subscription.endpoint, + pushCount: subscription.pushCount, + lastPush: subscription.lastPush, + quota: subscription.quota + }; + }, + + destroy() { + protocol.Actor.prototype.destroy.call(this); + this._subscription = null; + }, +}); + +let ServiceWorkerActor = protocol.ActorClassWithSpec(serviceWorkerSpec, { + initialize(conn, worker) { + protocol.Actor.prototype.initialize.call(this, conn); + this._worker = worker; + }, + + form() { + if (!this._worker) { + return null; + } + + return { + url: this._worker.scriptSpec, + state: this._worker.state, + }; + }, + + destroy() { + protocol.Actor.prototype.destroy.call(this); + this._worker = null; + }, +}); + +// Lazily load the service-worker-child.js process script only once. +let _serviceWorkerProcessScriptLoaded = false; + +let ServiceWorkerRegistrationActor = +protocol.ActorClassWithSpec(serviceWorkerRegistrationSpec, { + /** + * Create the ServiceWorkerRegistrationActor + * @param DebuggerServerConnection conn + * The server connection. + * @param ServiceWorkerRegistrationInfo registration + * The registration's information. + */ + initialize(conn, registration) { + protocol.Actor.prototype.initialize.call(this, conn); + this._conn = conn; + this._registration = registration; + this._pushSubscriptionActor = null; + this._registration.addListener(this); + + let {installingWorker, waitingWorker, activeWorker} = registration; + this._installingWorker = new ServiceWorkerActor(conn, installingWorker); + this._waitingWorker = new ServiceWorkerActor(conn, waitingWorker); + this._activeWorker = new ServiceWorkerActor(conn, activeWorker); + + Services.obs.addObserver(this, PushService.subscriptionModifiedTopic, false); + }, + + onChange() { + this._installingWorker.destroy(); + this._waitingWorker.destroy(); + this._activeWorker.destroy(); + + let {installingWorker, waitingWorker, activeWorker} = this._registration; + this._installingWorker = new ServiceWorkerActor(this._conn, installingWorker); + this._waitingWorker = new ServiceWorkerActor(this._conn, waitingWorker); + this._activeWorker = new ServiceWorkerActor(this._conn, activeWorker); + + events.emit(this, "registration-changed"); + }, + + form(detail) { + if (detail === "actorid") { + return this.actorID; + } + let registration = this._registration; + let installingWorker = this._installingWorker.form(); + let waitingWorker = this._waitingWorker.form(); + let activeWorker = this._activeWorker.form(); + + let isE10s = Services.appinfo.browserTabsRemoteAutostart; + return { + actor: this.actorID, + scope: registration.scope, + url: registration.scriptSpec, + installingWorker, + waitingWorker, + activeWorker, + // - In e10s: only active registrations are available. + // - In non-e10s: registrations always have at least one worker, if the worker is + // active, the registration is active. + active: isE10s ? true : !!activeWorker + }; + }, + + destroy() { + protocol.Actor.prototype.destroy.call(this); + Services.obs.removeObserver(this, PushService.subscriptionModifiedTopic, false); + this._registration.removeListener(this); + this._registration = null; + if (this._pushSubscriptionActor) { + this._pushSubscriptionActor.destroy(); + } + this._pushSubscriptionActor = null; + + this._installingWorker.destroy(); + this._waitingWorker.destroy(); + this._activeWorker.destroy(); + + this._installingWorker = null; + this._waitingWorker = null; + this._activeWorker = null; + }, + + disconnect() { + this.destroy(); + }, + + /** + * Standard observer interface to listen to push messages and changes. + */ + observe(subject, topic, data) { + let scope = this._registration.scope; + if (data !== scope) { + // This event doesn't concern us, pretend nothing happened. + return; + } + switch (topic) { + case PushService.subscriptionModifiedTopic: + if (this._pushSubscriptionActor) { + this._pushSubscriptionActor.destroy(); + this._pushSubscriptionActor = null; + } + events.emit(this, "push-subscription-modified"); + break; + } + }, + + start() { + if (!_serviceWorkerProcessScriptLoaded) { + Services.ppmm.loadProcessScript( + "resource://devtools/server/service-worker-child.js", true); + _serviceWorkerProcessScriptLoaded = true; + } + Services.ppmm.broadcastAsyncMessage("serviceWorkerRegistration:start", { + scope: this._registration.scope + }); + return { type: "started" }; + }, + + unregister() { + let { principal, scope } = this._registration; + let unregisterCallback = { + unregisterSucceeded: function () {}, + unregisterFailed: function () { + console.error("Failed to unregister the service worker for " + scope); + }, + QueryInterface: XPCOMUtils.generateQI( + [Ci.nsIServiceWorkerUnregisterCallback]) + }; + swm.propagateUnregister(principal, unregisterCallback, scope); + + return { type: "unregistered" }; + }, + + getPushSubscription() { + let registration = this._registration; + let pushSubscriptionActor = this._pushSubscriptionActor; + if (pushSubscriptionActor) { + return Promise.resolve(pushSubscriptionActor); + } + return new Promise((resolve, reject) => { + PushService.getSubscription( + registration.scope, + registration.principal, + (result, subscription) => { + if (!subscription) { + resolve(null); + return; + } + pushSubscriptionActor = new PushSubscriptionActor(this._conn, subscription); + this._pushSubscriptionActor = pushSubscriptionActor; + resolve(pushSubscriptionActor); + } + ); + }); + }, +}); + +function ServiceWorkerRegistrationActorList(conn) { + this._conn = conn; + this._actors = new Map(); + this._onListChanged = null; + this._mustNotify = false; + this.onRegister = this.onRegister.bind(this); + this.onUnregister = this.onUnregister.bind(this); +} + +ServiceWorkerRegistrationActorList.prototype = { + getList() { + // Create a set of registrations. + let registrations = new Set(); + let array = swm.getAllRegistrations(); + for (let index = 0; index < array.length; ++index) { + registrations.add( + array.queryElementAt(index, Ci.nsIServiceWorkerRegistrationInfo)); + } + + // Delete each actor for which we don't have a registration. + for (let [registration, ] of this._actors) { + if (!registrations.has(registration)) { + this._actors.delete(registration); + } + } + + // Create an actor for each registration for which we don't have one. + for (let registration of registrations) { + if (!this._actors.has(registration)) { + this._actors.set(registration, + new ServiceWorkerRegistrationActor(this._conn, registration)); + } + } + + if (!this._mustNotify) { + if (this._onListChanged !== null) { + swm.addListener(this); + } + this._mustNotify = true; + } + + let actors = []; + for (let [, actor] of this._actors) { + actors.push(actor); + } + + return Promise.resolve(actors); + }, + + get onListchanged() { + return this._onListchanged; + }, + + set onListChanged(onListChanged) { + if (typeof onListChanged !== "function" && onListChanged !== null) { + throw new Error("onListChanged must be either a function or null."); + } + + if (this._mustNotify) { + if (this._onListChanged === null && onListChanged !== null) { + swm.addListener(this); + } + if (this._onListChanged !== null && onListChanged === null) { + swm.removeListener(this); + } + } + this._onListChanged = onListChanged; + }, + + _notifyListChanged() { + this._onListChanged(); + + if (this._onListChanged !== null) { + swm.removeListener(this); + } + this._mustNotify = false; + }, + + onRegister(registration) { + this._notifyListChanged(); + }, + + onUnregister(registration) { + this._notifyListChanged(); + } +}; + +exports.ServiceWorkerRegistrationActorList = ServiceWorkerRegistrationActorList; |