summaryrefslogtreecommitdiffstats
path: root/devtools/server/actors/webconsole
diff options
context:
space:
mode:
Diffstat (limited to 'devtools/server/actors/webconsole')
-rw-r--r--devtools/server/actors/webconsole/commands.js245
-rw-r--r--devtools/server/actors/webconsole/content-process-forward.js143
-rw-r--r--devtools/server/actors/webconsole/eager-ecma-allowlist.js158
-rw-r--r--devtools/server/actors/webconsole/eager-function-allowlist.js72
-rw-r--r--devtools/server/actors/webconsole/eval-with-debugger.js619
-rw-r--r--devtools/server/actors/webconsole/listeners/console-api.js203
-rw-r--r--devtools/server/actors/webconsole/listeners/console-file-activity.js129
-rw-r--r--devtools/server/actors/webconsole/listeners/console-reflow.js93
-rw-r--r--devtools/server/actors/webconsole/listeners/console-service.js176
-rw-r--r--devtools/server/actors/webconsole/listeners/content-process.js50
-rw-r--r--devtools/server/actors/webconsole/listeners/document-events.js78
-rw-r--r--devtools/server/actors/webconsole/listeners/moz.build14
-rw-r--r--devtools/server/actors/webconsole/message-manager-mock.js70
-rw-r--r--devtools/server/actors/webconsole/moz.build22
-rw-r--r--devtools/server/actors/webconsole/utils.js685
-rw-r--r--devtools/server/actors/webconsole/webidl-deprecated-list.js43
-rw-r--r--devtools/server/actors/webconsole/webidl-pure-allowlist.js72
-rw-r--r--devtools/server/actors/webconsole/worker-listeners.js35
18 files changed, 2907 insertions, 0 deletions
diff --git a/devtools/server/actors/webconsole/commands.js b/devtools/server/actors/webconsole/commands.js
new file mode 100644
index 0000000000..9e3b8f90d0
--- /dev/null
+++ b/devtools/server/actors/webconsole/commands.js
@@ -0,0 +1,245 @@
+/* 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 validCommands = ["block", "help", "screenshot", "unblock"];
+
+const COMMAND = "command";
+const KEY = "key";
+const ARG = "arg";
+
+const COMMAND_PREFIX = /^:/;
+const KEY_PREFIX = /^--/;
+
+// default value for flags
+const DEFAULT_VALUE = true;
+const COMMAND_DEFAULT_FLAG = {
+ block: "url",
+ screenshot: "filename",
+ unblock: "url",
+};
+
+/**
+ * When given a string that begins with `:` and a unix style string,
+ * format a JS like object.
+ * This is intended to be used by the WebConsole actor only.
+ *
+ * @param String string
+ * A string to format that begins with `:`.
+ *
+ * @returns String formatted as `command({ ..args })`
+ */
+function formatCommand(string) {
+ if (!isCommand(string)) {
+ throw Error("formatCommand was called without `:`");
+ }
+ const tokens = string
+ .trim()
+ .split(/\s+/)
+ .map(createToken);
+ const { command, args } = parseCommand(tokens);
+ const argsString = formatArgs(args);
+ return `${command}(${argsString})`;
+}
+
+/**
+ * collapses the array of arguments from the parsed command into
+ * a single string
+ *
+ * @param Object tree
+ * A tree object produced by parseCommand
+ *
+ * @returns String formatted as ` { key: value, ... } ` or an empty string
+ */
+function formatArgs(args) {
+ return Object.keys(args).length ? JSON.stringify(args) : "";
+}
+
+/**
+ * creates a token object depending on a string which as a prefix,
+ * either `:` for a command or `--` for a key, or nothing for an argument
+ *
+ * @param String string
+ * A string to use as the basis for the token
+ *
+ * @returns Object Token Object, with the following shape
+ * { type: String, value: String }
+ */
+function createToken(string) {
+ if (isCommand(string)) {
+ const value = string.replace(COMMAND_PREFIX, "");
+ if (!value || !validCommands.includes(value)) {
+ throw Error(`'${value}' is not a valid command`);
+ }
+ return { type: COMMAND, value };
+ }
+ if (isKey(string)) {
+ const value = string.replace(KEY_PREFIX, "");
+ if (!value) {
+ throw Error("invalid flag");
+ }
+ return { type: KEY, value };
+ }
+ return { type: ARG, value: string };
+}
+
+/**
+ * returns a command Tree object for a set of tokens
+ *
+ *
+ * @param Array Tokens tokens
+ * An array of Token objects
+ *
+ * @returns Object Tree Object, with the following shape
+ * { command: String, args: Array of Strings }
+ */
+function parseCommand(tokens) {
+ let command = null;
+ const args = {};
+
+ for (let i = 0; i < tokens.length; i++) {
+ const token = tokens[i];
+ if (token.type === COMMAND) {
+ if (command) {
+ // we are throwing here because two commands have been passed and it is unclear
+ // what the user's intention was
+ throw Error("Invalid command");
+ }
+ command = token.value;
+ }
+
+ if (token.type === KEY) {
+ const nextTokenIndex = i + 1;
+ const nextToken = tokens[nextTokenIndex];
+ let values = args[token.value] || DEFAULT_VALUE;
+ if (nextToken && nextToken.type === ARG) {
+ const { value, offset } = collectString(
+ nextToken,
+ tokens,
+ nextTokenIndex
+ );
+ // in order for JSON.stringify to correctly output values, they must be correctly
+ // typed
+ // As per the old GCLI documentation, we can only have one value associated with a
+ // flag but multiple flags with the same name can exist and should be combined
+ // into and array. Here we are associating only the value on the right hand
+ // side if it is of type `arg` as a single value; the second case initializes
+ // an array, and the final case pushes a value to an existing array
+ const typedValue = getTypedValue(value);
+ if (values === DEFAULT_VALUE) {
+ values = typedValue;
+ } else if (!Array.isArray(values)) {
+ values = [values, typedValue];
+ } else {
+ values.push(typedValue);
+ }
+ // skip the next token since we have already consumed it
+ i = nextTokenIndex + offset;
+ }
+ args[token.value] = values;
+ }
+
+ // Since this has only been implemented for screenshot, we can only have one default
+ // value. Eventually we may have more default values. For now, ignore multiple
+ // unflagged args
+ const defaultFlag = COMMAND_DEFAULT_FLAG[command];
+ if (token.type === ARG && !args[defaultFlag]) {
+ const { value, offset } = collectString(token, tokens, i);
+ args[defaultFlag] = getTypedValue(value);
+ i = i + offset;
+ }
+ }
+ return { command, args };
+}
+
+const stringChars = ['"', "'", "`"];
+function isStringChar(testChar) {
+ return stringChars.includes(testChar);
+}
+
+function checkLastChar(string, testChar) {
+ const lastChar = string[string.length - 1];
+ return lastChar === testChar;
+}
+
+function hasUnescapedChar(value, char, rightOffset, leftOffset) {
+ const lastPos = value.length - 1;
+ const string = value.slice(rightOffset, lastPos - leftOffset);
+ const index = string.indexOf(char);
+ if (index === -1) {
+ return false;
+ }
+ const prevChar = index > 0 ? string[index - 1] : null;
+ // return false if the unexpected character is escaped, true if it is not
+ return prevChar !== "\\";
+}
+
+function collectString(token, tokens, index) {
+ const firstChar = token.value[0];
+ const isString = isStringChar(firstChar);
+ const UNESCAPED_CHAR_ERROR = segment =>
+ `String has unescaped \`${firstChar}\` in [${segment}...],` +
+ " may miss a space between arguments";
+ let value = token.value;
+
+ // the test value is not a string, or it is a string but a complete one
+ // i.e. `"test"`, as opposed to `"foo`. In either case, this we can return early
+ if (!isString || checkLastChar(value, firstChar)) {
+ return { value, offset: 0 };
+ }
+
+ if (hasUnescapedChar(value, firstChar, 1, 0)) {
+ throw Error(UNESCAPED_CHAR_ERROR(value));
+ }
+
+ let offset = null;
+ for (let i = index + 1; i <= tokens.length; i++) {
+ if (i === tokens.length) {
+ throw Error("String does not terminate");
+ }
+
+ const nextToken = tokens[i];
+ if (nextToken.type !== ARG) {
+ throw Error(`String does not terminate before flag "${nextToken.value}"`);
+ }
+
+ value = `${value} ${nextToken.value}`;
+
+ if (hasUnescapedChar(nextToken.value, firstChar, 0, 1)) {
+ throw Error(UNESCAPED_CHAR_ERROR(value));
+ }
+
+ if (checkLastChar(nextToken.value, firstChar)) {
+ offset = i - index;
+ break;
+ }
+ }
+ return { value, offset };
+}
+
+function isCommand(string) {
+ return COMMAND_PREFIX.test(string);
+}
+
+function isKey(string) {
+ return KEY_PREFIX.test(string);
+}
+
+function getTypedValue(value) {
+ if (!isNaN(value)) {
+ return Number(value);
+ }
+ if (value === "true" || value === "false") {
+ return Boolean(value);
+ }
+ if (isStringChar(value[0])) {
+ return value.slice(1, value.length - 1);
+ }
+ return value;
+}
+
+exports.formatCommand = formatCommand;
+exports.isCommand = isCommand;
+exports.validCommands = validCommands;
diff --git a/devtools/server/actors/webconsole/content-process-forward.js b/devtools/server/actors/webconsole/content-process-forward.js
new file mode 100644
index 0000000000..69720d51b3
--- /dev/null
+++ b/devtools/server/actors/webconsole/content-process-forward.js
@@ -0,0 +1,143 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "E10SUtils",
+ "resource://gre/modules/E10SUtils.jsm"
+);
+
+/*
+ * The message manager has an upper limit on message sizes that it can
+ * reliably forward to the parent so we limit the size of console log event
+ * messages that we forward here. The web console is local and receives the
+ * full console message, but addons subscribed to console event messages
+ * in the parent receive the truncated version. Due to fragmentation,
+ * messages as small as 1MB have resulted in IPC allocation failures on
+ * 32-bit platforms. To limit IPC allocation sizes, console.log messages
+ * with arguments with total size > MSG_MGR_CONSOLE_MAX_SIZE (bytes) have
+ * their arguments completely truncated. MSG_MGR_CONSOLE_VAR_SIZE is an
+ * approximation of how much space (in bytes) a JS non-string variable will
+ * require in the manager's implementation. For strings, we use 2 bytes per
+ * char. The console message URI and function name are limited to
+ * MSG_MGR_CONSOLE_INFO_MAX characters. We don't attempt to calculate
+ * the exact amount of space the message manager implementation will require
+ * for a given message so this is imperfect.
+ */
+const MSG_MGR_CONSOLE_MAX_SIZE = 1024 * 1024; // 1MB
+const MSG_MGR_CONSOLE_VAR_SIZE = 8;
+const MSG_MGR_CONSOLE_INFO_MAX = 1024;
+
+function ContentProcessForward() {
+ Services.obs.addObserver(this, "console-api-log-event");
+ Services.obs.addObserver(this, "xpcom-shutdown");
+ Services.cpmm.addMessageListener(
+ "DevTools:StopForwardingContentProcessMessage",
+ this
+ );
+}
+ContentProcessForward.prototype = {
+ QueryInterface: ChromeUtils.generateQI([
+ "nsIObserver",
+ "nsISupportsWeakReference",
+ ]),
+
+ receiveMessage(message) {
+ if (message.name == "DevTools:StopForwardingContentProcessMessage") {
+ this.uninit();
+ }
+ },
+
+ observe(subject, topic, data) {
+ switch (topic) {
+ case "console-api-log-event": {
+ const consoleMsg = subject.wrappedJSObject;
+
+ const msgData = {
+ ...consoleMsg,
+ arguments: [],
+ filename: consoleMsg.filename.substring(0, MSG_MGR_CONSOLE_INFO_MAX),
+ functionName:
+ consoleMsg.functionName &&
+ consoleMsg.functionName.substring(0, MSG_MGR_CONSOLE_INFO_MAX),
+ // Prevents cyclic object error when using msgData in sendAsyncMessage
+ wrappedJSObject: null,
+ };
+
+ // We can't send objects over the message manager, so we sanitize
+ // them out, replacing those arguments with "<unavailable>".
+ const unavailString = "<unavailable>";
+ const unavailStringLength = unavailString.length * 2; // 2-bytes per char
+
+ // When the sum of argument sizes reaches MSG_MGR_CONSOLE_MAX_SIZE,
+ // replace all arguments with "<truncated>".
+ let totalArgLength = 0;
+
+ // Walk through the arguments, checking the type and size.
+ for (let arg of consoleMsg.arguments) {
+ if (
+ (typeof arg == "object" || typeof arg == "function") &&
+ arg !== null
+ ) {
+ if (
+ Services.appinfo.remoteType === E10SUtils.EXTENSION_REMOTE_TYPE
+ ) {
+ // For OOP extensions: we want the developer to be able to see the
+ // logs in the Browser Console. When the Addon Toolbox will be more
+ // prominent we can revisit.
+ try {
+ // If the argument is clonable, then send it as-is. If
+ // cloning fails, fall back to the unavailable string.
+ arg = Cu.cloneInto(arg, {});
+ } catch (e) {
+ arg = unavailString;
+ }
+ } else {
+ arg = unavailString;
+ }
+ totalArgLength += unavailStringLength;
+ } else if (typeof arg == "string") {
+ totalArgLength += arg.length * 2; // 2-bytes per char
+ } else {
+ totalArgLength += MSG_MGR_CONSOLE_VAR_SIZE;
+ }
+
+ if (totalArgLength <= MSG_MGR_CONSOLE_MAX_SIZE) {
+ msgData.arguments.push(arg);
+ } else {
+ // arguments take up too much space
+ msgData.arguments = ["<truncated>"];
+ break;
+ }
+ }
+
+ Services.cpmm.sendAsyncMessage("Console:Log", msgData);
+ break;
+ }
+
+ case "xpcom-shutdown":
+ this.uninit();
+ break;
+ }
+ },
+
+ uninit() {
+ Services.obs.removeObserver(this, "console-api-log-event");
+ Services.obs.removeObserver(this, "xpcom-shutdown");
+ Services.cpmm.removeMessageListener(
+ "DevTools:StopForwardingContentProcessMessage",
+ this
+ );
+ },
+};
+
+// loadProcessScript loads in all processes, including the parent,
+// in which we don't need any forwarding
+if (Services.appinfo.processType == Services.appinfo.PROCESS_TYPE_CONTENT) {
+ new ContentProcessForward();
+}
diff --git a/devtools/server/actors/webconsole/eager-ecma-allowlist.js b/devtools/server/actors/webconsole/eager-ecma-allowlist.js
new file mode 100644
index 0000000000..a91cfd4807
--- /dev/null
+++ b/devtools/server/actors/webconsole/eager-ecma-allowlist.js
@@ -0,0 +1,158 @@
+/* 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/. */
+/* global BigInt */
+
+"use strict";
+
+function matchingProperties(obj, regexp) {
+ return Object.getOwnPropertyNames(obj)
+ .filter(n => regexp.test(n))
+ .map(n => obj[n])
+ .filter(v => typeof v == "function");
+}
+
+function allProperties(obj) {
+ return matchingProperties(obj, /./);
+}
+
+const TypedArray = Reflect.getPrototypeOf(Int8Array);
+
+module.exports = [
+ Array,
+ Array.from,
+ Array.isArray,
+ Array.of,
+ Array.prototype.concat,
+ Array.prototype.entries,
+ Array.prototype.every,
+ Array.prototype.filter,
+ Array.prototype.find,
+ Array.prototype.findIndex,
+ Array.prototype.flat,
+ Array.prototype.flatMap,
+ Array.prototype.forEach,
+ Array.prototype.includes,
+ Array.prototype.indexOf,
+ Array.prototype.join,
+ Array.prototype.keys,
+ Array.prototype.lastIndexOf,
+ Array.prototype.map,
+ Array.prototype.reduce,
+ Array.prototype.reduceRight,
+ Array.prototype.slice,
+ Array.prototype.some,
+ Array.prototype.values,
+ ArrayBuffer,
+ ArrayBuffer.isView,
+ ArrayBuffer.prototype.slice,
+ BigInt,
+ ...allProperties(BigInt),
+ Boolean,
+ DataView,
+ Date,
+ Date.now,
+ Date.parse,
+ Date.UTC,
+ ...matchingProperties(Date.prototype, /^get/),
+ ...matchingProperties(Date.prototype, /^to.*?String$/),
+ Error,
+ Function,
+ Function.prototype.apply,
+ Function.prototype.bind,
+ Function.prototype.call,
+ Function.prototype[Symbol.hasInstance],
+ Int8Array,
+ Uint8Array,
+ Uint8ClampedArray,
+ Int16Array,
+ Uint16Array,
+ Int32Array,
+ Uint32Array,
+ Float32Array,
+ Float64Array,
+ TypedArray.from,
+ TypedArray.of,
+ TypedArray.prototype.entries,
+ TypedArray.prototype.every,
+ TypedArray.prototype.filter,
+ TypedArray.prototype.find,
+ TypedArray.prototype.findIndex,
+ TypedArray.prototype.forEach,
+ TypedArray.prototype.includes,
+ TypedArray.prototype.indexOf,
+ TypedArray.prototype.join,
+ TypedArray.prototype.keys,
+ TypedArray.prototype.lastIndexOf,
+ TypedArray.prototype.map,
+ TypedArray.prototype.reduce,
+ TypedArray.prototype.reduceRight,
+ TypedArray.prototype.slice,
+ TypedArray.prototype.some,
+ TypedArray.prototype.subarray,
+ TypedArray.prototype.values,
+ ...allProperties(JSON),
+ Map,
+ Map.prototype.forEach,
+ Map.prototype.get,
+ Map.prototype.has,
+ Map.prototype.entries,
+ Map.prototype.keys,
+ Map.prototype.values,
+ ...allProperties(Math),
+ Number,
+ ...allProperties(Number),
+ ...allProperties(Number.prototype),
+ Object,
+ Object.create,
+ Object.keys,
+ Object.entries,
+ Object.getOwnPropertyDescriptor,
+ Object.getOwnPropertyDescriptors,
+ Object.getOwnPropertyNames,
+ Object.getOwnPropertySymbols,
+ Object.getPrototypeOf,
+ Object.is,
+ Object.isExtensible,
+ Object.isFrozen,
+ Object.isSealed,
+ Object.values,
+ Object.prototype.hasOwnProperty,
+ Object.prototype.isPrototypeOf,
+ Proxy,
+ Proxy.revocable,
+ Reflect.apply,
+ Reflect.construct,
+ Reflect.get,
+ Reflect.getOwnPropertyDescriptor,
+ Reflect.getPrototypeOf,
+ Reflect.has,
+ Reflect.isExtensible,
+ Reflect.ownKeys,
+ RegExp,
+ RegExp.prototype.exec,
+ RegExp.prototype.test,
+ Set,
+ Set.prototype.entries,
+ Set.prototype.forEach,
+ Set.prototype.has,
+ Set.prototype.values,
+ String,
+ ...allProperties(String),
+ ...allProperties(String.prototype),
+ Symbol,
+ Symbol.keyFor,
+ WeakMap,
+ WeakMap.prototype.get,
+ WeakMap.prototype.has,
+ WeakSet,
+ WeakSet.prototype.has,
+ decodeURI,
+ decodeURIComponent,
+ encodeURI,
+ encodeURIComponent,
+ escape,
+ isFinite,
+ isNaN,
+ unescape,
+];
diff --git a/devtools/server/actors/webconsole/eager-function-allowlist.js b/devtools/server/actors/webconsole/eager-function-allowlist.js
new file mode 100644
index 0000000000..87ef83d7f5
--- /dev/null
+++ b/devtools/server/actors/webconsole/eager-function-allowlist.js
@@ -0,0 +1,72 @@
+/* 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, Cu } = require("chrome");
+
+const idlPureAllowlist = require("devtools/server/actors/webconsole/webidl-pure-allowlist");
+
+// TODO: Bug 1616013 - Move more of these to be part of the pure list.
+const customEagerFunctions = {
+ Document: [
+ ["prototype", "getSelection"],
+ ["prototype", "hasStorageAccess"],
+ ],
+ Range: [
+ ["prototype", "isPointInRange"],
+ ["prototype", "comparePoint"],
+ ["prototype", "intersectsNode"],
+
+ // These two functions aren't pure because they do trigger layout when
+ // they are called, but in the context of eager evaluation, that should be
+ // a totally fine thing to do.
+ ["prototype", "getClientRects"],
+ ["prototype", "getBoundingClientRect"],
+ ],
+ Selection: [
+ ["prototype", "getRangeAt"],
+ ["prototype", "containsNode"],
+ ],
+};
+
+const mergedFunctions = {};
+for (const [key, values] of Object.entries(idlPureAllowlist)) {
+ mergedFunctions[key] = [...values];
+}
+for (const [key, values] of Object.entries(customEagerFunctions)) {
+ if (!mergedFunctions[key]) {
+ mergedFunctions[key] = [];
+ }
+ mergedFunctions[key].push(...values);
+}
+
+const natives = [];
+if (CC && Cu) {
+ const sandbox = Cu.Sandbox(
+ CC("@mozilla.org/systemprincipal;1", "nsIPrincipal")(),
+ {
+ invisibleToDebugger: true,
+ wantGlobalProperties: Object.keys(mergedFunctions),
+ }
+ );
+
+ for (const iface of Object.keys(mergedFunctions)) {
+ for (const path of mergedFunctions[iface]) {
+ let value = sandbox;
+ for (const part of [iface, ...path]) {
+ value = value[part];
+ if (!value) {
+ break;
+ }
+ }
+
+ if (value) {
+ natives.push(value);
+ }
+ }
+ }
+}
+
+module.exports = natives;
diff --git a/devtools/server/actors/webconsole/eval-with-debugger.js b/devtools/server/actors/webconsole/eval-with-debugger.js
new file mode 100644
index 0000000000..e6d5c18403
--- /dev/null
+++ b/devtools/server/actors/webconsole/eval-with-debugger.js
@@ -0,0 +1,619 @@
+/* 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 Debugger = require("Debugger");
+const DevToolsUtils = require("devtools/shared/DevToolsUtils");
+const Services = require("Services");
+
+loader.lazyRequireGetter(
+ this,
+ "Reflect",
+ "resource://gre/modules/reflect.jsm",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "formatCommand",
+ "devtools/server/actors/webconsole/commands",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "isCommand",
+ "devtools/server/actors/webconsole/commands",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "WebConsoleCommands",
+ "devtools/server/actors/webconsole/utils",
+ true
+);
+
+loader.lazyRequireGetter(
+ this,
+ "LongStringActor",
+ "devtools/server/actors/string",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "eagerEcmaAllowlist",
+ "devtools/server/actors/webconsole/eager-ecma-allowlist"
+);
+loader.lazyRequireGetter(
+ this,
+ "eagerFunctionAllowlist",
+ "devtools/server/actors/webconsole/eager-function-allowlist"
+);
+
+function isObject(value) {
+ return Object(value) === value;
+}
+
+/**
+ * Evaluates a string using the debugger API.
+ *
+ * To allow the variables view to update properties from the Web Console we
+ * provide the "selectedObjectActor" mechanism: the Web Console tells the
+ * ObjectActor ID for which it desires to evaluate an expression. The
+ * Debugger.Object pointed at by the actor ID is bound such that it is
+ * available during expression evaluation (executeInGlobalWithBindings()).
+ *
+ * Example:
+ * _self['foobar'] = 'test'
+ * where |_self| refers to the desired object.
+ *
+ * The |frameActor| property allows the Web Console client to provide the
+ * frame actor ID, such that the expression can be evaluated in the
+ * user-selected stack frame.
+ *
+ * For the above to work we need the debugger and the Web Console to share
+ * a connection, otherwise the Web Console actor will not find the frame
+ * actor.
+ *
+ * The Debugger.Frame comes from the jsdebugger's Debugger instance, which
+ * is different from the Web Console's Debugger instance. This means that
+ * for evaluation to work, we need to create a new instance for the Web
+ * Console Commands helpers - they need to be Debugger.Objects coming from the
+ * jsdebugger's Debugger instance.
+ *
+ * When |selectedObjectActor| is used objects can come from different iframes,
+ * from different domains. To avoid permission-related errors when objects
+ * come from a different window, we also determine the object's own global,
+ * such that evaluation happens in the context of that global. This means that
+ * evaluation will happen in the object's iframe, rather than the top level
+ * window.
+ *
+ * @param string string
+ * String to evaluate.
+ * @param object [options]
+ * Options for evaluation:
+ * - selectedObjectActor: the ObjectActor ID to use for evaluation.
+ * |evalWithBindings()| will be called with one additional binding:
+ * |_self| which will point to the Debugger.Object of the given
+ * ObjectActor. Executes with the top level window as the global.
+ * - frameActor: the FrameActor ID to use for evaluation. The given
+ * debugger frame is used for evaluation, instead of the global window.
+ * - selectedNodeActor: the NodeActor ID of the currently selected node
+ * in the Inspector (or null, if there is no selection). This is used
+ * for helper functions that make reference to the currently selected
+ * node, like $0.
+ * - innerWindowID: An optional window id to use instead of webConsole.evalWindow.
+ * This is used by function that need to evaluate in a different window for which
+ * we don't have a dedicated target (for example a non-remote iframe).
+ * - eager: Set to true if you want the evaluation to bail if it may have side effects.
+ * - url: the url to evaluate the script as. Defaults to "debugger eval code",
+ * or "debugger eager eval code" if eager is true.
+ * @return object
+ * An object that holds the following properties:
+ * - dbg: the debugger where the string was evaluated.
+ * - frame: (optional) the frame where the string was evaluated.
+ * - global: the Debugger.Object for the global where the string was evaluated in.
+ * - result: the result of the evaluation.
+ * - helperResult: any result coming from a Web Console commands
+ * function.
+ */
+exports.evalWithDebugger = function(string, options = {}, webConsole) {
+ if (isCommand(string.trim()) && options.eager) {
+ return {
+ result: null,
+ };
+ }
+
+ const evalString = getEvalInput(string);
+ const { frame, dbg } = getFrameDbg(options, webConsole);
+
+ const { dbgGlobal, bindSelf } = getDbgGlobal(options, dbg, webConsole);
+ const helpers = getHelpers(dbgGlobal, options, webConsole);
+ let { bindings, helperCache } = bindCommands(
+ isCommand(string),
+ dbgGlobal,
+ bindSelf,
+ frame,
+ helpers
+ );
+
+ if (options.bindings) {
+ bindings = { ...(bindings || {}), ...options.bindings };
+ }
+
+ // Ready to evaluate the string.
+ helpers.evalInput = string;
+ const evalOptions = {};
+
+ const urlOption =
+ options.url || (options.eager ? "debugger eager eval code" : null);
+ if (typeof urlOption === "string") {
+ evalOptions.url = urlOption;
+ }
+
+ if (typeof options.lineNumber === "number") {
+ evalOptions.lineNumber = options.lineNumber;
+ }
+
+ updateConsoleInputEvaluation(dbg, webConsole);
+
+ let noSideEffectDebugger = null;
+ if (options.eager) {
+ noSideEffectDebugger = makeSideeffectFreeDebugger();
+ }
+
+ let result;
+ try {
+ result = getEvalResult(
+ dbg,
+ evalString,
+ evalOptions,
+ bindings,
+ frame,
+ dbgGlobal,
+ noSideEffectDebugger
+ );
+ } finally {
+ // We need to be absolutely sure that the sideeffect-free debugger's
+ // debuggees are removed because otherwise we risk them terminating
+ // execution of later code in the case of unexpected exceptions.
+ if (noSideEffectDebugger) {
+ noSideEffectDebugger.removeAllDebuggees();
+ }
+ }
+
+ // Attempt to initialize any declarations found in the evaluated string
+ // since they may now be stuck in an "initializing" state due to the
+ // error. Already-initialized bindings will be ignored.
+ if (!frame && result && "throw" in result) {
+ parseErrorOutput(dbgGlobal, string);
+ }
+
+ const { helperResult } = helpers;
+
+ // Clean up helpers helpers and bindings
+ delete helpers.evalInput;
+ delete helpers.helperResult;
+ delete helpers.selectedNode;
+ cleanupBindings(bindings, helperCache);
+
+ return {
+ result,
+ helperResult,
+ dbg,
+ frame,
+ dbgGlobal,
+ };
+};
+
+function getEvalResult(
+ dbg,
+ string,
+ evalOptions,
+ bindings,
+ frame,
+ dbgGlobal,
+ noSideEffectDebugger
+) {
+ if (noSideEffectDebugger) {
+ // Bug 1637883 demonstrated an issue where dbgGlobal was somehow in the
+ // same compartment as the Debugger, meaning it could not be debugged
+ // and thus cannot handle eager evaluation. In that case we skip execution.
+ if (!noSideEffectDebugger.hasDebuggee(dbgGlobal.unsafeDereference())) {
+ return null;
+ }
+
+ // When a sideeffect-free debugger has been created, we need to eval
+ // in the context of that debugger in order for the side-effect tracking
+ // to apply.
+ frame = frame ? noSideEffectDebugger.adoptFrame(frame) : null;
+ dbgGlobal = noSideEffectDebugger.adoptDebuggeeValue(dbgGlobal);
+ if (bindings) {
+ bindings = Object.keys(bindings).reduce((acc, key) => {
+ acc[key] = noSideEffectDebugger.adoptDebuggeeValue(bindings[key]);
+ return acc;
+ }, {});
+ }
+ }
+
+ let result;
+ if (frame) {
+ result = frame.evalWithBindings(string, bindings, evalOptions);
+ } else {
+ result = dbgGlobal.executeInGlobalWithBindings(
+ string,
+ bindings,
+ evalOptions
+ );
+ }
+ if (noSideEffectDebugger && result) {
+ if ("return" in result) {
+ result.return = dbg.adoptDebuggeeValue(result.return);
+ }
+ if ("throw" in result) {
+ result.throw = dbg.adoptDebuggeeValue(result.throw);
+ }
+ }
+ return result;
+}
+
+function parseErrorOutput(dbgGlobal, string) {
+ // Reflect is not usable in workers, so return early to avoid logging an error
+ // to the console when loading it.
+ if (isWorker) {
+ return;
+ }
+
+ let ast;
+ // Parse errors will raise an exception. We can/should ignore the error
+ // since it's already being handled elsewhere and we are only interested
+ // in initializing bindings.
+ try {
+ ast = Reflect.parse(string);
+ } catch (ex) {
+ return;
+ }
+ for (const line of ast.body) {
+ // Only let and const declarations put bindings into an
+ // "initializing" state.
+ if (!(line.kind == "let" || line.kind == "const")) {
+ continue;
+ }
+
+ const identifiers = [];
+ for (const decl of line.declarations) {
+ switch (decl.id.type) {
+ case "Identifier":
+ // let foo = bar;
+ identifiers.push(decl.id.name);
+ break;
+ case "ArrayPattern":
+ // let [foo, bar] = [1, 2];
+ // let [foo=99, bar] = [1, 2];
+ for (const e of decl.id.elements) {
+ if (e.type == "Identifier") {
+ identifiers.push(e.name);
+ } else if (e.type == "AssignmentExpression") {
+ identifiers.push(e.left.name);
+ }
+ }
+ break;
+ case "ObjectPattern":
+ // let {bilbo, my} = {bilbo: "baggins", my: "precious"};
+ // let {blah: foo} = {blah: yabba()}
+ // let {blah: foo=99} = {blah: yabba()}
+ for (const prop of decl.id.properties) {
+ // key
+ if (prop.key.type == "Identifier") {
+ identifiers.push(prop.key.name);
+ }
+ // value
+ if (prop.value.type == "Identifier") {
+ identifiers.push(prop.value.name);
+ } else if (prop.value.type == "AssignmentExpression") {
+ identifiers.push(prop.value.left.name);
+ }
+ }
+ break;
+ }
+ }
+
+ for (const name of identifiers) {
+ dbgGlobal.forceLexicalInitializationByName(name);
+ }
+ }
+}
+
+function makeSideeffectFreeDebugger() {
+ // We ensure that the metadata for native functions is loaded before we
+ // initialize sideeffect-prevention because the data is lazy-loaded, and this
+ // logic can run inside of debuggee compartments because the
+ // "addAllGlobalsAsDebuggees" considers the vast majority of realms
+ // valid debuggees. Without this, eager-eval runs the risk of failing
+ // because building the list of valid native functions is itself a
+ // side-effectful operation because it needs to populate a
+ // module cache, among any number of other things.
+ ensureSideEffectFreeNatives();
+
+ // Note: It is critical for debuggee performance that we implement all of
+ // this debuggee tracking logic with a separate Debugger instance.
+ // Bug 1617666 arises otherwise if we set an onEnterFrame hook on the
+ // existing debugger object and then later clear it.
+ const dbg = new Debugger();
+ dbg.addAllGlobalsAsDebuggees();
+
+ const timeoutDuration = 100;
+ const endTime = Date.now() + timeoutDuration;
+ let count = 0;
+ function shouldCancel() {
+ // To keep the evaled code as quick as possible, we avoid querying the
+ // current time on ever single step and instead check every 100 steps
+ // as an arbitrary count that seemed to be "often enough".
+ return ++count % 100 === 0 && Date.now() > endTime;
+ }
+
+ const executedScripts = new Set();
+ const handler = {
+ hit: () => null,
+ };
+ dbg.onEnterFrame = frame => {
+ if (shouldCancel()) {
+ return null;
+ }
+ frame.onStep = () => {
+ if (shouldCancel()) {
+ return null;
+ }
+ return undefined;
+ };
+
+ const script = frame.script;
+
+ if (executedScripts.has(script)) {
+ return undefined;
+ }
+ executedScripts.add(script);
+
+ const offsets = script.getEffectfulOffsets();
+ for (const offset of offsets) {
+ script.setBreakpoint(offset, handler);
+ }
+
+ return undefined;
+ };
+
+ // The debugger only calls onNativeCall handlers on the debugger that is
+ // explicitly calling eval, so we need to add this hook on "dbg" even though
+ // the rest of our hooks work via "newDbg".
+ dbg.onNativeCall = (callee, reason) => {
+ try {
+ // Getters are never considered effectful, and setters are always effectful.
+ // Natives called normally are handled with an allowlist.
+ if (
+ reason == "get" ||
+ (reason == "call" && nativeHasNoSideEffects(callee))
+ ) {
+ // Returning undefined causes execution to continue normally.
+ return undefined;
+ }
+ } catch (err) {
+ DevToolsUtils.reportException(
+ "evalWithDebugger onNativeCall",
+ new Error("Unable to validate native function against allowlist")
+ );
+ }
+ // Returning null terminates the current evaluation.
+ return null;
+ };
+
+ return dbg;
+}
+
+// Native functions which are considered to be side effect free.
+let gSideEffectFreeNatives; // string => Array(Function)
+
+function ensureSideEffectFreeNatives() {
+ if (gSideEffectFreeNatives) {
+ return;
+ }
+
+ const natives = [
+ ...eagerEcmaAllowlist,
+
+ // Pull in all of the non-ECMAScript native functions that we want to
+ // allow as well.
+ ...eagerFunctionAllowlist,
+ ];
+
+ const map = new Map();
+ for (const n of natives) {
+ if (!map.has(n.name)) {
+ map.set(n.name, []);
+ }
+ map.get(n.name).push(n);
+ }
+
+ gSideEffectFreeNatives = map;
+}
+
+function nativeHasNoSideEffects(fn) {
+ if (fn.isBoundFunction) {
+ fn = fn.boundTargetFunction;
+ }
+
+ // Natives with certain names are always considered side effect free.
+ switch (fn.name) {
+ case "toString":
+ case "toLocaleString":
+ case "valueOf":
+ return true;
+ }
+
+ const natives = gSideEffectFreeNatives.get(fn.name);
+ return natives && natives.some(n => fn.isSameNative(n));
+}
+
+function updateConsoleInputEvaluation(dbg, webConsole) {
+ // Adopt webConsole._lastConsoleInputEvaluation value in the new debugger,
+ // to prevent "Debugger.Object belongs to a different Debugger" exceptions
+ // related to the $_ bindings if the debugger object is changed from the
+ // last evaluation.
+ if (webConsole._lastConsoleInputEvaluation) {
+ webConsole._lastConsoleInputEvaluation = dbg.adoptDebuggeeValue(
+ webConsole._lastConsoleInputEvaluation
+ );
+ }
+}
+
+function getEvalInput(string) {
+ const trimmedString = string.trim();
+ // The help function needs to be easy to guess, so we make the () optional.
+ if (trimmedString === "help" || trimmedString === "?") {
+ return "help()";
+ }
+ // we support Unix like syntax for commands if it is preceeded by `:`
+ if (isCommand(string)) {
+ try {
+ return formatCommand(string);
+ } catch (e) {
+ console.log(e);
+ return `throw "${e}"`;
+ }
+ }
+
+ // Add easter egg for console.mihai().
+ if (
+ trimmedString == "console.mihai()" ||
+ trimmedString == "console.mihai();"
+ ) {
+ return '"http://incompleteness.me/blog/2015/02/09/console-dot-mihai/"';
+ }
+ return string;
+}
+
+function getFrameDbg(options, webConsole) {
+ if (!options.frameActor) {
+ return { frame: null, dbg: webConsole.dbg };
+ }
+ // Find the Debugger.Frame of the given FrameActor.
+ const frameActor = webConsole.conn.getActor(options.frameActor);
+ if (frameActor) {
+ // If we've been given a frame actor in whose scope we should evaluate the
+ // expression, be sure to use that frame's Debugger (that is, the JavaScript
+ // debugger's Debugger) for the whole operation, not the console's Debugger.
+ // (One Debugger will treat a different Debugger's Debugger.Object instances
+ // as ordinary objects, not as references to be followed, so mixing
+ // debuggers causes strange behaviors.)
+ return { frame: frameActor.frame, dbg: frameActor.threadActor.dbg };
+ }
+ return DevToolsUtils.reportException(
+ "evalWithDebugger",
+ Error("The frame actor was not found: " + options.frameActor)
+ );
+}
+
+function getDbgGlobal(options, dbg, webConsole) {
+ let evalGlobal = webConsole.evalGlobal;
+
+ if (options.innerWindowID) {
+ const window = Services.wm.getCurrentInnerWindowWithId(
+ options.innerWindowID
+ );
+
+ if (window) {
+ evalGlobal = window;
+ }
+ }
+
+ const dbgGlobal = dbg.makeGlobalObjectReference(evalGlobal);
+
+ // If we have an object to bind to |_self|, create a Debugger.Object
+ // referring to that object, belonging to dbg.
+ if (!options.selectedObjectActor) {
+ return { bindSelf: null, dbgGlobal };
+ }
+
+ // For objects related to console messages, they will be registered under the Target Actor
+ // instead of the WebConsoleActor. That's because console messages are resources and all resources
+ // are emitted by the Target Actor.
+ const actor =
+ webConsole.getActorByID(options.selectedObjectActor) ||
+ webConsole.parentActor.getActorByID(options.selectedObjectActor);
+
+ if (!actor) {
+ return { bindSelf: null, dbgGlobal };
+ }
+
+ const jsVal = actor instanceof LongStringActor ? actor.str : actor.rawValue();
+ if (!isObject(jsVal)) {
+ return { bindSelf: jsVal, dbgGlobal };
+ }
+
+ // If we use the makeDebuggeeValue method of jsVal's own global, then
+ // we'll get a D.O that sees jsVal as viewed from its own compartment -
+ // that is, without wrappers. The evalWithBindings call will then wrap
+ // jsVal appropriately for the evaluation compartment.
+ const bindSelf = dbgGlobal.makeDebuggeeValue(jsVal);
+ return { bindSelf, dbgGlobal };
+}
+
+function getHelpers(dbgGlobal, options, webConsole) {
+ // Get the Web Console commands for the given debugger global.
+ const helpers = webConsole._getWebConsoleCommands(dbgGlobal);
+ if (options.selectedNodeActor) {
+ const actor = webConsole.conn.getActor(options.selectedNodeActor);
+ if (actor) {
+ helpers.selectedNode = actor.rawNode;
+ }
+ }
+
+ return helpers;
+}
+
+function cleanupBindings(bindings, helperCache) {
+ // Replaces bindings that were overwritten with commands saved in the helperCache
+ for (const [helperName, helper] of Object.entries(helperCache)) {
+ bindings[helperName] = helper;
+ }
+
+ if (bindings._self) {
+ delete bindings._self;
+ }
+}
+
+function bindCommands(isCmd, dbgGlobal, bindSelf, frame, helpers) {
+ const bindings = helpers.sandbox;
+ if (bindSelf) {
+ bindings._self = bindSelf;
+ }
+ // Check if the Debugger.Frame or Debugger.Object for the global include any of the
+ // helper function we set. We will not overwrite these functions with the Web Console
+ // commands.
+ const availableHelpers = [...WebConsoleCommands._originalCommands.keys()];
+
+ let helpersToDisable = [];
+ const helperCache = {};
+
+ // do not override command functions if we are using the command key `:`
+ // before the command string
+ if (!isCmd) {
+ if (frame) {
+ const env = frame.environment;
+ if (env) {
+ helpersToDisable = availableHelpers.filter(name => !!env.find(name));
+ }
+ } else {
+ helpersToDisable = availableHelpers.filter(
+ name => !!dbgGlobal.getOwnPropertyDescriptor(name)
+ );
+ }
+ // if we do not have the command key as a prefix, screenshot is disabled by default
+ helpersToDisable.push("screenshot");
+ }
+
+ for (const helper of helpersToDisable) {
+ helperCache[helper] = bindings[helper];
+ delete bindings[helper];
+ }
+ return { bindings, helperCache };
+}
diff --git a/devtools/server/actors/webconsole/listeners/console-api.js b/devtools/server/actors/webconsole/listeners/console-api.js
new file mode 100644
index 0000000000..59202556e2
--- /dev/null
+++ b/devtools/server/actors/webconsole/listeners/console-api.js
@@ -0,0 +1,203 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const { Cc, Ci } = require("chrome");
+const { isWindowIncluded } = require("devtools/shared/layout/utils");
+const Services = require("Services");
+const ChromeUtils = require("ChromeUtils");
+const {
+ CONSOLE_WORKER_IDS,
+ WebConsoleUtils,
+} = require("devtools/server/actors/webconsole/utils");
+
+// 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 Function handler
+ * This function 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.
+ */
+class ConsoleAPIListener {
+ constructor(window, handler, { addonId } = {}) {
+ this.window = window;
+ this.handler = handler;
+ this.addonId = addonId;
+ }
+
+ QueryInterface = ChromeUtils.generateQI([Ci.nsIObserver]);
+
+ /**
+ * The content window for which we listen to window.console API calls.
+ * @type nsIDOMWindow
+ */
+ window = null;
+
+ /**
+ * The function which is notified of window.console API calls. It is invoked with one
+ * argument: the console API call object that comes from the observer service.
+ *
+ * @type function
+ */
+ handler = 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() {
+ // 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");
+ }
+
+ /**
+ * 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(message, topic) {
+ if (!this.handler) {
+ 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.
+ const apiMessage = message.wrappedJSObject;
+
+ if (!this.isMessageRelevant(apiMessage)) {
+ return;
+ }
+
+ this.handler(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(message) {
+ const 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.
+ const scope = message.ID;
+
+ if (!this.window.shouldReportForServiceWorkerScope(scope)) {
+ return false;
+ }
+ }
+
+ if (this.window && !workerType) {
+ const 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.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(includePrivate = false) {
+ let messages = [];
+ const 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 {
+ const 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() {
+ Services.obs.removeObserver(this, "console-api-log-event");
+ this.window = this.handler = null;
+ }
+}
+exports.ConsoleAPIListener = ConsoleAPIListener;
diff --git a/devtools/server/actors/webconsole/listeners/console-file-activity.js b/devtools/server/actors/webconsole/listeners/console-file-activity.js
new file mode 100644
index 0000000000..fa33fae3ed
--- /dev/null
+++ b/devtools/server/actors/webconsole/listeners/console-file-activity.js
@@ -0,0 +1,129 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const { Ci } = require("chrome");
+const ChromeUtils = require("ChromeUtils");
+
+/**
+ * A WebProgressListener that listens for file loads.
+ *
+ * @constructor
+ * @param object window
+ * The window for which we need to track file loads.
+ * @param object owner
+ * The listener owner which needs to implement:
+ * - onFileActivity(aFileURI)
+ */
+function ConsoleFileActivityListener(window, owner) {
+ this.window = window;
+ this.owner = owner;
+}
+exports.ConsoleFileActivityListener = ConsoleFileActivityListener;
+
+ConsoleFileActivityListener.prototype = {
+ /**
+ * Tells if the console progress listener is initialized or not.
+ * @private
+ * @type boolean
+ */
+ _initialized: false,
+
+ _webProgress: null,
+
+ QueryInterface: ChromeUtils.generateQI([
+ "nsIWebProgressListener",
+ "nsISupportsWeakReference",
+ ]),
+
+ /**
+ * Initialize the ConsoleFileActivityListener.
+ * @private
+ */
+ _init: function() {
+ if (this._initialized) {
+ return;
+ }
+
+ this._webProgress = this.window.docShell.QueryInterface(Ci.nsIWebProgress);
+ this._webProgress.addProgressListener(
+ this,
+ Ci.nsIWebProgress.NOTIFY_STATE_ALL
+ );
+
+ this._initialized = true;
+ },
+
+ /**
+ * Start a monitor/tracker related to the current nsIWebProgressListener
+ * instance.
+ */
+ startMonitor: function() {
+ this._init();
+ },
+
+ /**
+ * Stop monitoring.
+ */
+ stopMonitor: function() {
+ this.destroy();
+ },
+
+ onStateChange: function(progress, request, state, status) {
+ if (!this.owner) {
+ return;
+ }
+
+ this._checkFileActivity(progress, request, state, status);
+ },
+
+ /**
+ * Check if there is any file load, given the arguments of
+ * nsIWebProgressListener.onStateChange. If the state change tells that a file
+ * URI has been loaded, then the remote Web Console instance is notified.
+ * @private
+ */
+ _checkFileActivity: function(progress, request, state, status) {
+ if (!(state & Ci.nsIWebProgressListener.STATE_START)) {
+ return;
+ }
+
+ let uri = null;
+ if (request instanceof Ci.imgIRequest) {
+ const imgIRequest = request.QueryInterface(Ci.imgIRequest);
+ uri = imgIRequest.URI;
+ } else if (request instanceof Ci.nsIChannel) {
+ const nsIChannel = request.QueryInterface(Ci.nsIChannel);
+ uri = nsIChannel.URI;
+ }
+
+ if (!uri || (!uri.schemeIs("file") && !uri.schemeIs("ftp"))) {
+ return;
+ }
+
+ this.owner.onFileActivity(uri.spec);
+ },
+
+ /**
+ * Destroy the ConsoleFileActivityListener.
+ */
+ destroy: function() {
+ if (!this._initialized) {
+ return;
+ }
+
+ this._initialized = false;
+
+ try {
+ this._webProgress.removeProgressListener(this);
+ } catch (ex) {
+ // This can throw during browser shutdown.
+ }
+
+ this._webProgress = null;
+ this.window = null;
+ this.owner = null;
+ },
+};
diff --git a/devtools/server/actors/webconsole/listeners/console-reflow.js b/devtools/server/actors/webconsole/listeners/console-reflow.js
new file mode 100644
index 0000000000..14f1f8c324
--- /dev/null
+++ b/devtools/server/actors/webconsole/listeners/console-reflow.js
@@ -0,0 +1,93 @@
+/* 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 { components } = require("chrome");
+const ChromeUtils = require("ChromeUtils");
+
+/**
+ * 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.docShell;
+ this.listener = listener;
+ this.docshell.addWeakReflowObserver(this);
+}
+
+exports.ConsoleReflowListener = ConsoleReflowListener;
+
+ConsoleReflowListener.prototype = {
+ QueryInterface: ChromeUtils.generateQI([
+ "nsIReflowObserver",
+ "nsISupportsWeakReference",
+ ]),
+ docshell: null,
+ listener: null,
+
+ /**
+ * Forward reflow event to listener.
+ *
+ * @param DOMHighResTimeStamp start
+ * @param DOMHighResTimeStamp end
+ * @param boolean interruptible
+ */
+ sendReflow: function(start, end, interruptible) {
+ const 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/webconsole/listeners/console-service.js b/devtools/server/actors/webconsole/listeners/console-service.js
new file mode 100644
index 0000000000..613a7cc7de
--- /dev/null
+++ b/devtools/server/actors/webconsole/listeners/console-service.js
@@ -0,0 +1,176 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const { Ci } = require("chrome");
+const { isWindowIncluded } = require("devtools/shared/layout/utils");
+const Services = require("Services");
+const ChromeUtils = require("ChromeUtils");
+const { WebConsoleUtils } = require("devtools/server/actors/webconsole/utils");
+
+// 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 Function handler
+ * This function is invoked with one argument, the nsIConsoleMessage, whenever a
+ * relevant message is received.
+ */
+class ConsoleServiceListener {
+ constructor(window, handler) {
+ this.window = window;
+ this.handler = handler;
+ }
+
+ QueryInterface = ChromeUtils.generateQI([Ci.nsIConsoleListener]);
+
+ /**
+ * The content window for which we listen to page errors.
+ * @type nsIDOMWindow
+ */
+ window = null;
+
+ /**
+ * The function which is notified of messages from the console service.
+ * @type function
+ */
+ handler = null;
+
+ /**
+ * Initialize the nsIConsoleService listener.
+ */
+ init() {
+ 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(message) {
+ if (!this.handler) {
+ return;
+ }
+
+ if (this.window) {
+ if (
+ !(message instanceof Ci.nsIScriptError) ||
+ !message.outerWindowID ||
+ !this.isCategoryAllowed(message.category)
+ ) {
+ return;
+ }
+
+ const errorWindow = Services.wm.getOuterWindowWithId(
+ message.outerWindowID
+ );
+ if (!errorWindow || !isWindowIncluded(this.window, errorWindow)) {
+ return;
+ }
+ }
+
+ // Don't display messages triggered by eager evaluation.
+ if (message.sourceName === "debugger eager eval code") {
+ return;
+ }
+ this.handler(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(category) {
+ if (!category) {
+ return false;
+ }
+
+ switch (category) {
+ case "XPConnect JavaScript":
+ case "component javascript":
+ case "chrome javascript":
+ case "chrome registration":
+ 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(includePrivate = false) {
+ const 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;
+ });
+ }
+
+ const ids = WebConsoleUtils.getInnerWindowIDsForFrames(this.window);
+
+ return errors.filter(error => {
+ if (error instanceof Ci.nsIScriptError) {
+ if (!includePrivate && error.isFromPrivateWindow) {
+ return false;
+ }
+ if (
+ ids &&
+ (!ids.includes(error.innerWindowID) ||
+ !this.isCategoryAllowed(error.category))
+ ) {
+ return false;
+ }
+ } else if (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() {
+ Services.console.unregisterListener(this);
+ this.handler = this.window = null;
+ }
+}
+
+exports.ConsoleServiceListener = ConsoleServiceListener;
diff --git a/devtools/server/actors/webconsole/listeners/content-process.js b/devtools/server/actors/webconsole/listeners/content-process.js
new file mode 100644
index 0000000000..e096ad9bfb
--- /dev/null
+++ b/devtools/server/actors/webconsole/listeners/content-process.js
@@ -0,0 +1,50 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const Services = require("Services");
+
+// Process script used to forward console calls from content processes to parent process
+const CONTENT_PROCESS_SCRIPT =
+ "resource://devtools/server/actors/webconsole/content-process-forward.js";
+
+/**
+ * Forward console message calls from content processes to the parent process.
+ * Used by non-multiprocess Browser Console and Browser Toolbox Console to see messages
+ * from all processes.
+ *
+ * @constructor
+ * @param Function handler
+ * This function is invoked with one argument, the message that was forwarded from
+ * the content process to the parent process.
+ */
+class ContentProcessListener {
+ constructor(handler) {
+ this.handler = handler;
+
+ Services.ppmm.addMessageListener("Console:Log", this);
+ Services.ppmm.loadProcessScript(CONTENT_PROCESS_SCRIPT, true);
+ }
+
+ receiveMessage(message) {
+ const logMsg = message.data;
+ logMsg.wrappedJSObject = logMsg;
+ this.handler(logMsg);
+ }
+
+ destroy() {
+ // Tell the content processes to stop listening and forwarding messages
+ Services.ppmm.broadcastAsyncMessage(
+ "DevTools:StopForwardingContentProcessMessage"
+ );
+
+ Services.ppmm.removeMessageListener("Console:Log", this);
+ Services.ppmm.removeDelayedProcessScript(CONTENT_PROCESS_SCRIPT);
+
+ this.handler = null;
+ }
+}
+
+exports.ContentProcessListener = ContentProcessListener;
diff --git a/devtools/server/actors/webconsole/listeners/document-events.js b/devtools/server/actors/webconsole/listeners/document-events.js
new file mode 100644
index 0000000000..9377d1e6e4
--- /dev/null
+++ b/devtools/server/actors/webconsole/listeners/document-events.js
@@ -0,0 +1,78 @@
+/* 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");
+
+/**
+ * Forward `DOMContentLoaded` and `load` events with precise timing
+ * of when events happened according to window.performance numbers.
+ *
+ * @constructor
+ * @param BrowsingContextTarget targetActor
+ */
+function DocumentEventsListener(targetActor) {
+ this.targetActor = targetActor;
+
+ EventEmitter.decorate(this);
+ this.onWindowReady = this.onWindowReady.bind(this);
+ this.onContentLoaded = this.onContentLoaded.bind(this);
+ this.onLoad = this.onLoad.bind(this);
+}
+
+exports.DocumentEventsListener = DocumentEventsListener;
+
+DocumentEventsListener.prototype = {
+ listen() {
+ EventEmitter.on(this.targetActor, "window-ready", this.onWindowReady);
+ this.onWindowReady({ window: this.targetActor.window, isTopLevel: true });
+ },
+
+ onWindowReady({ window, isTopLevel }) {
+ // Ignore iframes
+ if (!isTopLevel) {
+ return;
+ }
+
+ const time = window.performance.timing.navigationStart;
+ this.emit("dom-loading", time);
+
+ const { readyState } = window.document;
+ if (readyState != "interactive" && readyState != "complete") {
+ window.addEventListener("DOMContentLoaded", this.onContentLoaded, {
+ once: true,
+ });
+ } else {
+ this.onContentLoaded({ target: window.document });
+ }
+ if (readyState != "complete") {
+ window.addEventListener("load", this.onLoad, { once: true });
+ } else {
+ this.onLoad({ target: window.document });
+ }
+ },
+
+ onContentLoaded(event) {
+ // milliseconds since the UNIX epoch, when the parser finished its work
+ // on the main document, that is when its Document.readyState changes to
+ // 'interactive' and the corresponding readystatechange event is thrown
+ const window = event.target.defaultView;
+ const time = window.performance.timing.domInteractive;
+ this.emit("dom-interactive", time);
+ },
+
+ onLoad(event) {
+ // milliseconds since the UNIX epoch, when the parser finished its work
+ // on the main document, that is when its Document.readyState changes to
+ // 'complete' and the corresponding readystatechange event is thrown
+ const window = event.target.defaultView;
+ const time = window.performance.timing.domComplete;
+ this.emit("dom-complete", time);
+ },
+
+ destroy() {
+ this.listener = null;
+ },
+};
diff --git a/devtools/server/actors/webconsole/listeners/moz.build b/devtools/server/actors/webconsole/listeners/moz.build
new file mode 100644
index 0000000000..372818f6c9
--- /dev/null
+++ b/devtools/server/actors/webconsole/listeners/moz.build
@@ -0,0 +1,14 @@
+# -*- 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(
+ "console-api.js",
+ "console-file-activity.js",
+ "console-reflow.js",
+ "console-service.js",
+ "content-process.js",
+ "document-events.js",
+)
diff --git a/devtools/server/actors/webconsole/message-manager-mock.js b/devtools/server/actors/webconsole/message-manager-mock.js
new file mode 100644
index 0000000000..900dfcb3d5
--- /dev/null
+++ b/devtools/server/actors/webconsole/message-manager-mock.js
@@ -0,0 +1,70 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/**
+ * Implements a fake MessageManager class that allows to use the message
+ * manager API within the same process. This implementation will forward
+ * messages within the same process.
+ *
+ * It helps having the same codepath for actors being evaluated in the same
+ * process *and* in a remote one.
+ */
+function MessageManagerMock() {
+ this._listeners = new Map();
+}
+MessageManagerMock.prototype = {
+ addMessageListener(name, listener) {
+ let listeners = this._listeners.get(name);
+ if (!listeners) {
+ listeners = [];
+ this._listeners.set(name, listeners);
+ }
+ if (!listeners.includes(listener)) {
+ listeners.push(listener);
+ }
+ },
+ removeMessageListener(name, listener) {
+ const listeners = this._listeners.get(name);
+ const idx = listeners.indexOf(listener);
+ listeners.splice(idx, 1);
+ },
+ sendAsyncMessage(name, data) {
+ this.other.internalSendAsyncMessage(name, data);
+ },
+ internalSendAsyncMessage(name, data) {
+ const listeners = this._listeners.get(name);
+ if (!listeners) {
+ return;
+ }
+ const message = {
+ target: this,
+ data,
+ };
+ for (const listener of listeners) {
+ if (
+ typeof listener === "object" &&
+ typeof listener.receiveMessage === "function"
+ ) {
+ listener.receiveMessage(message);
+ } else if (typeof listener === "function") {
+ listener(message);
+ }
+ }
+ },
+};
+
+/**
+ * Create two MessageManager mocks, connected to each others.
+ * Calling sendAsyncMessage on the first will dispatch messages on the second one,
+ * and the other way around
+ */
+exports.createMessageManagerMocks = function() {
+ const a = new MessageManagerMock();
+ const b = new MessageManagerMock();
+ a.other = b;
+ b.other = a;
+ return [a, b];
+};
diff --git a/devtools/server/actors/webconsole/moz.build b/devtools/server/actors/webconsole/moz.build
new file mode 100644
index 0000000000..9e1197710f
--- /dev/null
+++ b/devtools/server/actors/webconsole/moz.build
@@ -0,0 +1,22 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+DIRS += [
+ "listeners",
+]
+
+DevToolsModules(
+ "commands.js",
+ "content-process-forward.js",
+ "eager-ecma-allowlist.js",
+ "eager-function-allowlist.js",
+ "eval-with-debugger.js",
+ "message-manager-mock.js",
+ "utils.js",
+ "webidl-deprecated-list.js",
+ "webidl-pure-allowlist.js",
+ "worker-listeners.js",
+)
diff --git a/devtools/server/actors/webconsole/utils.js b/devtools/server/actors/webconsole/utils.js
new file mode 100644
index 0000000000..6ddf74f695
--- /dev/null
+++ b/devtools/server/actors/webconsole/utils.js
@@ -0,0 +1,685 @@
+/* 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");
+
+// Note that this is only used in WebConsoleCommands, see $0 and screenshot.
+if (!isWorker) {
+ loader.lazyRequireGetter(
+ this,
+ "captureScreenshot",
+ "devtools/server/actors/utils/capture-screenshot",
+ true
+ );
+}
+
+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) {
+ const 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 = [];
+ object.forEach(function(value, index) {
+ if (!filter || filter(index, value, object)) {
+ temp.push(recursive ? WebConsoleUtils.cloneObject(value) : value);
+ }
+ });
+ } else {
+ temp = {};
+ for (const key in object) {
+ const 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|null
+ * Inner ID for the given window, null if we can't access it.
+ */
+ getInnerWindowId: function(window) {
+ // Might throw with SecurityError: Permission denied to access property
+ // "windowGlobalChild" on cross-origin object.
+ try {
+ return window.windowGlobalChild.innerWindowId;
+ } catch (e) {
+ return null;
+ }
+ },
+
+ /**
+ * 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) {
+ const innerWindowID = this.getInnerWindowId(window);
+ if (innerWindowID === null) {
+ return [];
+ }
+
+ let ids = [innerWindowID];
+
+ if (window.frames) {
+ for (let i = 0; i < window.frames.length; i++) {
+ const frame = window.frames[i];
+ ids = ids.concat(this.getInnerWindowIDsForFrames(frame));
+ }
+ }
+
+ return ids;
+ },
+
+ /**
+ * 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;
+ }
+ },
+
+ /**
+ * Remove any frames in a stack that are above a debugger-triggered evaluation
+ * and will correspond with devtools server code, which we never want to show
+ * to the user.
+ *
+ * @param array stack
+ * An array of frames, with the topmost first, and each of which has a
+ * 'filename' property.
+ * @return array
+ * An array of stack frames with any devtools server frames removed.
+ * The original array is not modified.
+ */
+ removeFramesAboveDebuggerEval(stack) {
+ const debuggerEvalFilename = "debugger eval code";
+
+ // Remove any frames for server code above the last debugger eval frame.
+ const evalIndex = stack.findIndex(({ filename }, idx, arr) => {
+ const nextFrame = arr[idx + 1];
+ return (
+ filename == debuggerEvalFilename &&
+ (!nextFrame || nextFrame.filename !== debuggerEvalFilename)
+ );
+ });
+ if (evalIndex != -1) {
+ return stack.slice(0, evalIndex + 1);
+ }
+
+ // In some cases (e.g. evaluated expression with SyntaxError), we might not have a
+ // "debugger eval code" frame but still have internal ones. If that's the case, we
+ // return null as the end user shouldn't see those frames.
+ if (
+ stack.some(
+ ({ filename }) =>
+ filename && filename.startsWith("resource://devtools/")
+ )
+ ) {
+ return null;
+ }
+
+ return stack;
+ },
+};
+
+exports.WebConsoleUtils = WebConsoleUtils;
+
+/**
+ * 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 Node or null
+ * The result of calling document.querySelector(selector).
+ */
+WebConsoleCommands._registerOriginal("$", function(owner, selector) {
+ try {
+ return owner.window.document.querySelector(selector);
+ } catch (err) {
+ // Throw an error like `err` but that belongs to `owner.window`.
+ throw new owner.window.DOMException(err.message, err.name);
+ }
+});
+
+/**
+ * Find the nodes matching a CSS selector.
+ *
+ * @param string selector
+ * A string that is passed to window.document.querySelectorAll.
+ * @return NodeList
+ * Returns the result of document.querySelectorAll(selector).
+ */
+WebConsoleCommands._registerOriginal("$$", function(owner, selector) {
+ let nodes;
+ try {
+ nodes = owner.window.document.querySelectorAll(selector);
+ } catch (err) {
+ // Throw an error like `err` but that belongs to `owner.window`.
+ throw new owner.window.DOMException(err.message, err.name);
+ }
+
+ // Calling owner.window.Array.from() doesn't work without accessing the
+ // wrappedJSObject, so just loop through the results instead.
+ const 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] Node context
+ * Context to run the xPath query on. Uses window.document if not set.
+ * @param [optional] string|number resultType
+ Specify the result type. Default value XPathResult.ANY_TYPE
+ * @return array of Node
+ */
+WebConsoleCommands._registerOriginal("$x", function(
+ owner,
+ xPath,
+ context,
+ resultType = owner.window.XPathResult.ANY_TYPE
+) {
+ const nodes = new owner.window.Array();
+ // Not waiving Xrays, since we want the original Document.evaluate function,
+ // instead of anything that's been redefined.
+ const doc = owner.window.document;
+ context = context || doc;
+ switch (resultType) {
+ case "number":
+ resultType = owner.window.XPathResult.NUMBER_TYPE;
+ break;
+
+ case "string":
+ resultType = owner.window.XPathResult.STRING_TYPE;
+ break;
+
+ case "bool":
+ resultType = owner.window.XPathResult.BOOLEAN_TYPE;
+ break;
+
+ case "node":
+ resultType = owner.window.XPathResult.FIRST_ORDERED_NODE_TYPE;
+ break;
+
+ case "nodes":
+ resultType = owner.window.XPathResult.UNORDERED_NODE_ITERATOR_TYPE;
+ break;
+ }
+ const results = doc.evaluate(xPath, context, null, resultType, null);
+ if (results.resultType === owner.window.XPathResult.NUMBER_TYPE) {
+ return results.numberValue;
+ }
+ if (results.resultType === owner.window.XPathResult.STRING_TYPE) {
+ return results.stringValue;
+ }
+ if (results.resultType === owner.window.XPathResult.BOOLEAN_TYPE) {
+ return results.booleanValue;
+ }
+ if (
+ results.resultType === owner.window.XPathResult.ANY_UNORDERED_NODE_TYPE ||
+ results.resultType === owner.window.XPathResult.FIRST_ORDERED_NODE_TYPE
+ ) {
+ return results.singleNodeValue;
+ }
+ if (
+ results.resultType ===
+ owner.window.XPathResult.UNORDERED_NODE_SNAPSHOT_TYPE ||
+ results.resultType === owner.window.XPathResult.ORDERED_NODE_SNAPSHOT_TYPE
+ ) {
+ for (let i = 0; i < results.snapshotLength; i++) {
+ nodes.push(results.snapshotItem(i));
+ }
+ return nodes;
+ }
+
+ 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) {
+ const values = [];
+ // Need to waive Xrays so we can iterate functions and accessor properties
+ const waived = Cu.waiveXrays(object);
+ const names = Object.getOwnPropertyNames(waived);
+
+ for (const 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" };
+});
+
+/**
+ * Inspects the passed object. This is done by opening the PropertyPanel.
+ *
+ * @param object object
+ * Object to inspect.
+ */
+WebConsoleCommands._registerOriginal("inspect", function(
+ owner,
+ object,
+ forceExpandInConsole = false
+) {
+ const dbgObj = owner.preprocessDebuggerObject(
+ owner.makeDebuggeeValue(object)
+ );
+
+ const grip = owner.createValueGrip(dbgObj);
+ owner.helperResult = {
+ type: "inspectObject",
+ input: owner.evalInput,
+ object: grip,
+ forceExpandInConsole,
+ };
+});
+
+/**
+ * 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 (Element.isInstance(value)) {
+ 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,
+ };
+});
+
+/**
+ * Take a screenshot of a page.
+ *
+ * @param object args
+ * The arguments to be passed to the screenshot
+ * @return void
+ */
+WebConsoleCommands._registerOriginal("screenshot", function(owner, args = {}) {
+ owner.helperResult = (async () => {
+ // creates data for saving the screenshot
+ // help is handled on the client side
+ const value = await captureScreenshot(args, owner.window.document);
+ return {
+ type: "screenshotOutput",
+ value,
+ // pass args through to the client, so that the client can take care of copying
+ // and saving the screenshot data on the client machine instead of on the
+ // remote machine
+ args,
+ };
+ })();
+});
+
+/**
+ * Block specific resource from loading
+ *
+ * @param object args
+ * an object with key "url", i.e. a filter
+ *
+ * @return void
+ */
+WebConsoleCommands._registerOriginal("block", function(owner, args = {}) {
+ if (!args.url) {
+ owner.helperResult = {
+ type: "error",
+ message: "webconsole.messages.commands.blockArgMissing",
+ };
+ return;
+ }
+
+ owner.helperResult = (async () => {
+ await owner.consoleActor.blockRequest(args);
+
+ return {
+ type: "blockURL",
+ args,
+ };
+ })();
+});
+
+/*
+ * Unblock a blocked a resource
+ *
+ * @param object filter
+ * an object with key "url", i.e. a filter
+ *
+ * @return void
+ */
+WebConsoleCommands._registerOriginal("unblock", function(owner, args = {}) {
+ if (!args.url) {
+ owner.helperResult = {
+ type: "error",
+ message: "webconsole.messages.commands.blockArgMissing",
+ };
+ return;
+ }
+
+ owner.helperResult = (async () => {
+ await owner.consoleActor.unblockRequest(args);
+
+ return {
+ type: "unblockURL",
+ args,
+ };
+ })();
+});
+
+/**
+ * (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) {
+ // Not supporting extra commands in workers yet. This should be possible to
+ // add one by one as long as they don't require jsm, Cu, etc.
+ const commands = isWorker ? [] : WebConsoleCommands._registeredCommands;
+ if (!owner) {
+ throw new Error("The owner is required");
+ }
+ for (const [name, command] of commands) {
+ if (typeof command === "function") {
+ owner.sandbox[name] = command.bind(undefined, owner);
+ } else if (typeof command === "object") {
+ const 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;
diff --git a/devtools/server/actors/webconsole/webidl-deprecated-list.js b/devtools/server/actors/webconsole/webidl-deprecated-list.js
new file mode 100644
index 0000000000..cb4b811bc1
--- /dev/null
+++ b/devtools/server/actors/webconsole/webidl-deprecated-list.js
@@ -0,0 +1,43 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+// This file is automatically generated by the GenerateDataFromWebIdls.py
+// script. Do not modify it manually.
+"use strict";
+
+module.exports = {
+ CanvasRenderingContext2D: [["prototype", "mozImageSmoothingEnabled"]],
+ Document: [
+ ["prototype", "onmozfullscreenchange"],
+ ["prototype", "onmozfullscreenerror"],
+ ],
+ Element: [["prototype", "mozRequestFullScreen"]],
+ HTMLElement: [
+ ["prototype", "onmozfullscreenchange"],
+ ["prototype", "onmozfullscreenerror"],
+ ],
+ ImageBitmapRenderingContext: [["prototype", "transferImageBitmap"]],
+ MathMLElement: [
+ ["prototype", "onmozfullscreenchange"],
+ ["prototype", "onmozfullscreenerror"],
+ ],
+ MouseEvent: [["prototype", "mozPressure"]],
+ Navigator: [["prototype", "mozGetUserMedia"]],
+ RTCPeerConnection: [
+ ["prototype", "getLocalStreams"],
+ ["prototype", "getRemoteStreams"],
+ ],
+ SVGElement: [
+ ["prototype", "onmozfullscreenchange"],
+ ["prototype", "onmozfullscreenerror"],
+ ],
+ Window: [
+ ["prototype", "onmozfullscreenchange"],
+ ["prototype", "onmozfullscreenerror"],
+ ],
+ XULElement: [
+ ["prototype", "onmozfullscreenchange"],
+ ["prototype", "onmozfullscreenerror"],
+ ],
+};
diff --git a/devtools/server/actors/webconsole/webidl-pure-allowlist.js b/devtools/server/actors/webconsole/webidl-pure-allowlist.js
new file mode 100644
index 0000000000..911dbfda85
--- /dev/null
+++ b/devtools/server/actors/webconsole/webidl-pure-allowlist.js
@@ -0,0 +1,72 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+// This file is automatically generated by the GenerateDataFromWebIdls.py
+// script. Do not modify it manually.
+"use strict";
+
+module.exports = {
+ DOMTokenList: [
+ ["prototype", "item"],
+ ["prototype", "contains"],
+ ],
+ Document: [
+ ["prototype", "getElementsByTagName"],
+ ["prototype", "getElementsByTagNameNS"],
+ ["prototype", "getElementsByClassName"],
+ ["prototype", "getElementById"],
+ ["prototype", "getElementsByName"],
+ ["prototype", "querySelector"],
+ ["prototype", "querySelectorAll"],
+ ["prototype", "createNSResolver"],
+ ],
+ Element: [
+ ["prototype", "getAttributeNames"],
+ ["prototype", "getAttribute"],
+ ["prototype", "getAttributeNS"],
+ ["prototype", "hasAttribute"],
+ ["prototype", "hasAttributeNS"],
+ ["prototype", "hasAttributes"],
+ ["prototype", "closest"],
+ ["prototype", "matches"],
+ ["prototype", "webkitMatchesSelector"],
+ ["prototype", "getElementsByTagName"],
+ ["prototype", "getElementsByTagNameNS"],
+ ["prototype", "getElementsByClassName"],
+ ["prototype", "mozMatchesSelector"],
+ ["prototype", "querySelector"],
+ ["prototype", "querySelectorAll"],
+ ["prototype", "getAsFlexContainer"],
+ ["prototype", "getGridFragments"],
+ ["prototype", "hasGridFragments"],
+ ["prototype", "getElementsWithGrid"],
+ ],
+ FormData: [
+ ["prototype", "entries"],
+ ["prototype", "keys"],
+ ["prototype", "values"],
+ ],
+ Headers: [
+ ["prototype", "entries"],
+ ["prototype", "keys"],
+ ["prototype", "values"],
+ ],
+ Node: [
+ ["prototype", "getRootNode"],
+ ["prototype", "hasChildNodes"],
+ ["prototype", "isSameNode"],
+ ["prototype", "isEqualNode"],
+ ["prototype", "compareDocumentPosition"],
+ ["prototype", "contains"],
+ ["prototype", "lookupPrefix"],
+ ["prototype", "lookupNamespaceURI"],
+ ["prototype", "isDefaultNamespace"],
+ ],
+ Performance: [["prototype", "now"]],
+ URLSearchParams: [
+ ["prototype", "entries"],
+ ["prototype", "keys"],
+ ["prototype", "values"],
+ ],
+};
diff --git a/devtools/server/actors/webconsole/worker-listeners.js b/devtools/server/actors/webconsole/worker-listeners.js
new file mode 100644
index 0000000000..6861c6da62
--- /dev/null
+++ b/devtools/server/actors/webconsole/worker-listeners.js
@@ -0,0 +1,35 @@
+/* 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/. */
+
+/* global setConsoleEventHandler, retrieveConsoleEvents */
+
+"use strict";
+
+// 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 but the listeners defined here
+// are imported by webconsole-utils and used for the webconsole actor.
+class ConsoleAPIListener {
+ constructor(window, listener, consoleID) {
+ this.window = window;
+ this.listener = listener;
+ this.consoleID = consoleID;
+ this.observe = this.observe.bind(this);
+ }
+
+ init() {
+ setConsoleEventHandler(this.observe);
+ }
+ destroy() {
+ setConsoleEventHandler(null);
+ }
+ observe(message) {
+ this.listener(message.wrappedJSObject);
+ }
+ getCachedMessages() {
+ return retrieveConsoleEvents();
+ }
+}
+
+exports.ConsoleAPIListener = ConsoleAPIListener;