/* 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 clearConsoleEvents */ "use strict"; const { Actor } = require("resource://devtools/shared/protocol.js"); const { webconsoleSpec, } = require("resource://devtools/shared/specs/webconsole.js"); const { DevToolsServer, } = require("resource://devtools/server/devtools-server.js"); const { ThreadActor } = require("resource://devtools/server/actors/thread.js"); const { ObjectActor } = require("resource://devtools/server/actors/object.js"); const { LongStringActor, } = require("resource://devtools/server/actors/string.js"); const { createValueGrip, isArray, stringIsLong, } = require("resource://devtools/server/actors/object/utils.js"); const DevToolsUtils = require("resource://devtools/shared/DevToolsUtils.js"); const ErrorDocs = require("resource://devtools/server/actors/errordocs.js"); const Targets = require("resource://devtools/server/actors/targets/index.js"); loader.lazyRequireGetter( this, "evalWithDebugger", "resource://devtools/server/actors/webconsole/eval-with-debugger.js", true ); loader.lazyRequireGetter( this, "ConsoleFileActivityListener", "resource://devtools/server/actors/webconsole/listeners/console-file-activity.js", true ); loader.lazyRequireGetter( this, "jsPropertyProvider", "resource://devtools/shared/webconsole/js-property-provider.js", true ); loader.lazyRequireGetter( this, ["isCommand"], "resource://devtools/server/actors/webconsole/commands/parser.js", true ); loader.lazyRequireGetter( this, ["CONSOLE_WORKER_IDS", "WebConsoleUtils"], "resource://devtools/server/actors/webconsole/utils.js", true ); loader.lazyRequireGetter( this, ["WebConsoleCommandsManager"], "resource://devtools/server/actors/webconsole/commands/manager.js", true ); loader.lazyRequireGetter( this, "EnvironmentActor", "resource://devtools/server/actors/environment.js", true ); loader.lazyRequireGetter( this, "EventEmitter", "resource://devtools/shared/event-emitter.js" ); loader.lazyRequireGetter( this, "MESSAGE_CATEGORY", "resource://devtools/shared/constants.js", true ); // Generated by /devtools/shared/webconsole/GenerateReservedWordsJS.py loader.lazyRequireGetter( this, "RESERVED_JS_KEYWORDS", "resource://devtools/shared/webconsole/reserved-js-words.js" ); // Overwrite implemented listeners for workers so that we don't attempt // to load an unsupported module. if (isWorker) { loader.lazyRequireGetter( this, ["ConsoleAPIListener", "ConsoleServiceListener"], "resource://devtools/server/actors/webconsole/worker-listeners.js", true ); } else { loader.lazyRequireGetter( this, "ConsoleAPIListener", "resource://devtools/server/actors/webconsole/listeners/console-api.js", true ); loader.lazyRequireGetter( this, "ConsoleServiceListener", "resource://devtools/server/actors/webconsole/listeners/console-service.js", true ); loader.lazyRequireGetter( this, "ConsoleReflowListener", "resource://devtools/server/actors/webconsole/listeners/console-reflow.js", true ); loader.lazyRequireGetter( this, "DocumentEventsListener", "resource://devtools/server/actors/webconsole/listeners/document-events.js", true ); } loader.lazyRequireGetter( this, "ObjectUtils", "resource://devtools/server/actors/object/utils.js" ); function isObject(value) { return Object(value) === value; } /** * The WebConsoleActor implements capabilities needed for the Web Console * feature. * * @constructor * @param object connection * The connection to the client, DevToolsServerConnection. * @param object [parentActor] * Optional, the parent actor. */ class WebConsoleActor extends Actor { constructor(connection, parentActor) { super(connection, webconsoleSpec); this.parentActor = parentActor; this.dbg = this.parentActor.dbg; this._gripDepth = 0; this._evalCounter = 0; this._listeners = new Set(); this._lastConsoleInputEvaluation = undefined; this.objectGrip = this.objectGrip.bind(this); this._onWillNavigate = this._onWillNavigate.bind(this); this._onChangedToplevelDocument = this._onChangedToplevelDocument.bind(this); this.onConsoleServiceMessage = this.onConsoleServiceMessage.bind(this); this.onConsoleAPICall = this.onConsoleAPICall.bind(this); this.onDocumentEvent = this.onDocumentEvent.bind(this); EventEmitter.on( this.parentActor, "changed-toplevel-document", this._onChangedToplevelDocument ); } /** * Debugger instance. * * @see jsdebugger.sys.mjs */ dbg = null; /** * This is used by the ObjectActor to keep track of the depth of grip() calls. * @private * @type number */ _gripDepth = null; /** * Holds a set of all currently registered listeners. * * @private * @type Set */ _listeners = null; /** * The global we work with (this can be a Window, a Worker global or even a Sandbox * for processes and addons). * * @type nsIDOMWindow, WorkerGlobalScope or Sandbox */ get global() { if (this.parentActor.isRootActor) { return this._getWindowForBrowserConsole(); } return this.parentActor.window || this.parentActor.workerGlobal; } /** * Get a window to use for the browser console. * * (note that is is also used for browser toolbox and webextension * i.e. all targets flagged with isRootActor=true) * * @private * @return nsIDOMWindow * The window to use, or null if no window could be found. */ _getWindowForBrowserConsole() { // Check if our last used chrome window is still live. let window = this._lastChromeWindow && this._lastChromeWindow.get(); // If not, look for a new one. // In case of WebExtension reload of the background page, the last // chrome window might be a dead wrapper, from which we can't check for window.closed. if (!window || Cu.isDeadWrapper(window) || window.closed) { window = this.parentActor.window; if (!window) { // Try to find the Browser Console window to use instead. window = Services.wm.getMostRecentWindow("devtools:webconsole"); // We prefer the normal chrome window over the console window, // so we'll look for those windows in order to replace our reference. const onChromeWindowOpened = () => { // We'll look for this window when someone next requests window() Services.obs.removeObserver(onChromeWindowOpened, "domwindowopened"); this._lastChromeWindow = null; }; Services.obs.addObserver(onChromeWindowOpened, "domwindowopened"); } this._handleNewWindow(window); } return window; } /** * Store a newly found window on the actor to be used in the future. * * @private * @param nsIDOMWindow window * The window to store on the actor (can be null). */ _handleNewWindow(window) { if (window) { if (this._hadChromeWindow) { Services.console.logStringMessage("Webconsole context has changed"); } this._lastChromeWindow = Cu.getWeakReference(window); this._hadChromeWindow = true; } else { this._lastChromeWindow = null; } } /** * Whether we've been using a window before. * * @private * @type boolean */ _hadChromeWindow = false; /** * A weak reference to the last chrome window we used to work with. * * @private * @type nsIWeakReference */ _lastChromeWindow = null; // The evalGlobal is used at the scope for JS evaluation. _evalGlobal = null; get evalGlobal() { return this._evalGlobal || this.global; } set evalGlobal(global) { this._evalGlobal = global; if (!this._progressListenerActive) { EventEmitter.on(this.parentActor, "will-navigate", this._onWillNavigate); this._progressListenerActive = true; } } /** * Flag used to track if we are listening for events from the progress * listener of the target actor. We use the progress listener to clear * this.evalGlobal on page navigation. * * @private * @type boolean */ _progressListenerActive = false; /** * The ConsoleServiceListener instance. * @type object */ consoleServiceListener = null; /** * The ConsoleAPIListener instance. */ consoleAPIListener = null; /** * The ConsoleFileActivityListener instance. */ consoleFileActivityListener = null; /** * The ConsoleReflowListener instance. */ consoleReflowListener = null; grip() { return { actor: this.actorID }; } _findProtoChain = ThreadActor.prototype._findProtoChain; _removeFromProtoChain = ThreadActor.prototype._removeFromProtoChain; /** * Destroy the current WebConsoleActor instance. */ destroy() { this.stopListeners(); super.destroy(); EventEmitter.off( this.parentActor, "changed-toplevel-document", this._onChangedToplevelDocument ); this._lastConsoleInputEvaluation = null; this._evalGlobal = null; this.dbg = null; } /** * Create and return an environment actor that corresponds to the provided * Debugger.Environment. This is a straightforward clone of the ThreadActor's * method except that it stores the environment actor in the web console * actor's pool. * * @param Debugger.Environment environment * The lexical environment we want to extract. * @return The EnvironmentActor for |environment| or |undefined| for host * functions or functions scoped to a non-debuggee global. */ createEnvironmentActor(environment) { if (!environment) { return undefined; } if (environment.actor) { return environment.actor; } const actor = new EnvironmentActor(environment, this); this.manage(actor); environment.actor = actor; return actor; } /** * Create a grip for the given value. * * @param mixed value * @return object */ createValueGrip(value) { return createValueGrip(value, this, this.objectGrip); } /** * Make a debuggee value for the given value. * * @param mixed value * The value you want to get a debuggee value for. * @param boolean useObjectGlobal * If |true| the object global is determined and added as a debuggee, * otherwise |this.global| is used when makeDebuggeeValue() is invoked. * @return object * Debuggee value for |value|. */ makeDebuggeeValue(value, useObjectGlobal) { if (useObjectGlobal && isObject(value)) { try { const global = Cu.getGlobalForObject(value); const dbgGlobal = this.dbg.makeGlobalObjectReference(global); return dbgGlobal.makeDebuggeeValue(value); } catch (ex) { // The above can throw an exception if value is not an actual object // or 'Object in compartment marked as invisible to Debugger' } } const dbgGlobal = this.dbg.makeGlobalObjectReference(this.global); return dbgGlobal.makeDebuggeeValue(value); } /** * Create a grip for the given object. * * @param object object * The object you want. * @param object pool * A Pool where the new actor instance is added. * @param object * The object grip. */ objectGrip(object, pool) { const actor = new ObjectActor( object, { thread: this.parentActor.threadActor, getGripDepth: () => this._gripDepth, incrementGripDepth: () => this._gripDepth++, decrementGripDepth: () => this._gripDepth--, createValueGrip: v => this.createValueGrip(v), createEnvironmentActor: env => this.createEnvironmentActor(env), }, this.conn ); pool.manage(actor); return actor.form(); } /** * Create a grip for the given string. * * @param string string * The string you want to create the grip for. * @param object pool * A Pool where the new actor instance is added. * @return object * A LongStringActor object that wraps the given string. */ longStringGrip(string, pool) { const actor = new LongStringActor(this.conn, string); pool.manage(actor); return actor.form(); } /** * Create a long string grip if needed for the given string. * * @private * @param string string * The string you want to create a long string grip for. * @return string|object * A string is returned if |string| is not a long string. * A LongStringActor grip is returned if |string| is a long string. */ _createStringGrip(string) { if (string && stringIsLong(string)) { return this.longStringGrip(string, this); } return string; } /** * Returns the latest web console input evaluation. * This is undefined if no evaluations have been completed. * * @return object */ getLastConsoleInputEvaluation() { return this._lastConsoleInputEvaluation; } /** * Preprocess a debugger object (e.g. return the `boundTargetFunction` * debugger object if the given debugger object is a bound function). * * This method is called by both the `inspect` binding implemented * for the webconsole and the one implemented for the devtools API * `browser.devtools.inspectedWindow.eval`. */ preprocessDebuggerObject(dbgObj) { // Returns the bound target function on a bound function. if (dbgObj?.isBoundFunction && dbgObj?.boundTargetFunction) { return dbgObj.boundTargetFunction; } return dbgObj; } /** * This helper is used by the WebExtensionInspectedWindowActor to * inspect an object in the developer toolbox. * * NOTE: shared parts related to preprocess the debugger object (between * this function and the `inspect` webconsole command defined in * "devtools/server/actor/webconsole/utils.js") should be added to * the webconsole actors' `preprocessDebuggerObject` method. */ inspectObject(dbgObj, inspectFromAnnotation) { dbgObj = this.preprocessDebuggerObject(dbgObj); this.emit("inspectObject", { objectActor: this.createValueGrip(dbgObj), inspectFromAnnotation, }); } // Request handlers for known packet types. /** * Handler for the "startListeners" request. * * @param array listeners * An array of events to start sent by the Web Console client. * @return object * The response object which holds the startedListeners array. */ // eslint-disable-next-line complexity async startListeners(listeners) { const startedListeners = []; const global = !this.parentActor.isRootActor ? this.global : null; const isTargetActorContentProcess = this.parentActor.targetType === Targets.TYPES.PROCESS; for (const event of listeners) { switch (event) { case "PageError": // Workers don't support this message type yet if (isWorker) { break; } if (!this.consoleServiceListener) { this.consoleServiceListener = new ConsoleServiceListener( global, this.onConsoleServiceMessage, { matchExactWindow: this.parentActor.ignoreSubFrames, } ); this.consoleServiceListener.init(); } startedListeners.push(event); break; case "ConsoleAPI": if (!this.consoleAPIListener) { // Create the consoleAPIListener // (and apply the filtering options defined in the parent actor). this.consoleAPIListener = new ConsoleAPIListener( global, this.onConsoleAPICall, { matchExactWindow: this.parentActor.ignoreSubFrames, ...(this.parentActor.consoleAPIListenerOptions || {}), } ); this.consoleAPIListener.init(); } startedListeners.push(event); break; case "NetworkActivity": // Workers don't support this message type if (isWorker) { break; } // Bug 1807650 removed this in favor of the new Watcher/Resources APIs const errorMessage = "NetworkActivity is no longer supported. " + "Instead use Watcher actor's watchResources and listen to NETWORK_EVENT resource"; dump(errorMessage + "\n"); throw new Error(errorMessage); case "FileActivity": // Workers don't support this message type if (isWorker) { break; } if (this.global instanceof Ci.nsIDOMWindow) { if (!this.consoleFileActivityListener) { this.consoleFileActivityListener = new ConsoleFileActivityListener(this.global, this); } this.consoleFileActivityListener.startMonitor(); startedListeners.push(event); } break; case "ReflowActivity": // Workers don't support this message type if (isWorker) { break; } if (!this.consoleReflowListener) { this.consoleReflowListener = new ConsoleReflowListener( this.global, this ); } startedListeners.push(event); break; case "DocumentEvents": // Workers don't support this message type if (isWorker || isTargetActorContentProcess) { break; } if (!this.documentEventsListener) { this.documentEventsListener = new DocumentEventsListener( this.parentActor ); this.documentEventsListener.on("dom-loading", data => this.onDocumentEvent("dom-loading", data) ); this.documentEventsListener.on("dom-interactive", data => this.onDocumentEvent("dom-interactive", data) ); this.documentEventsListener.on("dom-complete", data => this.onDocumentEvent("dom-complete", data) ); this.documentEventsListener.listen(); } startedListeners.push(event); break; } } // Update the live list of running listeners startedListeners.forEach(this._listeners.add, this._listeners); return { startedListeners, }; } /** * Handler for the "stopListeners" request. * * @param array listeners * An array of events to stop sent by the Web Console client. * @return object * The response packet to send to the client: holds the * stoppedListeners array. */ stopListeners(listeners) { const stoppedListeners = []; // If no specific listeners are requested to be detached, we stop all // listeners. const eventsToDetach = listeners || [ "PageError", "ConsoleAPI", "FileActivity", "ReflowActivity", "DocumentEvents", ]; for (const event of eventsToDetach) { switch (event) { case "PageError": if (this.consoleServiceListener) { this.consoleServiceListener.destroy(); this.consoleServiceListener = null; } stoppedListeners.push(event); break; case "ConsoleAPI": if (this.consoleAPIListener) { this.consoleAPIListener.destroy(); this.consoleAPIListener = null; } stoppedListeners.push(event); break; case "FileActivity": if (this.consoleFileActivityListener) { this.consoleFileActivityListener.stopMonitor(); this.consoleFileActivityListener = null; } stoppedListeners.push(event); break; case "ReflowActivity": if (this.consoleReflowListener) { this.consoleReflowListener.destroy(); this.consoleReflowListener = null; } stoppedListeners.push(event); break; case "DocumentEvents": if (this.documentEventsListener) { this.documentEventsListener.destroy(); this.documentEventsListener = null; } stoppedListeners.push(event); break; } } // Update the live list of running listeners stoppedListeners.forEach(this._listeners.delete, this._listeners); return { stoppedListeners }; } /** * Handler for the "getCachedMessages" request. This method sends the cached * error messages and the window.console API calls to the client. * * @param array messageTypes * An array of message types sent by the Web Console client. * @return object * The response packet to send to the client: it holds the cached * messages array. */ getCachedMessages(messageTypes) { if (!messageTypes) { return { error: "missingParameter", message: "The messageTypes parameter is missing.", }; } const messages = []; const consoleServiceCachedMessages = messageTypes.includes("PageError") || messageTypes.includes("LogMessage") ? this.consoleServiceListener?.getCachedMessages( !this.parentActor.isRootActor ) : null; for (const type of messageTypes) { switch (type) { case "ConsoleAPI": { if (!this.consoleAPIListener) { break; } // this.global might not be a window (can be a worker global or a Sandbox), // and in such case performance isn't defined const winStartTime = this.global?.performance?.timing?.navigationStart; const cache = this.consoleAPIListener.getCachedMessages( !this.parentActor.isRootActor ); cache.forEach(cachedMessage => { // Filter out messages that came from a ServiceWorker but happened // before the page was requested. if ( cachedMessage.innerID === "ServiceWorker" && winStartTime > cachedMessage.timeStamp ) { return; } messages.push({ message: this.prepareConsoleMessageForRemote(cachedMessage), type: "consoleAPICall", }); }); break; } case "PageError": { if (!consoleServiceCachedMessages) { break; } for (const cachedMessage of consoleServiceCachedMessages) { if (!(cachedMessage instanceof Ci.nsIScriptError)) { continue; } messages.push({ pageError: this.preparePageErrorForRemote(cachedMessage), type: "pageError", }); } break; } case "LogMessage": { if (!consoleServiceCachedMessages) { break; } for (const cachedMessage of consoleServiceCachedMessages) { if (cachedMessage instanceof Ci.nsIScriptError) { continue; } messages.push({ message: this._createStringGrip(cachedMessage.message), timeStamp: cachedMessage.microSecondTimeStamp / 1000, type: "logMessage", }); } break; } } } return { messages, }; } /** * Handler for the "evaluateJSAsync" request. This method evaluates a given * JavaScript string with an associated `resultID`. * * The result will be returned later as an unsolicited `evaluationResult`, * that can be associated back to this request via the `resultID` field. * * @param object request * The JSON request object received from the Web Console client. * @return object * The response packet to send to with the unique id in the * `resultID` field. */ async evaluateJSAsync(request) { const startTime = ChromeUtils.dateNow(); // Use a timestamp instead of a UUID as this code is used by workers, which // don't have access to the UUID XPCOM component. // Also use a counter in order to prevent mixing up response when calling // at the exact same time. const resultID = startTime + "-" + this._evalCounter++; // Execute the evaluation in the next event loop in order to immediately // reply with the resultID. // // The console input should be evaluated with micro task level != 0, // so that microtask checkpoint isn't performed while evaluating it. DevToolsUtils.executeSoonWithMicroTask(async () => { try { // Execute the script that may pause. let response = await this.evaluateJS(request); // Wait for any potential returned Promise. response = await this._maybeWaitForResponseResult(response); // Set the timestamp only now, so any messages logged in the expression (e.g. console.log) // can be appended before the result message (unlike the evaluation result, other // console resources are throttled before being handled by the webconsole client, // which might cause some ordering issue). // Use ChromeUtils.dateNow() as it gives us a higher precision than Date.now(). response.timestamp = ChromeUtils.dateNow(); // Finally, emit an unsolicited evaluationResult packet with the evaluation result. this.emit("evaluationResult", { type: "evaluationResult", resultID, startTime, ...response, }); } catch (e) { const message = `Encountered error while waiting for Helper Result: ${e}\n${e.stack}`; DevToolsUtils.reportException("evaluateJSAsync", Error(message)); } }); return { resultID }; } /** * In order to support async evaluations (e.g. top-level await, …), * we have to be able to handle promises. This method handles waiting for the promise, * and then returns the result. * * @private * @param object response * The response packet to send to with the unique id in the * `resultID` field, and potentially a promise in the `helperResult` or in the * `awaitResult` field. * * @return object * The updated response object. */ async _maybeWaitForResponseResult(response) { if (!response?.awaitResult) { return response; } let result; try { result = await response.awaitResult; // `createValueGrip` expect a debuggee value, while here we have the raw object. // We need to call `makeDebuggeeValue` on it to make it work. const dbgResult = this.makeDebuggeeValue(result); response.result = this.createValueGrip(dbgResult); } catch (e) { // The promise was rejected. We let the engine handle this as it will report a // `uncaught exception` error. response.topLevelAwaitRejected = true; } // Remove the promise from the response object. delete response.awaitResult; return response; } /** * Handler for the "evaluateJS" request. This method evaluates the given * JavaScript string and sends back the result. * * @param object request * The JSON request object received from the Web Console client. * @return object * The evaluation response packet. */ evaluateJS(request) { const input = request.text; const evalOptions = { frameActor: request.frameActor, url: request.url, innerWindowID: request.innerWindowID, selectedNodeActor: request.selectedNodeActor, selectedObjectActor: request.selectedObjectActor, eager: request.eager, bindings: request.bindings, lineNumber: request.lineNumber, // This flag is set to true in most cases as we consider most evaluations as internal and: // * prevent any breakpoint from being triggerred when evaluating the JS input // * prevent spawning Debugger.Source for the evaluated JS and showing it in Debugger UI // This is only set to false when evaluating the console input. disableBreaks: !!request.disableBreaks, // Optional flag, to be set to true when Console Commands should override local symbols with // the same name. Like if the page defines `$`, the evaluated string will use the `$` implemented // by the console command instead of the page's function. preferConsoleCommandsOverLocalSymbols: !!request.preferConsoleCommandsOverLocalSymbols, }; const { mapped } = request; // Set a flag on the thread actor which indicates an evaluation is being // done for the client. This is used to disable all types of breakpoints for all sources // via `disabledBreaks`. When this flag is used, `reportExceptionsWhenBreaksAreDisabled` // allows to still pause on exceptions. this.parentActor.threadActor.insideClientEvaluation = evalOptions; let evalInfo; try { evalInfo = evalWithDebugger(input, evalOptions, this); } finally { this.parentActor.threadActor.insideClientEvaluation = null; } return new Promise((resolve, reject) => { // Queue up a task to run in the next tick so any microtask created by the evaluated // expression has the time to be run. // e.g. in : // ``` // const promiseThenCb = result => "result: " + result; // new Promise(res => res("hello")).then(promiseThenCb) // ``` // we want`promiseThenCb` to have run before handling the result. DevToolsUtils.executeSoon(() => { try { const result = this.prepareEvaluationResult( evalInfo, input, request.eager, mapped ); resolve(result); } catch (err) { reject(err); } }); }); } // eslint-disable-next-line complexity prepareEvaluationResult(evalInfo, input, eager, mapped) { const evalResult = evalInfo.result; const helperResult = evalInfo.helperResult; let result, errorDocURL, errorMessage, errorNotes = null, errorGrip = null, frame = null, awaitResult, errorMessageName, exceptionStack; if (evalResult) { if ("return" in evalResult) { result = evalResult.return; if ( mapped?.await && result && result.class === "Promise" && typeof result.unsafeDereference === "function" ) { awaitResult = result.unsafeDereference(); } } else if ("yield" in evalResult) { result = evalResult.yield; } else if ("throw" in evalResult) { const error = evalResult.throw; errorGrip = this.createValueGrip(error); exceptionStack = this.prepareStackForRemote(evalResult.stack); if (exceptionStack) { // Set the frame based on the topmost stack frame for the exception. const { filename: source, sourceId, lineNumber: line, columnNumber: column, } = exceptionStack[0]; frame = { source, sourceId, line, column }; exceptionStack = WebConsoleUtils.removeFramesAboveDebuggerEval(exceptionStack); } errorMessage = String(error); if (typeof error === "object" && error !== null) { try { errorMessage = DevToolsUtils.callPropertyOnObject( error, "toString" ); } catch (e) { // If the debuggee is not allowed to access the "toString" property // of the error object, calling this property from the debuggee's // compartment will fail. The debugger should show the error object // as it is seen by the debuggee, so this behavior is correct. // // Unfortunately, we have at least one test that assumes calling the // "toString" property of an error object will succeed if the // debugger is allowed to access it, regardless of whether the // debuggee is allowed to access it or not. // // To accomodate these tests, if calling the "toString" property // from the debuggee compartment fails, we rewrap the error object // in the debugger's compartment, and then call the "toString" // property from there. if (typeof error.unsafeDereference === "function") { const rawError = error.unsafeDereference(); errorMessage = rawError ? rawError.toString() : ""; } } } // It is possible that we won't have permission to unwrap an // object and retrieve its errorMessageName. try { errorDocURL = ErrorDocs.GetURL(error); errorMessageName = error.errorMessageName; } catch (ex) { // ignored } try { const line = error.errorLineNumber; const column = error.errorColumnNumber; if (typeof line === "number" && typeof column === "number") { // Set frame only if we have line/column numbers. frame = { source: "debugger eval code", line, column, }; } } catch (ex) { // ignored } try { const notes = error.errorNotes; if (notes?.length) { errorNotes = []; for (const note of notes) { errorNotes.push({ messageBody: this._createStringGrip(note.message), frame: { source: note.fileName, line: note.lineNumber, column: note.columnNumber, }, }); } } } catch (ex) { // ignored } } } // If a value is encountered that the devtools server doesn't support yet, // the console should remain functional. let resultGrip; if (!awaitResult) { try { const objectActor = this.parentActor.threadActor.getThreadLifetimeObject(result); if (objectActor) { resultGrip = this.parentActor.threadActor.createValueGrip(result); } else { resultGrip = this.createValueGrip(result); } } catch (e) { errorMessage = e; } } // Don't update _lastConsoleInputEvaluation in eager evaluation, as it would interfere // with the $_ command. if (!eager) { if (!awaitResult) { this._lastConsoleInputEvaluation = result; } else { // If we evaluated a top-level await expression, we want to assign its result to the // _lastConsoleInputEvaluation only when the promise resolves, and only if it // resolves. If the promise rejects, we don't re-assign _lastConsoleInputEvaluation, // it will keep its previous value. const p = awaitResult.then(res => { this._lastConsoleInputEvaluation = this.makeDebuggeeValue(res); }); // If the top level await was already rejected (e.g. `await Promise.reject("bleh")`), // catch the resulting promise of awaitResult.then. // If we don't do that, the new Promise will also be rejected, and since it's // unhandled, it will generate an error. // We don't want to do that for pending promise (e.g. `await new Promise((res, rej) => setTimeout(rej,250))`), // as the the Promise rejection will be considered as handled, and the "Uncaught (in promise)" // message wouldn't be emitted. const { state } = ObjectUtils.getPromiseState(evalResult.return); if (state === "rejected") { p.catch(() => {}); } } } return { input, result: resultGrip, awaitResult, exception: errorGrip, exceptionMessage: this._createStringGrip(errorMessage), exceptionDocURL: errorDocURL, exceptionStack, hasException: errorGrip !== null, errorMessageName, frame, helperResult, notes: errorNotes, }; } /** * The Autocomplete request handler. * * @param string text * The request message - what input to autocomplete. * @param number cursor * The cursor position at the moment of starting autocomplete. * @param string frameActor * The frameactor id of the current paused frame. * @param string selectedNodeActor * The actor id of the currently selected node. * @param array authorizedEvaluations * Array of the properties access which can be executed by the engine. * @return object * The response message - matched properties. */ autocomplete( text, cursor, frameActorId, selectedNodeActor, authorizedEvaluations, expressionVars = [] ) { let dbgObject = null; let environment = null; let matches = []; let matchProp; let isElementAccess; const reqText = text.substr(0, cursor); if (isCommand(reqText)) { matchProp = reqText; matches = WebConsoleCommandsManager.getAllColonCommandNames() .filter(c => `:${c}`.startsWith(reqText)) .map(c => `:${c}`); } else { // This is the case of the paused debugger if (frameActorId) { const frameActor = this.conn.getActor(frameActorId); try { // Need to try/catch since accessing frame.environment // can throw "Debugger.Frame is not live" const frame = frameActor.frame; environment = frame.environment; } catch (e) { DevToolsUtils.reportException( "autocomplete", Error("The frame actor was not found: " + frameActorId) ); } } else { dbgObject = this.dbg.addDebuggee(this.evalGlobal); } const result = jsPropertyProvider({ dbgObject, environment, frameActorId, inputValue: text, cursor, webconsoleActor: this, selectedNodeActor, authorizedEvaluations, expressionVars, }); if (result === null) { return { matches: null, }; } if (result && result.isUnsafeGetter === true) { return { isUnsafeGetter: true, getterPath: result.getterPath, }; } matches = result.matches || new Set(); matchProp = result.matchProp || ""; isElementAccess = result.isElementAccess; // We consider '$' as alphanumeric because it is used in the names of some // helper functions; we also consider whitespace as alphanum since it should not // be seen as break in the evaled string. const lastNonAlphaIsDot = /[.][a-zA-Z0-9$\s]*$/.test(reqText); // We only return commands and keywords when we are not dealing with a property or // element access. if (matchProp && !lastNonAlphaIsDot && !isElementAccess) { const colonOnlyCommands = WebConsoleCommandsManager.getColonOnlyCommandNames(); for (const name of WebConsoleCommandsManager.getAllCommandNames()) { // Filter out commands like `screenshot` as it is inaccessible without the `:` prefix if ( !colonOnlyCommands.includes(name) && name.startsWith(result.matchProp) ) { matches.add(name); } } for (const keyword of RESERVED_JS_KEYWORDS) { if (keyword.startsWith(result.matchProp)) { matches.add(keyword); } } } // Sort the results in order to display lowercased item first (e.g. we want to // display `document` then `Document` as we loosely match the user input if the // first letter was lowercase). const firstMeaningfulCharIndex = isElementAccess ? 1 : 0; matches = Array.from(matches).sort((a, b) => { const aFirstMeaningfulChar = a[firstMeaningfulCharIndex]; const bFirstMeaningfulChar = b[firstMeaningfulCharIndex]; const lA = aFirstMeaningfulChar.toLocaleLowerCase() === aFirstMeaningfulChar; const lB = bFirstMeaningfulChar.toLocaleLowerCase() === bFirstMeaningfulChar; if (lA === lB) { if (a === matchProp) { return -1; } if (b === matchProp) { return 1; } return a.localeCompare(b); } return lA ? -1 : 1; }); } return { matches, matchProp, isElementAccess: isElementAccess === true, }; } /** * The "clearMessagesCacheAsync" request handler. */ clearMessagesCacheAsync() { if (isWorker) { // Defined on WorkerScope clearConsoleEvents(); return; } const windowId = !this.parentActor.isRootActor ? WebConsoleUtils.getInnerWindowId(this.global) : null; const ConsoleAPIStorage = Cc[ "@mozilla.org/consoleAPI-storage;1" ].getService(Ci.nsIConsoleAPIStorage); ConsoleAPIStorage.clearEvents(windowId); CONSOLE_WORKER_IDS.forEach(id => { ConsoleAPIStorage.clearEvents(id); }); if (this.parentActor.isRootActor || !this.global) { // If were dealing with the root actor (e.g. the browser console), we want // to remove all cached messages, not only the ones specific to a window. Services.console.reset(); } else if (this.parentActor.ignoreSubFrames) { Services.console.resetWindow(windowId); } else { WebConsoleUtils.getInnerWindowIDsForFrames(this.global).forEach(id => Services.console.resetWindow(id) ); } } // End of request handlers. // Event handlers for various listeners. /** * Handler for messages received from the ConsoleServiceListener. This method * sends the nsIConsoleMessage to the remote Web Console client. * * @param nsIConsoleMessage message * The message we need to send to the client. */ onConsoleServiceMessage(message) { if (message instanceof Ci.nsIScriptError) { this.emit("pageError", { pageError: this.preparePageErrorForRemote(message), }); } else { this.emit("logMessage", { message: this._createStringGrip(message.message), timeStamp: message.microSecondTimeStamp / 1000, }); } } getActorIdForInternalSourceId(id) { const actor = this.parentActor.sourcesManager.getSourceActorByInternalSourceId(id); return actor ? actor.actorID : null; } /** * Prepare a SavedFrame stack to be sent to the client. * * @param SavedFrame errorStack * Stack for an error we need to send to the client. * @return object * The object you can send to the remote client. */ prepareStackForRemote(errorStack) { // Convert stack objects to the JSON attributes expected by client code // Bug 1348885: If the global from which this error came from has been // nuked, stack is going to be a dead wrapper. if (!errorStack || (Cu && Cu.isDeadWrapper(errorStack))) { return null; } const stack = []; let s = errorStack; while (s) { stack.push({ filename: s.source, sourceId: this.getActorIdForInternalSourceId(s.sourceId), lineNumber: s.line, columnNumber: s.column, functionName: s.functionDisplayName, asyncCause: s.asyncCause ? s.asyncCause : undefined, }); s = s.parent || s.asyncParent; } return stack; } /** * Prepare an nsIScriptError to be sent to the client. * * @param nsIScriptError pageError * The page error we need to send to the client. * @return object * The object you can send to the remote client. */ preparePageErrorForRemote(pageError) { const stack = this.prepareStackForRemote(pageError.stack); let lineText = pageError.sourceLine; if ( lineText && lineText.length > DevToolsServer.LONG_STRING_INITIAL_LENGTH ) { lineText = lineText.substr(0, DevToolsServer.LONG_STRING_INITIAL_LENGTH); } let notesArray = null; const notes = pageError.notes; if (notes?.length) { notesArray = []; for (let i = 0, len = notes.length; i < len; i++) { const note = notes.queryElementAt(i, Ci.nsIScriptErrorNote); notesArray.push({ messageBody: this._createStringGrip(note.errorMessage), frame: { source: note.sourceName, sourceId: this.getActorIdForInternalSourceId(note.sourceId), line: note.lineNumber, column: note.columnNumber, }, }); } } // If there is no location information in the error but we have a stack, // fill in the location with the first frame on the stack. let { sourceName, sourceId, lineNumber, columnNumber } = pageError; if (!sourceName && !sourceId && !lineNumber && !columnNumber && stack) { sourceName = stack[0].filename; sourceId = stack[0].sourceId; lineNumber = stack[0].lineNumber; columnNumber = stack[0].columnNumber; } const isCSSMessage = pageError.category === MESSAGE_CATEGORY.CSS_PARSER; const result = { errorMessage: this._createStringGrip(pageError.errorMessage), errorMessageName: isCSSMessage ? undefined : pageError.errorMessageName, exceptionDocURL: ErrorDocs.GetURL(pageError), sourceName, sourceId: this.getActorIdForInternalSourceId(sourceId), lineText, lineNumber, columnNumber, category: pageError.category, innerWindowID: pageError.innerWindowID, timeStamp: pageError.microSecondTimeStamp / 1000, warning: !!(pageError.flags & pageError.warningFlag), error: !(pageError.flags & (pageError.warningFlag | pageError.infoFlag)), info: !!(pageError.flags & pageError.infoFlag), private: pageError.isFromPrivateWindow, stacktrace: stack, notes: notesArray, chromeContext: pageError.isFromChromeContext, isPromiseRejection: isCSSMessage ? undefined : pageError.isPromiseRejection, isForwardedFromContentProcess: pageError.isForwardedFromContentProcess, cssSelectors: isCSSMessage ? pageError.cssSelectors : undefined, }; // If the pageError does have an exception object, we want to return the grip for it, // but only if we do manage to get the grip, as we're checking the property on the // client to render things differently. if (pageError.hasException) { try { const obj = this.makeDebuggeeValue(pageError.exception, true); if (obj?.class !== "DeadObject") { result.exception = this.createValueGrip(obj); result.hasException = true; } } catch (e) {} } return result; } /** * Handler for window.console API calls received from the ConsoleAPIListener. * This method sends the object to the remote Web Console client. * * @see ConsoleAPIListener * @param object message * The console API call we need to send to the remote client. * @param object extraProperties * an object whose properties will be folded in the packet that is emitted. */ onConsoleAPICall(message, extraProperties = {}) { this.emit("consoleAPICall", { message: this.prepareConsoleMessageForRemote(message), ...extraProperties, }); } /** * Handler for the DocumentEventsListener. * * @see DocumentEventsListener * @param {String} name * The document event name that either of followings. * - dom-loading * - dom-interactive * - dom-complete * @param {Number} time * The time that the event is fired. * @param {Boolean} hasNativeConsoleAPI * Tells if the window.console object is native or overwritten by script in the page. * Only passed when `name` is "dom-complete" (see devtools/server/actors/webconsole/listeners/document-events.js). */ onDocumentEvent(name, { time, hasNativeConsoleAPI }) { this.emit("documentEvent", { name, time, hasNativeConsoleAPI, }); } /** * Handler for file activity. This method sends the file request information * to the remote Web Console client. * * @see ConsoleFileActivityListener * @param string fileURI * The requested file URI. */ onFileActivity(fileURI) { this.emit("fileActivity", { uri: fileURI, }); } // End of event handlers for various listeners. /** * Prepare a message from the console API to be sent to the remote Web Console * instance. * * @param object message * The original message received from the console storage listener. * @param boolean aUseObjectGlobal * If |true| the object global is determined and added as a debuggee, * otherwise |this.global| is used when makeDebuggeeValue() is invoked. * @return object * The object that can be sent to the remote client. */ prepareConsoleMessageForRemote(message, useObjectGlobal = true) { const result = { arguments: message.arguments ? message.arguments.map(obj => { const dbgObj = this.makeDebuggeeValue(obj, useObjectGlobal); return this.createValueGrip(dbgObj); }) : [], chromeContext: message.chromeContext, columnNumber: message.columnNumber, filename: message.filename, level: message.level, lineNumber: message.lineNumber, // messages emitted from Console.sys.mjs don't have a microSecondTimeStamp property timeStamp: message.microSecondTimeStamp ? message.microSecondTimeStamp / 1000 : message.timeStamp, sourceId: this.getActorIdForInternalSourceId(message.sourceId), category: message.category || "webdev", innerWindowID: message.innerID, }; // It only make sense to include the following properties in the message when they have // a meaningful value. Otherwise we simply don't include them so we save cycles in JSActor communication. if (message.counter) { result.counter = message.counter; } if (message.private) { result.private = message.private; } if (message.prefix) { result.prefix = message.prefix; } if (message.stacktrace) { result.stacktrace = message.stacktrace.map(frame => { return { ...frame, sourceId: this.getActorIdForInternalSourceId(frame.sourceId), }; }); } if (message.styles && message.styles.length) { result.styles = message.styles.map(string => { return this.createValueGrip(string); }); } if (message.timer) { result.timer = message.timer; } if (message.level === "table") { const tableItems = this._getConsoleTableMessageItems(result); if (tableItems) { result.arguments[0].ownProperties = tableItems; result.arguments[0].preview = null; } // Only return the 2 first params. result.arguments = result.arguments.slice(0, 2); } return result; } /** * Return the properties needed to display the appropriate table for a given * console.table call. * This function does a little more than creating an ObjectActor for the first * parameter of the message. When layout out the console table in the output, we want * to be able to look into sub-properties so the table can have a different layout ( * for arrays of arrays, objects with objects properties, arrays of objects, …). * So here we need to retrieve the properties of the first parameter, and also all the * sub-properties we might need. * * @param {Object} result: The console.table message. * @returns {Object} An object containing the properties of the first argument of the * console.table call. */ _getConsoleTableMessageItems(result) { if ( !result || !Array.isArray(result.arguments) || !result.arguments.length ) { return null; } const [tableItemGrip] = result.arguments; const dataType = tableItemGrip.class; const needEntries = ["Map", "WeakMap", "Set", "WeakSet"].includes(dataType); const ignoreNonIndexedProperties = isArray(tableItemGrip); const tableItemActor = this.getActorByID(tableItemGrip.actor); if (!tableItemActor) { return null; } // Retrieve the properties (or entries for Set/Map) of the console table first arg. const iterator = needEntries ? tableItemActor.enumEntries() : tableItemActor.enumProperties({ ignoreNonIndexedProperties, }); const { ownProperties } = iterator.all(); // The iterator returns a descriptor for each property, wherein the value could be // in one of those sub-property. const descriptorKeys = ["safeGetterValues", "getterValue", "value"]; Object.values(ownProperties).forEach(desc => { if (typeof desc !== "undefined") { descriptorKeys.forEach(key => { if (desc && desc.hasOwnProperty(key)) { const grip = desc[key]; // We need to load sub-properties as well to render the table in a nice way. const actor = grip && this.getActorByID(grip.actor); if (actor) { const res = actor .enumProperties({ ignoreNonIndexedProperties: isArray(grip), }) .all(); if (res?.ownProperties) { desc[key].ownProperties = res.ownProperties; } } } }); } }); return ownProperties; } /** * The "will-navigate" progress listener. This is used to clear the current * eval scope. */ _onWillNavigate({ isTopLevel }) { if (isTopLevel) { this._evalGlobal = null; EventEmitter.off(this.parentActor, "will-navigate", this._onWillNavigate); this._progressListenerActive = false; } } /** * This listener is called when we switch to another frame, * mostly to unregister previous listeners and start listening on the new document. */ _onChangedToplevelDocument() { // Convert the Set to an Array const listeners = [...this._listeners]; // Unregister existing listener on the previous document // (pass a copy of the array as it will shift from it) this.stopListeners(listeners.slice()); // This method is called after this.global is changed, // so we register new listener on this new global this.startListeners(listeners); // Also reset the cached top level chrome window being targeted this._lastChromeWindow = null; } } exports.WebConsoleActor = WebConsoleActor;