Source code

Revision control

Copy as Markdown

Other Tools

/* 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("resource://devtools/shared/event-emitter.js");
const KeyShortcuts = require("resource://devtools/client/shared/key-shortcuts.js");
const {
l10n,
const { BrowserLoader } = ChromeUtils.importESModule(
);
const {
getAdHocFrontOrPrimitiveGrip,
const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
});
loader.lazyRequireGetter(
this,
"START_IGNORE_ACTION",
true
);
const ZoomKeys = require("resource://devtools/client/shared/zoom-keys.js");
const PREF_SIDEBAR_ENABLED = "devtools.webconsole.sidebarToggle";
const PREF_BROWSERTOOLBOX_SCOPE = "devtools.browsertoolbox.scope";
/**
* A WebConsoleUI instance is an interactive console initialized *per target*
* that displays console log data as well as provides an interactive terminal to
* manipulate the target's document content.
*
* The WebConsoleUI is responsible for the actual Web Console UI
* implementation.
*/
class WebConsoleUI {
/*
* @param {WebConsole} hud: The WebConsole owner object.
*/
constructor(hud) {
this.hud = hud;
this.hudId = this.hud.hudId;
this.isBrowserConsole = this.hud.isBrowserConsole;
this.isBrowserToolboxConsole =
this.hud.commands.descriptorFront.isBrowserProcessDescriptor &&
!this.isBrowserConsole;
this.window = this.hud.iframeWindow;
this._onPanelSelected = this._onPanelSelected.bind(this);
this._onChangeSplitConsoleState =
this._onChangeSplitConsoleState.bind(this);
this._onTargetAvailable = this._onTargetAvailable.bind(this);
this._onTargetDestroyed = this._onTargetDestroyed.bind(this);
this._onResourceAvailable = this._onResourceAvailable.bind(this);
this._onNetworkResourceUpdated = this._onNetworkResourceUpdated.bind(this);
this._onScopePrefChanged = this._onScopePrefChanged.bind(this);
this._onShowConsoleEvaluation = this._onShowConsoleEvaluation.bind(this);
if (this.isBrowserConsole) {
Services.prefs.addObserver(
PREF_BROWSERTOOLBOX_SCOPE,
this._onScopePrefChanged
);
}
EventEmitter.decorate(this);
}
/**
* Initialize the WebConsoleUI instance.
* @return object
* A promise object that resolves once the frame is ready to use.
*/
init() {
if (this._initializer) {
return this._initializer;
}
this._initializer = (async () => {
this._initUI();
if (this.isBrowserConsole) {
// TargetCommand.startListening will start fetching additional targets
// and may overload the Browser Console with loads of targets and resources.
// We can call it from here, as `_attachTargets` is called after the UI is initialized.
// TargetCommand.startListening has to be called before:
// - `_attachTargets`, in order to set TargetCommand.watcherFront which is used by ResourceWatcher.watchResources.
// - `ConsoleCommands`, in order to set TargetCommand.targetFront which is wrapped by hud.currentTarget
await this.hud.commands.targetCommand.startListening();
if (this._destroyed) {
return;
}
}
await this.wrapper.init();
if (this._destroyed) {
return;
}
// Bug 1605763: It's important to call _attachTargets once the UI is initialized, as
// it may overload the Browser Console with many updates.
// It is also important to do it only after the wrapper is initialized,
// otherwise its `store` will be null while we already call a few dispatch methods
// from onResourceAvailable
await this._attachTargets();
if (this._destroyed) {
return;
}
// `_attachTargets` will process resources and throttle some actions
// Wait for these actions to be dispatched before reporting that the
// console is initialized. Otherwise `showToolbox` will resolve before
// all already existing console messages are displayed.
await this.wrapper.waitAsyncDispatches();
this._initNotifications();
})();
return this._initializer;
}
destroy() {
if (this._destroyed) {
return;
}
this._destroyed = true;
this.React = this.ReactDOM = this.FrameView = null;
if (this.wrapper) {
this.wrapper.getStore()?.dispatch(START_IGNORE_ACTION);
this.wrapper.destroy();
}
if (this.jsterm) {
this.jsterm.destroy();
this.jsterm = null;
}
const { toolbox } = this.hud;
if (toolbox) {
toolbox.off("webconsole-selected", this._onPanelSelected);
toolbox.off("split-console", this._onChangeSplitConsoleState);
toolbox.off("select", this._onChangeSplitConsoleState);
toolbox.off(
"show-original-variable-mapping-warnings",
this._onShowConsoleEvaluation
);
}
if (this.isBrowserConsole) {
Services.prefs.removeObserver(
PREF_BROWSERTOOLBOX_SCOPE,
this._onScopePrefChanged
);
}
// Stop listening for targets
this.hud.commands.targetCommand.unwatchTargets({
types: this.hud.commands.targetCommand.ALL_TYPES,
onAvailable: this._onTargetAvailable,
onDestroyed: this._onTargetDestroyed,
});
const resourceCommand = this.hud.resourceCommand;
resourceCommand.unwatchResources(
[
resourceCommand.TYPES.CONSOLE_MESSAGE,
resourceCommand.TYPES.ERROR_MESSAGE,
resourceCommand.TYPES.PLATFORM_MESSAGE,
resourceCommand.TYPES.DOCUMENT_EVENT,
resourceCommand.TYPES.LAST_PRIVATE_CONTEXT_EXIT,
resourceCommand.TYPES.JSTRACER_TRACE,
resourceCommand.TYPES.JSTRACER_STATE,
],
{ onAvailable: this._onResourceAvailable }
);
resourceCommand.unwatchResources([resourceCommand.TYPES.CSS_MESSAGE], {
onAvailable: this._onResourceAvailable,
});
this.stopWatchingNetworkResources();
if (this.networkDataProvider) {
this.networkDataProvider.destroy();
this.networkDataProvider = null;
}
// Nullify `hud` last as it nullify also target which is used on destroy
this.window = this.hud = this.wrapper = null;
}
/**
* Clear the Web Console output.
*
* This method emits the "messages-cleared" notification.
*
* @param boolean clearStorage
* True if you want to clear the console messages storage associated to
* this Web Console.
* @param object event
* If the event exists, calls preventDefault on it.
*/
async clearOutput(clearStorage, event) {
if (event) {
event.preventDefault();
}
if (this.wrapper) {
this.wrapper.dispatchMessagesClear();
}
if (clearStorage) {
await this.clearMessagesCache();
}
this.emitForTests("messages-cleared");
}
async clearMessagesCache() {
if (this._destroyed) {
return;
}
// This can be called during console destruction and getAllFronts would reject in such case.
try {
const consoleFronts = await this.hud.commands.targetCommand.getAllFronts(
this.hud.commands.targetCommand.ALL_TYPES,
"console"
);
const promises = [];
for (const consoleFront of consoleFronts) {
promises.push(consoleFront.clearMessagesCacheAsync());
}
await Promise.all(promises);
this.emitForTests("messages-cache-cleared");
} catch (e) {
console.warn("Exception in clearMessagesCache", e);
}
}
/**
* Remove all of the private messages from the Web Console output.
*
* This method emits the "private-messages-cleared" notification.
*/
clearPrivateMessages() {
if (this._destroyed) {
return;
}
this.wrapper.dispatchPrivateMessagesClear();
this.emitForTests("private-messages-cleared");
}
inspectObjectActor(objectActor) {
const { targetFront } = this.hud.commands.targetCommand;
this.wrapper.dispatchMessageAdd(
{
helperResult: {
type: "inspectObject",
object:
objectActor && objectActor.getGrip
? objectActor
: getAdHocFrontOrPrimitiveGrip(objectActor, targetFront),
},
},
true
);
return this.wrapper;
}
disableAllNetworkMessages() {
if (this._destroyed) {
return;
}
this.wrapper.dispatchNetworkMessagesDisable();
}
getPanelWindow() {
return this.window;
}
logWarningAboutReplacedAPI() {
return this.hud.currentTarget.logWarningInPage(
l10n.getStr("ConsoleAPIDisabled"),
"ConsoleAPIDisabled"
);
}
/**
* Connect to the server using the remote debugging protocol.
*
* @private
* @return object
* A promise object that is resolved/reject based on the proxies connections.
*/
async _attachTargets() {
const { commands, resourceCommand } = this.hud;
this.networkDataProvider = new FirefoxDataProvider({
commands,
actions: {
updateRequest: (id, data) =>
this.wrapper.batchedRequestUpdates({ id, data }),
},
owner: this,
});
// Listen for all target types, including:
// - frames, in order to get the parent process target
// which is considered as a frame rather than a process.
// - workers, for similar reason. When we open a toolbox
// for just a worker, the top level target is a worker target.
// - processes, as we want to spawn additional proxies for them.
await commands.targetCommand.watchTargets({
types: this.hud.commands.targetCommand.ALL_TYPES,
onAvailable: this._onTargetAvailable,
onDestroyed: this._onTargetDestroyed,
});
await resourceCommand.watchResources(
[
resourceCommand.TYPES.CONSOLE_MESSAGE,
resourceCommand.TYPES.ERROR_MESSAGE,
resourceCommand.TYPES.PLATFORM_MESSAGE,
resourceCommand.TYPES.DOCUMENT_EVENT,
resourceCommand.TYPES.LAST_PRIVATE_CONTEXT_EXIT,
resourceCommand.TYPES.JSTRACER_TRACE,
resourceCommand.TYPES.JSTRACER_STATE,
],
{ onAvailable: this._onResourceAvailable }
);
if (this.isBrowserConsole || this.isBrowserToolboxConsole) {
const shouldEnableNetworkMonitoring = Services.prefs.getBoolPref(
PREFS.UI.ENABLE_NETWORK_MONITORING
);
if (shouldEnableNetworkMonitoring) {
await this.startWatchingNetworkResources();
} else {
await this.stopWatchingNetworkResources();
}
} else {
// We should always watch for network resources in the webconsole
await this.startWatchingNetworkResources();
}
}
async startWatchingNetworkResources() {
const { commands, resourceCommand } = this.hud;
await resourceCommand.watchResources(
[
resourceCommand.TYPES.NETWORK_EVENT,
resourceCommand.TYPES.NETWORK_EVENT_STACKTRACE,
],
{
onAvailable: this._onResourceAvailable,
onUpdated: this._onNetworkResourceUpdated,
}
);
// When opening a worker toolbox from about:debugging,
// we do not instantiate any Watcher actor yet and would throw here.
// But even once we do, we wouldn't support network inspection anyway.
if (commands.targetCommand.hasTargetWatcherSupport()) {
const networkFront = await commands.watcherFront.getNetworkParentActor();
// There is no way to view response bodies from the Browser Console, so do
// not waste the memory.
const saveBodies =
!this.isBrowserConsole &&
Services.prefs.getBoolPref(
"devtools.netmonitor.saveRequestAndResponseBodies"
);
await networkFront.setSaveRequestAndResponseBodies(saveBodies);
}
}
async stopWatchingNetworkResources() {
if (this._destroyed) {
return;
}
await this.hud.resourceCommand.unwatchResources(
[
this.hud.resourceCommand.TYPES.NETWORK_EVENT,
this.hud.resourceCommand.TYPES.NETWORK_EVENT_STACKTRACE,
],
{
onAvailable: this._onResourceAvailable,
onUpdated: this._onNetworkResourceUpdated,
}
);
}
handleDocumentEvent(resource) {
// Only consider top level document, and ignore remote iframes top document
if (!resource.targetFront.isTopLevel) {
return;
}
if (resource.name == "will-navigate") {
this.handleWillNavigate({
timeStamp: resource.time,
url: resource.newURI,
});
} else if (resource.name == "dom-complete") {
this.handleNavigated({
hasNativeConsoleAPI: resource.hasNativeConsoleAPI,
});
}
// For now, ignore all other DOCUMENT_EVENT's.
}
/**
* Handler for when the page is done loading.
*
* @param Boolean hasNativeConsoleAPI
* True if the `console` object is the native one and hasn't been overloaded by a custom
* object by the page itself.
*/
async handleNavigated({ hasNativeConsoleAPI }) {
// Updates instant evaluation on page navigation
this.wrapper.dispatchUpdateInstantEvaluationResultForCurrentExpression();
// Wait for completion of any async dispatch before notifying that the console
// is fully updated after a page reload
await this.wrapper.waitAsyncDispatches();
if (!hasNativeConsoleAPI) {
this.logWarningAboutReplacedAPI();
}
this.emit("reloaded");
}
handleWillNavigate({ timeStamp, url }) {
this.wrapper.dispatchTabWillNavigate({ timeStamp, url });
}
async watchCssMessages() {
const { resourceCommand } = this.hud;
await resourceCommand.watchResources([resourceCommand.TYPES.CSS_MESSAGE], {
onAvailable: this._onResourceAvailable,
});
}
_onResourceAvailable(resources) {
if (this._destroyed) {
return;
}
const messages = [];
for (const resource of resources) {
const { TYPES } = this.hud.resourceCommand;
if (resource.resourceType === TYPES.DOCUMENT_EVENT) {
this.handleDocumentEvent(resource);
continue;
}
if (resource.resourceType == TYPES.LAST_PRIVATE_CONTEXT_EXIT) {
// Private messages only need to be removed from the output in Browser Console/Browser Toolbox
// (but in theory this resource should only be send from parent process watchers)
if (this.isBrowserConsole || this.isBrowserToolboxConsole) {
this.clearPrivateMessages();
}
continue;
}
// Ignore messages forwarded from content processes if we're in fission browser toolbox.
if (
!this.wrapper ||
((resource.resourceType === TYPES.ERROR_MESSAGE ||
resource.resourceType === TYPES.CSS_MESSAGE) &&
resource.pageError?.isForwardedFromContentProcess &&
(this.isBrowserToolboxConsole || this.isBrowserConsole))
) {
continue;
}
// Don't show messages emitted from a private window before the Browser Console was
// opened to avoid leaking data from past usage of the browser (e.g. content message
// from now closed private tabs)
if (
(this.isBrowserToolboxConsole || this.isBrowserConsole) &&
resource.isAlreadyExistingResource &&
(resource.pageError?.private || resource.message?.private)
) {
continue;
}
if (resource.resourceType === TYPES.NETWORK_EVENT_STACKTRACE) {
this.networkDataProvider?.onStackTraceAvailable(resource);
continue;
}
if (resource.resourceType === TYPES.NETWORK_EVENT) {
this.networkDataProvider?.onNetworkResourceAvailable(resource);
}
messages.push(resource);
}
this.wrapper.dispatchMessagesAdd(messages);
}
_onNetworkResourceUpdated(updates) {
if (this._destroyed) {
return;
}
const messageUpdates = [];
for (const { resource } of updates) {
if (
resource.resourceType == this.hud.resourceCommand.TYPES.NETWORK_EVENT
) {
this.networkDataProvider?.onNetworkResourceUpdated(resource);
messageUpdates.push(resource);
}
}
this.wrapper.dispatchMessagesUpdate(messageUpdates);
}
/**
* Called any time a new target is available.
* i.e. it was already existing or has just been created.
*
* @private
*/
async _onTargetAvailable() {
// onTargetAvailable is a mandatory argument for watchTargets,
// we still define it solely for being able to use onTargetDestroyed.
}
_onTargetDestroyed({ targetFront, isModeSwitching }) {
// Don't try to do anything if the WebConsole is being destroyed
if (this._destroyed) {
return;
}
// We only want to remove messages from a target destroyed when we're switching mode
// in the Browser Console/Browser Toolbox Console.
// For regular cases, we want to keep the message history (the output will still be
// cleared when the top level target navigates, if "Persist Logs" isn't true, via handleWillNavigate)
if (isModeSwitching) {
this.wrapper.dispatchTargetMessagesRemove(targetFront);
}
}
_initUI() {
this.document = this.window.document;
this.rootElement = this.document.documentElement;
this.outputNode = this.document.getElementById("app-wrapper");
const { toolbox } = this.hud;
// Initialize module loader and load all the WebConsoleWrapper. The entire code-base
// doesn't need any extra privileges and runs entirely in content scope.
const WebConsoleWrapper = BrowserLoader({
window: this.window,
this.wrapper = new WebConsoleWrapper(
this.outputNode,
this,
toolbox,
this.document
);
this._initShortcuts();
this._initOutputSyntaxHighlighting();
if (toolbox) {
toolbox.on("webconsole-selected", this._onPanelSelected);
toolbox.on("split-console", this._onChangeSplitConsoleState);
toolbox.on("select", this._onChangeSplitConsoleState);
}
}
_initOutputSyntaxHighlighting() {
// Given a DOM node, we syntax highlight identically to how the input field
const syntaxHighlightNode = node => {
const editor = this.jsterm && this.jsterm.editor;
if (node && editor) {
node.classList.add("cm-s-mozilla");
editor.CodeMirror.runMode(
node.textContent,
"application/javascript",
node
);
}
};
// Use a Custom Element to handle syntax highlighting to avoid
// dealing with refs or innerHTML from React.
const win = this.window;
win.customElements.define(
"syntax-highlighted",
class extends win.HTMLElement {
connectedCallback() {
if (!this.connected) {
this.connected = true;
syntaxHighlightNode(this);
// Highlight Again when the innerText changes
// We remove the listener before running codemirror mode and add
// it again to capture text changes
this.observer = new win.MutationObserver((mutations, observer) => {
observer.disconnect();
syntaxHighlightNode(this);
observer.observe(this, { childList: true });
});
this.observer.observe(this, { childList: true });
}
}
}
);
}
_initNotifications() {
if (this.hud.toolbox) {
this.wrapper.toggleOriginalVariableMappingEvaluationNotification(
!!this.hud.toolbox
.getPanel("jsdebugger")
?.shouldShowOriginalVariableMappingWarnings()
);
this.hud.toolbox.on(
"show-original-variable-mapping-warnings",
this._onShowConsoleEvaluation
);
}
}
_initShortcuts() {
const shortcuts = new KeyShortcuts({
window: this.window,
});
let clearShortcut;
if (lazy.AppConstants.platform === "macosx") {
const alternativaClearShortcut = l10n.getStr(
"webconsole.clear.alternativeKeyOSX"
);
shortcuts.on(alternativaClearShortcut, event =>
this.clearOutput(true, event)
);
clearShortcut = l10n.getStr("webconsole.clear.keyOSX");
} else {
clearShortcut = l10n.getStr("webconsole.clear.key");
}
shortcuts.on(clearShortcut, event => this.clearOutput(true, event));
if (this.isBrowserConsole) {
// Make sure keyboard shortcuts work immediately after opening
// the Browser Console (Bug 1461366).
this.window.focus();
shortcuts.on(
l10n.getStr("webconsole.close.key"),
this.window.close.bind(this.window)
);
ZoomKeys.register(this.window, shortcuts);
/* This is the same as DevelopmentHelpers.quickRestart, but it runs in all
* builds (even official). This allows a user to do a restart + session restore
* with Ctrl+Shift+J (open Browser Console) and then Ctrl+Alt+R (restart).
*/
shortcuts.on("CmdOrCtrl+Alt+R", () => {
this.hud.commands.targetCommand.reloadTopLevelTarget();
});
} else if (Services.prefs.getBoolPref(PREF_SIDEBAR_ENABLED)) {
shortcuts.on("Esc", () => {
this.wrapper.dispatchSidebarClose();
if (this.jsterm) {
this.jsterm.focus();
}
});
}
}
/**
* Sets the focus to JavaScript input field when the web console tab is
* selected or when there is a split console present.
* @private
*/
_onPanelSelected() {
// We can only focus when we have the jsterm reference. This is fine because if the
// jsterm is not mounted yet, it will be focused in JSTerm's componentDidMount.
if (this.jsterm) {
this.jsterm.focus();
}
}
_onChangeSplitConsoleState() {
this.wrapper.dispatchSplitConsoleCloseButtonToggle();
}
_onScopePrefChanged() {
if (this.isBrowserConsole) {
this.hud.updateWindowTitle();
}
}
_onShowConsoleEvaluation(isOriginalVariableMappingEnabled) {
this.wrapper.toggleOriginalVariableMappingEvaluationNotification(
isOriginalVariableMappingEnabled
);
}
getInputCursor() {
return this.jsterm && this.jsterm.getSelectionStart();
}
getJsTermTooltipAnchor() {
return this.outputNode.querySelector(".CodeMirror-cursor");
}
attachRef(id, node) {
this[id] = node;
}
getSelectedNodeActorID() {
const inspectorSelection = this.hud.getInspectorSelection();
return inspectorSelection?.nodeFront?.actorID;
}
}
exports.WebConsoleUI = WebConsoleUI;