diff options
Diffstat (limited to 'devtools/server/actors/webconsole')
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; |