diff options
Diffstat (limited to 'devtools/server/actors/utils')
-rw-r--r-- | devtools/server/actors/utils/TabSources.js | 833 | ||||
-rw-r--r-- | devtools/server/actors/utils/actor-registry-utils.js | 78 | ||||
-rw-r--r-- | devtools/server/actors/utils/audionodes.json | 113 | ||||
-rw-r--r-- | devtools/server/actors/utils/automation-timeline.js | 373 | ||||
-rw-r--r-- | devtools/server/actors/utils/css-grid-utils.js | 61 | ||||
-rw-r--r-- | devtools/server/actors/utils/make-debugger.js | 101 | ||||
-rw-r--r-- | devtools/server/actors/utils/map-uri-to-addon-id.js | 44 | ||||
-rw-r--r-- | devtools/server/actors/utils/moz.build | 19 | ||||
-rw-r--r-- | devtools/server/actors/utils/stack.js | 185 | ||||
-rw-r--r-- | devtools/server/actors/utils/walker-search.js | 278 | ||||
-rw-r--r-- | devtools/server/actors/utils/webconsole-utils.js | 1063 | ||||
-rw-r--r-- | devtools/server/actors/utils/webconsole-worker-utils.js | 20 |
12 files changed, 3168 insertions, 0 deletions
diff --git a/devtools/server/actors/utils/TabSources.js b/devtools/server/actors/utils/TabSources.js new file mode 100644 index 000000000..56e862939 --- /dev/null +++ b/devtools/server/actors/utils/TabSources.js @@ -0,0 +1,833 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { Ci, Cu } = require("chrome"); +const DevToolsUtils = require("devtools/shared/DevToolsUtils"); +const { assert, fetch } = DevToolsUtils; +const EventEmitter = require("devtools/shared/event-emitter"); +const { OriginalLocation, GeneratedLocation } = require("devtools/server/actors/common"); +const { resolve } = require("promise"); +const { joinURI } = require("devtools/shared/path"); + +loader.lazyRequireGetter(this, "SourceActor", "devtools/server/actors/source", true); +loader.lazyRequireGetter(this, "isEvalSource", "devtools/server/actors/source", true); +loader.lazyRequireGetter(this, "SourceMapConsumer", "source-map", true); +loader.lazyRequireGetter(this, "SourceMapGenerator", "source-map", true); + +/** + * Manages the sources for a thread. Handles source maps, locations in the + * sources, etc for ThreadActors. + */ +function TabSources(threadActor, allowSourceFn = () => true) { + EventEmitter.decorate(this); + + this._thread = threadActor; + this._useSourceMaps = true; + this._autoBlackBox = true; + this._anonSourceMapId = 1; + this.allowSource = source => { + return !isHiddenSource(source) && allowSourceFn(source); + }; + + this.blackBoxedSources = new Set(); + this.prettyPrintedSources = new Map(); + this.neverAutoBlackBoxSources = new Set(); + + // generated Debugger.Source -> promise of SourceMapConsumer + this._sourceMaps = new Map(); + // sourceMapURL -> promise of SourceMapConsumer + this._sourceMapCache = Object.create(null); + // Debugger.Source -> SourceActor + this._sourceActors = new Map(); + // url -> SourceActor + this._sourceMappedSourceActors = Object.create(null); +} + +/** + * Matches strings of the form "foo.min.js" or "foo-min.js", etc. If the regular + * expression matches, we can be fairly sure that the source is minified, and + * treat it as such. + */ +const MINIFIED_SOURCE_REGEXP = /\bmin\.js$/; + +TabSources.prototype = { + /** + * Update preferences and clear out existing sources + */ + setOptions: function (options) { + let shouldReset = false; + + if ("useSourceMaps" in options) { + shouldReset = true; + this._useSourceMaps = options.useSourceMaps; + } + + if ("autoBlackBox" in options) { + shouldReset = true; + this._autoBlackBox = options.autoBlackBox; + } + + if (shouldReset) { + this.reset(); + } + }, + + /** + * Clear existing sources so they are recreated on the next access. + * + * @param Object opts + * Specify { sourceMaps: true } if you also want to clear + * the source map cache (usually done on reload). + */ + reset: function (opts = {}) { + this._sourceActors = new Map(); + this._sourceMaps = new Map(); + this._sourceMappedSourceActors = Object.create(null); + + if (opts.sourceMaps) { + this._sourceMapCache = Object.create(null); + } + }, + + /** + * Return the source actor representing the `source` (or + * `originalUrl`), creating one if none exists already. May return + * null if the source is disallowed. + * + * @param Debugger.Source source + * The source to make an actor for + * @param String originalUrl + * The original source URL of a sourcemapped source + * @param optional Debguger.Source generatedSource + * The generated source that introduced this source via source map, + * if any. + * @param optional String contentType + * The content type of the source, if immediately available. + * @returns a SourceActor representing the source or null. + */ + source: function ({ source, originalUrl, generatedSource, + isInlineSource, contentType }) { + assert(source || (originalUrl && generatedSource), + "TabSources.prototype.source needs an originalUrl or a source"); + + if (source) { + // If a source is passed, we are creating an actor for a real + // source, which may or may not be sourcemapped. + + if (!this.allowSource(source)) { + return null; + } + + // It's a hack, but inline HTML scripts each have real sources, + // but we want to represent all of them as one source as the + // HTML page. The actor representing this fake HTML source is + // stored in this array, which always has a URL, so check it + // first. + if (source.url in this._sourceMappedSourceActors) { + return this._sourceMappedSourceActors[source.url]; + } + + if (isInlineSource) { + // If it's an inline source, the fake HTML source hasn't been + // created yet (would have returned above), so flip this source + // into a sourcemapped state by giving it an `originalUrl` which + // is the HTML url. + originalUrl = source.url; + source = null; + } + else if (this._sourceActors.has(source)) { + return this._sourceActors.get(source); + } + } + else if (originalUrl) { + // Not all "original" scripts are distinctly separate from the + // generated script. Pretty-printed sources have a sourcemap for + // themselves, so we need to make sure there a real source + // doesn't already exist with this URL. + for (let [source, actor] of this._sourceActors) { + if (source.url === originalUrl) { + return actor; + } + } + + if (originalUrl in this._sourceMappedSourceActors) { + return this._sourceMappedSourceActors[originalUrl]; + } + } + + let actor = new SourceActor({ + thread: this._thread, + source: source, + originalUrl: originalUrl, + generatedSource: generatedSource, + isInlineSource: isInlineSource, + contentType: contentType + }); + + let sourceActorStore = this._thread.sourceActorStore; + var id = sourceActorStore.getReusableActorId(source, originalUrl); + if (id) { + actor.actorID = id; + } + + this._thread.threadLifetimePool.addActor(actor); + sourceActorStore.setReusableActorId(source, originalUrl, actor.actorID); + + if (this._autoBlackBox && + !this.neverAutoBlackBoxSources.has(actor.url) && + this._isMinifiedURL(actor.url)) { + + this.blackBox(actor.url); + this.neverAutoBlackBoxSources.add(actor.url); + } + + if (source) { + this._sourceActors.set(source, actor); + } + else { + this._sourceMappedSourceActors[originalUrl] = actor; + } + + this._emitNewSource(actor); + return actor; + }, + + _emitNewSource: function (actor) { + if (!actor.source) { + // Always notify if we don't have a source because that means + // it's something that has been sourcemapped, or it represents + // the HTML file that contains inline sources. + this.emit("newSource", actor); + } + else { + // If sourcemapping is enabled and a source has sourcemaps, we + // create `SourceActor` instances for both the original and + // generated sources. The source actors for the generated + // sources are only for internal use, however; breakpoints are + // managed by these internal actors. We only want to notify the + // user of the original sources though, so if the actor has a + // `Debugger.Source` instance and a valid source map (meaning + // it's a generated source), don't send the notification. + this.fetchSourceMap(actor.source).then(map => { + if (!map) { + this.emit("newSource", actor); + } + }); + } + }, + + getSourceActor: function (source) { + if (source.url in this._sourceMappedSourceActors) { + return this._sourceMappedSourceActors[source.url]; + } + + if (this._sourceActors.has(source)) { + return this._sourceActors.get(source); + } + + throw new Error("getSource: could not find source actor for " + + (source.url || "source")); + }, + + getSourceActorByURL: function (url) { + if (url) { + for (let [source, actor] of this._sourceActors) { + if (source.url === url) { + return actor; + } + } + + if (url in this._sourceMappedSourceActors) { + return this._sourceMappedSourceActors[url]; + } + } + + throw new Error("getSourceActorByURL: could not find source for " + url); + return null; + }, + + /** + * Returns true if the URL likely points to a minified resource, false + * otherwise. + * + * @param String aURL + * The URL to test. + * @returns Boolean + */ + _isMinifiedURL: function (aURL) { + if (!aURL) { + return false; + } + + try { + let url = new URL(aURL); + let pathname = url.pathname; + return MINIFIED_SOURCE_REGEXP.test(pathname.slice(pathname.lastIndexOf("/") + 1)); + } catch (e) { + // Not a valid URL so don't try to parse out the filename, just test the + // whole thing with the minified source regexp. + return MINIFIED_SOURCE_REGEXP.test(aURL); + } + }, + + /** + * Create a source actor representing this source. This ignores + * source mapping and always returns an actor representing this real + * source. Use `createSourceActors` if you want to respect source maps. + * + * @param Debugger.Source aSource + * The source instance to create an actor for. + * @returns SourceActor + */ + createNonSourceMappedActor: function (aSource) { + // Don't use getSourceURL because we don't want to consider the + // displayURL property if it's an eval source. We only want to + // consider real URLs, otherwise if there is a URL but it's + // invalid the code below will not set the content type, and we + // will later try to fetch the contents of the URL to figure out + // the content type, but it's a made up URL for eval sources. + let url = isEvalSource(aSource) ? null : aSource.url; + let spec = { source: aSource }; + + // XXX bug 915433: We can't rely on Debugger.Source.prototype.text + // if the source is an HTML-embedded <script> tag. Since we don't + // have an API implemented to detect whether this is the case, we + // need to be conservative and only treat valid js files as real + // sources. Otherwise, use the `originalUrl` property to treat it + // as an HTML source that manages multiple inline sources. + + // Assume the source is inline if the element that introduced it is not a + // script element, or does not have a src attribute. + let element = aSource.element ? aSource.element.unsafeDereference() : null; + if (element && (element.tagName !== "SCRIPT" || !element.hasAttribute("src"))) { + spec.isInlineSource = true; + } else if (aSource.introductionType === "wasm") { + // Wasm sources are not JavaScript. Give them their own content-type. + spec.contentType = "text/wasm"; + } else { + if (url) { + // There are a few special URLs that we know are JavaScript: + // inline `javascript:` and code coming from the console + if (url.indexOf("Scratchpad/") === 0 || + url.indexOf("javascript:") === 0 || + url === "debugger eval code") { + spec.contentType = "text/javascript"; + } else { + try { + let pathname = new URL(url).pathname; + let filename = pathname.slice(pathname.lastIndexOf("/") + 1); + let index = filename.lastIndexOf("."); + let extension = index >= 0 ? filename.slice(index + 1) : ""; + if (extension === "xml") { + // XUL inline scripts may not correctly have the + // `source.element` property, so do a blunt check here if + // it's an xml page. + spec.isInlineSource = true; + } + else if (extension === "js") { + spec.contentType = "text/javascript"; + } + } catch (e) { + // This only needs to be here because URL is not yet exposed to + // workers. (BUG 1258892) + const filename = url; + const index = filename.lastIndexOf("."); + const extension = index >= 0 ? filename.slice(index + 1) : ""; + if (extension === "js") { + spec.contentType = "text/javascript"; + } + } + } + } + else { + // Assume the content is javascript if there's no URL + spec.contentType = "text/javascript"; + } + } + + return this.source(spec); + }, + + /** + * This is an internal function that returns a promise of an array + * of source actors representing all the source mapped sources of + * `aSource`, or `null` if the source is not sourcemapped or + * sourcemapping is disabled. Users should call `createSourceActors` + * instead of this. + * + * @param Debugger.Source aSource + * The source instance to create actors for. + * @return Promise of an array of source actors + */ + _createSourceMappedActors: function (aSource) { + if (!this._useSourceMaps || !aSource.sourceMapURL) { + return resolve(null); + } + + return this.fetchSourceMap(aSource) + .then(map => { + if (map) { + return map.sources.map(s => { + return this.source({ originalUrl: s, generatedSource: aSource }); + }).filter(isNotNull); + } + return null; + }); + }, + + /** + * Creates the source actors representing the appropriate sources + * of `aSource`. If sourcemapped, returns actors for all of the original + * sources, otherwise returns a 1-element array with the actor for + * `aSource`. + * + * @param Debugger.Source aSource + * The source instance to create actors for. + * @param Promise of an array of source actors + */ + createSourceActors: function (aSource) { + return this._createSourceMappedActors(aSource).then(actors => { + let actor = this.createNonSourceMappedActor(aSource); + return (actors || [actor]).filter(isNotNull); + }); + }, + + /** + * Return a promise of a SourceMapConsumer for the source map for + * `aSource`; if we already have such a promise extant, return that. + * This will fetch the source map if we don't have a cached object + * and source maps are enabled (see `_fetchSourceMap`). + * + * @param Debugger.Source aSource + * The source instance to get sourcemaps for. + * @return Promise of a SourceMapConsumer + */ + fetchSourceMap: function (aSource) { + if (!this._useSourceMaps) { + return resolve(null); + } + else if (this._sourceMaps.has(aSource)) { + return this._sourceMaps.get(aSource); + } + else if (!aSource || !aSource.sourceMapURL) { + return resolve(null); + } + + let sourceMapURL = aSource.sourceMapURL; + if (aSource.url) { + sourceMapURL = joinURI(aSource.url, sourceMapURL); + } + let result = this._fetchSourceMap(sourceMapURL, aSource.url); + + // The promises in `_sourceMaps` must be the exact same instances + // as returned by `_fetchSourceMap` for `clearSourceMapCache` to + // work. + this._sourceMaps.set(aSource, result); + return result; + }, + + /** + * Return a promise of a SourceMapConsumer for the source map for + * `aSource`. The resolved result may be null if the source does not + * have a source map or source maps are disabled. + */ + getSourceMap: function (aSource) { + return resolve(this._sourceMaps.get(aSource)); + }, + + /** + * Set a SourceMapConsumer for the source map for + * |aSource|. + */ + setSourceMap: function (aSource, aMap) { + this._sourceMaps.set(aSource, resolve(aMap)); + }, + + /** + * Return a promise of a SourceMapConsumer for the source map located at + * |aAbsSourceMapURL|, which must be absolute. If there is already such a + * promise extant, return it. This will not fetch if source maps are + * disabled. + * + * @param string aAbsSourceMapURL + * The source map URL, in absolute form, not relative. + * @param string aScriptURL + * When the source map URL is a data URI, there is no sourceRoot on the + * source map, and the source map's sources are relative, we resolve + * them from aScriptURL. + */ + _fetchSourceMap: function (aAbsSourceMapURL, aSourceURL) { + assert(this._useSourceMaps, + "Cannot fetch sourcemaps if they are disabled"); + + if (this._sourceMapCache[aAbsSourceMapURL]) { + return this._sourceMapCache[aAbsSourceMapURL]; + } + + let fetching = fetch(aAbsSourceMapURL, { loadFromCache: false }) + .then(({ content }) => { + let map = new SourceMapConsumer(content); + this._setSourceMapRoot(map, aAbsSourceMapURL, aSourceURL); + return map; + }) + .then(null, error => { + if (!DevToolsUtils.reportingDisabled) { + DevToolsUtils.reportException("TabSources.prototype._fetchSourceMap", error); + } + return null; + }); + this._sourceMapCache[aAbsSourceMapURL] = fetching; + return fetching; + }, + + /** + * Sets the source map's sourceRoot to be relative to the source map url. + */ + _setSourceMapRoot: function (aSourceMap, aAbsSourceMapURL, aScriptURL) { + // No need to do this fiddling if we won't be fetching any sources over the + // wire. + if (aSourceMap.hasContentsOfAllSources()) { + return; + } + + const base = this._dirname( + aAbsSourceMapURL.indexOf("data:") === 0 + ? aScriptURL + : aAbsSourceMapURL); + aSourceMap.sourceRoot = aSourceMap.sourceRoot + ? joinURI(base, aSourceMap.sourceRoot) + : base; + }, + + _dirname: function (aPath) { + let url = new URL(aPath); + let href = url.href; + return href.slice(0, href.lastIndexOf("/")); + }, + + /** + * Clears the source map cache. Source maps are cached by URL so + * they can be reused across separate Debugger instances (once in + * this cache, they will never be reparsed again). They are + * also cached by Debugger.Source objects for usefulness. By default + * this just removes the Debugger.Source cache, but you can remove + * the lower-level URL cache with the `hard` option. + * + * @param aSourceMapURL string + * The source map URL to uncache + * @param opts object + * An object with the following properties: + * - hard: Also remove the lower-level URL cache, which will + * make us completely forget about the source map. + */ + clearSourceMapCache: function (aSourceMapURL, opts = { hard: false }) { + let oldSm = this._sourceMapCache[aSourceMapURL]; + + if (opts.hard) { + delete this._sourceMapCache[aSourceMapURL]; + } + + if (oldSm) { + // Clear out the current cache so all sources will get the new one + for (let [source, sm] of this._sourceMaps.entries()) { + if (sm === oldSm) { + this._sourceMaps.delete(source); + } + } + } + }, + + /* + * Forcefully change the source map of a source, changing the + * sourceMapURL and installing the source map in the cache. This is + * necessary to expose changes across Debugger instances + * (pretty-printing is the use case). Generate a random url if one + * isn't specified, allowing you to set "anonymous" source maps. + * + * @param aSource Debugger.Source + * The source to change the sourceMapURL property + * @param aUrl string + * The source map URL (optional) + * @param aMap SourceMapConsumer + * The source map instance + */ + setSourceMapHard: function (aSource, aUrl, aMap) { + let url = aUrl; + if (!url) { + // This is a littly hacky, but we want to forcefully set a + // sourcemap regardless of sourcemap settings. We want to + // literally change the sourceMapURL so that all debuggers will + // get this and pretty-printing will Just Work (Debugger.Source + // instances are per-debugger, so we can't key off that). To + // avoid tons of work serializing the sourcemap into a data url, + // just make a fake URL and stick the sourcemap there. + url = "internal://sourcemap" + (this._anonSourceMapId++) + "/"; + } + aSource.sourceMapURL = url; + + // Forcefully set the sourcemap cache. This will be used even if + // sourcemaps are disabled. + this._sourceMapCache[url] = resolve(aMap); + this.emit("updatedSource", this.getSourceActor(aSource)); + }, + + /** + * Return the non-source-mapped location of the given Debugger.Frame. If the + * frame does not have a script, the location's properties are all null. + * + * @param Debugger.Frame aFrame + * The frame whose location we are getting. + * @returns Object + * Returns an object of the form { source, line, column } + */ + getFrameLocation: function (aFrame) { + if (!aFrame || !aFrame.script) { + return new GeneratedLocation(); + } + let {lineNumber, columnNumber} = + aFrame.script.getOffsetLocation(aFrame.offset); + return new GeneratedLocation( + this.createNonSourceMappedActor(aFrame.script.source), + lineNumber, + columnNumber + ); + }, + + /** + * Returns a promise of the location in the original source if the source is + * source mapped, otherwise a promise of the same location. This can + * be called with a source from *any* Debugger instance and we make + * sure to that it works properly, reusing source maps if already + * fetched. Use this from any actor that needs sourcemapping. + */ + getOriginalLocation: function (generatedLocation) { + let { + generatedSourceActor, + generatedLine, + generatedColumn + } = generatedLocation; + let source = generatedSourceActor.source; + let url = source ? source.url : generatedSourceActor._originalUrl; + + // In certain scenarios the source map may have not been fetched + // yet (or at least tied to this Debugger.Source instance), so use + // `fetchSourceMap` instead of `getSourceMap`. This allows this + // function to be called from anywere (across debuggers) and it + // should just automatically work. + return this.fetchSourceMap(source).then(map => { + if (map) { + let { + source: originalUrl, + line: originalLine, + column: originalColumn, + name: originalName + } = map.originalPositionFor({ + line: generatedLine, + column: generatedColumn == null ? Infinity : generatedColumn + }); + + // Since the `Debugger.Source` instance may come from a + // different `Debugger` instance (any actor can call this + // method), we can't rely on any of the source discovery + // setup (`_discoverSources`, etc) to have been run yet. So + // we have to assume that the actor may not already exist, + // and we might need to create it, so use `source` and give + // it the required parameters for a sourcemapped source. + return new OriginalLocation( + originalUrl ? this.source({ + originalUrl: originalUrl, + generatedSource: source + }) : null, + originalLine, + originalColumn, + originalName + ); + } + + // No source map + return OriginalLocation.fromGeneratedLocation(generatedLocation); + }); + }, + + getAllGeneratedLocations: function (originalLocation) { + let { + originalSourceActor, + originalLine, + originalColumn + } = originalLocation; + + let source = (originalSourceActor.source || + originalSourceActor.generatedSource); + + return this.fetchSourceMap(source).then((map) => { + if (map) { + map.computeColumnSpans(); + + return map.allGeneratedPositionsFor({ + source: originalSourceActor.url, + line: originalLine, + column: originalColumn + }).map(({ line, column, lastColumn }) => { + return new GeneratedLocation( + this.createNonSourceMappedActor(source), + line, + column, + lastColumn + ); + }); + } + + return [GeneratedLocation.fromOriginalLocation(originalLocation)]; + }); + }, + + + /** + * Returns a promise of the location in the generated source corresponding to + * the original source and line given. + * + * When we pass a script S representing generated code to `sourceMap`, + * above, that returns a promise P. The process of resolving P populates + * the tables this function uses; thus, it won't know that S's original + * source URLs map to S until P is resolved. + */ + getGeneratedLocation: function (originalLocation) { + let { originalSourceActor } = originalLocation; + + // Both original sources and normal sources could have sourcemaps, + // because normal sources can be pretty-printed which generates a + // sourcemap for itself. Check both of the source properties to make it work + // for both kinds of sources. + let source = originalSourceActor.source || originalSourceActor.generatedSource; + + // See comment about `fetchSourceMap` in `getOriginalLocation`. + return this.fetchSourceMap(source).then((map) => { + if (map) { + let { + originalLine, + originalColumn + } = originalLocation; + + let { + line: generatedLine, + column: generatedColumn + } = map.generatedPositionFor({ + source: originalSourceActor.url, + line: originalLine, + column: originalColumn == null ? 0 : originalColumn, + bias: SourceMapConsumer.LEAST_UPPER_BOUND + }); + + return new GeneratedLocation( + this.createNonSourceMappedActor(source), + generatedLine, + generatedColumn + ); + } + + return GeneratedLocation.fromOriginalLocation(originalLocation); + }); + }, + + /** + * Returns true if URL for the given source is black boxed. + * + * @param aURL String + * The URL of the source which we are checking whether it is black + * boxed or not. + */ + isBlackBoxed: function (aURL) { + return this.blackBoxedSources.has(aURL); + }, + + /** + * Add the given source URL to the set of sources that are black boxed. + * + * @param aURL String + * The URL of the source which we are black boxing. + */ + blackBox: function (aURL) { + this.blackBoxedSources.add(aURL); + }, + + /** + * Remove the given source URL to the set of sources that are black boxed. + * + * @param aURL String + * The URL of the source which we are no longer black boxing. + */ + unblackBox: function (aURL) { + this.blackBoxedSources.delete(aURL); + }, + + /** + * Returns true if the given URL is pretty printed. + * + * @param aURL String + * The URL of the source that might be pretty printed. + */ + isPrettyPrinted: function (aURL) { + return this.prettyPrintedSources.has(aURL); + }, + + /** + * Add the given URL to the set of sources that are pretty printed. + * + * @param aURL String + * The URL of the source to be pretty printed. + */ + prettyPrint: function (aURL, aIndent) { + this.prettyPrintedSources.set(aURL, aIndent); + }, + + /** + * Return the indent the given URL was pretty printed by. + */ + prettyPrintIndent: function (aURL) { + return this.prettyPrintedSources.get(aURL); + }, + + /** + * Remove the given URL from the set of sources that are pretty printed. + * + * @param aURL String + * The URL of the source that is no longer pretty printed. + */ + disablePrettyPrint: function (aURL) { + this.prettyPrintedSources.delete(aURL); + }, + + iter: function () { + let actors = Object.keys(this._sourceMappedSourceActors).map(k => { + return this._sourceMappedSourceActors[k]; + }); + for (let actor of this._sourceActors.values()) { + if (!this._sourceMaps.has(actor.source)) { + actors.push(actor); + } + } + return actors; + } +}; + +/* + * Checks if a source should never be displayed to the user because + * it's either internal or we don't support in the UI yet. + */ +function isHiddenSource(aSource) { + // Ignore the internal Function.prototype script + return aSource.text === "() {\n}"; +} + +/** + * Returns true if its argument is not null. + */ +function isNotNull(aThing) { + return aThing !== null; +} + +exports.TabSources = TabSources; +exports.isHiddenSource = isHiddenSource; diff --git a/devtools/server/actors/utils/actor-registry-utils.js b/devtools/server/actors/utils/actor-registry-utils.js new file mode 100644 index 000000000..5866827e1 --- /dev/null +++ b/devtools/server/actors/utils/actor-registry-utils.js @@ -0,0 +1,78 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +var { Cu, CC, Ci, Cc } = require("chrome"); + +const { DebuggerServer } = require("devtools/server/main"); +const promise = require("promise"); + +/** + * Support for actor registration. Main used by ActorRegistryActor + * for dynamic registration of new actors. + * + * @param sourceText {String} Source of the actor implementation + * @param fileName {String} URL of the actor module (for proper stack traces) + * @param options {Object} Configuration object + */ +exports.registerActor = function (sourceText, fileName, options) { + // Register in the current process + exports.registerActorInCurrentProcess(sourceText, fileName, options); + // Register in any child processes + return DebuggerServer.setupInChild({ + module: "devtools/server/actors/utils/actor-registry-utils", + setupChild: "registerActorInCurrentProcess", + args: [sourceText, fileName, options], + waitForEval: true + }); +}; + +exports.registerActorInCurrentProcess = function (sourceText, fileName, options) { + const principal = CC("@mozilla.org/systemprincipal;1", "nsIPrincipal")(); + const sandbox = Cu.Sandbox(principal); + sandbox.exports = {}; + sandbox.require = require; + + Cu.evalInSandbox(sourceText, sandbox, "1.8", fileName, 1); + + let { prefix, constructor, type } = options; + + if (type.global && !DebuggerServer.globalActorFactories.hasOwnProperty(prefix)) { + DebuggerServer.addGlobalActor({ + constructorName: constructor, + constructorFun: sandbox[constructor] + }, prefix); + } + + if (type.tab && !DebuggerServer.tabActorFactories.hasOwnProperty(prefix)) { + DebuggerServer.addTabActor({ + constructorName: constructor, + constructorFun: sandbox[constructor] + }, prefix); + } +}; + +exports.unregisterActor = function (options) { + // Unregister in the current process + exports.unregisterActorInCurrentProcess(options); + // Unregister in any child processes + DebuggerServer.setupInChild({ + module: "devtools/server/actors/utils/actor-registry-utils", + setupChild: "unregisterActorInCurrentProcess", + args: [options] + }); +}; + +exports.unregisterActorInCurrentProcess = function (options) { + if (options.tab) { + DebuggerServer.removeTabActor(options); + } + + if (options.global) { + DebuggerServer.removeGlobalActor(options); + } +}; diff --git a/devtools/server/actors/utils/audionodes.json b/devtools/server/actors/utils/audionodes.json new file mode 100644 index 000000000..12cc6c34b --- /dev/null +++ b/devtools/server/actors/utils/audionodes.json @@ -0,0 +1,113 @@ +{ + "OscillatorNode": { + "source": true, + "properties": { + "type": {}, + "frequency": { + "param": true + }, + "detune": { + "param": true + } + } + }, + "GainNode": { + "properties": { "gain": { "param": true }} + }, + "DelayNode": { + "properties": { "delayTime": { "param": true }} + }, + "AudioBufferSourceNode": { + "source": true, + "properties": { + "buffer": { "Buffer": true }, + "playbackRate": { + "param": true + }, + "loop": {}, + "loopStart": {}, + "loopEnd": {} + } + }, + "ScriptProcessorNode": { + "properties": { "bufferSize": { "readonly": true }} + }, + "PannerNode": { + "properties": { + "panningModel": {}, + "distanceModel": {}, + "refDistance": {}, + "maxDistance": {}, + "rolloffFactor": {}, + "coneInnerAngle": {}, + "coneOuterAngle": {}, + "coneOuterGain": {} + } + }, + "ConvolverNode": { + "properties": { + "buffer": { "Buffer": true }, + "normalize": {} + } + }, + "DynamicsCompressorNode": { + "properties": { + "threshold": { "param": true }, + "knee": { "param": true }, + "ratio": { "param": true }, + "reduction": {}, + "attack": { "param": true }, + "release": { "param": true } + } + }, + "BiquadFilterNode": { + "properties": { + "type": {}, + "frequency": { "param": true }, + "Q": { "param": true }, + "detune": { "param": true }, + "gain": { "param": true } + } + }, + "WaveShaperNode": { + "properties": { + "curve": { "Float32Array": true }, + "oversample": {} + } + }, + "AnalyserNode": { + "properties": { + "fftSize": {}, + "minDecibels": {}, + "maxDecibels": {}, + "smoothingTimeConstant": {}, + "frequencyBinCount": { "readonly": true } + } + }, + "AudioDestinationNode": { + "unbypassable": true + }, + "ChannelSplitterNode": { + "unbypassable": true + }, + "ChannelMergerNode": { + "unbypassable": true + }, + "MediaElementAudioSourceNode": { + "source": true + }, + "MediaStreamAudioSourceNode": { + "source": true + }, + "MediaStreamAudioDestinationNode": { + "unbypassable": true, + "properties": { + "stream": { "MediaStream": true } + } + }, + "StereoPannerNode": { + "properties": { + "pan": { "param": true } + } + } +} diff --git a/devtools/server/actors/utils/automation-timeline.js b/devtools/server/actors/utils/automation-timeline.js new file mode 100644 index 000000000..a086d90be --- /dev/null +++ b/devtools/server/actors/utils/automation-timeline.js @@ -0,0 +1,373 @@ +/** + * web-audio-automation-timeline - 1.0.3 + * https://github.com/jsantell/web-audio-automation-timeline + * MIT License, copyright (c) 2014 Jordan Santell + */ +!function(e){if("object"==typeof exports&&"undefined"!=typeof module)module.exports=e();else if("function"==typeof define&&define.amd)define([],e);else{var f;"undefined"!=typeof window?f=window:"undefined"!=typeof global?f=global:"undefined"!=typeof self&&(f=self),f.Timeline=e()}}(function(){var define,module,exports;return (function e(t,n,r){function s(o,u){if(!n[o]){if(!t[o]){var a=typeof require=="function"&&require;if(!u&&a)return a(o,!0);if(i)return i(o,!0);var f=new Error("Cannot find module '"+o+"'");throw f.code="MODULE_NOT_FOUND",f}var l=n[o]={exports:{}};t[o][0].call(l.exports,function(e){var n=t[o][1][e];return s(n?n:e)},l,l.exports,e,t,n,r)}return n[o].exports}var i=typeof require=="function"&&require;for(var o=0;o<r.length;o++)s(r[o]);return s})({1:[function(require,module,exports){ +module.exports = require("./lib/timeline").Timeline; + +},{"./lib/timeline":4}],2:[function(require,module,exports){ +var F = require("./formulas"); + +function TimelineEvent (eventName, value, time, timeConstant, duration) { + this.type = eventName; + this.value = value; + this.time = time; + this.constant = timeConstant || 0; + this.duration = duration || 0; +} +exports.TimelineEvent = TimelineEvent; + + +TimelineEvent.prototype.exponentialApproach = function (lastValue, time) { + return F.exponentialApproach(this.time, lastValue, this.value, this.constant, time); +} + +TimelineEvent.prototype.extractValueFromCurve = function (time) { + return F.extractValueFromCurve(this.time, this.value, this.value.length, this.duration, time); +} + +TimelineEvent.prototype.linearInterpolate = function (next, time) { + return F.linearInterpolate(this.time, this.value, next.time, next.value, time); +} + +TimelineEvent.prototype.exponentialInterpolate = function (next, time) { + return F.exponentialInterpolate(this.time, this.value, next.time, next.value, time); +} + +},{"./formulas":3}],3:[function(require,module,exports){ +var EPSILON = 0.0000000001; + +exports.linearInterpolate = function (t0, v0, t1, v1, t) { + return v0 + (v1 - v0) * ((t - t0) / (t1 - t0)); +}; + +exports.exponentialInterpolate = function (t0, v0, t1, v1, t) { + return v0 * Math.pow(v1 / v0, (t - t0) / (t1 - t0)); +}; + +exports.extractValueFromCurve = function (start, curve, curveLength, duration, t) { + var ratio; + + // If time is after duration, return the last curve value, + // or if ratio is >= 1 + if (t >= start + duration || (ratio = Math.max((t - start) / duration, 0)) >= 1) { + return curve[curveLength - 1]; + } + + return curve[~~(curveLength * ratio)]; +}; + +exports.exponentialApproach = function (t0, v0, v1, timeConstant, t) { + return v1 + (v0 - v1) * Math.exp(-(t - t0) / timeConstant); +}; + +// Since we are going to accumulate error by adding 0.01 multiple times +// in a loop, we want to fuzz the equality check in `getValueAtTime` +exports.fuzzyEqual = function (lhs, rhs) { + return Math.abs(lhs - rhs) < EPSILON; +}; + +exports.EPSILON = EPSILON; + +},{}],4:[function(require,module,exports){ +var TimelineEvent = require("./event").TimelineEvent; +var F = require("./formulas"); + +exports.Timeline = Timeline; + +function Timeline (defaultValue) { + this.events = []; + + this._value = defaultValue || 0; +} + +Timeline.prototype.getEventCount = function () { + return this.events.length; +}; + +Timeline.prototype.value = function () { + return this._value; +}; + +Timeline.prototype.setValue = function (value) { + if (this.events.length === 0) { + this._value = value; + } +}; + +Timeline.prototype.getValue = function () { + if (this.events.length) { + throw new Error("Can only call `getValue` when there are 0 events."); + } + + return this._value; +}; + +Timeline.prototype.getValueAtTime = function (time) { + return this._getValueAtTimeHelper(time); +}; + +Timeline.prototype._getValueAtTimeHelper = function (time) { + var bailOut = false; + var previous = null; + var next = null; + var lastComputedValue = null; // Used for `setTargetAtTime` nodes + var events = this.events; + var e; + + for (var i = 0; !bailOut && i < events.length; i++) { + if (F.fuzzyEqual(time, events[i].time)) { + // Find the last event with the same time as `time` + do { + ++i; + } while (i < events.length && F.fuzzyEqual(time, events[i].time)); + + e = events[i - 1]; + + // `setTargetAtTime` can be handled no matter what their next event is (if they have one) + if (e.type === "setTargetAtTime") { + lastComputedValue = this._lastComputedValue(e); + return e.exponentialApproach(lastComputedValue, time); + } + + // `setValueCurveAtTime` events can be handled no matter what their next event node is + // (if they have one) + if (e.type === "setValueCurveAtTime") { + return e.extractValueFromCurve(time); + } + + // For other event types + return e.value; + } + previous = next; + next = events[i]; + + if (time < events[i].time) { + bailOut = true; + } + } + + // Handle the case where the time is past all of the events + if (!bailOut) { + previous = next; + next = null; + } + + // Just return the default value if we did not find anything + if (!previous && !next) { + return this._value; + } + + // If the requested time is before all of the existing events + if (!previous) { + return this._value; + } + + // `setTargetAtTime` can be handled no matter what their next event is (if they have one) + if (previous.type === "setTargetAtTime") { + lastComputedValue = this._lastComputedValue(previous); + return previous.exponentialApproach(lastComputedValue, time); + } + + // `setValueCurveAtTime` events can be handled no matter what their next event node is + // (if they have one) + if (previous.type === "setValueCurveAtTime") { + return previous.extractValueFromCurve(time); + } + + if (!next) { + if (~["setValueAtTime", "linearRampToValueAtTime", "exponentialRampToValueAtTime"].indexOf(previous.type)) { + return previous.value; + } + if (previous.type === "setValueCurveAtTime") { + return previous.extractValueFromCurve(time); + } + if (previous.type === "setTargetAtTime") { + throw new Error("unreached"); + } + throw new Error("unreached"); + } + + // Finally handle the case where we have both a previous and a next event + // First handle the case where our range ends up in a ramp event + if (next.type === "linearRampToValueAtTime") { + return previous.linearInterpolate(next, time); + } else if (next.type === "exponentialRampToValueAtTime") { + return previous.exponentialInterpolate(next, time); + } + + // Now handle all other cases + if (~["setValueAtTime", "linearRampToValueAtTime", "exponentialRampToValueAtTime"].indexOf(previous.type)) { + // If the next event type is neither linear or exponential ramp, + // the value is constant. + return previous.value; + } + if (previous.type === "setValueCurveAtTime") { + return previous.extractValueFromCurve(time); + } + if (previous.type === "setTargetAtTime") { + throw new Error("unreached"); + } + throw new Error("unreached"); +}; + +Timeline.prototype._insertEvent = function (ev) { + var events = this.events; + + if (ev.type === "setValueCurveAtTime") { + if (!ev.value || !ev.value.length) { + throw new Error("NS_ERROR_DOM_SYNTAX_ERR"); + } + } + + if (ev.type === "setTargetAtTime") { + if (F.fuzzyEqual(ev.constant, 0)) { + throw new Error("NS_ERROR_DOM_SYNTAX_ERR"); + } + } + + // Make sure that non-curve events don't fall within the duration of a + // curve event. + for (var i = 0; i < events.length; i++) { + if (events[i].type === "setValueCurveAtTime" && + events[i].time <= ev.time && + (events[i].time + events[i].duration) >= ev.time) { + throw new Error("NS_ERROR_DOM_SYNTAX_ERR"); + } + } + + // Make sure that curve events don't fall in a range which includes other + // events. + if (ev.type === "setValueCurveAtTime") { + for (var i = 0; i < events.length; i++) { + if (events[i].time > ev.time && + events[i].time < (ev.time + ev.duration)) { + throw new Error("NS_ERROR_DOM_SYNTAX_ERR"); + } + } + } + + // Make sure that invalid values are not used for exponential curves + if (ev.type === "exponentialRampToValueAtTime") { + if (ev.value <= 0) throw new Error("NS_ERROR_DOM_SYNTAX_ERR"); + var prev = this._getPreviousEvent(ev.time); + if (prev) { + if (prev.value <= 0) { + throw new Error("NS_ERROR_DOM_SYNTAX_ERR"); + } + } else { + if (this._value <= 0) { + throw new Error("NS_ERROR_DOM_SYNTAX_ERR"); + } + } + } + + for (var i = 0; i < events.length; i++) { + if (ev.time === events[i].time) { + if (ev.type === events[i].type) { + // If times and types are equal, replace the event; + events[i] = ev; + } else { + // Otherwise, place the element after the last event of another type + do { i++; } + while (i < events.length && ev.type !== events[i].type && ev.time === events[i].time); + events.splice(i, 0, ev); + } + return; + } + // Otherwise, place the event right after the latest existing event + if (ev.time < events[i].time) { + events.splice(i, 0, ev); + return; + } + } + + // If we couldn't find a place for the event, just append it to the list + this.events.push(ev); +}; + +Timeline.prototype._getPreviousEvent = function (time) { + var previous = null, next = null; + var bailOut = false; + var events = this.events; + + for (var i = 0; !bailOut && i < events.length; i++) { + if (time === events[i]) { + do { ++i; } + while (i < events.length && time === events[i].time); + return events[i - 1]; + } + previous = next; + next = events[i]; + if (time < events[i].time) { + bailOut = true; + } + } + + // Handle the case where the time is past all the events + if (!bailOut) { + previous = next; + } + + return previous; +}; + +/** + * Calculates the previous value of the timeline, used for + * `setTargetAtTime` nodes. Takes an event, and returns + * the previous computed value for any sample taken during that + * exponential approach node. + */ +Timeline.prototype._lastComputedValue = function (event) { + // If equal times, return the value for the previous event, before + // the `setTargetAtTime` node. + var lastEvent = this._getPreviousEvent(event.time - F.EPSILON); + + // If no event before the setTargetAtTime event, then return the + // intrinsic value. + if (!lastEvent) { + return this._value; + } + // Otherwise, return the value for the previous event, which should + // always be the last computed value (? I think?) + else { + return lastEvent.value; + } +}; + +Timeline.prototype.setValueAtTime = function (value, startTime) { + this._insertEvent(new TimelineEvent("setValueAtTime", value, startTime)); +}; + +Timeline.prototype.linearRampToValueAtTime = function (value, endTime) { + this._insertEvent(new TimelineEvent("linearRampToValueAtTime", value, endTime)); +}; + +Timeline.prototype.exponentialRampToValueAtTime = function (value, endTime) { + this._insertEvent(new TimelineEvent("exponentialRampToValueAtTime", value, endTime)); +}; + +Timeline.prototype.setTargetAtTime = function (value, startTime, timeConstant) { + this._insertEvent(new TimelineEvent("setTargetAtTime", value, startTime, timeConstant)); +}; + +Timeline.prototype.setValueCurveAtTime = function (value, startTime, duration) { + this._insertEvent(new TimelineEvent("setValueCurveAtTime", value, startTime, null, duration)); +}; + +Timeline.prototype.cancelScheduledValues = function (time) { + for (var i = 0; i < this.events.length; i++) { + if (this.events[i].time >= time) { + this.events = this.events.slice(0, i); + break; + } + } +}; + +Timeline.prototype.cancelAllEvents = function () { + this.events.length = 0; +}; + +},{"./event":2,"./formulas":3}]},{},[1])(1) +});
\ No newline at end of file diff --git a/devtools/server/actors/utils/css-grid-utils.js b/devtools/server/actors/utils/css-grid-utils.js new file mode 100644 index 000000000..0b260117d --- /dev/null +++ b/devtools/server/actors/utils/css-grid-utils.js @@ -0,0 +1,61 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { Cu } = require("chrome"); + +/** + * Returns the grid fragment array with all the grid fragment data stringifiable. + * + * @param {Object} fragments + * Grid fragment object. + * @return {Array} representation with the grid fragment data stringifiable. + */ +function getStringifiableFragments(fragments = []) { + if (fragments[0] && Cu.isDeadWrapper(fragments[0])) { + return {}; + } + + return fragments.map(getStringifiableFragment); +} + +/** + * Returns a string representation of the CSS Grid data as returned by + * node.getGridFragments. This is useful to compare grid state at each update and redraw + * the highlighter if needed. It also seralizes the grid fragment data so it can be used + * by protocol.js. + * + * @param {Object} fragments + * Grid fragment object. + * @return {String} representation of the CSS grid fragment data. + */ +function stringifyGridFragments(fragments) { + return JSON.stringify(getStringifiableFragments(fragments)); +} + +function getStringifiableFragment(fragment) { + return { + cols: getStringifiableDimension(fragment.cols), + rows: getStringifiableDimension(fragment.rows) + }; +} + +function getStringifiableDimension(dimension) { + return { + lines: [...dimension.lines].map(getStringifiableLine), + tracks: [...dimension.tracks].map(getStringifiableTrack), + }; +} + +function getStringifiableLine({ breadth, number, start, names }) { + return { breadth, number, start, names }; +} + +function getStringifiableTrack({ breadth, start, state, type }) { + return { breadth, start, state, type }; +} + +exports.getStringifiableFragments = getStringifiableFragments; +exports.stringifyGridFragments = stringifyGridFragments; diff --git a/devtools/server/actors/utils/make-debugger.js b/devtools/server/actors/utils/make-debugger.js new file mode 100644 index 000000000..9bd43e567 --- /dev/null +++ b/devtools/server/actors/utils/make-debugger.js @@ -0,0 +1,101 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2; js-indent-level: 2 -*- */ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const EventEmitter = require("devtools/shared/event-emitter"); +const Debugger = require("Debugger"); + +const { reportException } = require("devtools/shared/DevToolsUtils"); + +/** + * Multiple actors that use a |Debugger| instance come in a few versions, each + * with a different set of debuggees. One version for content tabs (globals + * within a tab), one version for chrome debugging (all globals), and sometimes + * a third version for addon debugging (chrome globals the addon is loaded in + * and content globals the addon injects scripts into). The |makeDebugger| + * function helps us avoid repeating the logic for finding and maintaining the + * correct set of globals for a given |Debugger| instance across each version of + * all of our actors. + * + * The |makeDebugger| function expects a single object parameter with the + * following properties: + * + * @param Function findDebuggees + * Called with one argument: a |Debugger| instance. This function should + * return an iterable of globals to be added to the |Debugger| + * instance. The globals may be wrapped in a |Debugger.Object|, or + * unwrapped. + * + * @param Function shouldAddNewGlobalAsDebuggee + * Called with one argument: a |Debugger.Object| wrapping a global + * object. This function must return |true| if the global object should + * be added as debuggee, and |false| otherwise. + * + * @returns Debugger + * Returns a |Debugger| instance that can manage its set of debuggee + * globals itself and is decorated with the |EventEmitter| class. + * + * Events emitted by the returned |Debugger| instance: + * + * - "newGlobal": Emitted when a new global has been added as a + * debuggee. Passes the |Debugger.Object| wrapping the new + * debuggee global to listeners. + * + * Existing |Debugger| properties set on the returned |Debugger| + * instance: + * + * - onNewGlobalObject: The |Debugger| will automatically add new + * globals as debuggees if calling |shouldAddNewGlobalAsDebuggee| + * with the global returns true. + * + * - uncaughtExceptionHook: The |Debugger| already has an error + * reporter attached to |uncaughtExceptionHook|, so if any + * |Debugger| hooks fail, the error will be reported. + * + * New properties set on the returned |Debugger| instance: + * + * - addDebuggees: A function which takes no arguments. It adds all + * current globals that should be debuggees (as determined by + * |findDebuggees|) to the |Debugger| instance. + */ +module.exports = function makeDebugger({ findDebuggees, shouldAddNewGlobalAsDebuggee }) { + const dbg = new Debugger(); + EventEmitter.decorate(dbg); + + dbg.allowUnobservedAsmJS = true; + dbg.uncaughtExceptionHook = reportDebuggerHookException; + + dbg.onNewGlobalObject = function (global) { + if (shouldAddNewGlobalAsDebuggee(global)) { + safeAddDebuggee(this, global); + } + }; + + dbg.addDebuggees = function () { + for (let global of findDebuggees(this)) { + safeAddDebuggee(this, global); + } + }; + + return dbg; +}; + +const reportDebuggerHookException = e => reportException("Debugger Hook", e); + +/** + * Add |global| as a debuggee to |dbg|, handling error cases. + */ +function safeAddDebuggee(dbg, global) { + try { + let wrappedGlobal = dbg.addDebuggee(global); + if (wrappedGlobal) { + dbg.emit("newGlobal", wrappedGlobal); + } + } catch (e) { + // Ignoring attempt to add the debugger's compartment as a debuggee. + } +} diff --git a/devtools/server/actors/utils/map-uri-to-addon-id.js b/devtools/server/actors/utils/map-uri-to-addon-id.js new file mode 100644 index 000000000..6f3316b14 --- /dev/null +++ b/devtools/server/actors/utils/map-uri-to-addon-id.js @@ -0,0 +1,44 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2; js-indent-level: 2 -*- */ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const DevToolsUtils = require("devtools/shared/DevToolsUtils"); +const Services = require("Services"); + +loader.lazyServiceGetter(this, "AddonPathService", + "@mozilla.org/addon-path-service;1", + "amIAddonPathService"); + +const B2G_ID = "{3c2e2abc-06d4-11e1-ac3b-374f68613e61}"; +const GRAPHENE_ID = "{d1bfe7d9-c01e-4237-998b-7b5f960a4314}"; + +/** + * This is a wrapper around amIAddonPathService.mapURIToAddonID which always returns + * false on B2G and graphene to avoid loading the add-on manager there and + * reports any exceptions rather than throwing so that the caller doesn't have + * to worry about them. + */ +if (!Services.appinfo + || Services.appinfo.processType == Services.appinfo.PROCESS_TYPE_CONTENT + || Services.appinfo.ID === undefined /* XPCShell */ + || Services.appinfo.ID == B2G_ID + || Services.appinfo.ID == GRAPHENE_ID + || !AddonPathService) { + module.exports = function mapURIToAddonId(uri) { + return false; + }; +} else { + module.exports = function mapURIToAddonId(uri) { + try { + return AddonPathService.mapURIToAddonId(uri); + } + catch (e) { + DevToolsUtils.reportException("mapURIToAddonId", e); + return false; + } + }; +} diff --git a/devtools/server/actors/utils/moz.build b/devtools/server/actors/utils/moz.build new file mode 100644 index 000000000..0dcf40faf --- /dev/null +++ b/devtools/server/actors/utils/moz.build @@ -0,0 +1,19 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +DevToolsModules( + 'actor-registry-utils.js', + 'audionodes.json', + 'automation-timeline.js', + 'css-grid-utils.js', + 'make-debugger.js', + 'map-uri-to-addon-id.js', + 'stack.js', + 'TabSources.js', + 'walker-search.js', + 'webconsole-utils.js', + 'webconsole-worker-utils.js', +) diff --git a/devtools/server/actors/utils/stack.js b/devtools/server/actors/utils/stack.js new file mode 100644 index 000000000..a6a3d1137 --- /dev/null +++ b/devtools/server/actors/utils/stack.js @@ -0,0 +1,185 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +var {Class} = require("sdk/core/heritage"); + +/** + * A helper class that stores stack frame objects. Each frame is + * assigned an index, and if a frame is added more than once, the same + * index is used. Users of the class can get an array of all frames + * that have been added. + */ +var StackFrameCache = Class({ + /** + * Initialize this object. + */ + initialize: function () { + this._framesToIndices = null; + this._framesToForms = null; + this._lastEventSize = 0; + }, + + /** + * Prepare to accept frames. + */ + initFrames: function () { + if (this._framesToIndices) { + // The maps are already initialized. + return; + } + + this._framesToIndices = new Map(); + this._framesToForms = new Map(); + this._lastEventSize = 0; + }, + + /** + * Forget all stored frames and reset to the initialized state. + */ + clearFrames: function () { + this._framesToIndices.clear(); + this._framesToIndices = null; + this._framesToForms.clear(); + this._framesToForms = null; + this._lastEventSize = 0; + }, + + /** + * Add a frame to this stack frame cache, and return the index of + * the frame. + */ + addFrame: function (frame) { + this._assignFrameIndices(frame); + this._createFrameForms(frame); + return this._framesToIndices.get(frame); + }, + + /** + * A helper method for the memory actor. This populates the packet + * object with "frames" property. Each of these + * properties will be an array indexed by frame ID. "frames" will + * contain frame objects (see makeEvent). + * + * @param packet + * The packet to update. + * + * @returns packet + */ + updateFramePacket: function (packet) { + // Now that we are guaranteed to have a form for every frame, we know the + // size the "frames" property's array must be. We use that information to + // create dense arrays even though we populate them out of order. + const size = this._framesToForms.size; + packet.frames = Array(size).fill(null); + + // Populate the "frames" properties. + for (let [stack, index] of this._framesToIndices) { + packet.frames[index] = this._framesToForms.get(stack); + } + + return packet; + }, + + /** + * If any new stack frames have been added to this cache since the + * last call to makeEvent (clearing the cache also resets the "last + * call"), then return a new array describing the new frames. If no + * new frames are available, return null. + * + * The frame cache assumes that the user of the cache keeps track of + * all previously-returned arrays and, in theory, concatenates them + * all to form a single array holding all frames added to the cache + * since the last reset. This concatenated array can be indexed by + * the frame ID. The array returned by this function, though, is + * dense and starts at 0. + * + * Each element in the array is an object of the form: + * { + * line: <line number for this frame>, + * column: <column number for this frame>, + * source: <filename string for this frame>, + * functionDisplayName: <this frame's inferred function name function or null>, + * parent: <frame ID -- an index into the concatenated array mentioned above> + * asyncCause: the async cause, or null + * asyncParent: <frame ID -- an index into the concatenated array mentioned above> + * } + * + * The intent of this approach is to make it simpler to efficiently + * send frame information over the debugging protocol, by only + * sending new frames. + * + * @returns array or null + */ + makeEvent: function () { + const size = this._framesToForms.size; + if (!size || size <= this._lastEventSize) { + return null; + } + + let packet = Array(size - this._lastEventSize).fill(null); + for (let [stack, index] of this._framesToIndices) { + if (index >= this._lastEventSize) { + packet[index - this._lastEventSize] = this._framesToForms.get(stack); + } + } + + this._lastEventSize = size; + + return packet; + }, + + /** + * Assigns an index to the given frame and its parents, if an index is not + * already assigned. + * + * @param SavedFrame frame + * A frame to assign an index to. + */ + _assignFrameIndices: function (frame) { + if (this._framesToIndices.has(frame)) { + return; + } + + if (frame) { + this._assignFrameIndices(frame.parent); + this._assignFrameIndices(frame.asyncParent); + } + + const index = this._framesToIndices.size; + this._framesToIndices.set(frame, index); + }, + + /** + * Create the form for the given frame, if one doesn't already exist. + * + * @param SavedFrame frame + * A frame to create a form for. + */ + _createFrameForms: function (frame) { + if (this._framesToForms.has(frame)) { + return; + } + + let form = null; + if (frame) { + form = { + line: frame.line, + column: frame.column, + source: frame.source, + functionDisplayName: frame.functionDisplayName, + parent: this._framesToIndices.get(frame.parent), + asyncParent: this._framesToIndices.get(frame.asyncParent), + asyncCause: frame.asyncCause + }; + this._createFrameForms(frame.parent); + this._createFrameForms(frame.asyncParent); + } + + this._framesToForms.set(frame, form); + }, +}); + +exports.StackFrameCache = StackFrameCache; diff --git a/devtools/server/actors/utils/walker-search.js b/devtools/server/actors/utils/walker-search.js new file mode 100644 index 000000000..0955a3919 --- /dev/null +++ b/devtools/server/actors/utils/walker-search.js @@ -0,0 +1,278 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +/** + * The walker-search module provides a simple API to index and search strings + * and elements inside a given document. + * It indexes tag names, attribute names and values, and text contents. + * It provides a simple search function that returns a list of nodes that + * matched. + */ + +const {Ci, Cu} = require("chrome"); + +/** + * The WalkerIndex class indexes the document (and all subdocs) from + * a given walker. + * + * It is only indexed the first time the data is accessed and will be + * re-indexed if a mutation happens between requests. + * + * @param {Walker} walker The walker to be indexed + */ +function WalkerIndex(walker) { + this.walker = walker; + this.clearIndex = this.clearIndex.bind(this); + + // Kill the index when mutations occur, the next data get will re-index. + this.walker.on("any-mutation", this.clearIndex); +} + +WalkerIndex.prototype = { + /** + * Destroy this instance, releasing all data and references + */ + destroy: function () { + this.walker.off("any-mutation", this.clearIndex); + }, + + clearIndex: function () { + if (!this.currentlyIndexing) { + this._data = null; + } + }, + + get doc() { + return this.walker.rootDoc; + }, + + /** + * Get the indexed data + * This getter also indexes if it hasn't been done yet or if the state is + * dirty + * + * @returns Map<String, Array<{type:String, node:DOMNode}>> + * A Map keyed on the searchable value, containing an array with + * objects containing the 'type' (one of ALL_RESULTS_TYPES), and + * the DOM Node. + */ + get data() { + if (!this._data) { + this._data = new Map(); + this.index(); + } + + return this._data; + }, + + _addToIndex: function (type, node, value) { + // Add an entry for this value if there isn't one + let entry = this._data.get(value); + if (!entry) { + this._data.set(value, []); + } + + // Add the type/node to the list + this._data.get(value).push({ + type: type, + node: node + }); + }, + + index: function () { + // Handle case where iterating nextNode() with the deepTreeWalker triggers + // a mutation (Bug 1222558) + this.currentlyIndexing = true; + + let documentWalker = this.walker.getDocumentWalker(this.doc); + while (documentWalker.nextNode()) { + let node = documentWalker.currentNode; + + if (node.nodeType === 1) { + // For each element node, we get the tagname and all attributes names + // and values + let localName = node.localName; + if (localName === "_moz_generated_content_before") { + this._addToIndex("tag", node, "::before"); + this._addToIndex("text", node, node.textContent.trim()); + } else if (localName === "_moz_generated_content_after") { + this._addToIndex("tag", node, "::after"); + this._addToIndex("text", node, node.textContent.trim()); + } else { + this._addToIndex("tag", node, node.localName); + } + + for (let {name, value} of node.attributes) { + this._addToIndex("attributeName", node, name); + this._addToIndex("attributeValue", node, value); + } + } else if (node.textContent && node.textContent.trim().length) { + // For comments and text nodes, we get the text + this._addToIndex("text", node, node.textContent.trim()); + } + } + + this.currentlyIndexing = false; + } +}; + +exports.WalkerIndex = WalkerIndex; + +/** + * The WalkerSearch class provides a way to search an indexed document as well + * as find elements that match a given css selector. + * + * Usage example: + * let s = new WalkerSearch(doc); + * let res = s.search("lang", index); + * for (let {matched, results} of res) { + * for (let {node, type} of results) { + * console.log("The query matched a node's " + type); + * console.log("Node that matched", node); + * } + * } + * s.destroy(); + * + * @param {Walker} the walker to be searched + */ +function WalkerSearch(walker) { + this.walker = walker; + this.index = new WalkerIndex(this.walker); +} + +WalkerSearch.prototype = { + destroy: function () { + this.index.destroy(); + this.walker = null; + }, + + _addResult: function (node, type, results) { + if (!results.has(node)) { + results.set(node, []); + } + + let matches = results.get(node); + + // Do not add if the exact same result is already in the list + let isKnown = false; + for (let match of matches) { + if (match.type === type) { + isKnown = true; + break; + } + } + + if (!isKnown) { + matches.push({type}); + } + }, + + _searchIndex: function (query, options, results) { + for (let [matched, res] of this.index.data) { + if (!options.searchMethod(query, matched)) { + continue; + } + + // Add any relevant results (skipping non-requested options). + res.filter(entry => { + return options.types.indexOf(entry.type) !== -1; + }).forEach(({node, type}) => { + this._addResult(node, type, results); + }); + } + }, + + _searchSelectors: function (query, options, results) { + // If the query is just one "word", no need to search because _searchIndex + // will lead the same results since it has access to tagnames anyway + let isSelector = query && query.match(/[ >~.#\[\]]/); + if (options.types.indexOf("selector") === -1 || !isSelector) { + return; + } + + let nodes = this.walker._multiFrameQuerySelectorAll(query); + for (let node of nodes) { + this._addResult(node, "selector", results); + } + }, + + /** + * Search the document + * @param {String} query What to search for + * @param {Object} options The following options are accepted: + * - searchMethod {String} one of WalkerSearch.SEARCH_METHOD_* + * defaults to WalkerSearch.SEARCH_METHOD_CONTAINS (does not apply to + * selector search type) + * - types {Array} a list of things to search for (tag, text, attributes, etc) + * defaults to WalkerSearch.ALL_RESULTS_TYPES + * @return {Array} An array is returned with each item being an object like: + * { + * node: <the dom node that matched>, + * type: <the type of match: one of WalkerSearch.ALL_RESULTS_TYPES> + * } + */ + search: function (query, options = {}) { + options.searchMethod = options.searchMethod || WalkerSearch.SEARCH_METHOD_CONTAINS; + options.types = options.types || WalkerSearch.ALL_RESULTS_TYPES; + + // Empty strings will return no results, as will non-string input + if (typeof query !== "string") { + query = ""; + } + + // Store results in a map indexed by nodes to avoid duplicate results + let results = new Map(); + + // Search through the indexed data + this._searchIndex(query, options, results); + + // Search with querySelectorAll + this._searchSelectors(query, options, results); + + // Concatenate all results into an Array to return + let resultList = []; + for (let [node, matches] of results) { + for (let {type} of matches) { + resultList.push({ + node: node, + type: type, + }); + + // For now, just do one result per node since the frontend + // doesn't have a way to highlight each result individually + // yet. + break; + } + } + + let documents = this.walker.tabActor.windows.map(win=>win.document); + + // Sort the resulting nodes by order of appearance in the DOM + resultList.sort((a, b) => { + // Disconnected nodes won't get good results from compareDocumentPosition + // so check the order of their document instead. + if (a.node.ownerDocument != b.node.ownerDocument) { + let indA = documents.indexOf(a.node.ownerDocument); + let indB = documents.indexOf(b.node.ownerDocument); + return indA - indB; + } + // If the same document, then sort on DOCUMENT_POSITION_FOLLOWING (4) + // which means B is after A. + return a.node.compareDocumentPosition(b.node) & 4 ? -1 : 1; + }); + + return resultList; + } +}; + +WalkerSearch.SEARCH_METHOD_CONTAINS = (query, candidate) => { + return query && candidate.toLowerCase().indexOf(query.toLowerCase()) !== -1; +}; + +WalkerSearch.ALL_RESULTS_TYPES = ["tag", "text", "attributeName", + "attributeValue", "selector"]; + +exports.WalkerSearch = WalkerSearch; diff --git a/devtools/server/actors/utils/webconsole-utils.js b/devtools/server/actors/utils/webconsole-utils.js new file mode 100644 index 000000000..597f1ddb3 --- /dev/null +++ b/devtools/server/actors/utils/webconsole-utils.js @@ -0,0 +1,1063 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ft= javascript ts=2 et sw=2 tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const {Cc, Ci, Cu, components} = require("chrome"); +const {isWindowIncluded} = require("devtools/shared/layout/utils"); +const Services = require("Services"); +const {XPCOMUtils} = require("resource://gre/modules/XPCOMUtils.jsm"); + +// TODO: Bug 842672 - browser/ imports modules from toolkit/. +// Note that these are only used in WebConsoleCommands, see $0 and pprint(). +loader.lazyImporter(this, "VariablesView", "resource://devtools/client/shared/widgets/VariablesView.jsm"); + +XPCOMUtils.defineLazyServiceGetter(this, + "swm", + "@mozilla.org/serviceworkers/manager;1", + "nsIServiceWorkerManager"); + +const CONSOLE_WORKER_IDS = exports.CONSOLE_WORKER_IDS = [ + "SharedWorker", + "ServiceWorker", + "Worker" +]; + +var WebConsoleUtils = { + + /** + * Given a message, return one of CONSOLE_WORKER_IDS if it matches + * one of those. + * + * @return string + */ + getWorkerType: function (message) { + let id = message ? message.innerID : null; + return CONSOLE_WORKER_IDS[CONSOLE_WORKER_IDS.indexOf(id)] || null; + }, + + /** + * Clone an object. + * + * @param object object + * The object you want cloned. + * @param boolean recursive + * Tells if you want to dig deeper into the object, to clone + * recursively. + * @param function [filter] + * Optional, filter function, called for every property. Three + * arguments are passed: key, value and object. Return true if the + * property should be added to the cloned object. Return false to skip + * the property. + * @return object + * The cloned object. + */ + cloneObject: function (object, recursive, filter) { + if (typeof object != "object") { + return object; + } + + let temp; + + if (Array.isArray(object)) { + temp = []; + Array.forEach(object, function (value, index) { + if (!filter || filter(index, value, object)) { + temp.push(recursive ? WebConsoleUtils.cloneObject(value) : value); + } + }); + } else { + temp = {}; + for (let key in object) { + let value = object[key]; + if (object.hasOwnProperty(key) && + (!filter || filter(key, value, object))) { + temp[key] = recursive ? WebConsoleUtils.cloneObject(value) : value; + } + } + } + + return temp; + }, + + /** + * Gets the ID of the inner window of this DOM window. + * + * @param nsIDOMWindow window + * @return integer + * Inner ID for the given window. + */ + getInnerWindowId: function (window) { + return window.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDOMWindowUtils).currentInnerWindowID; + }, + + /** + * Recursively gather a list of inner window ids given a + * top level window. + * + * @param nsIDOMWindow window + * @return Array + * list of inner window ids. + */ + getInnerWindowIDsForFrames: function (window) { + let innerWindowID = this.getInnerWindowId(window); + let ids = [innerWindowID]; + + if (window.frames) { + for (let i = 0; i < window.frames.length; i++) { + let frame = window.frames[i]; + ids = ids.concat(this.getInnerWindowIDsForFrames(frame)); + } + } + + return ids; + }, + + /** + * Get the property descriptor for the given object. + * + * @param object object + * The object that contains the property. + * @param string prop + * The property you want to get the descriptor for. + * @return object + * Property descriptor. + */ + getPropertyDescriptor: function (object, prop) { + let desc = null; + while (object) { + try { + if ((desc = Object.getOwnPropertyDescriptor(object, prop))) { + break; + } + } catch (ex) { + // Native getters throw here. See bug 520882. + // null throws TypeError. + if (ex.name != "NS_ERROR_XPC_BAD_CONVERT_JS" && + ex.name != "NS_ERROR_XPC_BAD_OP_ON_WN_PROTO" && + ex.name != "TypeError") { + throw ex; + } + } + + try { + object = Object.getPrototypeOf(object); + } catch (ex) { + if (ex.name == "TypeError") { + return desc; + } + throw ex; + } + } + return desc; + }, + + /** + * Create a grip for the given value. If the value is an object, + * an object wrapper will be created. + * + * @param mixed value + * The value you want to create a grip for, before sending it to the + * client. + * @param function objectWrapper + * If the value is an object then the objectWrapper function is + * invoked to give us an object grip. See this.getObjectGrip(). + * @return mixed + * The value grip. + */ + createValueGrip: function (value, objectWrapper) { + switch (typeof value) { + case "boolean": + return value; + case "string": + return objectWrapper(value); + case "number": + if (value === Infinity) { + return { type: "Infinity" }; + } else if (value === -Infinity) { + return { type: "-Infinity" }; + } else if (Number.isNaN(value)) { + return { type: "NaN" }; + } else if (!value && 1 / value === -Infinity) { + return { type: "-0" }; + } + return value; + case "undefined": + return { type: "undefined" }; + case "object": + if (value === null) { + return { type: "null" }; + } + // Fall through. + case "function": + return objectWrapper(value); + default: + console.error("Failed to provide a grip for value of " + typeof value + + ": " + value); + return null; + } + }, +}; + +exports.Utils = WebConsoleUtils; + +// The page errors listener + +/** + * The nsIConsoleService listener. This is used to send all of the console + * messages (JavaScript, CSS and more) to the remote Web Console instance. + * + * @constructor + * @param nsIDOMWindow [window] + * Optional - the window object for which we are created. This is used + * for filtering out messages that belong to other windows. + * @param object listener + * The listener object must have one method: + * - onConsoleServiceMessage(). This method is invoked with one argument, + * the nsIConsoleMessage, whenever a relevant message is received. + */ +function ConsoleServiceListener(window, listener) { + this.window = window; + this.listener = listener; +} +exports.ConsoleServiceListener = ConsoleServiceListener; + +ConsoleServiceListener.prototype = +{ + QueryInterface: XPCOMUtils.generateQI([Ci.nsIConsoleListener]), + + /** + * The content window for which we listen to page errors. + * @type nsIDOMWindow + */ + window: null, + + /** + * The listener object which is notified of messages from the console service. + * @type object + */ + listener: null, + + /** + * Initialize the nsIConsoleService listener. + */ + init: function () { + Services.console.registerListener(this); + }, + + /** + * The nsIConsoleService observer. This method takes all the script error + * messages belonging to the current window and sends them to the remote Web + * Console instance. + * + * @param nsIConsoleMessage message + * The message object coming from the nsIConsoleService. + */ + observe: function (message) { + if (!this.listener) { + return; + } + + if (this.window) { + if (!(message instanceof Ci.nsIScriptError) || + !message.outerWindowID || + !this.isCategoryAllowed(message.category)) { + return; + } + + let errorWindow = Services.wm.getOuterWindowWithId(message.outerWindowID); + if (!errorWindow || !isWindowIncluded(this.window, errorWindow)) { + return; + } + } + + this.listener.onConsoleServiceMessage(message); + }, + + /** + * Check if the given message category is allowed to be tracked or not. + * We ignore chrome-originating errors as we only care about content. + * + * @param string category + * The message category you want to check. + * @return boolean + * True if the category is allowed to be logged, false otherwise. + */ + isCategoryAllowed: function (category) { + if (!category) { + return false; + } + + switch (category) { + case "XPConnect JavaScript": + case "component javascript": + case "chrome javascript": + case "chrome registration": + case "XBL": + case "XBL Prototype Handler": + case "XBL Content Sink": + case "xbl javascript": + return false; + } + + return true; + }, + + /** + * Get the cached page errors for the current inner window and its (i)frames. + * + * @param boolean [includePrivate=false] + * Tells if you want to also retrieve messages coming from private + * windows. Defaults to false. + * @return array + * The array of cached messages. Each element is an nsIScriptError or + * an nsIConsoleMessage + */ + getCachedMessages: function (includePrivate = false) { + let errors = Services.console.getMessageArray() || []; + + // if !this.window, we're in a browser console. Still need to filter + // private messages. + if (!this.window) { + return errors.filter((error) => { + if (error instanceof Ci.nsIScriptError) { + if (!includePrivate && error.isFromPrivateWindow) { + return false; + } + } + + return true; + }); + } + + let ids = WebConsoleUtils.getInnerWindowIDsForFrames(this.window); + + return errors.filter((error) => { + if (error instanceof Ci.nsIScriptError) { + if (!includePrivate && error.isFromPrivateWindow) { + return false; + } + if (ids && + (ids.indexOf(error.innerWindowID) == -1 || + !this.isCategoryAllowed(error.category))) { + return false; + } + } else if (ids && ids[0]) { + // If this is not an nsIScriptError and we need to do window-based + // filtering we skip this message. + return false; + } + + return true; + }); + }, + + /** + * Remove the nsIConsoleService listener. + */ + destroy: function () { + Services.console.unregisterListener(this); + this.listener = this.window = null; + }, +}; + +// The window.console API observer + +/** + * The window.console API observer. This allows the window.console API messages + * to be sent to the remote Web Console instance. + * + * @constructor + * @param nsIDOMWindow window + * Optional - the window object for which we are created. This is used + * for filtering out messages that belong to other windows. + * @param object owner + * The owner object must have the following methods: + * - onConsoleAPICall(). This method is invoked with one argument, the + * Console API message that comes from the observer service, whenever + * a relevant console API call is received. + * @param object filteringOptions + * Optional - The filteringOptions that this listener should listen to: + * - addonId: filter console messages based on the addonId. + */ +function ConsoleAPIListener(window, owner, {addonId} = {}) { + this.window = window; + this.owner = owner; + this.addonId = addonId; +} +exports.ConsoleAPIListener = ConsoleAPIListener; + +ConsoleAPIListener.prototype = +{ + QueryInterface: XPCOMUtils.generateQI([Ci.nsIObserver]), + + /** + * The content window for which we listen to window.console API calls. + * @type nsIDOMWindow + */ + window: null, + + /** + * The owner object which is notified of window.console API calls. It must + * have a onConsoleAPICall method which is invoked with one argument: the + * console API call object that comes from the observer service. + * + * @type object + * @see WebConsoleActor + */ + owner: null, + + /** + * The addonId that we listen for. If not null then only messages from this + * console will be returned. + */ + addonId: null, + + /** + * Initialize the window.console API observer. + */ + init: function () { + // Note that the observer is process-wide. We will filter the messages as + // needed, see CAL_observe(). + Services.obs.addObserver(this, "console-api-log-event", false); + }, + + /** + * The console API message observer. When messages are received from the + * observer service we forward them to the remote Web Console instance. + * + * @param object message + * The message object receives from the observer service. + * @param string topic + * The message topic received from the observer service. + */ + observe: function (message, topic) { + if (!this.owner) { + return; + } + + // Here, wrappedJSObject is not a security wrapper but a property defined + // by the XPCOM component which allows us to unwrap the XPCOM interface and + // access the underlying JSObject. + let apiMessage = message.wrappedJSObject; + + if (!this.isMessageRelevant(apiMessage)) { + return; + } + + this.owner.onConsoleAPICall(apiMessage); + }, + + /** + * Given a message, return true if this window should show it and false + * if it should be ignored. + * + * @param message + * The message from the Storage Service + * @return bool + * Do we care about this message? + */ + isMessageRelevant: function (message) { + let workerType = WebConsoleUtils.getWorkerType(message); + + if (this.window && workerType === "ServiceWorker") { + // For messages from Service Workers, message.ID is the + // scope, which can be used to determine whether it's controlling + // a window. + let scope = message.ID; + + if (!swm.shouldReportToWindow(this.window, scope)) { + return false; + } + } + + if (this.window && !workerType) { + let msgWindow = Services.wm.getCurrentInnerWindowWithId(message.innerID); + if (!msgWindow || !isWindowIncluded(this.window, msgWindow)) { + // Not the same window! + return false; + } + } + + if (this.addonId) { + // ConsoleAPI.jsm messages contains a consoleID, (and it is currently + // used in Addon SDK add-ons), the standard 'console' object + // (which is used in regular webpages and in WebExtensions pages) + // contains the originAttributes of the source document principal. + + // Filtering based on the originAttributes used by + // the Console API object. + if (message.originAttributes && + message.originAttributes.addonId == this.addonId) { + return true; + } + + // Filtering based on the old-style consoleID property used by + // the legacy Console JSM module. + if (message.consoleID && message.consoleID == `addon/${this.addonId}`) { + return true; + } + + return false; + } + + return true; + }, + + /** + * Get the cached messages for the current inner window and its (i)frames. + * + * @param boolean [includePrivate=false] + * Tells if you want to also retrieve messages coming from private + * windows. Defaults to false. + * @return array + * The array of cached messages. + */ + getCachedMessages: function (includePrivate = false) { + let messages = []; + let ConsoleAPIStorage = Cc["@mozilla.org/consoleAPI-storage;1"] + .getService(Ci.nsIConsoleAPIStorage); + + // if !this.window, we're in a browser console. Retrieve all events + // for filtering based on privacy. + if (!this.window) { + messages = ConsoleAPIStorage.getEvents(); + } else { + let ids = WebConsoleUtils.getInnerWindowIDsForFrames(this.window); + ids.forEach((id) => { + messages = messages.concat(ConsoleAPIStorage.getEvents(id)); + }); + } + + CONSOLE_WORKER_IDS.forEach((id) => { + messages = messages.concat(ConsoleAPIStorage.getEvents(id)); + }); + + messages = messages.filter(msg => { + return this.isMessageRelevant(msg); + }); + + if (includePrivate) { + return messages; + } + + return messages.filter((m) => !m.private); + }, + + /** + * Destroy the console API listener. + */ + destroy: function () { + Services.obs.removeObserver(this, "console-api-log-event"); + this.window = this.owner = null; + }, +}; + +/** + * WebConsole commands manager. + * + * Defines a set of functions /variables ("commands") that are available from + * the Web Console but not from the web page. + * + */ +var WebConsoleCommands = { + _registeredCommands: new Map(), + _originalCommands: new Map(), + + /** + * @private + * Reserved for built-in commands. To register a command from the code of an + * add-on, see WebConsoleCommands.register instead. + * + * @see WebConsoleCommands.register + */ + _registerOriginal: function (name, command) { + this.register(name, command); + this._originalCommands.set(name, this.getCommand(name)); + }, + + /** + * Register a new command. + * @param {string} name The command name (exemple: "$") + * @param {(function|object)} command The command to register. + * It can be a function so the command is a function (like "$()"), + * or it can also be a property descriptor to describe a getter / value (like + * "$0"). + * + * The command function or the command getter are passed a owner object as + * their first parameter (see the example below). + * + * Note that setters don't work currently and "enumerable" and "configurable" + * are forced to true. + * + * @example + * + * WebConsoleCommands.register("$", function JSTH_$(owner, selector) + * { + * return owner.window.document.querySelector(selector); + * }); + * + * WebConsoleCommands.register("$0", { + * get: function(owner) { + * return owner.makeDebuggeeValue(owner.selectedNode); + * } + * }); + */ + register: function (name, command) { + this._registeredCommands.set(name, command); + }, + + /** + * Unregister a command. + * + * If the command being unregister overrode a built-in command, + * the latter is restored. + * + * @param {string} name The name of the command + */ + unregister: function (name) { + this._registeredCommands.delete(name); + if (this._originalCommands.has(name)) { + this.register(name, this._originalCommands.get(name)); + } + }, + + /** + * Returns a command by its name. + * + * @param {string} name The name of the command. + * + * @return {(function|object)} The command. + */ + getCommand: function (name) { + return this._registeredCommands.get(name); + }, + + /** + * Returns true if a command is registered with the given name. + * + * @param {string} name The name of the command. + * + * @return {boolean} True if the command is registered. + */ + hasCommand: function (name) { + return this._registeredCommands.has(name); + }, +}; + +exports.WebConsoleCommands = WebConsoleCommands; + +/* + * Built-in commands. + * + * A list of helper functions used by Firebug can be found here: + * http://getfirebug.com/wiki/index.php/Command_Line_API + */ + +/** + * Find a node by ID. + * + * @param string id + * The ID of the element you want. + * @return nsIDOMNode or null + * The result of calling document.querySelector(selector). + */ +WebConsoleCommands._registerOriginal("$", function (owner, selector) { + return owner.window.document.querySelector(selector); +}); + +/** + * Find the nodes matching a CSS selector. + * + * @param string selector + * A string that is passed to window.document.querySelectorAll. + * @return nsIDOMNodeList + * Returns the result of document.querySelectorAll(selector). + */ +WebConsoleCommands._registerOriginal("$$", function (owner, selector) { + let nodes = owner.window.document.querySelectorAll(selector); + + // Calling owner.window.Array.from() doesn't work without accessing the + // wrappedJSObject, so just loop through the results instead. + let result = new owner.window.Array(); + for (let i = 0; i < nodes.length; i++) { + result.push(nodes[i]); + } + return result; +}); + +/** + * Returns the result of the last console input evaluation + * + * @return object|undefined + * Returns last console evaluation or undefined + */ +WebConsoleCommands._registerOriginal("$_", { + get: function (owner) { + return owner.consoleActor.getLastConsoleInputEvaluation(); + } +}); + +/** + * Runs an xPath query and returns all matched nodes. + * + * @param string xPath + * xPath search query to execute. + * @param [optional] nsIDOMNode context + * Context to run the xPath query on. Uses window.document if not set. + * @return array of nsIDOMNode + */ +WebConsoleCommands._registerOriginal("$x", function (owner, xPath, context) { + let nodes = new owner.window.Array(); + + // Not waiving Xrays, since we want the original Document.evaluate function, + // instead of anything that's been redefined. + let doc = owner.window.document; + context = context || doc; + + let results = doc.evaluate(xPath, context, null, + Ci.nsIDOMXPathResult.ANY_TYPE, null); + let node; + while ((node = results.iterateNext())) { + nodes.push(node); + } + + return nodes; +}); + +/** + * Returns the currently selected object in the highlighter. + * + * @return Object representing the current selection in the + * Inspector, or null if no selection exists. + */ +WebConsoleCommands._registerOriginal("$0", { + get: function (owner) { + return owner.makeDebuggeeValue(owner.selectedNode); + } +}); + +/** + * Clears the output of the WebConsole. + */ +WebConsoleCommands._registerOriginal("clear", function (owner) { + owner.helperResult = { + type: "clearOutput", + }; +}); + +/** + * Clears the input history of the WebConsole. + */ +WebConsoleCommands._registerOriginal("clearHistory", function (owner) { + owner.helperResult = { + type: "clearHistory", + }; +}); + +/** + * Returns the result of Object.keys(object). + * + * @param object object + * Object to return the property names from. + * @return array of strings + */ +WebConsoleCommands._registerOriginal("keys", function (owner, object) { + // Need to waive Xrays so we can iterate functions and accessor properties + return Cu.cloneInto(Object.keys(Cu.waiveXrays(object)), owner.window); +}); + +/** + * Returns the values of all properties on object. + * + * @param object object + * Object to display the values from. + * @return array of string + */ +WebConsoleCommands._registerOriginal("values", function (owner, object) { + let values = []; + // Need to waive Xrays so we can iterate functions and accessor properties + let waived = Cu.waiveXrays(object); + let names = Object.getOwnPropertyNames(waived); + + for (let name of names) { + values.push(waived[name]); + } + + return Cu.cloneInto(values, owner.window); +}); + +/** + * Opens a help window in MDN. + */ +WebConsoleCommands._registerOriginal("help", function (owner) { + owner.helperResult = { type: "help" }; +}); + +/** + * Change the JS evaluation scope. + * + * @param DOMElement|string|window window + * The window object to use for eval scope. This can be a string that + * is used to perform document.querySelector(), to find the iframe that + * you want to cd() to. A DOMElement can be given as well, the + * .contentWindow property is used. Lastly, you can directly pass + * a window object. If you call cd() with no arguments, the current + * eval scope is cleared back to its default (the top window). + */ +WebConsoleCommands._registerOriginal("cd", function (owner, window) { + if (!window) { + owner.consoleActor.evalWindow = null; + owner.helperResult = { type: "cd" }; + return; + } + + if (typeof window == "string") { + window = owner.window.document.querySelector(window); + } + if (window instanceof Ci.nsIDOMElement && window.contentWindow) { + window = window.contentWindow; + } + if (!(window instanceof Ci.nsIDOMWindow)) { + owner.helperResult = { + type: "error", + message: "cdFunctionInvalidArgument" + }; + return; + } + + owner.consoleActor.evalWindow = window; + owner.helperResult = { type: "cd" }; +}); + +/** + * Inspects the passed object. This is done by opening the PropertyPanel. + * + * @param object object + * Object to inspect. + */ +WebConsoleCommands._registerOriginal("inspect", function (owner, object) { + let dbgObj = owner.makeDebuggeeValue(object); + let grip = owner.createValueGrip(dbgObj); + owner.helperResult = { + type: "inspectObject", + input: owner.evalInput, + object: grip, + }; +}); + +/** + * Prints object to the output. + * + * @param object object + * Object to print to the output. + * @return string + */ +WebConsoleCommands._registerOriginal("pprint", function (owner, object) { + if (object === null || object === undefined || object === true || + object === false) { + owner.helperResult = { + type: "error", + message: "helperFuncUnsupportedTypeError", + }; + return null; + } + + owner.helperResult = { rawOutput: true }; + + if (typeof object == "function") { + return object + "\n"; + } + + let output = []; + + let obj = object; + for (let name in obj) { + let desc = WebConsoleUtils.getPropertyDescriptor(obj, name) || {}; + if (desc.get || desc.set) { + // TODO: Bug 842672 - toolkit/ imports modules from browser/. + let getGrip = VariablesView.getGrip(desc.get); + let setGrip = VariablesView.getGrip(desc.set); + let getString = VariablesView.getString(getGrip); + let setString = VariablesView.getString(setGrip); + output.push(name + ":", " get: " + getString, " set: " + setString); + } else { + let valueGrip = VariablesView.getGrip(obj[name]); + let valueString = VariablesView.getString(valueGrip); + output.push(name + ": " + valueString); + } + } + + return " " + output.join("\n "); +}); + +/** + * Print the String representation of a value to the output, as-is. + * + * @param any value + * A value you want to output as a string. + * @return void + */ +WebConsoleCommands._registerOriginal("print", function (owner, value) { + owner.helperResult = { rawOutput: true }; + if (typeof value === "symbol") { + return Symbol.prototype.toString.call(value); + } + // Waiving Xrays here allows us to see a closer representation of the + // underlying object. This may execute arbitrary content code, but that + // code will run with content privileges, and the result will be rendered + // inert by coercing it to a String. + return String(Cu.waiveXrays(value)); +}); + +/** + * Copy the String representation of a value to the clipboard. + * + * @param any value + * A value you want to copy as a string. + * @return void + */ +WebConsoleCommands._registerOriginal("copy", function (owner, value) { + let payload; + try { + if (value instanceof Ci.nsIDOMElement) { + payload = value.outerHTML; + } else if (typeof value == "string") { + payload = value; + } else { + payload = JSON.stringify(value, null, " "); + } + } catch (ex) { + payload = "/* " + ex + " */"; + } + owner.helperResult = { + type: "copyValueToClipboard", + value: payload, + }; +}); + +/** + * (Internal only) Add the bindings to |owner.sandbox|. + * This is intended to be used by the WebConsole actor only. + * + * @param object owner + * The owning object. + */ +function addWebConsoleCommands(owner) { + if (!owner) { + throw new Error("The owner is required"); + } + for (let [name, command] of WebConsoleCommands._registeredCommands) { + if (typeof command === "function") { + owner.sandbox[name] = command.bind(undefined, owner); + } else if (typeof command === "object") { + let clone = Object.assign({}, command, { + // We force the enumerability and the configurability (so the + // WebConsoleActor can reconfigure the property). + enumerable: true, + configurable: true + }); + + if (typeof command.get === "function") { + clone.get = command.get.bind(undefined, owner); + } + if (typeof command.set === "function") { + clone.set = command.set.bind(undefined, owner); + } + + Object.defineProperty(owner.sandbox, name, clone); + } + } +} + +exports.addWebConsoleCommands = addWebConsoleCommands; + +/** + * A ReflowObserver that listens for reflow events from the page. + * Implements nsIReflowObserver. + * + * @constructor + * @param object window + * The window for which we need to track reflow. + * @param object owner + * The listener owner which needs to implement: + * - onReflowActivity(reflowInfo) + */ + +function ConsoleReflowListener(window, listener) { + this.docshell = window.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIWebNavigation) + .QueryInterface(Ci.nsIDocShell); + this.listener = listener; + this.docshell.addWeakReflowObserver(this); +} + +exports.ConsoleReflowListener = ConsoleReflowListener; + +ConsoleReflowListener.prototype = +{ + QueryInterface: XPCOMUtils.generateQI([Ci.nsIReflowObserver, + Ci.nsISupportsWeakReference]), + docshell: null, + listener: null, + + /** + * Forward reflow event to listener. + * + * @param DOMHighResTimeStamp start + * @param DOMHighResTimeStamp end + * @param boolean interruptible + */ + sendReflow: function (start, end, interruptible) { + let frame = components.stack.caller.caller; + + let filename = frame ? frame.filename : null; + + if (filename) { + // Because filename could be of the form "xxx.js -> xxx.js -> xxx.js", + // we only take the last part. + filename = filename.split(" ").pop(); + } + + this.listener.onReflowActivity({ + interruptible: interruptible, + start: start, + end: end, + sourceURL: filename, + sourceLine: frame ? frame.lineNumber : null, + functionName: frame ? frame.name : null + }); + }, + + /** + * On uninterruptible reflow + * + * @param DOMHighResTimeStamp start + * @param DOMHighResTimeStamp end + */ + reflow: function (start, end) { + this.sendReflow(start, end, false); + }, + + /** + * On interruptible reflow + * + * @param DOMHighResTimeStamp start + * @param DOMHighResTimeStamp end + */ + reflowInterruptible: function (start, end) { + this.sendReflow(start, end, true); + }, + + /** + * Unregister listener. + */ + destroy: function () { + this.docshell.removeWeakReflowObserver(this); + this.listener = this.docshell = null; + }, +}; diff --git a/devtools/server/actors/utils/webconsole-worker-utils.js b/devtools/server/actors/utils/webconsole-worker-utils.js new file mode 100644 index 000000000..0c1142967 --- /dev/null +++ b/devtools/server/actors/utils/webconsole-worker-utils.js @@ -0,0 +1,20 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ft= javascript ts=2 et sw=2 tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +// XXXworkers This file is loaded on the server side for worker debugging. +// Since the server is running in the worker thread, it doesn't +// have access to Services / Components. This functionality +// is stubbed out to prevent errors, and will need to implemented +// for Bug 1209353. + +exports.Utils = { L10n: function () {} }; +exports.ConsoleServiceListener = function () {}; +exports.ConsoleAPIListener = function () {}; +exports.addWebConsoleCommands = function () {}; +exports.ConsoleReflowListener = function () {}; +exports.CONSOLE_WORKER_IDS = []; |