From 5f8de423f190bbb79a62f804151bc24824fa32d8 Mon Sep 17 00:00:00 2001 From: "Matt A. Tobin" Date: Fri, 2 Feb 2018 04:16:08 -0500 Subject: Add m-esr52 at 52.6.0 --- devtools/server/actors/actor-registry.js | 54 + devtools/server/actors/addon.js | 352 +++ devtools/server/actors/addons.js | 41 + devtools/server/actors/animation.js | 751 +++++ devtools/server/actors/breakpoint.js | 189 ++ devtools/server/actors/call-watcher.js | 634 ++++ devtools/server/actors/canvas.js | 728 +++++ devtools/server/actors/child-process.js | 146 + devtools/server/actors/childtab.js | 82 + devtools/server/actors/chrome.js | 185 ++ devtools/server/actors/common.js | 521 ++++ devtools/server/actors/css-properties.js | 120 + devtools/server/actors/csscoverage.js | 726 +++++ devtools/server/actors/device.js | 70 + devtools/server/actors/director-manager.js | 615 ++++ devtools/server/actors/director-registry.js | 254 ++ devtools/server/actors/emulation.js | 241 ++ devtools/server/actors/environment.js | 199 ++ devtools/server/actors/errordocs.js | 84 + devtools/server/actors/eventlooplag.js | 60 + devtools/server/actors/frame.js | 100 + devtools/server/actors/framerate.js | 33 + devtools/server/actors/gcli.js | 233 ++ devtools/server/actors/heap-snapshot-file.js | 68 + devtools/server/actors/highlighters.css | 536 ++++ devtools/server/actors/highlighters.js | 715 +++++ .../server/actors/highlighters/auto-refresh.js | 215 ++ devtools/server/actors/highlighters/box-model.js | 712 +++++ devtools/server/actors/highlighters/css-grid.js | 737 +++++ .../server/actors/highlighters/css-transform.js | 243 ++ devtools/server/actors/highlighters/eye-dropper.js | 534 ++++ .../server/actors/highlighters/geometry-editor.js | 704 +++++ .../server/actors/highlighters/measuring-tool.js | 563 ++++ devtools/server/actors/highlighters/moz.build | 23 + devtools/server/actors/highlighters/rect.js | 102 + devtools/server/actors/highlighters/rulers.js | 294 ++ devtools/server/actors/highlighters/selector.js | 83 + .../server/actors/highlighters/simple-outline.js | 67 + .../server/actors/highlighters/utils/markup.js | 609 ++++ .../server/actors/highlighters/utils/moz.build | 9 + devtools/server/actors/inspector.js | 3186 ++++++++++++++++++++ devtools/server/actors/layout.js | 131 + devtools/server/actors/memory.js | 83 + devtools/server/actors/monitor.js | 145 + devtools/server/actors/moz.build | 69 + devtools/server/actors/object.js | 2251 ++++++++++++++ devtools/server/actors/performance-entries.js | 65 + devtools/server/actors/performance-recording.js | 148 + devtools/server/actors/performance.js | 116 + devtools/server/actors/preference.js | 81 + devtools/server/actors/pretty-print-worker.js | 50 + devtools/server/actors/process.js | 83 + devtools/server/actors/profiler.js | 60 + devtools/server/actors/promises.js | 200 ++ devtools/server/actors/reflow.js | 514 ++++ devtools/server/actors/root.js | 535 ++++ devtools/server/actors/script.js | 2360 +++++++++++++++ devtools/server/actors/settings.js | 146 + devtools/server/actors/source.js | 902 ++++++ devtools/server/actors/storage.js | 2542 ++++++++++++++++ devtools/server/actors/string.js | 43 + devtools/server/actors/styleeditor.js | 528 ++++ devtools/server/actors/styles.js | 1687 +++++++++++ devtools/server/actors/stylesheets.js | 982 ++++++ devtools/server/actors/timeline.js | 98 + devtools/server/actors/utils/TabSources.js | 833 +++++ .../server/actors/utils/actor-registry-utils.js | 78 + devtools/server/actors/utils/audionodes.json | 113 + .../server/actors/utils/automation-timeline.js | 373 +++ devtools/server/actors/utils/css-grid-utils.js | 61 + devtools/server/actors/utils/make-debugger.js | 101 + .../server/actors/utils/map-uri-to-addon-id.js | 44 + devtools/server/actors/utils/moz.build | 19 + devtools/server/actors/utils/stack.js | 185 ++ devtools/server/actors/utils/walker-search.js | 278 ++ devtools/server/actors/utils/webconsole-utils.js | 1063 +++++++ .../server/actors/utils/webconsole-worker-utils.js | 20 + devtools/server/actors/webaudio.js | 856 ++++++ devtools/server/actors/webbrowser.js | 2529 ++++++++++++++++ devtools/server/actors/webconsole.js | 2346 ++++++++++++++ devtools/server/actors/webextension.js | 333 ++ devtools/server/actors/webgl.js | 1322 ++++++++ devtools/server/actors/worker.js | 611 ++++ 83 files changed, 40802 insertions(+) create mode 100644 devtools/server/actors/actor-registry.js create mode 100644 devtools/server/actors/addon.js create mode 100644 devtools/server/actors/addons.js create mode 100644 devtools/server/actors/animation.js create mode 100644 devtools/server/actors/breakpoint.js create mode 100644 devtools/server/actors/call-watcher.js create mode 100644 devtools/server/actors/canvas.js create mode 100644 devtools/server/actors/child-process.js create mode 100644 devtools/server/actors/childtab.js create mode 100644 devtools/server/actors/chrome.js create mode 100644 devtools/server/actors/common.js create mode 100644 devtools/server/actors/css-properties.js create mode 100644 devtools/server/actors/csscoverage.js create mode 100644 devtools/server/actors/device.js create mode 100644 devtools/server/actors/director-manager.js create mode 100644 devtools/server/actors/director-registry.js create mode 100644 devtools/server/actors/emulation.js create mode 100644 devtools/server/actors/environment.js create mode 100644 devtools/server/actors/errordocs.js create mode 100644 devtools/server/actors/eventlooplag.js create mode 100644 devtools/server/actors/frame.js create mode 100644 devtools/server/actors/framerate.js create mode 100644 devtools/server/actors/gcli.js create mode 100644 devtools/server/actors/heap-snapshot-file.js create mode 100644 devtools/server/actors/highlighters.css create mode 100644 devtools/server/actors/highlighters.js create mode 100644 devtools/server/actors/highlighters/auto-refresh.js create mode 100644 devtools/server/actors/highlighters/box-model.js create mode 100644 devtools/server/actors/highlighters/css-grid.js create mode 100644 devtools/server/actors/highlighters/css-transform.js create mode 100644 devtools/server/actors/highlighters/eye-dropper.js create mode 100644 devtools/server/actors/highlighters/geometry-editor.js create mode 100644 devtools/server/actors/highlighters/measuring-tool.js create mode 100644 devtools/server/actors/highlighters/moz.build create mode 100644 devtools/server/actors/highlighters/rect.js create mode 100644 devtools/server/actors/highlighters/rulers.js create mode 100644 devtools/server/actors/highlighters/selector.js create mode 100644 devtools/server/actors/highlighters/simple-outline.js create mode 100644 devtools/server/actors/highlighters/utils/markup.js create mode 100644 devtools/server/actors/highlighters/utils/moz.build create mode 100644 devtools/server/actors/inspector.js create mode 100644 devtools/server/actors/layout.js create mode 100644 devtools/server/actors/memory.js create mode 100644 devtools/server/actors/monitor.js create mode 100644 devtools/server/actors/moz.build create mode 100644 devtools/server/actors/object.js create mode 100644 devtools/server/actors/performance-entries.js create mode 100644 devtools/server/actors/performance-recording.js create mode 100644 devtools/server/actors/performance.js create mode 100644 devtools/server/actors/preference.js create mode 100644 devtools/server/actors/pretty-print-worker.js create mode 100644 devtools/server/actors/process.js create mode 100644 devtools/server/actors/profiler.js create mode 100644 devtools/server/actors/promises.js create mode 100644 devtools/server/actors/reflow.js create mode 100644 devtools/server/actors/root.js create mode 100644 devtools/server/actors/script.js create mode 100644 devtools/server/actors/settings.js create mode 100644 devtools/server/actors/source.js create mode 100644 devtools/server/actors/storage.js create mode 100644 devtools/server/actors/string.js create mode 100644 devtools/server/actors/styleeditor.js create mode 100644 devtools/server/actors/styles.js create mode 100644 devtools/server/actors/stylesheets.js create mode 100644 devtools/server/actors/timeline.js create mode 100644 devtools/server/actors/utils/TabSources.js create mode 100644 devtools/server/actors/utils/actor-registry-utils.js create mode 100644 devtools/server/actors/utils/audionodes.json create mode 100644 devtools/server/actors/utils/automation-timeline.js create mode 100644 devtools/server/actors/utils/css-grid-utils.js create mode 100644 devtools/server/actors/utils/make-debugger.js create mode 100644 devtools/server/actors/utils/map-uri-to-addon-id.js create mode 100644 devtools/server/actors/utils/moz.build create mode 100644 devtools/server/actors/utils/stack.js create mode 100644 devtools/server/actors/utils/walker-search.js create mode 100644 devtools/server/actors/utils/webconsole-utils.js create mode 100644 devtools/server/actors/utils/webconsole-worker-utils.js create mode 100644 devtools/server/actors/webaudio.js create mode 100644 devtools/server/actors/webbrowser.js create mode 100644 devtools/server/actors/webconsole.js create mode 100644 devtools/server/actors/webextension.js create mode 100644 devtools/server/actors/webgl.js create mode 100644 devtools/server/actors/worker.js (limited to 'devtools/server/actors') 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({ + * ||: { + * selectorText: , + * test: , + * cssText: , + * isUsed: , + * presentOn: Set([ , ... ]), + * preLoadOn: Set([ , ... ]), + * isError: , + * } + * }) + * + * 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