gecko-dev/devtools/client/framework/toolbox.js
Alexandre Poirot fc8714580a Bug 1485676 - Rename TabTarget.makeRemote to TabTarget.attach. r=jdescottes
Summary:
Now that all the "remoting" of this method has been moved to TargetFactory.createTargetForTab,
we should rename this method to what it does now. It mostly call attach requests
of the target actor and its child console actor.
It also "connect" the webextension target actor, but I would like to eventually move that
outside of TabTarget.attach, like makeRemote.

Depends On D4078

Reviewers: yulia!

Tags: #secure-revision

Bug #: 1485676

Differential Revision: https://phabricator.services.mozilla.com/D6161

MozReview-Commit-ID: KmFi1LIUBga
2018-09-24 09:52:57 -07:00

3336 lines
110 KiB
JavaScript

/* 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 SOURCE_MAP_WORKER = "resource://devtools/client/shared/source-map/worker.js";
const MAX_ORDINAL = 99;
const SPLITCONSOLE_ENABLED_PREF = "devtools.toolbox.splitconsoleEnabled";
const SPLITCONSOLE_HEIGHT_PREF = "devtools.toolbox.splitconsoleHeight";
const DISABLE_AUTOHIDE_PREF = "ui.popup.disable_autohide";
const HOST_HISTOGRAM = "DEVTOOLS_TOOLBOX_HOST";
const CURRENT_THEME_SCALAR = "devtools.current_theme";
const HTML_NS = "http://www.w3.org/1999/xhtml";
var {Ci, Cc} = require("chrome");
var promise = require("promise");
const { debounce } = require("devtools/shared/debounce");
var Services = require("Services");
var ChromeUtils = require("ChromeUtils");
var {gDevTools} = require("devtools/client/framework/devtools");
var EventEmitter = require("devtools/shared/event-emitter");
var Telemetry = require("devtools/client/shared/telemetry");
const { getUnicodeUrl } = require("devtools/client/shared/unicode-url");
var { attachThread, detachThread } = require("./attach-thread");
var { DOMHelpers } = require("resource://devtools/client/shared/DOMHelpers.jsm");
const { KeyCodes } = require("devtools/client/shared/keycodes");
var Startup = Cc["@mozilla.org/devtools/startup-clh;1"].getService(Ci.nsISupports)
.wrappedJSObject;
const { BrowserLoader } =
ChromeUtils.import("resource://devtools/client/shared/browser-loader.js", {});
const {LocalizationHelper} = require("devtools/shared/l10n");
const L10N = new LocalizationHelper("devtools/client/locales/toolbox.properties");
loader.lazyRequireGetter(this, "AppConstants",
"resource://gre/modules/AppConstants.jsm", true);
loader.lazyRequireGetter(this, "getHighlighterUtils",
"devtools/client/framework/toolbox-highlighter-utils", true);
loader.lazyRequireGetter(this, "Selection",
"devtools/client/framework/selection", true);
loader.lazyRequireGetter(this, "flags",
"devtools/shared/flags");
loader.lazyRequireGetter(this, "KeyShortcuts",
"devtools/client/shared/key-shortcuts");
loader.lazyRequireGetter(this, "ZoomKeys",
"devtools/client/shared/zoom-keys");
loader.lazyRequireGetter(this, "settleAll",
"devtools/shared/ThreadSafeDevToolsUtils", true);
loader.lazyRequireGetter(this, "ToolboxButtons",
"devtools/client/definitions", true);
loader.lazyRequireGetter(this, "SourceMapURLService",
"devtools/client/framework/source-map-url-service", true);
loader.lazyRequireGetter(this, "HUDService",
"devtools/client/webconsole/hudservice", true);
loader.lazyRequireGetter(this, "viewSource",
"devtools/client/shared/view-source");
loader.lazyRequireGetter(this, "buildHarLog",
"devtools/client/netmonitor/src/har/har-builder-utils", true);
loader.lazyRequireGetter(this, "NetMonitorAPI",
"devtools/client/netmonitor/src/api", true);
loader.lazyRequireGetter(this, "sortPanelDefinitions",
"devtools/client/framework/toolbox-tabs-order-manager", true);
loader.lazyGetter(this, "domNodeConstants", () => {
return require("devtools/shared/dom-node-constants");
});
loader.lazyGetter(this, "registerHarOverlay", () => {
return require("devtools/client/netmonitor/src/har/toolbox-overlay").register;
});
/**
* A "Toolbox" is the component that holds all the tools for one specific
* target. Visually, it's a document that includes the tools tabs and all
* the iframes where the tool panels will be living in.
*
* @param {object} target
* The object the toolbox is debugging.
* @param {string} selectedTool
* Tool to select initially
* @param {Toolbox.HostType} hostType
* Type of host that will host the toolbox (e.g. sidebar, window)
* @param {DOMWindow} contentWindow
* The window object of the toolbox document
* @param {string} frameId
* A unique identifier to differentiate toolbox documents from the
* chrome codebase when passing DOM messages
* @param {Number} msSinceProcessStart
* the number of milliseconds since process start using monotonic
* timestamps (unaffected by system clock changes).
*/
function Toolbox(target, selectedTool, hostType, contentWindow, frameId,
msSinceProcessStart) {
this._target = target;
this._win = contentWindow;
this.frameId = frameId;
this.telemetry = new Telemetry();
// The session ID is used to determine which telemetry events belong to which
// toolbox session. Because we use Amplitude to analyse the telemetry data we
// must use the time since the system wide epoch as the session ID.
this.sessionId = msSinceProcessStart;
// Map of the available DevTools WebExtensions:
// Map<extensionUUID, extensionName>
this._webExtensions = new Map();
this._toolPanels = new Map();
// Map of tool startup components for given tool id.
this._toolStartups = new Map();
this._inspectorExtensionSidebars = new Map();
this._initInspector = null;
this._inspector = null;
this._netMonitorAPI = null;
// Map of frames (id => frame-info) and currently selected frame id.
this.frameMap = new Map();
this.selectedFrameId = null;
this._toolRegistered = this._toolRegistered.bind(this);
this._toolUnregistered = this._toolUnregistered.bind(this);
this._onWillNavigate = this._onWillNavigate.bind(this);
this._refreshHostTitle = this._refreshHostTitle.bind(this);
this.toggleNoAutohide = this.toggleNoAutohide.bind(this);
this._updateFrames = this._updateFrames.bind(this);
this._splitConsoleOnKeypress = this._splitConsoleOnKeypress.bind(this);
this.destroy = this.destroy.bind(this);
this.highlighterUtils = getHighlighterUtils(this);
this._highlighterReady = this._highlighterReady.bind(this);
this._highlighterHidden = this._highlighterHidden.bind(this);
this._applyCacheSettings = this._applyCacheSettings.bind(this);
this._applyServiceWorkersTestingSettings =
this._applyServiceWorkersTestingSettings.bind(this);
this._saveSplitConsoleHeight = this._saveSplitConsoleHeight.bind(this);
this._onFocus = this._onFocus.bind(this);
this._onBrowserMessage = this._onBrowserMessage.bind(this);
this._updateTextBoxMenuItems = this._updateTextBoxMenuItems.bind(this);
this._onPerformanceFrontEvent = this._onPerformanceFrontEvent.bind(this);
this._onTabsOrderUpdated = this._onTabsOrderUpdated.bind(this);
this._onToolbarFocus = this._onToolbarFocus.bind(this);
this._onToolbarArrowKeypress = this._onToolbarArrowKeypress.bind(this);
this._onPickerClick = this._onPickerClick.bind(this);
this._onPickerKeypress = this._onPickerKeypress.bind(this);
this._onPickerStarted = this._onPickerStarted.bind(this);
this._onPickerStopped = this._onPickerStopped.bind(this);
this._onInspectObject = this._onInspectObject.bind(this);
this._onNewSelectedNodeFront = this._onNewSelectedNodeFront.bind(this);
this._onToolSelected = this._onToolSelected.bind(this);
this.updateToolboxButtonsVisibility = this.updateToolboxButtonsVisibility.bind(this);
this.updateToolboxButtons = this.updateToolboxButtons.bind(this);
this.selectTool = this.selectTool.bind(this);
this._pingTelemetrySelectTool = this._pingTelemetrySelectTool.bind(this);
this.toggleSplitConsole = this.toggleSplitConsole.bind(this);
this.toggleOptions = this.toggleOptions.bind(this);
this.togglePaintFlashing = this.togglePaintFlashing.bind(this);
this.isPaintFlashing = false;
this._target.on("close", this.destroy);
if (!selectedTool) {
selectedTool = Services.prefs.getCharPref(this._prefs.LAST_TOOL);
}
this._defaultToolId = selectedTool;
this._hostType = hostType;
this.isOpen = new Promise(function(resolve) {
this._resolveIsOpen = resolve;
}.bind(this));
EventEmitter.decorate(this);
this._target.on("will-navigate", this._onWillNavigate);
this._target.on("navigate", this._refreshHostTitle);
this._target.on("frame-update", this._updateFrames);
this._target.on("inspect-object", this._onInspectObject);
this.on("host-changed", this._refreshHostTitle);
this.on("select", this._onToolSelected);
gDevTools.on("tool-registered", this._toolRegistered);
gDevTools.on("tool-unregistered", this._toolUnregistered);
this.on("picker-started", this._onPickerStarted);
this.on("picker-stopped", this._onPickerStopped);
/**
* Get text direction for the current locale direction.
*
* `getComputedStyle` forces a synchronous reflow, so use a lazy getter in order to
* call it only once.
*/
loader.lazyGetter(this, "direction", () => {
// Get the direction from browser.xul document
const top = this.win.top;
const topDocEl = top.document.documentElement;
const isRtl = top.getComputedStyle(topDocEl).direction === "rtl";
return isRtl ? "rtl" : "ltr";
});
}
exports.Toolbox = Toolbox;
/**
* The toolbox can be 'hosted' either embedded in a browser window
* or in a separate window.
*/
Toolbox.HostType = {
BOTTOM: "bottom",
RIGHT: "right",
LEFT: "left",
WINDOW: "window",
CUSTOM: "custom"
};
Toolbox.prototype = {
_URL: "about:devtools-toolbox",
_prefs: {
LAST_TOOL: "devtools.toolbox.selectedTool",
SIDE_ENABLED: "devtools.toolbox.sideEnabled",
},
get currentToolId() {
return this._currentToolId;
},
set currentToolId(id) {
this._currentToolId = id;
this.component.setCurrentToolId(id);
},
get defaultToolId() {
return this._defaultToolId;
},
get panelDefinitions() {
return this._panelDefinitions;
},
set panelDefinitions(definitions) {
this._panelDefinitions = definitions;
this._combineAndSortPanelDefinitions();
},
get visibleAdditionalTools() {
if (!this._visibleAdditionalTools) {
this._visibleAdditionalTools = [];
}
return this._visibleAdditionalTools;
},
set visibleAdditionalTools(tools) {
this._visibleAdditionalTools = tools;
if (this.isReady) {
this._combineAndSortPanelDefinitions();
}
},
/**
* Combines the built-in panel definitions and the additional tool definitions that
* can be set by add-ons.
*/
_combineAndSortPanelDefinitions() {
let definitions = [...this._panelDefinitions, ...this.getVisibleAdditionalTools()];
definitions = sortPanelDefinitions(definitions);
this.component.setPanelDefinitions(definitions);
},
lastUsedToolId: null,
/**
* Returns a *copy* of the _toolPanels collection.
*
* @return {Map} panels
* All the running panels in the toolbox
*/
getToolPanels: function() {
return new Map(this._toolPanels);
},
/**
* Access the panel for a given tool
*/
getPanel: function(id) {
return this._toolPanels.get(id);
},
/**
* Get the panel instance for a given tool once it is ready.
* If the tool is already opened, the promise will resolve immediately,
* otherwise it will wait until the tool has been opened before resolving.
*
* Note that this does not open the tool, use selectTool if you'd
* like to select the tool right away.
*
* @param {String} id
* The id of the panel, for example "jsdebugger".
* @returns Promise
* A promise that resolves once the panel is ready.
*/
getPanelWhenReady: function(id) {
const panel = this.getPanel(id);
return new Promise(resolve => {
if (panel) {
resolve(panel);
} else {
this.on(id + "-ready", initializedPanel => {
resolve(initializedPanel);
});
}
});
},
/**
* This is a shortcut for getPanel(currentToolId) because it is much more
* likely that we're going to want to get the panel that we've just made
* visible
*/
getCurrentPanel: function() {
return this._toolPanels.get(this.currentToolId);
},
/**
* Get/alter the target of a Toolbox so we're debugging something different.
* See Target.jsm for more details.
* TODO: Do we allow |toolbox.target = null;| ?
*/
get target() {
return this._target;
},
get threadClient() {
return this._threadClient;
},
/**
* Get/alter the host of a Toolbox, i.e. is it in browser or in a separate
* tab. See HostType for more details.
*/
get hostType() {
return this._hostType;
},
/**
* Shortcut to the window containing the toolbox UI
*/
get win() {
return this._win;
},
/**
* Shortcut to the document containing the toolbox UI
*/
get doc() {
return this.win.document;
},
/**
* Get the toolbox highlighter front. Note that it may not always have been
* initialized first. Use `initInspector()` if needed.
* Consider using highlighterUtils instead, it exposes the highlighter API in
* a useful way for the toolbox panels
*/
get highlighter() {
return this._highlighter;
},
/**
* Get the toolbox's performance front. Note that it may not always have been
* initialized first. Use `initPerformance()` if needed.
*/
get performance() {
return this._performance;
},
/**
* Get the toolbox's inspector front. Note that it may not always have been
* initialized first. Use `initInspector()` if needed.
*/
get inspector() {
return this._inspector;
},
/**
* Get the toolbox's walker front. Note that it may not always have been
* initialized first. Use `initInspector()` if needed.
*/
get walker() {
return this._walker;
},
/**
* Get the toolbox's node selection. Note that it may not always have been
* initialized first. Use `initInspector()` if needed.
*/
get selection() {
return this._selection;
},
/**
* Get the toggled state of the split console
*/
get splitConsole() {
return this._splitConsole;
},
/**
* Get the focused state of the split console
*/
isSplitConsoleFocused: function() {
if (!this._splitConsole) {
return false;
}
const focusedWin = Services.focus.focusedWindow;
return focusedWin && focusedWin ===
this.doc.querySelector("#toolbox-panel-iframe-webconsole").contentWindow;
},
/**
* Open the toolbox
*/
open: function() {
return (async function() {
this.browserRequire = BrowserLoader({
window: this.doc.defaultView,
useOnlyShared: true
}).require;
if (this.win.location.href.startsWith(this._URL)) {
// Update the URL so that onceDOMReady watch for the right url.
this._URL = this.win.location.href;
}
const domHelper = new DOMHelpers(this.win);
const domReady = new Promise(resolve => {
domHelper.onceDOMReady(() => {
resolve();
}, this._URL);
});
// Optimization: fire up a few other things before waiting on
// the iframe being ready (makes startup faster)
// Load the toolbox-level actor fronts and utilities now
await this._target.attach();
// Start tracking network activity on toolbox open for targets such as tabs.
// (Workers and potentially others don't manage the console client in the target.)
if (this._target.activeConsole) {
await this._target.activeConsole.startListeners([
"NetworkActivity",
]);
}
// Attach the thread
this._threadClient = await attachThread(this);
await domReady;
this.isReady = true;
const framesPromise = this._listFrames();
Services.prefs.addObserver("devtools.cache.disabled", this._applyCacheSettings);
Services.prefs.addObserver("devtools.serviceWorkers.testing.enabled",
this._applyServiceWorkersTestingSettings);
this.textBoxContextMenuPopup =
this.doc.getElementById("toolbox-textbox-context-popup");
this.textBoxContextMenuPopup.addEventListener("popupshowing",
this._updateTextBoxMenuItems, true);
this.doc.addEventListener("contextmenu", (e) => {
if (e.originalTarget.closest("input[type=text]") ||
e.originalTarget.closest("input[type=search]") ||
e.originalTarget.closest("input:not([type])") ||
e.originalTarget.closest("textarea")) {
e.stopPropagation();
e.preventDefault();
this.openTextBoxContextMenu(e.screenX, e.screenY);
}
});
this.shortcuts = new KeyShortcuts({
window: this.doc.defaultView
});
// Get the DOM element to mount the ToolboxController to.
this._componentMount = this.doc.getElementById("toolbox-toolbar-mount");
this._mountReactComponent();
this._buildDockOptions();
this._buildOptions();
this._buildTabs();
this._applyCacheSettings();
this._applyServiceWorkersTestingSettings();
this._addKeysToWindow();
this._addReloadKeys();
this._addHostListeners();
this._registerOverlays();
if (!this._hostOptions || this._hostOptions.zoom === true) {
ZoomKeys.register(this.win);
}
this._componentMount.addEventListener("keypress", this._onToolbarArrowKeypress);
this._componentMount.setAttribute("aria-label", L10N.getStr("toolbox.label"));
this.webconsolePanel = this.doc.querySelector("#toolbox-panel-webconsole");
this.webconsolePanel.height = Services.prefs.getIntPref(SPLITCONSOLE_HEIGHT_PREF);
this.webconsolePanel.addEventListener("resize", this._saveSplitConsoleHeight);
this._buildButtons();
this._pingTelemetry();
// The isTargetSupported check needs to happen after the target is
// remoted, otherwise we could have done it in the toolbox constructor
// (bug 1072764).
const toolDef = gDevTools.getToolDefinition(this._defaultToolId);
if (!toolDef || !toolDef.isTargetSupported(this._target)) {
this._defaultToolId = "webconsole";
}
// Start rendering the toolbox toolbar before selecting the tool, as the tools
// can take a few hundred milliseconds seconds to start up.
//
// Delay React rendering as Toolbox.open is synchronous.
// Even if this involve promises, it is synchronous. Toolbox.open already loads
// react modules and freeze the event loop for a significant time.
// requestIdleCallback allows releasing it to allow user events to be processed.
// Use 16ms maximum delay to allow one frame to be rendered at 60FPS
// (1000ms/60FPS=16ms)
this.win.requestIdleCallback(() => {
this.component.setCanRender();
}, {timeout: 16});
await this.selectTool(this._defaultToolId, "initial_panel");
// Wait until the original tool is selected so that the split
// console input will receive focus.
let splitConsolePromise = promise.resolve();
if (Services.prefs.getBoolPref(SPLITCONSOLE_ENABLED_PREF)) {
splitConsolePromise = this.openSplitConsole();
this.telemetry.addEventProperty(
"devtools.main", "open", "tools", null, "splitconsole", true);
} else {
this.telemetry.addEventProperty(
"devtools.main", "open", "tools", null, "splitconsole", false);
}
await promise.all([
splitConsolePromise,
framesPromise
]);
// Lazily connect to the profiler here and don't wait for it to complete,
// used to intercept console.profile calls before the performance tools are open.
const performanceFrontConnection = this.initPerformance();
// If in testing environment, wait for performance connection to finish,
// so we don't have to explicitly wait for this in tests; ideally, all tests
// will handle this on their own, but each have their own tear down function.
if (flags.testing) {
await performanceFrontConnection;
}
this.emit("ready");
this._resolveIsOpen();
}.bind(this))().catch(e => {
console.error("Exception while opening the toolbox", String(e), e);
// While the exception stack is correctly printed in the Browser console when
// passing `e` to console.error, it is not on the stdout, so print it via dump.
dump(e.stack + "\n");
});
},
/**
* loading React modules when needed (to avoid performance penalties
* during Firefox start up time).
*/
get React() {
return this.browserRequire("devtools/client/shared/vendor/react");
},
get ReactDOM() {
return this.browserRequire("devtools/client/shared/vendor/react-dom");
},
get ReactRedux() {
return this.browserRequire("devtools/client/shared/vendor/react-redux");
},
get ToolboxController() {
return this.browserRequire("devtools/client/framework/components/ToolboxController");
},
/**
* Unconditionally create and get the source map service.
*/
_createSourceMapService: function() {
if (this._sourceMapService) {
return this._sourceMapService;
}
// Uses browser loader to access the `Worker` global.
const service = this.browserRequire("devtools/client/shared/source-map/index");
// Provide a wrapper for the service that reports errors more nicely.
this._sourceMapService = new Proxy(service, {
get: (target, name) => {
switch (name) {
case "getOriginalURLs":
return (urlInfo) => {
return target.getOriginalURLs(urlInfo)
.catch(text => {
const message = L10N.getFormatStr("toolbox.sourceMapFailure",
text, urlInfo.url,
urlInfo.sourceMapURL);
this.target.logWarningInPage(message, "source map");
// It's ok to swallow errors here, because a null
// result just means that no source map was found.
return null;
});
};
case "getOriginalSourceText":
return (originalSource) => {
return target.getOriginalSourceText(originalSource)
.catch(text => {
const message = L10N.getFormatStr("toolbox.sourceMapSourceFailure",
text, originalSource.url);
this.target.logWarningInPage(message, "source map");
// Also replace the result with the error text.
// Note that this result has to have the same form
// as whatever the upstream getOriginalSourceText
// returns.
return {
text: message,
contentType: "text/plain",
};
});
};
case "applySourceMap":
return (generatedId, url, code, mappings) => {
return target.applySourceMap(generatedId, url, code, mappings)
.then(result => {
// If a tool has changed or introduced a source map
// (e.g, by pretty-printing a source), tell the
// source map URL service about the change, so that
// subscribers to that service can be updated as
// well.
if (this._sourceMapURLService) {
this._sourceMapURLService.sourceMapChanged(generatedId, url);
}
return result;
});
};
default:
return target[name];
}
},
});
this._sourceMapService.startSourceMapWorker(SOURCE_MAP_WORKER);
return this._sourceMapService;
},
/**
* A common access point for the client-side mapping service for source maps that
* any panel can use. This is a "low-level" API that connects to
* the source map worker.
*/
get sourceMapService() {
if (!Services.prefs.getBoolPref("devtools.source-map.client-service.enabled")) {
return null;
}
return this._createSourceMapService();
},
/**
* A common access point for the client-side parser service that any panel can use.
*/
get parserService() {
if (this._parserService) {
return this._parserService;
}
this._parserService =
this.browserRequire("devtools/client/debugger/new/src/workers/parser/index");
this._parserService
.start("resource://devtools/client/debugger/new/dist/parser-worker.js", this.win);
return this._parserService;
},
/**
* Clients wishing to use source maps but that want the toolbox to
* track the source and style sheet actor mapping can use this
* source map service. This is a higher-level service than the one
* returned by |sourceMapService|, in that it automatically tracks
* source and style sheet actor IDs.
*/
get sourceMapURLService() {
if (this._sourceMapURLService) {
return this._sourceMapURLService;
}
const sourceMaps = this._createSourceMapService();
this._sourceMapURLService = new SourceMapURLService(this, sourceMaps);
return this._sourceMapURLService;
},
// Return HostType id for telemetry
_getTelemetryHostId: function() {
switch (this.hostType) {
case Toolbox.HostType.BOTTOM: return 0;
case Toolbox.HostType.RIGHT: return 1;
case Toolbox.HostType.WINDOW: return 2;
case Toolbox.HostType.CUSTOM: return 3;
case Toolbox.HostType.LEFT: return 4;
default: return 9;
}
},
// Return HostType string for telemetry
_getTelemetryHostString: function() {
switch (this.hostType) {
case Toolbox.HostType.BOTTOM: return "bottom";
case Toolbox.HostType.LEFT: return "left";
case Toolbox.HostType.RIGHT: return "right";
case Toolbox.HostType.WINDOW: return "window";
case Toolbox.HostType.CUSTOM: return "other";
default: return "bottom";
}
},
_pingTelemetry: function() {
this.telemetry.toolOpened("toolbox");
this.telemetry.getHistogramById(HOST_HISTOGRAM).add(this._getTelemetryHostId());
// Log current theme. The question we want to answer is:
// "What proportion of users use which themes?"
const currentTheme = Services.prefs.getCharPref("devtools.theme");
this.telemetry.keyedScalarAdd(CURRENT_THEME_SCALAR, currentTheme, 1);
this.telemetry.preparePendingEvent("devtools.main", "open", "tools", null, [
"entrypoint", "first_panel", "host", "shortcut",
"splitconsole", "width", "session_id"
]);
this.telemetry.addEventProperty(
"devtools.main", "open", "tools", null, "host", this._getTelemetryHostString()
);
},
/**
* Create a simple object to store the state of a toolbox button. The checked state of
* a button can be updated arbitrarily outside of the scope of the toolbar and its
* controllers. In order to simplify this interaction this object emits an
* "updatechecked" event any time the isChecked value is updated, allowing any consuming
* components to listen and respond to updates.
*
* @param {Object} options:
*
* @property {String} id - The id of the button or command.
* @property {String} className - An optional additional className for the button.
* @property {String} description - The value that will display as a tooltip and in
* the options panel for enabling/disabling.
* @property {Boolean} disabled - An optional disabled state for the button.
* @property {Function} onClick - The function to run when the button is activated by
* click or keyboard shortcut. First argument will be the 'click'
* event, and second argument is the toolbox instance.
* @property {Boolean} isInStartContainer - Buttons can either be placed at the start
* of the toolbar, or at the end.
* @property {Function} setup - Function run immediately to listen for events changing
* whenever the button is checked or unchecked. The toolbox object
* is passed as first argument and a callback is passed as second
* argument, to be called whenever the checked state changes.
* @property {Function} teardown - Function run on toolbox close to let a chance to
* unregister listeners set when `setup` was called and avoid
* memory leaks. The same arguments than `setup` function are
* passed to `teardown`.
* @property {Function} isTargetSupported - Function to automatically enable/disable
* the button based on the target. If the target don't support
* the button feature, this method should return false.
* @property {Function} isCurrentlyVisible - Function to automatically
* hide/show the button based on current state.
* @property {Function} isChecked - Optional function called to known if the button
* is toggled or not. The function should return true when
* the button should be displayed as toggled on.
*/
_createButtonState: function(options) {
let isCheckedValue = false;
const {
id,
className,
description,
disabled,
onClick,
isInStartContainer,
setup,
teardown,
isTargetSupported,
isCurrentlyVisible,
isChecked,
onKeyDown
} = options;
const toolbox = this;
const button = {
id,
className,
description,
disabled,
async onClick(event) {
if (typeof onClick == "function") {
await onClick(event, toolbox);
button.emit("updatechecked");
}
},
onKeyDown(event) {
if (typeof onKeyDown == "function") {
onKeyDown(event, toolbox);
}
},
isTargetSupported,
isCurrentlyVisible,
get isChecked() {
if (typeof isChecked == "function") {
return isChecked(toolbox);
}
return isCheckedValue;
},
set isChecked(value) {
// Note that if options.isChecked is given, this is ignored
isCheckedValue = value;
this.emit("updatechecked");
},
// The preference for having this button visible.
visibilityswitch: `devtools.${id}.enabled`,
// The toolbar has a container at the start and end of the toolbar for
// holding buttons. By default the buttons are placed in the end container.
isInStartContainer: !!isInStartContainer
};
if (typeof setup == "function") {
const onChange = () => {
button.emit("updatechecked");
};
setup(this, onChange);
// Save a reference to the cleanup method that will unregister the onChange
// callback. Immediately bind the function argument so that we don't have to
// also save a reference to them.
button.teardown = teardown.bind(options, this, onChange);
}
button.isVisible = this._commandIsVisible(button);
EventEmitter.decorate(button);
return button;
},
_buildOptions: function() {
this.shortcuts.on(L10N.getStr("toolbox.help.key"), this.toggleOptions);
},
_splitConsoleOnKeypress: function(e) {
if (e.keyCode === KeyCodes.DOM_VK_ESCAPE) {
this.toggleSplitConsole();
// If the debugger is paused, don't let the ESC key stop any pending
// navigation.
if (this._threadClient.state == "paused") {
e.preventDefault();
}
}
},
/**
* Add a shortcut key that should work when a split console
* has focus to the toolbox.
*
* @param {String} key
* The electron key shortcut.
* @param {Function} handler
* The callback that should be called when the provided key shortcut is pressed.
* @param {String} whichTool
* The tool the key belongs to. The corresponding handler will only be triggered
* if this tool is active.
*/
useKeyWithSplitConsole: function(key, handler, whichTool) {
this.shortcuts.on(key, event => {
if (this.currentToolId === whichTool && this.isSplitConsoleFocused()) {
handler();
event.preventDefault();
}
});
},
_addReloadKeys: function() {
[
["reload", false],
["reload2", false],
["forceReload", true],
["forceReload2", true]
].forEach(([id, force]) => {
const key = L10N.getStr("toolbox." + id + ".key");
this.shortcuts.on(key, event => {
this.reloadTarget(force);
// Prevent Firefox shortcuts from reloading the page
event.preventDefault();
});
});
},
_addHostListeners: function() {
// Add navigation keys
this.shortcuts.on(L10N.getStr("toolbox.nextTool.key"),
event => {
this.selectNextTool();
event.preventDefault();
});
this.shortcuts.on(L10N.getStr("toolbox.previousTool.key"),
event => {
this.selectPreviousTool();
event.preventDefault();
});
this.shortcuts.on(L10N.getStr("toolbox.toggleHost.key"),
event => {
this.switchToPreviousHost();
event.preventDefault();
});
// Close toolbox key-shortcut handler
const onClose = event => this.destroy();
this.shortcuts.on(L10N.getStr("toolbox.toggleToolboxF12.key"), onClose);
// CmdOrCtrl+W is registered only when the toolbox is running in
// detached window. In the other case the entire browser tab
// is closed when the user uses this shortcut.
if (this.hostType == "window") {
this.shortcuts.on(L10N.getStr("toolbox.closeToolbox.key"), onClose);
}
if (AppConstants.platform == "macosx") {
this.shortcuts.on(L10N.getStr("toolbox.toggleToolboxOSX.key"), onClose);
} else {
this.shortcuts.on(L10N.getStr("toolbox.toggleToolbox.key"), onClose);
}
// Add event listeners
this.doc.addEventListener("keypress", this._splitConsoleOnKeypress);
this.doc.addEventListener("focus", this._onFocus, true);
this.win.addEventListener("unload", this.destroy);
this.win.addEventListener("message", this._onBrowserMessage, true);
},
_removeHostListeners: function() {
// The host iframe's contentDocument may already be gone.
if (this.doc) {
this.doc.removeEventListener("keypress", this._splitConsoleOnKeypress);
this.doc.removeEventListener("focus", this._onFocus, true);
this.win.removeEventListener("unload", this.destroy);
this.win.removeEventListener("message", this._onBrowserMessage, true);
}
},
// Called whenever the chrome send a message
_onBrowserMessage: function(event) {
if (event.data && event.data.name === "switched-host") {
this._onSwitchedHost(event.data);
}
},
_registerOverlays: function() {
registerHarOverlay(this);
},
_saveSplitConsoleHeight: function() {
Services.prefs.setIntPref(SPLITCONSOLE_HEIGHT_PREF,
this.webconsolePanel.height);
},
/**
* Make sure that the console is showing up properly based on all the
* possible conditions.
* 1) If the console tab is selected, then regardless of split state
* it should take up the full height of the deck, and we should
* hide the deck and splitter.
* 2) If the console tab is not selected and it is split, then we should
* show the splitter, deck, and console.
* 3) If the console tab is not selected and it is *not* split,
* then we should hide the console and splitter, and show the deck
* at full height.
*/
_refreshConsoleDisplay: function() {
const deck = this.doc.getElementById("toolbox-deck");
const webconsolePanel = this.webconsolePanel;
const splitter = this.doc.getElementById("toolbox-console-splitter");
const openedConsolePanel = this.currentToolId === "webconsole";
if (openedConsolePanel) {
deck.setAttribute("collapsed", "true");
splitter.setAttribute("hidden", "true");
webconsolePanel.removeAttribute("collapsed");
} else {
deck.removeAttribute("collapsed");
if (this.splitConsole) {
webconsolePanel.removeAttribute("collapsed");
splitter.removeAttribute("hidden");
} else {
webconsolePanel.setAttribute("collapsed", "true");
splitter.setAttribute("hidden", "true");
}
}
},
/**
* Adds the keys and commands to the Toolbox Window in window mode.
*/
_addKeysToWindow: function() {
if (this.hostType != Toolbox.HostType.WINDOW) {
return;
}
for (const item of Startup.KeyShortcuts) {
const { id, toolId, shortcut, modifiers } = item;
const electronKey = KeyShortcuts.parseXulKey(modifiers, shortcut);
if (id == "browserConsole") {
// Add key for toggling the browser console from the detached window
this.shortcuts.on(electronKey, () => {
HUDService.toggleBrowserConsole();
});
} else if (toolId) {
// KeyShortcuts contain tool-specific and global key shortcuts,
// here we only need to copy shortcut specific to each tool.
this.shortcuts.on(electronKey, () => {
this.selectTool(toolId, "key_shortcut").then(() => this.fireCustomKey(toolId));
});
}
}
},
/**
* Handle any custom key events. Returns true if there was a custom key
* binding run.
* @param {string} toolId Which tool to run the command on (skip if not
* current)
*/
fireCustomKey: function(toolId) {
const toolDefinition = gDevTools.getToolDefinition(toolId);
if (toolDefinition.onkey &&
((this.currentToolId === toolId) ||
(toolId == "webconsole" && this.splitConsole))) {
toolDefinition.onkey(this.getCurrentPanel(), this);
}
},
/**
* Build the notification box as soon as needed.
*/
get notificationBox() {
if (!this._notificationBox) {
let { NotificationBox, PriorityLevels } =
this.browserRequire("devtools/client/shared/components/NotificationBox");
NotificationBox = this.React.createFactory(NotificationBox);
// Render NotificationBox and assign priority levels to it.
const box = this.doc.getElementById("toolbox-notificationbox");
this._notificationBox = Object.assign(
this.ReactDOM.render(NotificationBox({}), box),
PriorityLevels);
}
return this._notificationBox;
},
/**
* Build the options for changing hosts. Called every time
* the host changes.
*/
_buildDockOptions: function() {
if (!this._target.isLocalTab) {
this.component.setDockOptionsEnabled(false);
this.component.setCanCloseToolbox(false);
return;
}
this.component.setDockOptionsEnabled(true);
this.component.setCanCloseToolbox(this.hostType !== Toolbox.HostType.WINDOW);
const sideEnabled = Services.prefs.getBoolPref(this._prefs.SIDE_ENABLED);
const hostTypes = [];
for (const type in Toolbox.HostType) {
const position = Toolbox.HostType[type];
if (position == Toolbox.HostType.CUSTOM ||
(!sideEnabled &&
(position == Toolbox.HostType.LEFT || position == Toolbox.HostType.RIGHT))) {
continue;
}
hostTypes.push({
position,
switchHost: this.switchHost.bind(this, position)
});
}
this.component.setCurrentHostType(this.hostType);
this.component.setHostTypes(hostTypes);
},
postMessage: function(msg) {
// We sometime try to send messages in middle of destroy(), where the
// toolbox iframe may already be detached and no longer have a parent.
if (this.win.parent) {
// Toolbox document is still chrome and disallow identifying message
// origin via event.source as it is null. So use a custom id.
msg.frameId = this.frameId;
this.win.parent.postMessage(msg, "*");
}
},
/**
* Initiate ToolboxTabs React component and all it's properties. Do the initial render.
*/
_buildTabs: async function() {
// Get the initial list of tab definitions. This list can be amended at a later time
// by tools registering themselves.
const definitions = gDevTools.getToolDefinitionArray();
definitions.forEach(definition => this._buildPanelForTool(definition));
// Get the definitions that will only affect the main tab area.
this.panelDefinitions = definitions.filter(definition =>
definition.isTargetSupported(this._target) && definition.id !== "options");
// Do async lookup of disable pop-up auto-hide state.
if (this.disableAutohideAvailable) {
const disable = await this._isDisableAutohideEnabled();
this.component.setDisableAutohide(disable);
}
},
_mountReactComponent: function() {
// Ensure the toolbar doesn't try to render until the tool is ready.
const element = this.React.createElement(this.ToolboxController, {
L10N,
currentToolId: this.currentToolId,
selectTool: this.selectTool,
toggleOptions: this.toggleOptions,
toggleSplitConsole: this.toggleSplitConsole,
toggleNoAutohide: this.toggleNoAutohide,
closeToolbox: this.destroy,
focusButton: this._onToolbarFocus,
toolbox: this,
onTabsOrderUpdated: this._onTabsOrderUpdated,
});
this.component = this.ReactDOM.render(element, this._componentMount);
},
/**
* Reset tabindex attributes across all focusable elements inside the toolbar.
* Only have one element with tabindex=0 at a time to make sure that tabbing
* results in navigating away from the toolbar container.
* @param {FocusEvent} event
*/
_onToolbarFocus: function(id) {
this.component.setFocusedButton(id);
},
/**
* On left/right arrow press, attempt to move the focus inside the toolbar to
* the previous/next focusable element. This is not in the React component
* as it is difficult to coordinate between different component elements.
* The components are responsible for setting the correct tabindex value
* for if they are the focused element.
* @param {KeyboardEvent} event
*/
_onToolbarArrowKeypress: function(event) {
const { key, target, ctrlKey, shiftKey, altKey, metaKey } = event;
// If any of the modifier keys are pressed do not attempt navigation as it
// might conflict with global shortcuts (Bug 1327972).
if (ctrlKey || shiftKey || altKey || metaKey) {
return;
}
const buttons = [...this._componentMount.querySelectorAll("button")];
const curIndex = buttons.indexOf(target);
if (curIndex === -1) {
console.warn(target + " is not found among Developer Tools tab bar " +
"focusable elements.");
return;
}
let newTarget;
if (key === "ArrowLeft") {
// Do nothing if already at the beginning.
if (curIndex === 0) {
return;
}
newTarget = buttons[curIndex - 1];
} else if (key === "ArrowRight") {
// Do nothing if already at the end.
if (curIndex === buttons.length - 1) {
return;
}
newTarget = buttons[curIndex + 1];
} else {
return;
}
newTarget.focus();
event.preventDefault();
event.stopPropagation();
},
/**
* Add buttons to the UI as specified in devtools/client/definitions.js
*/
_buildButtons() {
// Beyond the normal preference filtering
this.toolbarButtons = [
this._buildPickerButton(),
this._buildFrameButton(),
];
ToolboxButtons.forEach(definition => {
const button = this._createButtonState(definition);
this.toolbarButtons.push(button);
});
this.component.setToolboxButtons(this.toolbarButtons);
},
/**
* Button to select a frame for the inspector to target.
*/
_buildFrameButton() {
this.frameButton = this._createButtonState({
id: "command-button-frames",
description: L10N.getStr("toolbox.frames.tooltip"),
isTargetSupported: target => {
return target.activeTab && target.activeTab.traits.frames;
},
isCurrentlyVisible: () => {
const hasFrames = this.frameMap.size > 1;
const isOnOptionsPanel = this.currentToolId === "options";
return hasFrames || isOnOptionsPanel;
},
});
// Listen for the shortcut key to show the frame list
this.shortcuts.on(L10N.getStr("toolbox.showFrames.key"), event => {
if (event.target.id === "command-button-frames") {
event.target.click();
}
});
return this.frameButton;
},
/**
* Toggle the picker, but also decide whether or not the highlighter should
* focus the window. This is only desirable when the toolbox is mounted to the
* window. When devtools is free floating, then the target window should not
* pop in front of the viewer when the picker is clicked.
*
* Note: Toggle picker can be overwritten by panel other than the inspector to
* allow for custom picker behaviour.
*/
_onPickerClick: function() {
const focus = this.hostType === Toolbox.HostType.BOTTOM ||
this.hostType === Toolbox.HostType.LEFT ||
this.hostType === Toolbox.HostType.RIGHT;
const currentPanel = this.getCurrentPanel();
if (currentPanel.togglePicker) {
currentPanel.togglePicker(focus);
} else {
this.highlighterUtils.togglePicker(focus);
}
},
/**
* If the picker is activated, then allow the Escape key to deactivate the
* functionality instead of the default behavior of toggling the console.
*/
_onPickerKeypress: function(event) {
if (event.keyCode === KeyCodes.DOM_VK_ESCAPE) {
const currentPanel = this.getCurrentPanel();
if (currentPanel.cancelPicker) {
currentPanel.cancelPicker();
} else {
this.highlighterUtils.cancelPicker();
}
// Stop the console from toggling.
event.stopImmediatePropagation();
}
},
_onPickerStarted: function() {
this.doc.addEventListener("keypress", this._onPickerKeypress, true);
},
_onPickerStopped: function() {
this.doc.removeEventListener("keypress", this._onPickerKeypress, true);
},
/**
* The element picker button enables the ability to select a DOM node by clicking
* it on the page.
*/
_buildPickerButton() {
this.pickerButton = this._createButtonState({
id: "command-button-pick",
description: L10N.getStr("pickButton.tooltip"),
onClick: this._onPickerClick,
isInStartContainer: true,
isTargetSupported: target => {
return target.activeTab && target.activeTab.traits.frames;
}
});
return this.pickerButton;
},
/**
* Apply the current cache setting from devtools.cache.disabled to this
* toolbox's tab.
*/
_applyCacheSettings: function() {
const pref = "devtools.cache.disabled";
const cacheDisabled = Services.prefs.getBoolPref(pref);
if (this.target.activeTab) {
this.target.activeTab.reconfigure({"cacheDisabled": cacheDisabled});
}
},
/**
* Apply the current service workers testing setting from
* devtools.serviceWorkers.testing.enabled to this toolbox's tab.
*/
_applyServiceWorkersTestingSettings: function() {
const pref = "devtools.serviceWorkers.testing.enabled";
const serviceWorkersTestingEnabled =
Services.prefs.getBoolPref(pref) || false;
if (this.target.activeTab) {
this.target.activeTab.reconfigure({
"serviceWorkersTestingEnabled": serviceWorkersTestingEnabled
});
}
},
/**
* Update the visibility of the buttons.
*/
updateToolboxButtonsVisibility() {
this.toolbarButtons.forEach(button => {
button.isVisible = this._commandIsVisible(button);
});
this.component.setToolboxButtons(this.toolbarButtons);
},
/**
* Update the buttons.
*/
updateToolboxButtons() {
const inspector = this.inspector;
// two of the buttons have highlighters that need to be cleared
// on will-navigate, otherwise we hold on to the stale highlighter
const hasHighlighters = inspector &&
(inspector.hasHighlighter("RulersHighlighter") ||
inspector.hasHighlighter("MeasuringToolHighlighter"));
if (hasHighlighters || this.isPaintFlashing) {
if (this.isPaintFlashing) {
this.togglePaintFlashing();
}
if (hasHighlighters) {
inspector.destroyHighlighters();
}
this.component.setToolboxButtons(this.toolbarButtons);
}
},
/**
* Set paintflashing to enabled or disabled for this toolbox's tab.
*/
togglePaintFlashing: function() {
if (this.isPaintFlashing) {
this.telemetry.toolOpened("paintflashing");
} else {
this.telemetry.toolClosed("paintflashing");
}
this.isPaintFlashing = !this.isPaintFlashing;
return this.target.activeTab.reconfigure({"paintFlashing": this.isPaintFlashing});
},
/**
* Visually update picker button.
* This function is called on every "select" event. Newly selected panel can
* update the visual state of the picker button such as disabled state,
* additional CSS classes (className), and tooltip (description).
*/
updatePickerButton() {
const button = this.pickerButton;
const currentPanel = this.getCurrentPanel();
if (currentPanel && currentPanel.updatePickerButton) {
currentPanel.updatePickerButton();
} else {
// If the current panel doesn't define a custom updatePickerButton,
// revert the button to its default state
button.description = L10N.getStr("pickButton.tooltip");
button.className = null;
button.disabled = null;
}
},
/**
* Update the visual state of the Frame picker button.
*/
updateFrameButton() {
if (this.currentToolId === "options" && this.frameMap.size <= 1) {
// If the button is only visible because the user is on the Options panel, disable
// the button and set an appropriate description.
this.frameButton.disabled = true;
this.frameButton.description = L10N.getStr("toolbox.frames.disabled.tooltip");
} else {
// Otherwise, enable the button and update the description.
this.frameButton.disabled = false;
this.frameButton.description = L10N.getStr("toolbox.frames.tooltip");
}
this.frameButton.isVisible = this._commandIsVisible(this.frameButton);
},
/**
* Ensure the visibility of each toolbox button matches the preference value.
*/
_commandIsVisible: function(button) {
const {
isTargetSupported,
isCurrentlyVisible,
visibilityswitch
} = button;
if (!Services.prefs.getBoolPref(visibilityswitch, true)) {
return false;
}
if (isTargetSupported && !isTargetSupported(this.target)) {
return false;
}
if (isCurrentlyVisible && !isCurrentlyVisible()) {
return false;
}
return true;
},
/**
* Build a panel for a tool definition.
*
* @param {string} toolDefinition
* Tool definition of the tool to build a tab for.
*/
_buildPanelForTool: function(toolDefinition) {
if (!toolDefinition.isTargetSupported(this._target)) {
return;
}
const deck = this.doc.getElementById("toolbox-deck");
const id = toolDefinition.id;
if (toolDefinition.ordinal == undefined || toolDefinition.ordinal < 0) {
toolDefinition.ordinal = MAX_ORDINAL;
}
if (!toolDefinition.bgTheme) {
toolDefinition.bgTheme = "theme-toolbar";
}
const panel = this.doc.createXULElement("vbox");
panel.className = "toolbox-panel " + toolDefinition.bgTheme;
// There is already a container for the webconsole frame.
if (!this.doc.getElementById("toolbox-panel-" + id)) {
panel.id = "toolbox-panel-" + id;
}
deck.appendChild(panel);
if (toolDefinition.buildToolStartup && !this._toolStartups.has(id)) {
this._toolStartups.set(id, toolDefinition.buildToolStartup(this));
}
this._addKeysToWindow();
},
/**
* Lazily created map of the additional tools registered to this toolbox.
*
* @returns {Map<string, object>}
* a map of the tools definitions registered to this
* particular toolbox (the key is the toolId string, the value
* is the tool definition plain javascript object).
*/
get additionalToolDefinitions() {
if (!this._additionalToolDefinitions) {
this._additionalToolDefinitions = new Map();
}
return this._additionalToolDefinitions;
},
/**
* Retrieve the array of the additional tools registered to this toolbox.
*
* @return {Array<object>}
* the array of additional tool definitions registered on this toolbox.
*/
getAdditionalTools() {
if (this._additionalToolDefinitions) {
return Array.from(this.additionalToolDefinitions.values());
}
return [];
},
/**
* Get the additional tools that have been registered and are visible.
*
* @return {Array<object>}
* the array of additional tool definitions registered on this toolbox.
*/
getVisibleAdditionalTools() {
return this.visibleAdditionalTools
.map(toolId => this.additionalToolDefinitions.get(toolId));
},
/**
* Test the existence of a additional tools registered to this toolbox by tool id.
*
* @param {string} toolId
* the id of the tool to test for existence.
*
* @return {boolean}
*
*/
hasAdditionalTool(toolId) {
return this.additionalToolDefinitions.has(toolId);
},
/**
* Register and load an additional tool on this particular toolbox.
*
* @param {object} definition
* the additional tool definition to register and add to this toolbox.
*/
addAdditionalTool(definition) {
if (!definition.id) {
throw new Error("Tool definition id is missing");
}
if (this.isToolRegistered(definition.id)) {
throw new Error("Tool definition already registered: " +
definition.id);
}
this.additionalToolDefinitions.set(definition.id, definition);
this.visibleAdditionalTools = [...this.visibleAdditionalTools, definition.id];
const buildPanel = () => this._buildPanelForTool(definition);
if (this.isReady) {
buildPanel();
} else {
this.once("ready", buildPanel);
}
},
/**
* Retrieve the registered inspector extension sidebars
* (used by the inspector panel during its deferred initialization).
*/
get inspectorExtensionSidebars() {
return this._inspectorExtensionSidebars;
},
/**
* Register an extension sidebar for the inspector panel.
*
* @param {String} id
* An unique sidebar id
* @param {Object} options
* @param {String} options.title
* A title for the sidebar
*/
async registerInspectorExtensionSidebar(id, options) {
this._inspectorExtensionSidebars.set(id, options);
// Defer the extension sidebar creation if the inspector
// has not been created yet (and do not create the inspector
// only to register an extension sidebar).
if (!this._inspector) {
return;
}
const inspector = this.getPanel("inspector");
inspector.addExtensionSidebar(id, options);
},
/**
* Unregister an extension sidebar for the inspector panel.
*
* @param {String} id
* An unique sidebar id
*/
unregisterInspectorExtensionSidebar(id) {
const sidebarDef = this._inspectorExtensionSidebars.get(id);
if (!sidebarDef) {
return;
}
this._inspectorExtensionSidebars.delete(id);
// Remove the created sidebar instance if the inspector panel
// has been already created.
if (!this._inspector) {
return;
}
const inspector = this.getPanel("inspector");
inspector.removeExtensionSidebar(id);
},
/**
* Unregister and unload an additional tool from this particular toolbox.
*
* @param {string} toolId
* the id of the additional tool to unregister and remove.
*/
removeAdditionalTool(toolId) {
// Early exit if the toolbox is already destroying itself.
if (this._destroyer) {
return;
}
if (!this.hasAdditionalTool(toolId)) {
throw new Error("Tool definition not registered to this toolbox: " +
toolId);
}
this.additionalToolDefinitions.delete(toolId);
this.visibleAdditionalTools = this.visibleAdditionalTools
.filter(id => id !== toolId);
this.unloadTool(toolId);
},
/**
* Ensure the tool with the given id is loaded.
*
* @param {string} id
* The id of the tool to load.
*/
loadTool: function(id) {
if (id === "inspector" && !this._inspector) {
return this.initInspector().then(() => this.loadTool(id));
}
let iframe = this.doc.getElementById("toolbox-panel-iframe-" + id);
if (iframe) {
const panel = this._toolPanels.get(id);
return new Promise(resolve => {
if (panel) {
resolve(panel);
} else {
this.once(id + "-ready", initializedPanel => {
resolve(initializedPanel);
});
}
});
}
return new Promise((resolve, reject) => {
// Retrieve the tool definition (from the global or the per-toolbox tool maps)
const definition = this.getToolDefinition(id);
if (!definition) {
reject(new Error("no such tool id " + id));
return;
}
iframe = this.doc.createXULElement("iframe");
iframe.className = "toolbox-panel-iframe";
iframe.id = "toolbox-panel-iframe-" + id;
iframe.setAttribute("flex", 1);
iframe.setAttribute("forceOwnRefreshDriver", "");
iframe.tooltip = "aHTMLTooltip";
iframe.style.visibility = "hidden";
gDevTools.emit(id + "-init", this, iframe);
this.emit(id + "-init", iframe);
// If no parent yet, append the frame into default location.
if (!iframe.parentNode) {
const vbox = this.doc.getElementById("toolbox-panel-" + id);
vbox.appendChild(iframe);
vbox.visibility = "visible";
}
const onLoad = () => {
// Prevent flicker while loading by waiting to make visible until now.
iframe.style.visibility = "visible";
// Try to set the dir attribute as early as possible.
this.setIframeDocumentDir(iframe);
// The build method should return a panel instance, so events can
// be fired with the panel as an argument. However, in order to keep
// backward compatibility with existing extensions do a check
// for a promise return value.
let built = definition.build(iframe.contentWindow, this);
if (!(typeof built.then == "function")) {
const panel = built;
iframe.panel = panel;
// The panel instance is expected to fire (and listen to) various
// framework events, so make sure it's properly decorated with
// appropriate API (on, off, once, emit).
// In this case we decorate panel instances directly returned by
// the tool definition 'build' method.
if (typeof panel.emit == "undefined") {
EventEmitter.decorate(panel);
}
gDevTools.emit(id + "-build", this, panel);
this.emit(id + "-build", panel);
// The panel can implement an 'open' method for asynchronous
// initialization sequence.
if (typeof panel.open == "function") {
built = panel.open();
} else {
built = new Promise(resolve => {
resolve(panel);
});
}
}
// Wait till the panel is fully ready and fire 'ready' events.
promise.resolve(built).then((panel) => {
this._toolPanels.set(id, panel);
// Make sure to decorate panel object with event API also in case
// where the tool definition 'build' method returns only a promise
// and the actual panel instance is available as soon as the
// promise is resolved.
if (typeof panel.emit == "undefined") {
EventEmitter.decorate(panel);
}
gDevTools.emit(id + "-ready", this, panel);
this.emit(id + "-ready", panel);
resolve(panel);
}, console.error);
};
iframe.setAttribute("src", definition.url);
if (definition.panelLabel) {
iframe.setAttribute("aria-label", definition.panelLabel);
}
// Depending on the host, iframe.contentWindow is not always
// defined at this moment. If it is not defined, we use an
// event listener on the iframe DOM node. If it's defined,
// we use the chromeEventHandler. We can't use a listener
// on the DOM node every time because this won't work
// if the (xul chrome) iframe is loaded in a content docshell.
if (iframe.contentWindow) {
const domHelper = new DOMHelpers(iframe.contentWindow);
domHelper.onceDOMReady(onLoad);
} else {
const callback = () => {
iframe.removeEventListener("DOMContentLoaded", callback);
onLoad();
};
iframe.addEventListener("DOMContentLoaded", callback);
}
});
},
/**
* Set the dir attribute on the content document element of the provided iframe.
*
* @param {IFrameElement} iframe
*/
setIframeDocumentDir: function(iframe) {
const docEl = iframe.contentWindow && iframe.contentWindow.document.documentElement;
if (!docEl || docEl.namespaceURI !== HTML_NS) {
// Bail out if the content window or document is not ready or if the document is not
// HTML.
return;
}
if (docEl.hasAttribute("dir")) {
// Set the dir attribute value only if dir is already present on the document.
docEl.setAttribute("dir", this.direction);
}
},
/**
* Mark all in collection as unselected; and id as selected
* @param {string} collection
* DOM collection of items
* @param {string} id
* The Id of the item within the collection to select
*/
selectSingleNode: function(collection, id) {
[...collection].forEach(node => {
if (node.id === id) {
node.setAttribute("selected", "true");
node.setAttribute("aria-selected", "true");
} else {
node.removeAttribute("selected");
node.removeAttribute("aria-selected");
}
// The webconsole panel is in a special location due to split console
if (!node.id) {
node = this.webconsolePanel;
}
const iframe = node.querySelector(".toolbox-panel-iframe");
if (iframe) {
let visible = node.id == id;
// Prevents hiding the split-console if it is currently enabled
if (node == this.webconsolePanel && this.splitConsole) {
visible = true;
}
this.setIframeVisible(iframe, visible);
}
});
},
/**
* Make a privileged iframe visible/hidden.
*
* For now, XUL Iframes loading chrome documents (i.e. <iframe type!="content" />)
* can't be hidden at platform level. And so don't support 'visibilitychange' event.
*
* This helper workarounds that by at least being able to send these kind of events.
* It will help panel react differently depending on them being displayed or in
* background.
*/
setIframeVisible: function(iframe, visible) {
const state = visible ? "visible" : "hidden";
const win = iframe.contentWindow;
const doc = win.document;
if (doc.visibilityState != state) {
// 1) Overload document's `visibilityState` attribute
// Use defineProperty, as by default `document.visbilityState` is read only.
Object.defineProperty(doc, "visibilityState", { value: state, configurable: true });
// 2) Fake the 'visibilitychange' event
doc.dispatchEvent(new win.Event("visibilitychange"));
}
},
/**
* Switch to the tool with the given id
*
* @param {string} id
* The id of the tool to switch to
* @param {string} reason
* Reason the tool was opened
*/
selectTool: function(id, reason = "unknown") {
this.emit("panel-changed");
if (this.currentToolId == id) {
const panel = this._toolPanels.get(id);
if (panel) {
// We have a panel instance, so the tool is already fully loaded.
// re-focus tool to get key events again
this.focusTool(id);
// Return the existing panel in order to have a consistent return value.
return promise.resolve(panel);
}
// Otherwise, if there is no panel instance, it is still loading,
// so we are racing another call to selectTool with the same id.
return this.once("select").then(() => promise.resolve(this._toolPanels.get(id)));
}
if (!this.isReady) {
throw new Error("Can't select tool, wait for toolbox 'ready' event");
}
// Check if the tool exists.
if (this.panelDefinitions.find((definition) => definition.id === id) ||
id === "options" ||
this.additionalToolDefinitions.get(id)) {
if (this.currentToolId) {
this.telemetry.toolClosed(this.currentToolId);
}
this._pingTelemetrySelectTool(id, reason);
} else {
throw new Error("No tool found");
}
// and select the right iframe
const toolboxPanels = this.doc.querySelectorAll(".toolbox-panel");
this.selectSingleNode(toolboxPanels, "toolbox-panel-" + id);
this.lastUsedToolId = this.currentToolId;
this.currentToolId = id;
this._refreshConsoleDisplay();
if (id != "options") {
Services.prefs.setCharPref(this._prefs.LAST_TOOL, id);
}
return this.loadTool(id).then(panel => {
// focus the tool's frame to start receiving key events
this.focusTool(id);
this.emit("select", id);
this.emit(id + "-selected", panel);
return panel;
});
},
_pingTelemetrySelectTool(id, reason) {
const width = Math.ceil(this.win.outerWidth / 50) * 50;
const panelName = this.getTelemetryPanelNameOrOther(id);
const prevPanelName = this.getTelemetryPanelNameOrOther(this.currentToolId);
const cold = !this.getPanel(id);
const pending = ["host", "width", "start_state", "panel_name", "cold", "session_id"];
// On first load this.currentToolId === undefined so we need to skip sending
// a devtools.main.exit telemetry event.
if (this.currentToolId) {
this.telemetry.recordEvent("devtools.main", "exit", prevPanelName, null, {
"host": this._hostType,
"width": width,
"panel_name": prevPanelName,
"next_panel": panelName,
"reason": reason,
"session_id": this.sessionId
});
}
this.telemetry.addEventProperties("devtools.main", "open", "tools", null, {
"width": width,
"session_id": this.sessionId
});
if (id === "webconsole") {
pending.push("message_count");
}
this.telemetry.preparePendingEvent("devtools.main", "enter", panelName, null, pending);
this.telemetry.addEventProperties("devtools.main", "enter", panelName, null, {
"host": this._hostType,
"start_state": reason,
"panel_name": panelName,
"cold": cold,
"session_id": this.sessionId
});
if (reason !== "initial_panel") {
const width = Math.ceil(this.win.outerWidth / 50) * 50;
this.telemetry.addEventProperty(
"devtools.main", "enter", panelName, null, "width", width
);
}
// Cold webconsole event message_count is handled in
// devtools/client/webconsole/webconsole-output-wrapper.js
if (!cold && id === "webconsole") {
this.telemetry.addEventProperty(
"devtools.main", "enter", "webconsole", null, "message_count", 0);
}
this.telemetry.toolOpened(id);
},
/**
* Focus a tool's panel by id
* @param {string} id
* The id of tool to focus
*/
focusTool: function(id, state = true) {
const iframe = this.doc.getElementById("toolbox-panel-iframe-" + id);
if (state) {
iframe.focus();
} else {
iframe.blur();
}
},
/**
* Focus split console's input line
*/
focusConsoleInput: function() {
const consolePanel = this.getPanel("webconsole");
if (consolePanel) {
consolePanel.focusInput();
}
},
/**
* If the console is split and we are focusing an element outside
* of the console, then store the newly focused element, so that
* it can be restored once the split console closes.
*/
_onFocus: function({originalTarget}) {
// Ignore any non element nodes, or any elements contained
// within the webconsole frame.
const webconsoleURL = gDevTools.getToolDefinition("webconsole").url;
if (originalTarget.nodeType !== 1 ||
originalTarget.baseURI === webconsoleURL) {
return;
}
this._lastFocusedElement = originalTarget;
},
_onTabsOrderUpdated: function() {
this._combineAndSortPanelDefinitions();
},
/**
* Opens the split console.
*
* @returns {Promise} a promise that resolves once the tool has been
* loaded and focused.
*/
openSplitConsole: function() {
this._splitConsole = true;
Services.prefs.setBoolPref(SPLITCONSOLE_ENABLED_PREF, true);
this._refreshConsoleDisplay();
// Ensure split console is visible if console was already loaded in background
const iframe = this.webconsolePanel.querySelector(".toolbox-panel-iframe");
if (iframe) {
this.setIframeVisible(iframe, true);
}
return this.loadTool("webconsole").then(() => {
this.component.setIsSplitConsoleActive(true);
this.telemetry.recordEvent("devtools.main", "activate", "split_console", null, {
"host": this._getTelemetryHostString(),
"width": Math.ceil(this.win.outerWidth / 50) * 50,
"session_id": this.sessionId
});
this.emit("split-console");
this.focusConsoleInput();
});
},
/**
* Closes the split console.
*
* @returns {Promise} a promise that resolves once the tool has been
* closed.
*/
closeSplitConsole: function() {
this._splitConsole = false;
Services.prefs.setBoolPref(SPLITCONSOLE_ENABLED_PREF, false);
this._refreshConsoleDisplay();
this.component.setIsSplitConsoleActive(false);
this.telemetry.recordEvent("devtools.main", "deactivate", "split_console", null, {
"host": this._getTelemetryHostString(),
"width": Math.ceil(this.win.outerWidth / 50) * 50,
"session_id": this.sessionId
});
this.emit("split-console");
if (this._lastFocusedElement) {
this._lastFocusedElement.focus();
}
return promise.resolve();
},
/**
* Toggles the split state of the webconsole. If the webconsole panel
* is already selected then this command is ignored.
*
* @returns {Promise} a promise that resolves once the tool has been
* opened or closed.
*/
toggleSplitConsole: function() {
if (this.currentToolId !== "webconsole") {
return this.splitConsole ?
this.closeSplitConsole() :
this.openSplitConsole();
}
return promise.resolve();
},
/**
* Toggles the options panel.
* If the option panel is already selected then select the last selected panel.
*/
toggleOptions: function() {
// Flip back to the last used panel if we are already
// on the options panel.
if (this.currentToolId === "options" &&
gDevTools.getToolDefinition(this.lastUsedToolId)) {
this.selectTool(this.lastUsedToolId, "toggle_settings_off");
} else {
this.selectTool("options", "toggle_settings_on");
}
},
/**
* Tells the target tab to reload.
*/
reloadTarget: function(force) {
this.target.activeTab.reload({ force: force });
},
/**
* Loads the tool next to the currently selected tool.
*/
selectNextTool: function() {
const definitions = this.component.panelDefinitions;
const index = definitions.findIndex(({id}) => id === this.currentToolId);
const definition = index === -1 || index >= definitions.length - 1
? definitions[0]
: definitions[index + 1];
return this.selectTool(definition.id, "select_next_key");
},
/**
* Loads the tool just left to the currently selected tool.
*/
selectPreviousTool: function() {
const definitions = this.component.panelDefinitions;
const index = definitions.findIndex(({id}) => id === this.currentToolId);
const definition = index === -1 || index < 1
? definitions[definitions.length - 1]
: definitions[index - 1];
return this.selectTool(definition.id, "select_prev_key");
},
/**
* Check if the tool's tab is highlighted.
*
* @param {string} id
* The id of the tool to be checked
*/
async isToolHighlighted(id) {
if (!this.component) {
await this.isOpen;
}
return this.component.isToolHighlighted(id);
},
/**
* Highlights the tool's tab if it is not the currently selected tool.
*
* @param {string} id
* The id of the tool to highlight
*/
async highlightTool(id) {
if (!this.component) {
await this.isOpen;
}
this.component.highlightTool(id);
},
/**
* De-highlights the tool's tab.
*
* @param {string} id
* The id of the tool to unhighlight
*/
async unhighlightTool(id) {
if (!this.component) {
await this.isOpen;
}
this.component.unhighlightTool(id);
},
/**
* Raise the toolbox host.
*/
raise: function() {
this.postMessage({
name: "raise-host"
});
},
/**
* Fired when user just started navigating away to another web page.
*/
async _onWillNavigate() {
this.updateToolboxButtons();
const toolId = this.currentToolId;
// For now, only inspector, webconsole and netmonitor fire "reloaded" event
if (toolId != "inspector" && toolId != "webconsole" && toolId != "netmonitor") {
return;
}
const start = this.win.performance.now();
const panel = this.getPanel(toolId);
// Ignore the timing if the panel is still loading
if (!panel) {
return;
}
await panel.once("reloaded");
const delay = this.win.performance.now() - start;
const telemetryKey = "DEVTOOLS_TOOLBOX_PAGE_RELOAD_DELAY_MS";
this.telemetry.getKeyedHistogramById(telemetryKey).add(toolId, delay);
},
/**
* Refresh the host's title.
*/
_refreshHostTitle: function() {
let title;
if (this.target.name && this.target.name != this.target.url) {
const url = this.target.isWebExtension ?
this.target.getExtensionPathName(this.target.url) :
getUnicodeUrl(this.target.url);
title = L10N.getFormatStr("toolbox.titleTemplate2", this.target.name,
url);
} else {
title = L10N.getFormatStr("toolbox.titleTemplate1",
getUnicodeUrl(this.target.url));
}
this.postMessage({
name: "set-host-title",
title
});
},
/**
* Returns an instance of the preference actor. This is a lazily initialized root
* actor that persists preferences to the debuggee, instead of just to the DevTools
* client. See the definition of the preference actor for more information.
*/
get preferenceFront() {
return this.target.client.mainRoot.getFront("preference");
},
// Is the disable auto-hide of pop-ups feature available in this context?
get disableAutohideAvailable() {
return this._target.chrome;
},
async toggleNoAutohide() {
const front = await this.preferenceFront;
const toggledValue = !(await this._isDisableAutohideEnabled());
front.setBoolPref(DISABLE_AUTOHIDE_PREF, toggledValue);
if (this.disableAutohideAvailable) {
this.component.setDisableAutohide(toggledValue);
}
this._autohideHasBeenToggled = true;
},
async _isDisableAutohideEnabled() {
// Ensure that the tools are open and the feature is available in this
// context.
await this.isOpen;
if (!this.disableAutohideAvailable) {
return false;
}
const prefFront = await this.preferenceFront;
return prefFront.getBoolPref(DISABLE_AUTOHIDE_PREF);
},
_listFrames: async function(event) {
if (!this.target.activeTab || !this.target.activeTab.traits.frames) {
// We are not targetting a regular BrowsingContextTargetActor
// it can be either an addon or browser toolbox actor
return promise.resolve();
}
const { frames } = await this.target.activeTab.listFrames();
this._updateFrames({ frames });
},
/**
* Select a frame by sending 'switchToFrame' packet to the backend.
*/
onSelectFrame: function(frameId) {
// Send packet to the backend to select specified frame and
// wait for 'frameUpdate' event packet to update the UI.
this.target.activeTab.switchToFrame(frameId);
},
/**
* Highlight a frame in the page
*/
onHighlightFrame: async function(frameId) {
// Need to initInspector to check presence of getNodeActorFromWindowID
// and use the highlighter later
await this.initInspector();
// Only enable frame highlighting when the top level document is targeted
if (this._supportsFrameHighlight && this.rootFrameSelected) {
const frameActor = await this.walker.getNodeActorFromWindowID(frameId);
this.highlighterUtils.highlightNodeFront(frameActor);
}
},
/**
* A handler for 'frameUpdate' packets received from the backend.
* Following properties might be set on the packet:
*
* destroyAll {Boolean}: All frames have been destroyed.
* selected {Number}: A frame has been selected
* frames {Array}: list of frames. Every frame can have:
* id {Number}: frame ID
* url {String}: frame URL
* title {String}: frame title
* destroy {Boolean}: Set to true if destroyed
* parentID {Number}: ID of the parent frame (not set
* for top level window)
*/
_updateFrames: function(data) {
// We may receive this event before the toolbox is ready.
if (!this.isReady) {
return;
}
// Store (synchronize) data about all existing frames on the backend
if (data.destroyAll) {
this.frameMap.clear();
this.selectedFrameId = null;
} else if (data.selected) {
this.selectedFrameId = data.selected;
} else if (data.frames) {
data.frames.forEach(frame => {
if (frame.destroy) {
this.frameMap.delete(frame.id);
// Reset the currently selected frame if it's destroyed.
if (this.selectedFrameId == frame.id) {
this.selectedFrameId = null;
}
} else {
this.frameMap.set(frame.id, frame);
}
});
}
// If there is no selected frame select the first top level
// frame by default. Note that there might be more top level
// frames in case of the BrowserToolbox.
if (!this.selectedFrameId) {
const frames = [...this.frameMap.values()];
const topFrames = frames.filter(frame => !frame.parentID);
this.selectedFrameId = topFrames.length ? topFrames[0].id : null;
}
// Check out whether top frame is currently selected.
// Note that only child frame has parentID.
const frame = this.frameMap.get(this.selectedFrameId);
const topFrameSelected = frame ? !frame.parentID : false;
this._framesButtonChecked = false;
// If non-top level frame is selected the toolbar button is
// marked as 'checked' indicating that a child frame is active.
if (!topFrameSelected && this.selectedFrameId) {
this._framesButtonChecked = false;
}
// We may need to hide/show the frames button now.
const wasVisible = this.frameButton.isVisible;
const wasDisabled = this.frameButton.disabled;
this.updateFrameButton();
const toolbarUpdate = () => {
if (this.frameButton.isVisible === wasVisible &&
this.frameButton.disabled === wasDisabled) {
return;
}
this.component.setToolboxButtons(this.toolbarButtons);
};
// If we are navigating/reloading, however (in which case data.destroyAll
// will be true), we should debounce the update to avoid unnecessary
// flickering/rendering.
if (data.destroyAll && !this.debouncedToolbarUpdate) {
this.debouncedToolbarUpdate = debounce(() => {
toolbarUpdate();
this.debouncedToolbarUpdate = null;
}, 200, this);
}
if (this.debouncedToolbarUpdate) {
this.debouncedToolbarUpdate();
} else {
toolbarUpdate();
}
},
/**
* Returns a 0-based selected frame depth.
*
* For example, if the root frame is selected, the returned value is 0. For a sub-frame
* of the root document, the returned value is 1, and so on.
*/
get selectedFrameDepth() {
// If the frame switcher is disabled, we won't have a selected frame ID.
// In this case, we're always showing the root frame.
if (!this.selectedFrameId) {
return 0;
}
let depth = 0;
let frame = this.frameMap.get(this.selectedFrameId);
while (frame) {
depth++;
frame = this.frameMap.get(frame.parentID);
}
return depth - 1;
},
/**
* Returns whether a root frame (with no parent frame) is selected.
*/
get rootFrameSelected() {
return this.selectedFrameDepth == 0;
},
/**
* Switch to the last used host for the toolbox UI.
*/
switchToPreviousHost: function() {
return this.switchHost("previous");
},
/**
* Switch to a new host for the toolbox UI. E.g. bottom, sidebar, window,
* and focus the window when done.
*
* @param {string} hostType
* The host type of the new host object
*/
switchHost: function(hostType) {
if (hostType == this.hostType || !this._target.isLocalTab) {
return null;
}
this.emit("host-will-change", hostType);
// ToolboxHostManager is going to call swapFrameLoaders which mess up with
// focus. We have to blur before calling it in order to be able to restore
// the focus after, in _onSwitchedHost.
this.focusTool(this.currentToolId, false);
// Host code on the chrome side will send back a message once the host
// switched
this.postMessage({
name: "switch-host",
hostType
});
return this.once("host-changed");
},
_onSwitchedHost: function({ hostType }) {
this._hostType = hostType;
this._buildDockOptions();
this._addKeysToWindow();
// We blurred the tools at start of switchHost, but also when clicking on
// host switching button. We now have to restore the focus.
this.focusTool(this.currentToolId, true);
this.emit("host-changed");
this.telemetry.getHistogramById(HOST_HISTOGRAM).add(this._getTelemetryHostId());
this.component.setCurrentHostType(hostType);
},
/**
* Test the availability of a tool (both globally registered tools and
* additional tools registered to this toolbox) by tool id.
*
* @param {string} toolId
* Id of the tool definition to search in the per-toolbox or globally
* registered tools.
*
* @returns {bool}
* Returns true if the tool is registered globally or on this toolbox.
*/
isToolRegistered: function(toolId) {
return !!this.getToolDefinition(toolId);
},
/**
* Return the tool definition registered globally or additional tools registered
* to this toolbox.
*
* @param {string} toolId
* Id of the tool definition to retrieve for the per-toolbox and globally
* registered tools.
*
* @returns {object}
* The plain javascript object that represents the requested tool definition.
*/
getToolDefinition: function(toolId) {
return gDevTools.getToolDefinition(toolId) ||
this.additionalToolDefinitions.get(toolId);
},
/**
* Internal helper that removes a loaded tool from the toolbox,
* it removes a loaded tool panel and tab from the toolbox without removing
* its definition, so that it can still be listed in options and re-added later.
*
* @param {string} toolId
* Id of the tool to be removed.
*/
unloadTool: function(toolId) {
if (typeof toolId != "string") {
throw new Error("Unexpected non-string toolId received.");
}
if (this._toolPanels.has(toolId)) {
const instance = this._toolPanels.get(toolId);
instance.destroy();
this._toolPanels.delete(toolId);
}
const panel = this.doc.getElementById("toolbox-panel-" + toolId);
// Select another tool.
if (this.currentToolId == toolId) {
const index = this.panelDefinitions.findIndex(({id}) => id === toolId);
const nextTool = this.panelDefinitions[index + 1];
const previousTool = this.panelDefinitions[index - 1];
let toolNameToSelect;
if (nextTool) {
toolNameToSelect = nextTool.id;
}
if (previousTool) {
toolNameToSelect = previousTool.id;
}
if (toolNameToSelect) {
this.selectTool(toolNameToSelect, "tool_unloaded");
}
}
// Remove this tool from the current panel definitions.
this.panelDefinitions = this.panelDefinitions.filter(({id}) => id !== toolId);
this.visibleAdditionalTools = this.visibleAdditionalTools
.filter(id => id !== toolId);
this._combineAndSortPanelDefinitions();
if (panel) {
panel.remove();
}
if (this.hostType == Toolbox.HostType.WINDOW) {
const doc = this.win.parent.document;
const key = doc.getElementById("key_" + toolId);
if (key) {
key.remove();
}
}
},
/**
* Get a startup component for a given tool.
* @param {string} toolId
* Id of the tool to get the startup component for.
*/
getToolStartup: function(toolId) {
return this._toolStartups.get(toolId);
},
_unloadToolStartup: async function(toolId) {
const startup = this.getToolStartup(toolId);
if (!startup) {
return;
}
this._toolStartups.delete(toolId);
await startup.destroy();
},
/**
* Handler for the tool-registered event.
* @param {string} toolId
* Id of the tool that was registered
*/
_toolRegistered: function(toolId) {
// Tools can either be in the global devtools, or added to this specific toolbox
// as an additional tool.
let definition = gDevTools.getToolDefinition(toolId);
let isAdditionalTool = false;
if (!definition) {
definition = this.additionalToolDefinitions.get(toolId);
isAdditionalTool = true;
}
if (definition.isTargetSupported(this._target)) {
if (isAdditionalTool) {
this.visibleAdditionalTools = [...this.visibleAdditionalTools, toolId];
this._combineAndSortPanelDefinitions();
} else {
this.panelDefinitions = this.panelDefinitions.concat(definition);
}
this._buildPanelForTool(definition);
// Emit the event so tools can listen to it from the toolbox level
// instead of gDevTools.
this.emit("tool-registered", toolId);
}
},
/**
* Handler for the tool-unregistered event.
* @param {string} toolId
* id of the tool that was unregistered
*/
_toolUnregistered: function(toolId) {
this.unloadTool(toolId);
this._unloadToolStartup(toolId);
// Emit the event so tools can listen to it from the toolbox level
// instead of gDevTools
this.emit("tool-unregistered", toolId);
},
/**
* Initialize the inspector/walker/selection/highlighter fronts.
* Returns a promise that resolves when the fronts are initialized
*/
initInspector: function() {
if (!this._initInspector) {
this._initInspector = (async function() {
this._inspector = this.target.getFront("inspector");
const pref = "devtools.inspector.showAllAnonymousContent";
const showAllAnonymousContent = Services.prefs.getBoolPref(pref);
this._walker = await this._inspector.getWalker({ showAllAnonymousContent });
this._selection = new Selection(this._walker);
this._selection.on("new-node-front", this._onNewSelectedNodeFront);
if (this.highlighterUtils.isRemoteHighlightable()) {
this.walker.on("highlighter-ready", this._highlighterReady);
this.walker.on("highlighter-hide", this._highlighterHidden);
const autohide = !flags.testing;
this._highlighter = await this._inspector.getHighlighter(autohide);
}
if (!("_supportsFrameHighlight" in this)) {
// Only works with FF58+ targets
this._supportsFrameHighlight =
await this.target.actorHasMethod("domwalker", "getNodeActorFromWindowID");
}
}.bind(this))();
}
return this._initInspector;
},
_onNewSelectedNodeFront: function() {
// Emit a "selection-changed" event when the toolbox.selection has been set
// to a new node (or cleared). Currently used in the WebExtensions APIs (to
// provide the `devtools.panels.elements.onSelectionChanged` event).
this.emit("selection-changed");
},
_onInspectObject: function(packet) {
this.inspectObjectActor(packet.objectActor, packet.inspectFromAnnotation);
},
_onToolSelected: function() {
this._refreshHostTitle();
this.updatePickerButton();
this.updateFrameButton();
// Calling setToolboxButtons in case the visibility of a button changed.
this.component.setToolboxButtons(this.toolbarButtons);
},
inspectObjectActor: async function(objectActor, inspectFromAnnotation) {
if (objectActor.preview &&
objectActor.preview.nodeType === domNodeConstants.ELEMENT_NODE) {
// Open the inspector and select the DOM Element.
await this.loadTool("inspector");
const inspector = this.getPanel("inspector");
const nodeFound = await inspector.inspectNodeActor(objectActor.actor,
inspectFromAnnotation);
if (nodeFound) {
await this.selectTool("inspector");
}
} else if (objectActor.type !== "null" &&
objectActor.type !== "undefined") {
// Open then split console and inspect the object in the variables view,
// when the objectActor doesn't represent an undefined or null value.
await this.openSplitConsole();
const panel = this.getPanel("webconsole");
const jsterm = panel.hud.jsterm;
jsterm.inspectObjectActor(objectActor);
}
},
/**
* Destroy the inspector/walker/selection fronts
* Returns a promise that resolves when the fronts are destroyed
*/
destroyInspector: function() {
if (this._destroyingInspector) {
return this._destroyingInspector;
}
this._destroyingInspector = (async function() {
if (!this._inspector) {
return;
}
// Ensure that the inspector isn't still being initiated, otherwise race conditions
// in the initialization process can throw errors.
await this._initInspector;
const currentPanel = this.getCurrentPanel();
if (currentPanel.stopPicker) {
await currentPanel.stopPicker();
} else {
await this.highlighterUtils.stopPicker();
}
if (this._highlighter) {
// Note that if the toolbox is closed, this will work fine, but will fail
// in case the browser is closed and will trigger a noSuchActor message.
// We ignore the promise that |_hideBoxModel| returns, since we should still
// proceed with the rest of destruction if it fails.
// FF42+ now does the cleanup from the actor.
if (!this.highlighter.traits.autoHideOnDestroy) {
this.highlighterUtils.unhighlight();
}
await this._highlighter.destroy();
}
if (this._selection) {
this._selection.off("new-node-front", this._onNewSelectedNodeFront);
this._selection.destroy();
}
if (this.walker) {
this.walker.off("highlighter-ready", this._highlighterReady);
this.walker.off("highlighter-hide", this._highlighterHidden);
}
this._inspector = null;
this._highlighter = null;
this._selection = null;
this._walker = null;
}.bind(this))();
return this._destroyingInspector;
},
/**
* Get the toolbox's notification component
*
* @return The notification box component.
*/
getNotificationBox: function() {
return this.notificationBox;
},
/**
* Remove all UI elements, detach from target and clear up
*/
destroy: function() {
// If several things call destroy then we give them all the same
// destruction promise so we're sure to destroy only once
if (this._destroyer) {
return this._destroyer;
}
this.emit("destroy");
this._target.off("inspect-object", this._onInspectObject);
this._target.off("will-navigate", this._onWillNavigate);
this._target.off("navigate", this._refreshHostTitle);
this._target.off("frame-update", this._updateFrames);
this.off("select", this._onToolSelected);
this.off("host-changed", this._refreshHostTitle);
gDevTools.off("tool-registered", this._toolRegistered);
gDevTools.off("tool-unregistered", this._toolUnregistered);
Services.prefs.removeObserver("devtools.cache.disabled", this._applyCacheSettings);
Services.prefs.removeObserver("devtools.serviceWorkers.testing.enabled",
this._applyServiceWorkersTestingSettings);
// We normally handle toolClosed from selectTool() but in the event of the
// toolbox closing we need to handle it here instead.
this.telemetry.toolClosed(this.currentToolId);
this._lastFocusedElement = null;
if (this._sourceMapURLService) {
this._sourceMapURLService.destroy();
this._sourceMapURLService = null;
}
if (this._sourceMapService) {
this._sourceMapService.stopSourceMapWorker();
this._sourceMapService = null;
}
if (this._parserService) {
this._parserService.stop();
this._parserService = null;
}
if (this.webconsolePanel) {
this._saveSplitConsoleHeight();
this.webconsolePanel.removeEventListener("resize",
this._saveSplitConsoleHeight);
this.webconsolePanel = null;
}
if (this.textBoxContextMenuPopup) {
this.textBoxContextMenuPopup.removeEventListener("popupshowing",
this._updateTextBoxMenuItems, true);
this.textBoxContextMenuPopup = null;
}
if (this._componentMount) {
this._componentMount.removeEventListener("keypress", this._onToolbarArrowKeypress);
this.ReactDOM.unmountComponentAtNode(this._componentMount);
this._componentMount = null;
}
const outstanding = [];
for (const [id, panel] of this._toolPanels) {
try {
gDevTools.emit(id + "-destroy", this, panel);
this.emit(id + "-destroy", panel);
outstanding.push(panel.destroy());
} catch (e) {
// We don't want to stop here if any panel fail to close.
console.error("Panel " + id + ":", e);
}
}
for (const id of this._toolStartups.keys()) {
outstanding.push(this._unloadToolStartup(id));
}
this.browserRequire = null;
this._toolNames = null;
// Now that we are closing the toolbox we can re-enable the cache settings
// and disable the service workers testing settings for the current tab.
// FF41+ automatically cleans up state in actor on disconnect.
if (this.target.activeTab && !this.target.activeTab.traits.noTabReconfigureOnClose) {
this.target.activeTab.reconfigure({
"cacheDisabled": false,
"serviceWorkersTestingEnabled": false
});
}
// Destroying the walker and inspector fronts
outstanding.push(this.destroyInspector());
// Destroy the profiler connection
outstanding.push(this.destroyPerformance());
// Reset preferences set by the toolbox
outstanding.push(this.resetPreference());
// Detach the thread
detachThread(this._threadClient);
this._threadClient = null;
// Unregister buttons listeners
this.toolbarButtons.forEach(button => {
if (typeof button.teardown == "function") {
// teardown arguments have already been bound in _createButtonState
button.teardown();
}
});
// We need to grab a reference to win before this._host is destroyed.
const win = this.win;
const host = this._getTelemetryHostString();
const width = Math.ceil(win.outerWidth / 50) * 50;
const prevPanelName = this.getTelemetryPanelNameOrOther(this.currentToolId);
this.telemetry.toolClosed("toolbox");
this.telemetry.recordEvent("devtools.main", "exit", prevPanelName, null, {
"host": host,
"width": width,
"panel_name": this.getTelemetryPanelNameOrOther(this.currentToolId),
"next_panel": "none",
"reason": "toolbox_close",
"session_id": this.sessionId
});
this.telemetry.recordEvent("devtools.main", "close", "tools", null, {
"host": host,
"width": width,
"session_id": this.sessionId
});
// Finish all outstanding tasks (which means finish destroying panels and
// then destroying the host, successfully or not) before destroying the
// target.
this._destroyer = new Promise(resolve => {
resolve(settleAll(outstanding)
.catch(console.error)
.then(() => {
const api = this._netMonitorAPI;
this._netMonitorAPI = null;
return api ? api.destroy() : null;
}, console.error)
.then(() => {
this._removeHostListeners();
// `location` may already be 'invalid' if the toolbox document is
// already in process of destruction. Otherwise if it is still
// around, ensure releasing toolbox document and triggering cleanup
// thanks to unload event. We do that precisely here, before
// nullifying the target as various cleanup code depends on the
// target attribute to be still
// defined.
try {
win.location.replace("about:blank");
} catch (e) {
// Do nothing;
}
// Targets need to be notified that the toolbox is being torn down.
// This is done after other destruction tasks since it may tear down
// fronts and the debugger transport which earlier destroy methods may
// require to complete.
if (!this._target) {
return null;
}
const target = this._target;
this._target = null;
this.highlighterUtils.release();
target.off("close", this.destroy);
return target.destroy();
}, console.error).then(() => {
this.emit("destroyed");
// Free _host after the call to destroyed in order to let a chance
// to destroyed listeners to still query toolbox attributes
this._host = null;
this._win = null;
this._toolPanels.clear();
// Force GC to prevent long GC pauses when running tests and to free up
// memory in general when the toolbox is closed.
if (flags.testing) {
win.windowUtils.garbageCollect();
}
}).catch(console.error));
});
const leakCheckObserver = ({wrappedJSObject: barrier}) => {
// Make the leak detector wait until this toolbox is properly destroyed.
barrier.client.addBlocker("DevTools: Wait until toolbox is destroyed",
this._destroyer);
};
const topic = "shutdown-leaks-before-check";
Services.obs.addObserver(leakCheckObserver, topic);
this._destroyer.then(() => {
Services.obs.removeObserver(leakCheckObserver, topic);
});
return this._destroyer;
},
_highlighterReady: function() {
this.emit("highlighter-ready");
},
_highlighterHidden: function() {
this.emit("highlighter-hide");
},
/**
* Enable / disable necessary textbox menu items using globalOverlay.js.
*/
_updateTextBoxMenuItems: function() {
const window = this.win;
["cmd_undo", "cmd_delete", "cmd_cut",
"cmd_copy", "cmd_paste", "cmd_selectAll"].forEach(window.goUpdateCommand);
},
/**
* Open the textbox context menu at given coordinates.
* Panels in the toolbox can call this on contextmenu events with event.screenX/Y
* instead of having to implement their own copy/paste/selectAll menu.
* @param {Number} x
* @param {Number} y
*/
openTextBoxContextMenu: function(x, y) {
this.textBoxContextMenuPopup.openPopupAtScreen(x, y, true);
},
/**
* Connects to the Gecko Profiler when the developer tools are open. This is
* necessary because of the WebConsole's `profile` and `profileEnd` methods.
*/
async initPerformance() {
// If target does not have performance actor (addons), do not
// even register the shared performance connection.
if (!this.target.hasActor("performance")) {
return promise.resolve();
}
if (this._performanceFrontConnection) {
return this._performanceFrontConnection;
}
let resolvePerformance;
this._performanceFrontConnection = new Promise(function(resolve) {
resolvePerformance = resolve;
});
this._performance = this.target.getFront("performance");
await this.performance.connect();
// Emit an event when connected, but don't wait on startup for this.
this.emit("profiler-connected");
this.performance.on("*", this._onPerformanceFrontEvent);
resolvePerformance(this.performance);
return this._performanceFrontConnection;
},
/**
* Disconnects the underlying Performance actor. If the connection
* has not finished initializing, as opening a toolbox does not wait,
* the performance connection destroy method will wait for it on its own.
*/
async destroyPerformance() {
if (!this.performance) {
return;
}
// If still connecting to performance actor, allow the
// actor to resolve its connection before attempting to destroy.
if (this._performanceFrontConnection) {
await this._performanceFrontConnection;
}
this.performance.off("*", this._onPerformanceFrontEvent);
await this.performance.destroy();
this._performance = null;
},
/**
* Reset preferences set by the toolbox.
*/
async resetPreference() {
if (!this._preferenceFront) {
return;
}
// Only reset the autohide pref in the Browser Toolbox if it's been toggled
// in the UI (don't reset the pref if it was already set before opening)
if (this._autohideHasBeenToggled) {
await this._preferenceFront.clearUserPref(DISABLE_AUTOHIDE_PREF);
}
this._preferenceFront = null;
},
/**
* Called when any event comes from the PerformanceFront. If the performance tool is
* already loaded when the first event comes in, immediately unbind this handler, as
* this is only used to queue up observed recordings before the performance tool can
* handle them, which will only occur when `console.profile()` recordings are started
* before the tool loads.
*/
async _onPerformanceFrontEvent(eventName, recording) {
if (this.getPanel("performance")) {
this.performance.off("*", this._onPerformanceFrontEvent);
return;
}
this._performanceQueuedRecordings = this._performanceQueuedRecordings || [];
const recordings = this._performanceQueuedRecordings;
// Before any console recordings, we'll get a `console-profile-start` event
// warning us that a recording will come later (via `recording-started`), so
// start to boot up the tool and populate the tool with any other recordings
// observed during that time.
if (eventName === "console-profile-start" && !this._performanceToolOpenedViaConsole) {
this._performanceToolOpenedViaConsole = this.loadTool("performance");
const panel = await this._performanceToolOpenedViaConsole;
await panel.open();
panel.panelWin.PerformanceController.populateWithRecordings(recordings);
this.performance.off("*", this._onPerformanceFrontEvent);
}
// Otherwise, if it's a recording-started event, we've already started loading
// the tool, so just store this recording in our array to be later populated
// once the tool loads.
if (eventName === "recording-started") {
recordings.push(recording);
}
},
/**
* Returns gViewSourceUtils for viewing source.
*/
get gViewSourceUtils() {
return this.win.gViewSourceUtils;
},
/**
* Opens source in style editor. Falls back to plain "view-source:".
* @see devtools/client/shared/source-utils.js
*/
viewSourceInStyleEditor: function(sourceURL, sourceLine) {
return viewSource.viewSourceInStyleEditor(this, sourceURL, sourceLine);
},
/**
* Opens source in debugger. Falls back to plain "view-source:".
* @see devtools/client/shared/source-utils.js
*/
viewSourceInDebugger: function(sourceURL, sourceLine, reason) {
return viewSource.viewSourceInDebugger(this, sourceURL, sourceLine, reason);
},
/**
* Opens source in scratchpad. Falls back to plain "view-source:".
* TODO The `sourceURL` for scratchpad instances are like `Scratchpad/1`.
* If instances are scoped one-per-browser-window, then we should be able
* to infer the URL from this toolbox, or use the built in scratchpad IN
* the toolbox.
*
* @see devtools/client/shared/source-utils.js
*/
viewSourceInScratchpad: function(sourceURL, sourceLine) {
return viewSource.viewSourceInScratchpad(sourceURL, sourceLine);
},
/**
* Opens source in plain "view-source:".
* @see devtools/client/shared/source-utils.js
*/
viewSource: function(sourceURL, sourceLine) {
return viewSource.viewSource(this, sourceURL, sourceLine);
},
// Support for WebExtensions API (`devtools.network.*`)
/**
* Return Netmonitor API object. This object offers Network monitor
* public API that can be consumed by other panels or WE API.
*/
getNetMonitorAPI: async function() {
const netPanel = this.getPanel("netmonitor");
// Return Net panel if it exists.
if (netPanel) {
return netPanel.panelWin.Netmonitor.api;
}
if (this._netMonitorAPI) {
return this._netMonitorAPI;
}
// Create and initialize Network monitor API object.
// This object is only connected to the backend - not to the UI.
this._netMonitorAPI = new NetMonitorAPI();
await this._netMonitorAPI.connect(this);
return this._netMonitorAPI;
},
/**
* Returns data (HAR) collected by the Network panel.
*/
getHARFromNetMonitor: async function() {
const netMonitor = await this.getNetMonitorAPI();
let har = await netMonitor.getHar();
// Return default empty HAR file if needed.
har = har || buildHarLog(Services.appinfo);
// Return the log directly to be compatible with
// Chrome WebExtension API.
return har.log;
},
/**
* Add listener for `onRequestFinished` events.
*
* @param {Object} listener
* The listener to be called it's expected to be
* a function that takes ({harEntry, requestId})
* as first argument.
*/
addRequestFinishedListener: async function(listener) {
const netMonitor = await this.getNetMonitorAPI();
netMonitor.addRequestFinishedListener(listener);
},
removeRequestFinishedListener: async function(listener) {
const netMonitor = await this.getNetMonitorAPI();
netMonitor.removeRequestFinishedListener(listener);
// Destroy Network monitor API object if the following is true:
// 1) there is no listener
// 2) the Net panel doesn't exist/use the API object (if the panel
// exists it's also responsible for destroying it,
// see `NetMonitorPanel.open` for more details)
const netPanel = this.getPanel("netmonitor");
const hasListeners = netMonitor.hasRequestFinishedListeners();
if (this._netMonitorAPI && !hasListeners && !netPanel) {
this._netMonitorAPI.destroy();
this._netMonitorAPI = null;
}
},
/**
* Used to lazily fetch HTTP response content within
* `onRequestFinished` event listener.
*
* @param {String} requestId
* Id of the request for which the response content
* should be fetched.
*/
fetchResponseContent: async function(requestId) {
const netMonitor = await this.getNetMonitorAPI();
return netMonitor.fetchResponseContent(requestId);
},
// Support management of installed WebExtensions that provide a devtools_page.
/**
* List the subset of the active WebExtensions which have a devtools_page (used by
* toolbox-options.js to create the list of the tools provided by the enabled
* WebExtensions).
* @see devtools/client/framework/toolbox-options.js
*/
listWebExtensions: function() {
// Return the array of the enabled webextensions (we can't use the prefs list here,
// because some of them may be disabled by the Addon Manager and still have a devtools
// preference).
return Array.from(this._webExtensions).map(([uuid, {name, pref}]) => {
return {uuid, name, pref};
});
},
/**
* Add a WebExtension to the list of the active extensions (given the extension UUID,
* a unique id assigned to an extension when it is installed, and its name),
* and emit a "webextension-registered" event to allow toolbox-options.js
* to refresh the listed tools accordingly.
* @see browser/components/extensions/ext-devtools.js
*/
registerWebExtension: function(extensionUUID, {name, pref}) {
// Ensure that an installed extension (active in the AddonManager) which
// provides a devtools page is going to be listed in the toolbox options
// (and refresh its name if it was already listed).
this._webExtensions.set(extensionUUID, {name, pref});
this.emit("webextension-registered", extensionUUID);
},
/**
* Remove an active WebExtension from the list of the active extensions (given the
* extension UUID, a unique id assigned to an extension when it is installed, and its
* name), and emit a "webextension-unregistered" event to allow toolbox-options.js
* to refresh the listed tools accordingly.
* @see browser/components/extensions/ext-devtools.js
*/
unregisterWebExtension: function(extensionUUID) {
// Ensure that an extension that has been disabled/uninstalled from the AddonManager
// is going to be removed from the toolbox options.
this._webExtensions.delete(extensionUUID);
this.emit("webextension-unregistered", extensionUUID);
},
/**
* A helper function which returns true if the extension with the given UUID is listed
* as active for the toolbox and has its related devtools about:config preference set
* to true.
* @see browser/components/extensions/ext-devtools.js
*/
isWebExtensionEnabled: function(extensionUUID) {
const extInfo = this._webExtensions.get(extensionUUID);
return extInfo && Services.prefs.getBoolPref(extInfo.pref, false);
},
/**
* Returns a panel id in the case of built in panels or "other" in the case of
* third party panels. This is necessary due to limitations in addon id strings,
* the permitted length of event telemetry property values and what we actually
* want to see in our telemetry.
*
* @param {String} id
* The panel id we would like to process.
*/
getTelemetryPanelNameOrOther: function(id) {
if (!this._toolNames) {
const definitions = gDevTools.getToolDefinitionArray();
const definitionIds = definitions.map(definition => definition.id);
this._toolNames = new Set(definitionIds);
}
if (!this._toolNames.has(id)) {
return "other";
}
return id;
},
};