fune/devtools/startup/DevToolsStartup.sys.mjs
Cristian Tuns 621bf1a656 Backed out 8 changesets (bug 1651518, bug 1883847) dt failures in nsTArray.h. CLOSED TREE
Backed out changeset 481558275c1d (bug 1651518)
Backed out changeset 5337435e8bca (bug 1651518)
Backed out changeset 8fe0c1a4da7f (bug 1651518)
Backed out changeset 840a0d9db260 (bug 1651518)
Backed out changeset eaab9de5819d (bug 1651518)
Backed out changeset cf71de92d782 (bug 1651518)
Backed out changeset d778691464db (bug 1651518)
Backed out changeset af28e5f719e8 (bug 1883847)
2024-05-22 09:02:19 -04:00

1408 lines
47 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/. */
/**
* This XPCOM component is loaded very early.
* Be careful to lazy load dependencies as much as possible.
*
* It manages all the possible entry points for DevTools:
* - Handles command line arguments like -jsconsole,
* - Register all key shortcuts,
* - Listen for "Browser Tools" system menu opening, under "Tools",
* - Inject the wrench icon in toolbar customization, which is used
* by the "Browser Tools" list displayed in the hamburger menu,
* - Register the JSON Viewer protocol handler.
* - Inject the profiler recording button in toolbar customization.
*
* Only once any of these entry point is fired, this module ensures starting
* core modules like 'devtools-browser.js' that hooks the browser windows
* and ensure setting up tools.
**/
const kDebuggerPrefs = [
"devtools.debugger.remote-enabled",
"devtools.chrome.enabled",
];
const DEVTOOLS_POLICY_DISABLED_PREF = "devtools.policy.disabled";
import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
CustomizableUI: "resource:///modules/CustomizableUI.sys.mjs",
CustomizableWidgets: "resource:///modules/CustomizableWidgets.sys.mjs",
PanelMultiView: "resource:///modules/PanelMultiView.sys.mjs",
PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
ProfilerMenuButton:
"resource://devtools/client/performance-new/popup/menu-button.sys.mjs",
WebChannel: "resource://gre/modules/WebChannel.sys.mjs",
});
// We don't want to spend time initializing the full loader here so we create
// our own lazy require.
ChromeUtils.defineLazyGetter(lazy, "Telemetry", function () {
const { require } = ChromeUtils.importESModule(
"resource://devtools/shared/loader/Loader.sys.mjs"
);
// eslint-disable-next-line no-shadow
const Telemetry = require("devtools/client/shared/telemetry");
return Telemetry;
});
ChromeUtils.defineLazyGetter(lazy, "KeyShortcutsBundle", function () {
return new Localization(["devtools/startup/key-shortcuts.ftl"], true);
});
/**
* Safely retrieve a localized DevTools key shortcut from KeyShortcutsBundle.
* If the shortcut is not available, this will return null. Consumer code
* should rely on this to skip unavailable shortcuts.
*
* Note that all shortcuts should always be available, but there is a notable
* exception, which is why we have to do this. When a localization change is
* uplifted to beta, language packs will not be updated immediately when the
* updated beta is available.
*
* This means that language pack users might get a new Beta version but will not
* have a language pack with the new strings yet.
*/
function getLocalizedKeyShortcut(id) {
try {
return lazy.KeyShortcutsBundle.formatValueSync(id);
} catch (e) {
console.error("Failed to retrieve DevTools localized shortcut for id", id);
return null;
}
}
ChromeUtils.defineLazyGetter(lazy, "KeyShortcuts", function () {
const isMac = AppConstants.platform == "macosx";
// Common modifier shared by most key shortcuts
const modifiers = isMac ? "accel,alt" : "accel,shift";
// List of all key shortcuts triggering installation UI
// `id` should match tool's id from client/definitions.js
const shortcuts = [
// The following keys are also registered in /client/menus.js
// And should be synced.
// Both are toggling the toolbox on the last selected panel
// or the default one.
{
id: "toggleToolbox",
shortcut: getLocalizedKeyShortcut("devtools-commandkey-toggle-toolbox"),
modifiers,
},
// All locales are using F12
{
id: "toggleToolboxF12",
shortcut: getLocalizedKeyShortcut(
"devtools-commandkey-toggle-toolbox-f12"
),
modifiers: "", // F12 is the only one without modifiers
},
// Open the Browser Toolbox
{
id: "browserToolbox",
shortcut: getLocalizedKeyShortcut("devtools-commandkey-browser-toolbox"),
modifiers: "accel,alt,shift",
},
// Open the Browser Console
{
id: "browserConsole",
shortcut: getLocalizedKeyShortcut("devtools-commandkey-browser-console"),
modifiers: "accel,shift",
},
// Toggle the Responsive Design Mode
{
id: "responsiveDesignMode",
shortcut: getLocalizedKeyShortcut(
"devtools-commandkey-responsive-design-mode"
),
modifiers,
},
// The following keys are also registered in /client/definitions.js
// and should be synced.
// Key for opening the Inspector
{
toolId: "inspector",
shortcut: getLocalizedKeyShortcut("devtools-commandkey-inspector"),
modifiers,
},
// Key for opening the Web Console
{
toolId: "webconsole",
shortcut: getLocalizedKeyShortcut("devtools-commandkey-webconsole"),
modifiers,
},
// Key for opening the Debugger
{
toolId: "jsdebugger",
shortcut: getLocalizedKeyShortcut("devtools-commandkey-jsdebugger"),
modifiers,
},
// Key for opening the Network Monitor
{
toolId: "netmonitor",
shortcut: getLocalizedKeyShortcut("devtools-commandkey-netmonitor"),
modifiers,
},
// Key for opening the Style Editor
{
toolId: "styleeditor",
shortcut: getLocalizedKeyShortcut("devtools-commandkey-styleeditor"),
modifiers: "shift",
},
// Key for opening the Performance Panel
{
toolId: "performance",
shortcut: getLocalizedKeyShortcut("devtools-commandkey-performance"),
modifiers: "shift",
},
// Key for opening the Storage Panel
{
toolId: "storage",
shortcut: getLocalizedKeyShortcut("devtools-commandkey-storage"),
modifiers: "shift",
},
// Key for opening the DOM Panel
{
toolId: "dom",
shortcut: getLocalizedKeyShortcut("devtools-commandkey-dom"),
modifiers,
},
// Key for opening the Accessibility Panel
{
toolId: "accessibility",
shortcut: getLocalizedKeyShortcut(
"devtools-commandkey-accessibility-f12"
),
modifiers: "shift",
},
];
if (isMac) {
// Add the extra key command for macOS, so you can open the inspector with cmd+shift+C
// like on Chrome DevTools.
shortcuts.push({
id: "inspectorMac",
toolId: "inspector",
shortcut: getLocalizedKeyShortcut("devtools-commandkey-inspector"),
modifiers: "accel,shift",
});
}
if (lazy.ProfilerMenuButton.isInNavbar()) {
shortcuts.push(...getProfilerKeyShortcuts());
}
// Allow toggling the JavaScript tracing not only from DevTools UI,
// but also from the web page when it is focused.
if (
Services.prefs.getBoolPref(
"devtools.debugger.features.javascript-tracing",
false
)
) {
shortcuts.push({
id: "javascriptTracingToggle",
shortcut: getLocalizedKeyShortcut(
"devtools-commandkey-javascript-tracing-toggle"
),
modifiers: "control,shift",
});
}
return shortcuts;
});
function getProfilerKeyShortcuts() {
return [
// Start/stop the profiler
{
id: "profilerStartStop",
shortcut: getLocalizedKeyShortcut(
"devtools-commandkey-profiler-start-stop"
),
modifiers: "control,shift",
},
// Capture a profile
{
id: "profilerCapture",
shortcut: getLocalizedKeyShortcut("devtools-commandkey-profiler-capture"),
modifiers: "control,shift",
},
// Because it's not uncommon for content or extension to bind this
// shortcut, allow using alt as well for starting and stopping the profiler
{
id: "profilerStartStopAlternate",
shortcut: getLocalizedKeyShortcut(
"devtools-commandkey-profiler-start-stop"
),
modifiers: "control,shift,alt",
},
{
id: "profilerCaptureAlternate",
shortcut: getLocalizedKeyShortcut("devtools-commandkey-profiler-capture"),
modifiers: "control,shift,alt",
},
];
}
/**
* Validate the URL that will be used for the WebChannel for the profiler.
*
* @param {string} targetUrl
* @returns {string}
*/
export function validateProfilerWebChannelUrl(targetUrl) {
const frontEndUrl = "https://profiler.firefox.com";
if (targetUrl !== frontEndUrl) {
// The user can specify either localhost or deploy previews as well as
// the official frontend URL for testing.
if (
// Allow a test URL.
/^https?:\/\/example\.com$/.test(targetUrl) ||
// Allows the following:
// "http://localhost:4242"
// "http://localhost:4242/"
// "http://localhost:3"
// "http://localhost:334798455"
/^http:\/\/localhost:\d+\/?$/.test(targetUrl) ||
// Allows the following:
// "https://deploy-preview-1234--perf-html.netlify.com"
// "https://deploy-preview-1234--perf-html.netlify.com/"
// "https://deploy-preview-1234567--perf-html.netlify.app"
// "https://main--perf-html.netlify.app"
/^https:\/\/(?:deploy-preview-\d+|main)--perf-html\.netlify\.(?:com|app)\/?$/.test(
targetUrl
)
) {
// This URL is one of the allowed ones to be used for configuration.
return targetUrl;
}
console.error(
`The preference "devtools.performance.recording.ui-base-url" was set to a ` +
"URL that is not allowed. No WebChannel messages will be sent between the " +
`browser and that URL. Falling back to ${frontEndUrl}. Only localhost ` +
"and deploy previews URLs are allowed.",
targetUrl
);
}
return frontEndUrl;
}
ChromeUtils.defineLazyGetter(lazy, "ProfilerPopupBackground", function () {
return ChromeUtils.importESModule(
"resource://devtools/client/performance-new/shared/background.sys.mjs"
);
});
export function DevToolsStartup() {
this.onWindowReady = this.onWindowReady.bind(this);
this.addDevToolsItemsToSubview = this.addDevToolsItemsToSubview.bind(this);
this.onMoreToolsViewShowing = this.onMoreToolsViewShowing.bind(this);
this.toggleProfilerKeyShortcuts = this.toggleProfilerKeyShortcuts.bind(this);
}
DevToolsStartup.prototype = {
/**
* Boolean flag to check if DevTools have been already initialized or not.
* By initialized, we mean that its main modules are loaded.
*/
initialized: false,
/**
* Boolean flag to check if the devtools initialization was already sent to telemetry.
* We only want to record one devtools entry point per Firefox run, but we are not
* interested in all the entry points.
*/
recorded: false,
get telemetry() {
if (!this._telemetry) {
this._telemetry = new lazy.Telemetry();
this._telemetry.setEventRecordingEnabled(true);
}
return this._telemetry;
},
/**
* Flag that indicates if the developer toggle was already added to customizableUI.
*/
developerToggleCreated: false,
/**
* Flag that indicates if the profiler recording popup was already added to
* customizableUI.
*/
profilerRecordingButtonCreated: false,
isDisabledByPolicy() {
return Services.prefs.getBoolPref(DEVTOOLS_POLICY_DISABLED_PREF, false);
},
handle(cmdLine) {
const flags = this.readCommandLineFlags(cmdLine);
// handle() can be called after browser startup (e.g. opening links from other apps).
const isInitialLaunch =
cmdLine.state == Ci.nsICommandLine.STATE_INITIAL_LAUNCH;
if (isInitialLaunch) {
// Store devtoolsFlag to check it later in onWindowReady.
this.devtoolsFlag = flags.devtools;
/* eslint-disable mozilla/balanced-observers */
// We are not expecting to remove those listeners until Firefox closes.
// Only top level Firefox Windows fire a browser-delayed-startup-finished event
Services.obs.addObserver(
this.onWindowReady,
"browser-delayed-startup-finished"
);
// Add DevTools menu items to the "More Tools" view.
Services.obs.addObserver(
this.onMoreToolsViewShowing,
"web-developer-tools-view-showing"
);
/* eslint-enable mozilla/balanced-observers */
if (!this.isDisabledByPolicy()) {
if (AppConstants.MOZ_DEV_EDITION) {
// On DevEdition, the developer toggle is displayed by default in the navbar
// area and should be created before the first paint.
this.hookDeveloperToggle();
}
this.hookProfilerRecordingButton();
}
}
if (flags.console) {
this.commandLine = true;
this.handleConsoleFlag(cmdLine);
}
if (flags.debugger) {
this.commandLine = true;
const binaryPath =
typeof flags.debugger == "string" ? flags.debugger : null;
this.handleDebuggerFlag(cmdLine, binaryPath);
}
if (flags.devToolsServer) {
this.handleDevToolsServerFlag(cmdLine, flags.devToolsServer);
}
// If Firefox is already opened, and DevTools are also already opened,
// try to open links passed via command line arguments.
if (!isInitialLaunch && this.initialized && cmdLine.length) {
this.checkForDebuggerLink(cmdLine);
}
},
/**
* Lookup in all arguments passed to firefox binary to find
* URLs including a precise location, like this:
* https://domain.com/file.js:1:10 (URL ending with `:${line}:${number}`)
* When such argument exists, try to open this source and precise location
* in the debugger.
*
* @param {nsICommandLine} cmdLine
*/
checkForDebuggerLink(cmdLine) {
const urlFlagIdx = cmdLine.findFlag("url", false);
// Bail out when there is no -url argument, or if that's last and so there is no URL after it.
if (urlFlagIdx == -1 && urlFlagIdx + 1 < cmdLine.length) {
return;
}
// The following code would only work if we have a top level browser window opened
const window = Services.wm.getMostRecentWindow("navigator:browser");
if (!window) {
return;
}
const urlParam = cmdLine.getArgument(urlFlagIdx + 1);
// Avoid processing valid url like:
// http://foo@user:123
// Note that when loading `http://foo.com` the URL of the default html page will be `http://foo.com/`.
// So that there will always be another `/` after `https://`
if (
(urlParam.startsWith("http://") || urlParam.startsWith("https://")) &&
urlParam.lastIndexOf("/") <= 7
) {
return;
}
let match = urlParam.match(/^(?<url>\w+:.+):(?<line>\d+):(?<column>\d+)$/);
if (!match) {
// fallback on only having the line when there is no column
match = urlParam.match(/^(?<url>\w+:.+):(?<line>\d+)?$/);
if (!match) {
return;
}
}
// line and column are supposed to be 1-based.
const { url, line, column } = match.groups;
// Debugger internal uses 0-based column number.
// NOTE: Non-debugger view-source doesn't use column number.
const columnOneBased = parseInt(column || 0, 10);
const columnZeroBased = columnOneBased > 0 ? columnOneBased - 1 : 0;
// If for any reason the final url is invalid, ignore it
try {
Services.io.newURI(url);
} catch (e) {
return;
}
const require = this.initDevTools("CommandLine");
const { gDevTools } = require("devtools/client/framework/devtools");
const toolbox = gDevTools.getToolboxForTab(window.gBrowser.selectedTab);
// Ignore the url if there is no devtools currently opened for the current tab
if (!toolbox) {
return;
}
// Avoid regular Firefox code from processing this argument,
// otherwise we would open the source in DevTools and in a new tab.
//
// /!\ This has to be called synchronously from the call to `DevToolsStartup.handle(cmdLine)`
// Otherwise the next command lines listener will interpret the argument redundantly.
cmdLine.removeArguments(urlFlagIdx, urlFlagIdx + 1);
// Avoid opening a new empty top level window if there is no more arguments
if (!cmdLine.length) {
cmdLine.preventDefault = true;
}
// Immediately focus the browser window in order, to focus devtools, or the view-source tab.
// Otherwise, without this, the terminal would still be the topmost window.
toolbox.win.focus();
// Note that the following method is async and returns a promise.
// But the current method has to be synchronous because of cmdLine.removeArguments.
// Also note that it will fallback to view-source when the source url isn't found in the debugger
toolbox.viewSourceInDebugger(
url,
parseInt(line, 10),
columnZeroBased,
null,
"CommandLine"
);
},
readCommandLineFlags(cmdLine) {
// All command line flags are disabled if DevTools are disabled by policy.
if (this.isDisabledByPolicy()) {
return {
console: false,
debugger: false,
devtools: false,
devToolsServer: false,
};
}
const console = cmdLine.handleFlag("jsconsole", false);
const devtools = cmdLine.handleFlag("devtools", false);
let devToolsServer;
try {
devToolsServer = cmdLine.handleFlagWithParam(
"start-debugger-server",
false
);
} catch (e) {
// We get an error if the option is given but not followed by a value.
// By catching and trying again, the value is effectively optional.
devToolsServer = cmdLine.handleFlag("start-debugger-server", false);
}
let debuggerFlag;
try {
debuggerFlag = cmdLine.handleFlagWithParam("jsdebugger", false);
} catch (e) {
// We get an error if the option is given but not followed by a value.
// By catching and trying again, the value is effectively optional.
debuggerFlag = cmdLine.handleFlag("jsdebugger", false);
}
return { console, debugger: debuggerFlag, devtools, devToolsServer };
},
/**
* Called when receiving the "browser-delayed-startup-finished" event for a new
* top-level window.
*/
onWindowReady(window) {
if (
this.isDisabledByPolicy() ||
AppConstants.MOZ_APP_NAME == "thunderbird"
) {
return;
}
this.hookWindow(window);
// This listener is called for all Firefox windows, but we want to execute some code
// only once.
if (!this._firstWindowReadyReceived) {
this.onFirstWindowReady(window);
this._firstWindowReadyReceived = true;
}
JsonView.initialize();
},
onFirstWindowReady(window) {
if (this.devtoolsFlag) {
this.handleDevToolsFlag(window);
// In the case of the --jsconsole and --jsdebugger command line parameters
// there was no browser window when they were processed so we act on the
// this.commandline flag instead.
if (this.commandLine) {
this.sendEntryPointTelemetry("CommandLine");
}
}
this.setSlowScriptDebugHandler();
},
/**
* Register listeners to all possible entry points for Developer Tools.
* But instead of implementing the actual actions, defer to DevTools codebase.
* In most cases, it only needs to call this.initDevTools which handles the rest.
* We do that to prevent loading any DevTools module until the user intent to use them.
*/
hookWindow(window) {
// Key Shortcuts need to be added on all the created windows.
this.hookKeyShortcuts(window);
// In some situations (e.g. starting Firefox with --jsconsole) DevTools will be
// initialized before the first browser-delayed-startup-finished event is received.
// We use a dedicated flag because we still need to hook the developer toggle.
this.hookDeveloperToggle();
this.hookProfilerRecordingButton();
// The developer menu hook only needs to be added if devtools have not been
// initialized yet.
if (!this.initialized) {
this.hookBrowserToolsMenu(window);
}
},
/**
* Dynamically register a wrench icon in the customization menu.
* You can use this button by right clicking on Firefox toolbar
* and dragging it from the customization panel to the toolbar.
* (i.e. this isn't displayed by default to users!)
*
* _But_, the "Browser Tools" entry in the hamburger menu (the menu with
* 3 horizontal lines), is using this "developer-button" view to populate
* its menu. So we have to register this button for the menu to work.
*
* Also, this menu duplicates its own entries from the "Browser Tools"
* menu in the system menu, under "Tools" main menu item. The system
* menu is being hooked by "hookBrowserToolsMenu" which ends up calling
* devtools/client/framework/browser-menus to create the items for real,
* initDevTools, from onViewShowing is also calling browser-menu.
*/
hookDeveloperToggle() {
if (this.developerToggleCreated) {
return;
}
const id = "developer-button";
const widget = lazy.CustomizableUI.getWidget(id);
if (widget && widget.provider == lazy.CustomizableUI.PROVIDER_API) {
return;
}
const panelviewId = "PanelUI-developer-tools";
const subviewId = "PanelUI-developer-tools-view";
const item = {
id,
type: "view",
viewId: panelviewId,
shortcutId: "key_toggleToolbox",
tooltiptext: "developer-button.tooltiptext2",
onViewShowing: event => {
const doc = event.target.ownerDocument;
const developerItems = lazy.PanelMultiView.getViewNode(doc, subviewId);
this.addDevToolsItemsToSubview(developerItems);
},
onInit(anchor) {
// Since onBeforeCreated already bails out when initialized, we can call
// it right away.
this.onBeforeCreated(anchor.ownerDocument);
},
onBeforeCreated: doc => {
// The developer toggle needs the "key_toggleToolbox" <key> element.
// In DEV EDITION, the toggle is added before 1st paint and hookKeyShortcuts() is
// not called yet when CustomizableUI creates the widget.
this.hookKeyShortcuts(doc.defaultView);
},
};
lazy.CustomizableUI.createWidget(item);
lazy.CustomizableWidgets.push(item);
this.developerToggleCreated = true;
},
addDevToolsItemsToSubview(subview) {
// Initialize DevTools to create all menuitems in the system menu before
// trying to copy them.
this.initDevTools("HamburgerMenu");
// Populate the subview with whatever menuitems are in the developer
// menu. We skip menu elements, because the menu panel has no way
// of dealing with those right now.
const doc = subview.ownerDocument;
const menu = doc.getElementById("menuWebDeveloperPopup");
const itemsToDisplay = [...menu.children];
lazy.CustomizableUI.clearSubview(subview);
lazy.CustomizableUI.fillSubviewFromMenuItems(itemsToDisplay, subview);
},
onMoreToolsViewShowing(moreToolsView) {
this.addDevToolsItemsToSubview(moreToolsView);
},
/**
* Register the profiler recording button. This button will be available
* in the customization palette for the Firefox toolbar. In addition, it can be
* enabled from profiler.firefox.com.
*/
hookProfilerRecordingButton() {
if (this.profilerRecordingButtonCreated) {
return;
}
const featureFlagPref = "devtools.performance.popup.feature-flag";
const isPopupFeatureFlagEnabled =
Services.prefs.getBoolPref(featureFlagPref);
this.profilerRecordingButtonCreated = true;
// Listen for messages from the front-end. This needs to happen even if the
// button isn't enabled yet. This will allow the front-end to turn on the
// popup for our users, regardless of if the feature is enabled by default.
this.initializeProfilerWebChannel();
if (isPopupFeatureFlagEnabled) {
// Initialize the CustomizableUI widget.
lazy.ProfilerMenuButton.initialize(this.toggleProfilerKeyShortcuts);
} else {
// The feature flag is not enabled, but watch for it to be enabled. If it is,
// initialize everything.
const enable = () => {
lazy.ProfilerMenuButton.initialize(this.toggleProfilerKeyShortcuts);
Services.prefs.removeObserver(featureFlagPref, enable);
};
Services.prefs.addObserver(featureFlagPref, enable);
}
},
/**
* Initialize the WebChannel for profiler.firefox.com. This function happens at
* startup, so care should be taken to minimize its performance impact. The WebChannel
* is a mechanism that is used to communicate between the browser, and front-end code.
*/
initializeProfilerWebChannel() {
let channel;
// Register a channel for the URL in preferences. Also update the WebChannel if
// the URL changes.
const urlPref = "devtools.performance.recording.ui-base-url";
// This method is only run once per Firefox instance, so it should not be
// strictly necessary to remove observers here.
// eslint-disable-next-line mozilla/balanced-observers
Services.prefs.addObserver(urlPref, registerWebChannel);
registerWebChannel();
function registerWebChannel() {
if (channel) {
channel.stopListening();
}
const urlForWebChannel = Services.io.newURI(
validateProfilerWebChannelUrl(Services.prefs.getStringPref(urlPref))
);
channel = new lazy.WebChannel("profiler.firefox.com", urlForWebChannel);
channel.listen((id, message, target) => {
// Defer loading the ProfilerPopupBackground script until it's absolutely needed,
// as this code path gets loaded at startup.
lazy.ProfilerPopupBackground.handleWebChannelMessage(
channel,
id,
message,
target
);
});
}
},
/*
* We listen to the "Browser Tools" system menu, which is under "Tools" main item.
* This menu item is hardcoded empty in Firefox UI. We listen for its opening to
* populate it lazily. Loading main DevTools module is going to populate it.
*/
hookBrowserToolsMenu(window) {
const menu = window.document.getElementById("browserToolsMenu");
const onPopupShowing = () => {
menu.removeEventListener("popupshowing", onPopupShowing);
this.initDevTools("SystemMenu");
};
menu.addEventListener("popupshowing", onPopupShowing);
},
/**
* Check if the user is a DevTools user by looking at our selfxss pref.
* This preference is incremented everytime the console is used (up to 5).
*
* @return {Boolean} true if the user can be considered as a devtools user.
*/
isDevToolsUser() {
const selfXssCount = Services.prefs.getIntPref("devtools.selfxss.count", 0);
return selfXssCount > 0;
},
hookKeyShortcuts(window) {
const doc = window.document;
// hookKeyShortcuts can be called both from hookWindow and from the developer toggle
// onBeforeCreated. Make sure shortcuts are only added once per window.
if (doc.getElementById("devtoolsKeyset")) {
return;
}
const keyset = doc.createXULElement("keyset");
keyset.setAttribute("id", "devtoolsKeyset");
this.attachKeys(doc, lazy.KeyShortcuts, keyset);
// Appending a <key> element is not always enough. The <keyset> needs
// to be detached and reattached to make sure the <key> is taken into
// account (see bug 832984).
const mainKeyset = doc.getElementById("mainKeyset");
mainKeyset.parentNode.insertBefore(keyset, mainKeyset);
},
/**
* This method attaches on the key elements to the devtools keyset.
*/
attachKeys(doc, keyShortcuts, keyset = doc.getElementById("devtoolsKeyset")) {
const window = doc.defaultView;
for (const key of keyShortcuts) {
if (!key.shortcut) {
// Shortcuts might be missing when a user relies on a language packs
// which is missing a recently uplifted shortcut. Language packs are
// typically updated a few days after a code uplift.
continue;
}
const xulKey = this.createKey(doc, key, () => this.onKey(window, key));
keyset.appendChild(xulKey);
}
},
/**
* This method removes keys from the devtools keyset.
*/
removeKeys(doc, keyShortcuts) {
for (const key of keyShortcuts) {
const keyElement = doc.getElementById(this.getKeyElementId(key));
if (keyElement) {
keyElement.remove();
}
}
},
/**
* We only want to have the keyboard shortcuts active when the menu button is on.
* This function either adds or removes the elements.
* @param {boolean} isEnabled
*/
toggleProfilerKeyShortcuts(isEnabled) {
const profilerKeyShortcuts = getProfilerKeyShortcuts();
for (const { document } of Services.wm.getEnumerator(null)) {
const devtoolsKeyset = document.getElementById("devtoolsKeyset");
const mainKeyset = document.getElementById("mainKeyset");
if (!devtoolsKeyset || !mainKeyset) {
// There may not be devtools keyset on this window.
continue;
}
const areProfilerKeysPresent = !!document.getElementById(
"key_profilerStartStop"
);
if (isEnabled === areProfilerKeysPresent) {
// Don't double add or double remove the shortcuts.
continue;
}
if (isEnabled) {
this.attachKeys(document, profilerKeyShortcuts);
} else {
this.removeKeys(document, profilerKeyShortcuts);
}
// Appending a <key> element is not always enough. The <keyset> needs
// to be detached and reattached to make sure the <key> is taken into
// account (see bug 832984).
mainKeyset.parentNode.insertBefore(devtoolsKeyset, mainKeyset);
}
},
async onKey(window, key) {
try {
// The profiler doesn't care if DevTools is loaded, so provide a quick check
// first to bail out of checking if DevTools is available.
switch (key.id) {
case "profilerStartStop":
case "profilerStartStopAlternate": {
lazy.ProfilerPopupBackground.toggleProfiler("aboutprofiling");
return;
}
case "profilerCapture":
case "profilerCaptureAlternate": {
lazy.ProfilerPopupBackground.captureProfile("aboutprofiling");
return;
}
}
// Ignore the following key shortcut if DevTools aren't yet opened.
// The key shortcut is registered in this core component in order to
// work even when the web page is focused.
if (key.id == "javascriptTracingToggle" && !this.initialized) {
return;
}
// Record the timing at which this event started in order to compute later in
// gDevTools.showToolbox, the complete time it takes to open the toolbox.
// i.e. especially take `initDevTools` into account.
const startTime = Cu.now();
const require = this.initDevTools("KeyShortcut", key);
const {
gDevToolsBrowser,
} = require("devtools/client/framework/devtools-browser");
await gDevToolsBrowser.onKeyShortcut(window, key, startTime);
} catch (e) {
console.error(`Exception while trigerring key ${key}: ${e}\n${e.stack}`);
}
},
getKeyElementId({ id, toolId }) {
return "key_" + (id || toolId);
},
// Create a <xul:key> DOM Element
createKey(doc, key, oncommand) {
const { shortcut, modifiers: mod } = key;
const k = doc.createXULElement("key");
k.id = this.getKeyElementId(key);
if (shortcut.startsWith("VK_")) {
k.setAttribute("keycode", shortcut);
if (shortcut.match(/^VK_\d$/)) {
// Add the event keydown attribute to ensure that shortcuts work for combinations
// such as ctrl shift 1.
k.setAttribute("event", "keydown");
}
} else {
k.setAttribute("key", shortcut);
}
if (mod) {
k.setAttribute("modifiers", mod);
}
k.addEventListener("command", oncommand);
return k;
},
initDevTools(reason, key = "") {
// In the case of the --jsconsole and --jsdebugger command line parameters
// there is no browser window yet so we don't send any telemetry yet.
if (reason !== "CommandLine") {
this.sendEntryPointTelemetry(reason, key);
}
this.initialized = true;
const { require } = ChromeUtils.importESModule(
"resource://devtools/shared/loader/Loader.sys.mjs"
);
// Ensure loading main devtools module that hooks up into browser UI
// and initialize all devtools machinery.
// eslint-disable-next-line import/no-unassigned-import
require("devtools/client/framework/devtools-browser");
return require;
},
handleConsoleFlag(cmdLine) {
const window = Services.wm.getMostRecentWindow("devtools:webconsole");
if (!window) {
const require = this.initDevTools("CommandLine");
const {
BrowserConsoleManager,
} = require("devtools/client/webconsole/browser-console-manager");
BrowserConsoleManager.toggleBrowserConsole().catch(console.error);
} else {
// the Browser Console was already open
window.focus();
}
if (cmdLine.state == Ci.nsICommandLine.STATE_REMOTE_AUTO) {
cmdLine.preventDefault = true;
}
},
// Open the toolbox on the selected tab once the browser starts up.
async handleDevToolsFlag(window) {
const require = this.initDevTools("CommandLine");
const { gDevTools } = require("devtools/client/framework/devtools");
await gDevTools.showToolboxForTab(window.gBrowser.selectedTab);
},
_isRemoteDebuggingEnabled() {
let remoteDebuggingEnabled = false;
try {
remoteDebuggingEnabled = kDebuggerPrefs.every(pref => {
return Services.prefs.getBoolPref(pref);
});
} catch (ex) {
console.error(ex);
return false;
}
if (!remoteDebuggingEnabled) {
const errorMsg =
"Could not run chrome debugger! You need the following " +
"prefs to be set to true: " +
kDebuggerPrefs.join(", ");
console.error(new Error(errorMsg));
// Dump as well, as we're doing this from a commandline, make sure people
// don't miss it:
dump(errorMsg + "\n");
}
return remoteDebuggingEnabled;
},
handleDebuggerFlag(cmdLine, binaryPath) {
if (!this._isRemoteDebuggingEnabled()) {
return;
}
let devtoolsThreadResumed = false;
const pauseOnStartup = cmdLine.handleFlag("wait-for-jsdebugger", false);
if (pauseOnStartup) {
const observe = function () {
devtoolsThreadResumed = true;
Services.obs.removeObserver(observe, "devtools-thread-ready");
};
Services.obs.addObserver(observe, "devtools-thread-ready");
}
const { BrowserToolboxLauncher } = ChromeUtils.importESModule(
"resource://devtools/client/framework/browser-toolbox/Launcher.sys.mjs"
);
// --jsdebugger $binaryPath is an helper alias to set MOZ_BROWSER_TOOLBOX_BINARY=$binaryPath
// See comment within BrowserToolboxLauncher.
// Setting it as an environment variable helps it being reused if we restart the browser via CmdOrCtrl+R
Services.env.set("MOZ_BROWSER_TOOLBOX_BINARY", binaryPath);
const browserToolboxLauncherConfig = {};
// If user passed the --jsdebugger in mochitests, we want to enable the
// multiprocess Browser Toolbox (by default it's parent process only)
if (Services.prefs.getBoolPref("devtools.testing", false)) {
browserToolboxLauncherConfig.forceMultiprocess = true;
}
BrowserToolboxLauncher.init(browserToolboxLauncherConfig);
if (pauseOnStartup) {
// Spin the event loop until the debugger connects.
const tm = Cc["@mozilla.org/thread-manager;1"].getService();
tm.spinEventLoopUntil("DevToolsStartup.jsm:handleDebuggerFlag", () => {
return devtoolsThreadResumed;
});
}
if (cmdLine.state == Ci.nsICommandLine.STATE_REMOTE_AUTO) {
cmdLine.preventDefault = true;
}
},
/**
* Handle the --start-debugger-server command line flag. The options are:
* --start-debugger-server
* The portOrPath parameter is boolean true in this case. Reads and uses the defaults
* from devtools.debugger.remote-port and devtools.debugger.remote-websocket prefs.
* The default values of these prefs are port 6000, WebSocket disabled.
*
* --start-debugger-server 6789
* Start the non-WebSocket server on port 6789.
*
* --start-debugger-server /path/to/filename
* Start the server on a Unix domain socket.
*
* --start-debugger-server ws:6789
* Start the WebSocket server on port 6789.
*
* --start-debugger-server ws:
* Start the WebSocket server on the default port (taken from d.d.remote-port)
*/
handleDevToolsServerFlag(cmdLine, portOrPath) {
if (!this._isRemoteDebuggingEnabled()) {
return;
}
let webSocket = false;
const defaultPort = Services.prefs.getIntPref(
"devtools.debugger.remote-port"
);
if (portOrPath === true) {
// Default to pref values if no values given on command line
webSocket = Services.prefs.getBoolPref(
"devtools.debugger.remote-websocket"
);
portOrPath = defaultPort;
} else if (portOrPath.startsWith("ws:")) {
webSocket = true;
const port = portOrPath.slice(3);
portOrPath = Number(port) ? port : defaultPort;
}
const {
useDistinctSystemPrincipalLoader,
releaseDistinctSystemPrincipalLoader,
} = ChromeUtils.importESModule(
"resource://devtools/shared/loader/DistinctSystemPrincipalLoader.sys.mjs"
);
try {
// Create a separate loader instance, so that we can be sure to receive
// a separate instance of the DebuggingServer from the rest of the
// devtools. This allows us to safely use the tools against even the
// actors and DebuggingServer itself, especially since we can mark
// serverLoader as invisible to the debugger (unlike the usual loader
// settings).
const serverLoader = useDistinctSystemPrincipalLoader(this);
const { DevToolsServer: devToolsServer } = serverLoader.require(
"resource://devtools/server/devtools-server.js"
);
const { SocketListener } = serverLoader.require(
"resource://devtools/shared/security/socket.js"
);
devToolsServer.init();
// Force the server to be kept running when the last connection closes.
// So that another client can connect after the previous one is disconnected.
devToolsServer.keepAlive = true;
devToolsServer.registerAllActors();
devToolsServer.allowChromeProcess = true;
const socketOptions = { portOrPath, webSocket };
const listener = new SocketListener(devToolsServer, socketOptions);
listener.open();
dump("Started devtools server on " + portOrPath + "\n");
// Prevent leaks on shutdown.
const close = () => {
Services.obs.removeObserver(close, "quit-application");
dump("Stopped devtools server on " + portOrPath + "\n");
if (listener) {
listener.close();
}
if (devToolsServer) {
devToolsServer.destroy();
}
releaseDistinctSystemPrincipalLoader(this);
};
Services.obs.addObserver(close, "quit-application");
} catch (e) {
dump("Unable to start devtools server on " + portOrPath + ": " + e);
}
if (cmdLine.state == Ci.nsICommandLine.STATE_REMOTE_AUTO) {
cmdLine.preventDefault = true;
}
},
/**
* Send entry point telemetry explaining how the devtools were launched. This
* functionality also lives inside `devtools/client/framework/browser-menus.js`
* because this codepath is only used the first time a toolbox is opened for a
* tab.
*
* @param {String} reason
* One of "KeyShortcut", "SystemMenu", "HamburgerMenu", "ContextMenu",
* "CommandLine".
* @param {String} key
* The key used by a key shortcut.
*/
sendEntryPointTelemetry(reason, key = "") {
if (!reason) {
return;
}
let keys = "";
if (reason === "KeyShortcut") {
let { modifiers, shortcut } = key;
modifiers = modifiers.replace(",", "+");
if (shortcut.startsWith("VK_")) {
shortcut = shortcut.substr(3);
}
keys = `${modifiers}+${shortcut}`;
}
const window = Services.wm.getMostRecentWindow("navigator:browser");
this.telemetry.addEventProperty(
window,
"open",
"tools",
null,
"shortcut",
keys
);
this.telemetry.addEventProperty(
window,
"open",
"tools",
null,
"entrypoint",
reason
);
if (this.recorded) {
return;
}
// Only save the first call for each firefox run as next call
// won't necessarely start the tool. For example key shortcuts may
// only change the currently selected tool.
try {
this.telemetry.getHistogramById("DEVTOOLS_ENTRY_POINT").add(reason);
} catch (e) {
dump("DevTools telemetry entry point failed: " + e + "\n");
}
this.recorded = true;
},
/**
* Hook the debugger tool to the "Debug Script" button of the slow script dialog.
*/
setSlowScriptDebugHandler() {
const debugService = Cc["@mozilla.org/dom/slow-script-debug;1"].getService(
Ci.nsISlowScriptDebug
);
debugService.activationHandler = window => {
const chromeWindow = window.browsingContext.topChromeWindow;
let setupFinished = false;
this.slowScriptDebugHandler(chromeWindow.gBrowser.selectedTab).then(
() => {
setupFinished = true;
}
);
// Don't return from the interrupt handler until the debugger is brought
// up; no reason to continue executing the slow script.
const utils = window.windowUtils;
utils.enterModalState();
Services.tm.spinEventLoopUntil(
"devtools-browser.js:debugService.activationHandler",
() => {
return setupFinished;
}
);
utils.leaveModalState();
};
debugService.remoteActivationHandler = async (browser, callback) => {
try {
// Force selecting the freezing tab
const chromeWindow = browser.ownerGlobal;
const tab = chromeWindow.gBrowser.getTabForBrowser(browser);
chromeWindow.gBrowser.selectedTab = tab;
await this.slowScriptDebugHandler(tab);
} catch (e) {
console.error(e);
}
callback.finishDebuggerStartup();
};
},
/**
* Called by setSlowScriptDebugHandler, when a tab freeze because of a slow running script
*/
async slowScriptDebugHandler(tab) {
const require = this.initDevTools("SlowScript");
const { gDevTools } = require("devtools/client/framework/devtools");
const toolbox = await gDevTools.showToolboxForTab(tab, {
toolId: "jsdebugger",
});
const threadFront = toolbox.threadFront;
// Break in place, which means resuming the debuggee thread and pausing
// right before the next step happens.
switch (threadFront.state) {
case "paused":
// When the debugger is already paused.
threadFront.resumeThenPause();
break;
case "attached":
// When the debugger is already open.
const onPaused = threadFront.once("paused");
threadFront.interrupt();
await onPaused;
threadFront.resumeThenPause();
break;
case "resuming":
// The debugger is newly opened.
const onResumed = threadFront.once("resumed");
await threadFront.interrupt();
await onResumed;
threadFront.resumeThenPause();
break;
default:
throw Error(
"invalid thread front state in slow script debug handler: " +
threadFront.state
);
}
},
// Used by tests and the toolbox to register the same key shortcuts in toolboxes loaded
// in a window window.
get KeyShortcuts() {
return lazy.KeyShortcuts;
},
get wrappedJSObject() {
return this;
},
get jsdebuggerHelpInfo() {
return ` --jsdebugger [<path>] Open the Browser Toolbox. Defaults to the local build
but can be overridden by a firefox path.
--wait-for-jsdebugger Spin event loop until JS debugger connects.
Enables debugging (some) application startup code paths.
Only has an effect when \`--jsdebugger\` is also supplied.
--start-debugger-server [ws:][ <port> | <path> ] Start the devtools server on
a TCP port or Unix domain socket path. Defaults to TCP port
6000. Use WebSocket protocol if ws: prefix is specified.
`;
},
get helpInfo() {
return ` --jsconsole Open the Browser Console.
--devtools Open DevTools on initial load.
${this.jsdebuggerHelpInfo}`;
},
classID: Components.ID("{9e9a9283-0ce9-4e4a-8f1c-ba129a032c32}"),
QueryInterface: ChromeUtils.generateQI(["nsICommandLineHandler"]),
};
/**
* Singleton object that represents the JSON View in-content tool.
* It has the same lifetime as the browser.
*/
const JsonView = {
initialized: false,
initialize() {
// Prevent loading the frame script multiple times if we call this more than once.
if (this.initialized) {
return;
}
this.initialized = true;
// Register for messages coming from the child process.
// This is never removed as there is no particular need to unregister
// it during shutdown.
Services.mm.addMessageListener("devtools:jsonview:save", this.onSave);
},
// Message handlers for events from child processes
/**
* Save JSON to a file needs to be implemented here
* in the parent process.
*/
onSave(message) {
const browser = message.target;
const chrome = browser.ownerGlobal;
if (message.data === null) {
// Save original contents
chrome.saveBrowser(browser);
} else {
if (
!message.data.startsWith("blob:resource://devtools/") ||
browser.contentPrincipal.origin != "resource://devtools"
) {
console.error("Got invalid request to save JSON data");
return;
}
// The following code emulates saveBrowser, but:
// - Uses the given blob URL containing the custom contents to save.
// - Obtains the file name from the URL of the document, not the blob.
// - avoids passing the document and explicitly passes system principal.
// We have a blob created by a null principal to save, and the null
// principal is from the child. Null principals don't survive crossing
// over IPC, so there's no other principal that'll work.
const persistable = browser.frameLoader;
persistable.startPersistence(null, {
onDocumentReady(doc) {
const uri = chrome.makeURI(doc.documentURI, doc.characterSet);
const filename = chrome.getDefaultFileName(undefined, uri, doc, null);
chrome.internalSave(
message.data,
null /* originalURL */,
null,
filename,
null,
doc.contentType,
false /* bypass cache */,
null /* filepicker title key */,
null /* file chosen */,
null /* referrer */,
doc.cookieJarSettings,
null /* initiating document */,
false /* don't skip prompt for a location */,
null /* cache key */,
lazy.PrivateBrowsingUtils.isBrowserPrivate(
browser
) /* private browsing ? */,
Services.scriptSecurityManager.getSystemPrincipal()
);
},
onError() {
throw new Error("JSON Viewer's onSave failed in startPersistence");
},
});
}
},
};