/* This Smurce 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"; loader.lazyRequireGetter( this, ["getCommandAndArgs"], "resource://devtools/server/actors/webconsole/commands/parser.js", true ); loader.lazyRequireGetter( this, ["DOM_MUTATIONS"], "resource://devtools/server/tracer/tracer.jsm", true ); loader.lazyGetter(this, "l10n", () => { return new Localization( [ "devtools/shared/webconsole-commands.ftl", "devtools/server/actors/webconsole/commands/experimental-commands.ftl", ], true ); }); const USAGE_STRING_MAPPING = { block: "webconsole-commands-usage-block", trace: "webconsole-commands-usage-trace3", unblock: "webconsole-commands-usage-unblock", }; /** * WebConsole commands manager. * * Defines a set of functions / variables ("commands") that are available from * the Web Console but not from the web page. * */ const WebConsoleCommandsManager = { // Flag used by eager evaluation in order to allow the execution of commands // which are side effect free and disallow all the others. SIDE_EFFECT_FREE: Symbol("SIDE_EFFECT_FREE"), // Map of command name to command function or property descriptor (see register method) _registeredCommands: new Map(), // Map of command name to optional array of accepted argument names _validArguments: new Map(), // Set of command names that are side effect free _sideEffectFreeCommands: new Set(), /** * Register a new command. * * @param {Object} options * @param {string} options.name * The command name (exemple: "$", "screenshot",...)) * @param {Boolean} isSideEffectFree * Tells if the command is free of any side effect to know * if it can run in eager console evaluation. * @param {function|object} options.command * The command to register. * It can be: * - a function for the command like "$()" or ":screenshot" * which triggers some code. * - a property descriptor for getters like "$0", * which only returns a value. * @param {Array} options.validArguments * Optional list of valid arguments. * If passed, we will assert that passed arguments are all valid on execution. * * The command function or the command getter are passed a: * - "owner" object as their first parameter (see the example below). * See _createOwnerObject for definition. * - "args" object with all parameters when this is ran as a ":my-command" command. * See getCommandAndArgs for definition. * * Note that if you want to support `--help` argument, you need to provide a usage string in: * devtools/shared/locales/en-US/webconsole-commands.properties * * @example * * WebConsoleCommandsManager.register("$", function (owner, selector) * { * return owner.window.document.querySelector(selector); * }, * ["my-argument"]); * * WebConsoleCommandsManager.register("$0", { * get: function(owner) { * return owner.makeDebuggeeValue(owner.selectedNode); * } * }); */ register({ name, isSideEffectFree, command, validArguments }) { if ( typeof command != "function" && !(typeof command == "object" && typeof command.get == "function") ) { throw new Error( "Invalid web console command. It can only be a function, or an object with a function as 'get' attribute" ); } if (typeof isSideEffectFree !== "boolean") { throw new Error( "Invalid web console command. 'isSideEffectFree' attribute should be set and be a boolean" ); } this._registeredCommands.set(name, command); if (validArguments) { this._validArguments.set(name, validArguments); } if (isSideEffectFree) { this._sideEffectFreeCommands.add(name); } }, /** * Return the name of all registered commands. * * @return {array} List of all command names. */ getAllCommandNames() { return [...this._registeredCommands.keys()]; }, /** * There is two types of "commands" here. * * - Functions or variables exposed in the scope of the evaluated string from the WebConsole input. * Example: $(), $0, copy(), clear(),... * - "True commands", which can also be ran from the WebConsole input with ":" prefix. * Example: this list of commands. * Note that some "true commands" are not exposed as function (see getColonOnlyCommandNames). * * The following list distinguish these "true commands" from the first category. * It especially avoid any JavaScript evaluation when the frontend tries to execute * a string starting with ':' character. */ getAllColonCommandNames() { return ["block", "help", "history", "screenshot", "unblock", "trace"]; }, /** * Some commands are not exposed in the scope of the evaluated string, * and can only be used via `:command-name`. */ getColonOnlyCommandNames() { return ["screenshot", "trace"]; }, /** * Map of all command objects keyed by command name. * Commands object are the objects passed to register() method. * * @return {Map command>} */ getAllCommands() { return this._registeredCommands; }, /** * Is the command name possibly overriding a symbol which * already exists in the paused frame or the global into which * we are about to execute into? */ _isCommandNameAlreadyInScope(name, frame, dbgGlobal) { if (frame && frame.environment) { return !!frame.environment.find(name); } // Fallback on global scope when Debugger.Frame doesn't come along an // Environment, or is not a frame. try { // This can throw in Browser Toolbox tests const globalEnv = dbgGlobal.asEnvironment(); if (globalEnv) { return !!dbgGlobal.asEnvironment().find(name); } } catch {} return !!dbgGlobal.getOwnPropertyDescriptor(name); }, _createOwnerObject( consoleActor, debuggerGlobal, evalInput, selectedNodeActorID ) { const owner = { window: consoleActor.evalGlobal, makeDebuggeeValue: debuggerGlobal.makeDebuggeeValue.bind(debuggerGlobal), createValueGrip: consoleActor.createValueGrip.bind(consoleActor), preprocessDebuggerObject: consoleActor.preprocessDebuggerObject.bind(consoleActor), helperResult: null, consoleActor, evalInput, }; if (selectedNodeActorID) { const actor = consoleActor.conn.getActor(selectedNodeActorID); if (actor) { owner.selectedNode = actor.rawNode; } } return owner; }, _getCommandsForCurrentEnvironment() { // Not supporting extra commands in workers yet. This should be possible to // add one by one as long as they don't require jsm/mjs, Cu, etc. return isWorker ? new Map() : this.getAllCommands(); }, /** * Create an object with the API we expose to the Web Console during * JavaScript evaluation. * This object inherits properties and methods from the Web Console actor. * * @param object consoleActor * The related web console actor evaluating some code. * @param object debuggerGlobal * A Debugger.Object that wraps a content global. This is used for the * Web Console Commands. * @param object frame (optional) * The frame where the string was evaluated. * @param string evalInput * String to evaluate. * @param string selectedNodeActorID * The Node actor ID of the currently selected DOM Element, if any is selected. * @param bool preferConsoleCommandsOverLocalSymbols * If true, define all bindings even if there's conflicting existing * symbols. This is for the case evaluating non-user code in frame * environment. * * @return object * Object with two properties: * - 'bindings', the object with all commands set as attribute on this object. * - 'getHelperResult', a live getter returning the additional data the last command * which executed want to convey to the frontend. * (The return value of commands isn't returned to the client but it only * returned to the code ran from console evaluation) */ getWebConsoleCommands( consoleActor, debuggerGlobal, frame, evalInput, selectedNodeActorID, preferConsoleCommandsOverLocalSymbols ) { const bindings = Object.create(null); const owner = this._createOwnerObject( consoleActor, debuggerGlobal, evalInput, selectedNodeActorID ); const evalGlobal = consoleActor.evalGlobal; function maybeExport(obj, name) { if (typeof obj[name] != "function") { return; } // By default, chrome-implemented functions that are exposed to content // refuse to accept arguments that are cross-origin for the caller. This // is generally the safe thing, but causes problems for certain console // helpers like cd(), where we users sometimes want to pass a cross-origin // window. To circumvent this restriction, we use exportFunction along // with a special option designed for this purpose. See bug 1051224. obj[name] = Cu.exportFunction(obj[name], evalGlobal, { allowCrossOriginArguments: true, }); } const commands = this._getCommandsForCurrentEnvironment(); const colonOnlyCommandNames = this.getColonOnlyCommandNames(); for (const [name, command] of commands) { // When we run user code in frame, we want to avoid overriding existing // symbols with commands. // // When we run user code in global scope, all bindings are automatically // shadowed, except for "help" function which is checked by getEvalInput. // // When preferConsoleCommandsOverLocalSymbols is true, ignore symbols in // the current scope and always use commands ones. if ( !preferConsoleCommandsOverLocalSymbols && (frame || name === "help") && this._isCommandNameAlreadyInScope(name, frame, debuggerGlobal) ) { continue; } // Also ignore commands which can only be run with the `:` prefix. if (colonOnlyCommandNames.includes(name)) { continue; } const descriptor = { // We force the enumerability and the configurability (so the // WebConsoleActor can reconfigure the property). enumerable: true, configurable: true, }; if (typeof command === "function") { // Function commands descriptor.value = command.bind(undefined, owner); maybeExport(descriptor, "value"); // Unfortunately evalWithBindings will access all bindings values, // which would trigger a debuggee native call because bindings's property // is using Cu.exportFunction. // Put a magic symbol attribute on them in order to carefully accept // all bindings as being side effect safe by default. if (this._sideEffectFreeCommands.has(name)) { descriptor.value.isSideEffectFree = this.SIDE_EFFECT_FREE; } // Make sure the helpers can be used during eval. descriptor.value = debuggerGlobal.makeDebuggeeValue(descriptor.value); } else if (typeof command?.get === "function") { // Getter commands descriptor.get = command.get.bind(undefined, owner); maybeExport(descriptor, "get"); // See comment in previous block. if (this._sideEffectFreeCommands.has(name)) { descriptor.get.isSideEffectFree = this.SIDE_EFFECT_FREE; } } Object.defineProperty(bindings, name, descriptor); } return { // Use a method as commands will update owner.helperResult later getHelperResult() { return owner.helperResult; }, bindings, }; }, /** * Create a function for given ':command'-style command. * * @param object consoleActor * The related web console actor evaluating some code. * @param object debuggerGlobal * A Debugger.Object that wraps a content global. This is used for the * Web Console Commands. * @param string selectedNodeActorID * The Node actor ID of the currently selected DOM Element, if any is selected. * @param string evalInput * String to evaluate. * * @return object * Object with two properties: * - 'commandFunc', a function corresponds to the 'commandName' * - 'getHelperResult', a live getter returning the data the command * which executed want to convey to the frontend. */ executeCommand(consoleActor, debuggerGlobal, selectedNodeActorID, evalInput) { const { command, args } = getCommandAndArgs(evalInput); const commands = this._getCommandsForCurrentEnvironment(); if (!commands.has(command)) { throw new Error(`Unsupported command '${command}'`); } if (args.help || args.usage) { const l10nKey = USAGE_STRING_MAPPING[command]; if (l10nKey) { const message = l10n.formatValueSync(l10nKey); if (message && message !== l10nKey) { return { result: null, helperResult: { type: "usage", message, }, }; } } } const validArguments = this._validArguments.get(command); if (validArguments) { for (const key of Object.keys(args)) { if (!validArguments.includes(key)) { throw new Error( `:${command} command doesn't support '${key}' argument.` ); } } } const owner = this._createOwnerObject( consoleActor, debuggerGlobal, evalInput, selectedNodeActorID ); const commandFunction = commands.get(command); // This is where we run the command passed to register method const result = commandFunction(owner, args); return { result, // commandFunction may mutate owner.helperResult which is used // to convey additional data to the frontend. helperResult: owner.helperResult, }; }, }; exports.WebConsoleCommandsManager = WebConsoleCommandsManager; /* * 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 the first node matching a CSS selector. * * @param string selector * A string that is passed to window.document.querySelector * @param [optional] Node element * An optional Node to replace window.document * @return Node or null * The result of calling document.querySelector(selector). */ WebConsoleCommandsManager.register({ name: "$", isSideEffectFree: true, command(owner, selector, element) { try { if ( element && element.querySelector && (element.nodeType == Node.ELEMENT_NODE || element.nodeType == Node.DOCUMENT_NODE || element.nodeType == Node.DOCUMENT_FRAGMENT_NODE) ) { return element.querySelector(selector); } 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. * @param [optional] Node element * An optional Node to replace window.document * @return array of Node * The result of calling document.querySelector(selector) in an array. */ WebConsoleCommandsManager.register({ name: "$$", isSideEffectFree: true, command(owner, selector, element) { let scope = owner.window.document; try { if ( element && element.querySelectorAll && (element.nodeType == Node.ELEMENT_NODE || element.nodeType == Node.DOCUMENT_NODE || element.nodeType == Node.DOCUMENT_FRAGMENT_NODE) ) { scope = element; } const nodes = scope.querySelectorAll(selector); const result = new owner.window.Array(); // Calling owner.window.Array.from() doesn't work without accessing the // wrappedJSObject, so just loop through the results instead. for (let i = 0; i < nodes.length; i++) { result.push(nodes[i]); } return result; } catch (err) { // Throw an error like `err` but that belongs to `owner.window`. throw new owner.window.DOMException(err.message, err.name); } }, }); /** * Returns the result of the last console input evaluation * * @return object|undefined * Returns last console evaluation or undefined */ WebConsoleCommandsManager.register({ name: "$_", isSideEffectFree: true, command: { get(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 */ WebConsoleCommandsManager.register({ name: "$x", isSideEffectFree: true, command( 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. */ WebConsoleCommandsManager.register({ name: "$0", isSideEffectFree: true, command: { get(owner) { return owner.makeDebuggeeValue(owner.selectedNode); }, }, }); /** * Clears the output of the WebConsole. */ WebConsoleCommandsManager.register({ name: "clear", isSideEffectFree: false, command(owner) { owner.helperResult = { type: "clearOutput", }; }, }); /** * Clears the input history of the WebConsole. */ WebConsoleCommandsManager.register({ name: "clearHistory", isSideEffectFree: false, command(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 */ WebConsoleCommandsManager.register({ name: "keys", isSideEffectFree: true, command(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 */ WebConsoleCommandsManager.register({ name: "values", isSideEffectFree: true, command(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. */ WebConsoleCommandsManager.register({ name: "help", isSideEffectFree: false, command(owner) { owner.helperResult = { type: "help" }; }, }); /** * Inspects the passed object. This is done by opening the PropertyPanel. * * @param object object * Object to inspect. */ WebConsoleCommandsManager.register({ name: "inspect", isSideEffectFree: false, command(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 */ WebConsoleCommandsManager.register({ name: "copy", isSideEffectFree: false, command(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) { owner.helperResult = { type: "error", message: "webconsole.error.commands.copyError", messageArgs: [ex.toString()], }; return; } 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 */ WebConsoleCommandsManager.register({ name: "screenshot", isSideEffectFree: false, command(owner, args = {}) { owner.helperResult = { type: "screenshotOutput", args, }; }, }); /** * Shows a history of commands and expressions previously executed within the command line. * * @param object args * The arguments to be passed to the history * @return void */ WebConsoleCommandsManager.register({ name: "history", isSideEffectFree: false, command(owner, args = {}) { owner.helperResult = { type: "historyOutput", args, }; }, }); /** * Block specific resource from loading * * @param object args * an object with key "url", i.e. a filter * * @return void */ WebConsoleCommandsManager.register({ name: "block", isSideEffectFree: false, command(owner, args = {}) { // Note that this command is implemented in the frontend, from actions's input.js // We only forward the command arguments back to the client. if (!args.url) { owner.helperResult = { type: "error", message: "webconsole.messages.commands.blockArgMissing", }; return; } owner.helperResult = { type: "blockURL", args, }; }, validArguments: ["url"], }); /* * Unblock a blocked a resource * * @param object filter * an object with key "url", i.e. a filter * * @return void */ WebConsoleCommandsManager.register({ name: "unblock", isSideEffectFree: false, command(owner, args = {}) { // Note that this command is implemented in the frontend, from actions's input.js // We only forward the command arguments back to the client. if (!args.url) { owner.helperResult = { type: "error", message: "webconsole.messages.commands.blockArgMissing", }; return; } owner.helperResult = { type: "unblockURL", args, }; }, validArguments: ["url"], }); /* * Toggle JavaScript tracing * * @param object args * An object with various configuration only valid when starting the tracing. * * @return void */ WebConsoleCommandsManager.register({ name: "trace", isSideEffectFree: false, command(owner, args) { if (isWorker) { throw new Error(":trace command isn't supported in workers"); } // Disable :trace command on worker until this feature is enabled by default if ( !Services.prefs.getBoolPref( "devtools.debugger.features.javascript-tracing", false ) ) { throw new Error( ":trace requires 'devtools.debugger.features.javascript-tracing' preference to be true" ); } const tracerActor = owner.consoleActor.parentActor.getTargetScopedActor("tracer"); const logMethod = args.logMethod || "console"; let traceDOMMutations = null; if ("dom-mutations" in args) { // When no value is passed, track all types of mutations if (args["dom-mutations"] === true) { traceDOMMutations = ["add", "attributes", "remove"]; } else if (typeof args["dom-mutations"] == "string") { // Otherwise consider the value as coma seperated list and remove any white space. traceDOMMutations = args["dom-mutations"].split(",").map(e => e.trim()); const acceptedValues = Object.values(DOM_MUTATIONS); if (!traceDOMMutations.every(e => acceptedValues.includes(e))) { throw new Error( `:trace --dom-mutations only accept a list of strings whose values can be: ${acceptedValues}` ); } } else { throw new Error( ":trace --dom-mutations accept only no arguments, or a list mutation type strings (add,attributes,remove)" ); } } // Note that toggleTracing does some sanity checks and will throw meaningful error // when the arguments are wrong. const enabled = tracerActor.toggleTracing({ logMethod, prefix: args.prefix || null, traceFunctionReturn: !!args.returns, traceValues: !!args.values, traceOnNextInteraction: args["on-next-interaction"] || null, traceDOMMutations, maxDepth: args["max-depth"] || null, maxRecords: args["max-records"] || null, }); owner.helperResult = { type: "traceOutput", enabled, logMethod, }; }, validArguments: [ "logMethod", "max-depth", "max-records", "on-next-interaction", "dom-mutations", "prefix", "returns", "values", ], });