summaryrefslogtreecommitdiffstats
path: root/devtools/server/actors
diff options
context:
space:
mode:
authorMatt A. Tobin <mattatobin@localhost.localdomain>2018-02-02 04:16:08 -0500
committerMatt A. Tobin <mattatobin@localhost.localdomain>2018-02-02 04:16:08 -0500
commit5f8de423f190bbb79a62f804151bc24824fa32d8 (patch)
tree10027f336435511475e392454359edea8e25895d /devtools/server/actors
parent49ee0794b5d912db1f95dce6eb52d781dc210db5 (diff)
downloadUXP-5f8de423f190bbb79a62f804151bc24824fa32d8.tar
UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.tar.gz
UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.tar.lz
UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.tar.xz
UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.zip
Add m-esr52 at 52.6.0
Diffstat (limited to 'devtools/server/actors')
-rw-r--r--devtools/server/actors/actor-registry.js54
-rw-r--r--devtools/server/actors/addon.js352
-rw-r--r--devtools/server/actors/addons.js41
-rw-r--r--devtools/server/actors/animation.js751
-rw-r--r--devtools/server/actors/breakpoint.js189
-rw-r--r--devtools/server/actors/call-watcher.js634
-rw-r--r--devtools/server/actors/canvas.js728
-rw-r--r--devtools/server/actors/child-process.js146
-rw-r--r--devtools/server/actors/childtab.js82
-rw-r--r--devtools/server/actors/chrome.js185
-rw-r--r--devtools/server/actors/common.js521
-rw-r--r--devtools/server/actors/css-properties.js120
-rw-r--r--devtools/server/actors/csscoverage.js726
-rw-r--r--devtools/server/actors/device.js70
-rw-r--r--devtools/server/actors/director-manager.js615
-rw-r--r--devtools/server/actors/director-registry.js254
-rw-r--r--devtools/server/actors/emulation.js241
-rw-r--r--devtools/server/actors/environment.js199
-rw-r--r--devtools/server/actors/errordocs.js84
-rw-r--r--devtools/server/actors/eventlooplag.js60
-rw-r--r--devtools/server/actors/frame.js100
-rw-r--r--devtools/server/actors/framerate.js33
-rw-r--r--devtools/server/actors/gcli.js233
-rw-r--r--devtools/server/actors/heap-snapshot-file.js68
-rw-r--r--devtools/server/actors/highlighters.css536
-rw-r--r--devtools/server/actors/highlighters.js715
-rw-r--r--devtools/server/actors/highlighters/auto-refresh.js215
-rw-r--r--devtools/server/actors/highlighters/box-model.js712
-rw-r--r--devtools/server/actors/highlighters/css-grid.js737
-rw-r--r--devtools/server/actors/highlighters/css-transform.js243
-rw-r--r--devtools/server/actors/highlighters/eye-dropper.js534
-rw-r--r--devtools/server/actors/highlighters/geometry-editor.js704
-rw-r--r--devtools/server/actors/highlighters/measuring-tool.js563
-rw-r--r--devtools/server/actors/highlighters/moz.build23
-rw-r--r--devtools/server/actors/highlighters/rect.js102
-rw-r--r--devtools/server/actors/highlighters/rulers.js294
-rw-r--r--devtools/server/actors/highlighters/selector.js83
-rw-r--r--devtools/server/actors/highlighters/simple-outline.js67
-rw-r--r--devtools/server/actors/highlighters/utils/markup.js609
-rw-r--r--devtools/server/actors/highlighters/utils/moz.build9
-rw-r--r--devtools/server/actors/inspector.js3186
-rw-r--r--devtools/server/actors/layout.js131
-rw-r--r--devtools/server/actors/memory.js83
-rw-r--r--devtools/server/actors/monitor.js145
-rw-r--r--devtools/server/actors/moz.build69
-rw-r--r--devtools/server/actors/object.js2251
-rw-r--r--devtools/server/actors/performance-entries.js65
-rw-r--r--devtools/server/actors/performance-recording.js148
-rw-r--r--devtools/server/actors/performance.js116
-rw-r--r--devtools/server/actors/preference.js81
-rw-r--r--devtools/server/actors/pretty-print-worker.js50
-rw-r--r--devtools/server/actors/process.js83
-rw-r--r--devtools/server/actors/profiler.js60
-rw-r--r--devtools/server/actors/promises.js200
-rw-r--r--devtools/server/actors/reflow.js514
-rw-r--r--devtools/server/actors/root.js535
-rw-r--r--devtools/server/actors/script.js2360
-rw-r--r--devtools/server/actors/settings.js146
-rw-r--r--devtools/server/actors/source.js902
-rw-r--r--devtools/server/actors/storage.js2542
-rw-r--r--devtools/server/actors/string.js43
-rw-r--r--devtools/server/actors/styleeditor.js528
-rw-r--r--devtools/server/actors/styles.js1687
-rw-r--r--devtools/server/actors/stylesheets.js982
-rw-r--r--devtools/server/actors/timeline.js98
-rw-r--r--devtools/server/actors/utils/TabSources.js833
-rw-r--r--devtools/server/actors/utils/actor-registry-utils.js78
-rw-r--r--devtools/server/actors/utils/audionodes.json113
-rw-r--r--devtools/server/actors/utils/automation-timeline.js373
-rw-r--r--devtools/server/actors/utils/css-grid-utils.js61
-rw-r--r--devtools/server/actors/utils/make-debugger.js101
-rw-r--r--devtools/server/actors/utils/map-uri-to-addon-id.js44
-rw-r--r--devtools/server/actors/utils/moz.build19
-rw-r--r--devtools/server/actors/utils/stack.js185
-rw-r--r--devtools/server/actors/utils/walker-search.js278
-rw-r--r--devtools/server/actors/utils/webconsole-utils.js1063
-rw-r--r--devtools/server/actors/utils/webconsole-worker-utils.js20
-rw-r--r--devtools/server/actors/webaudio.js856
-rw-r--r--devtools/server/actors/webbrowser.js2529
-rw-r--r--devtools/server/actors/webconsole.js2346
-rw-r--r--devtools/server/actors/webextension.js333
-rw-r--r--devtools/server/actors/webgl.js1322
-rw-r--r--devtools/server/actors/worker.js611
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 000000000..6a083ba6f
--- /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 000000000..7f152e984
--- /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 000000000..297a3a438
--- /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 000000000..642c4bcaf
--- /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 000000000..547dcd0f1
--- /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 000000000..5729f9508
--- /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 000000000..f6e1f57ec
--- /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 000000000..7b0e2eaf8
--- /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 000000000..96d82e281
--- /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 000000000..07cd2ad99
--- /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 000000000..0177c6749
--- /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 000000000..d24c133d4
--- /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 000000000..2f700656f
--- /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 000000000..bf400913a
--- /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 000000000..027a456db
--- /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 000000000..54584bcde
--- /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 000000000..b69183305
--- /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 000000000..bd03586eb
--- /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 000000000..27f687dc7
--- /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 000000000..6e0a101c9
--- /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 000000000..fefcad1b0
--- /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 000000000..872cd7360
--- /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 000000000..651825a28
--- /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 000000000..545f2265d
--- /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 000000000..87375ea36
--- /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 000000000..087750ab3
--- /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 000000000..31f89de20
--- /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 000000000..35f201a04
--- /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 000000000..0ed1ee961
--- /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 000000000..83a2c7e59
--- /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 000000000..a90ec22bd
--- /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 000000000..35b33eec1
--- /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 000000000..e1e1de94f
--- /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 000000000..317d0832c
--- /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 000000000..69ff09880
--- /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 000000000..01e082e67
--- /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 000000000..557a6d541
--- /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 000000000..dae20f2d9
--- /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 000000000..8750014bc
--- /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 000000000..4bb429bc3
--- /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 000000000..20a227a40
--- /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 000000000..0b9242b5f
--- /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 000000000..5c41a7dc1
--- /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 000000000..17b076a9e
--- /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 000000000..5980876e2
--- /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 000000000..1f417b951
--- /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 000000000..89434324a
--- /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 000000000..ef5907495
--- /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 000000000..8b294a4de
--- /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 000000000..8d4140155
--- /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 000000000..5fc6b6959
--- /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 000000000..ff1c4313f
--- /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 000000000..c4b594408
--- /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 000000000..a9a56219d
--- /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 000000000..0ebe00207
--- /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 000000000..b6f8c0ee4
--- /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 000000000..e8e39546c
--- /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 000000000..179c82aa5
--- /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 000000000..e76c14fe8
--- /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 000000000..572cd6b68
--- /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 000000000..d90a8b31c
--- /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 000000000..5793a2baf
--- /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 000000000..cdb812882
--- /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 000000000..f20634e6c
--- /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 000000000..221454144
--- /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 000000000..56e862939
--- /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 000000000..5866827e1
--- /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 000000000..12cc6c34b
--- /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 000000000..a086d90be
--- /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 000000000..0b260117d
--- /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 000000000..9bd43e567
--- /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 000000000..6f3316b14
--- /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 000000000..0dcf40faf
--- /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 000000000..a6a3d1137
--- /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 000000000..0955a3919
--- /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 000000000..597f1ddb3
--- /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 000000000..0c1142967
--- /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 000000000..d9035a907
--- /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 000000000..0edcdc187
--- /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 000000000..9712ff32d
--- /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 000000000..0e83fc999
--- /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 000000000..137448647
--- /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 000000000..1937229d5
--- /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;