diff options
Diffstat (limited to 'devtools/client/webconsole/webconsole-ui.js')
-rw-r--r-- | devtools/client/webconsole/webconsole-ui.js | 716 |
1 files changed, 716 insertions, 0 deletions
diff --git a/devtools/client/webconsole/webconsole-ui.js b/devtools/client/webconsole/webconsole-ui.js new file mode 100644 index 0000000000..914cf9b22d --- /dev/null +++ b/devtools/client/webconsole/webconsole-ui.js @@ -0,0 +1,716 @@ +/* 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, +} = require("resource://devtools/client/webconsole/utils/messages.js"); + +const { BrowserLoader } = ChromeUtils.import( + "resource://devtools/shared/loader/browser-loader.js" +); +const { + getAdHocFrontOrPrimitiveGrip, +} = require("resource://devtools/client/fronts/object.js"); + +const { PREFS } = require("resource://devtools/client/webconsole/constants.js"); + +const FirefoxDataProvider = require("resource://devtools/client/netmonitor/src/connector/firefox-data-provider.js"); + +const lazy = {}; +ChromeUtils.defineESModuleGetters(lazy, { + AppConstants: "resource://gre/modules/AppConstants.sys.mjs", +}); + +loader.lazyRequireGetter( + this, + "START_IGNORE_ACTION", + "resource://devtools/client/shared/redux/middleware/ignore.js", + 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); + + 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) { + // Bug 1605763: + // 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. + // Bug 1642599: + // 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(); + })(); + + 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); + } + + 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, + ], + { 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, + ], + { 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 + * @param Front targetFront + * The Front of the target that is available. + * This Front inherits from TargetMixin and is typically + * composed of a WindowGlobalTargetFront or ContentProcessTargetFront. + */ + async _onTargetAvailable({ targetFront }) { + // 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({ + baseURI: "resource://devtools/client/webconsole/", + window: this.window, + }).require("resource://devtools/client/webconsole/webconsole-wrapper.js"); + + 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 + // looks. See https://codemirror.net/demo/runmode.html; + 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 }); + } + } + } + ); + } + + _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", event => { + 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(); + } + } + + 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; |