diff options
Diffstat (limited to 'devtools/startup')
18 files changed, 2486 insertions, 0 deletions
diff --git a/devtools/startup/AboutDebuggingRegistration.sys.mjs b/devtools/startup/AboutDebuggingRegistration.sys.mjs new file mode 100644 index 0000000000..8ae4f380b9 --- /dev/null +++ b/devtools/startup/AboutDebuggingRegistration.sys.mjs @@ -0,0 +1,37 @@ +/* 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/. */ + +// Register the about:debugging URL, that allows to debug tabs, extensions, workers on +// the current instance of Firefox or on a remote Firefox. + +const { nsIAboutModule } = Ci; + +export function AboutDebugging() {} + +AboutDebugging.prototype = { + classDescription: "about:debugging", + classID: Components.ID("1060afaf-dc9e-43da-8646-23a2faf48493"), + contractID: "@mozilla.org/network/protocol/about;1?what=debugging", + + QueryInterface: ChromeUtils.generateQI([nsIAboutModule]), + + newChannel(_, loadInfo) { + const chan = Services.io.newChannelFromURIWithLoadInfo( + Services.io.newURI("chrome://devtools/content/aboutdebugging/index.html"), + loadInfo + ); + chan.owner = Services.scriptSecurityManager.getSystemPrincipal(); + return chan; + }, + + getURIFlags(uri) { + return nsIAboutModule.ALLOW_SCRIPT | nsIAboutModule.IS_SECURE_CHROME_UI; + }, + + getChromeURI(_uri) { + return Services.io.newURI( + "chrome://devtools/content/aboutdebugging/index.html" + ); + }, +}; diff --git a/devtools/startup/AboutDevToolsToolboxRegistration.sys.mjs b/devtools/startup/AboutDevToolsToolboxRegistration.sys.mjs new file mode 100644 index 0000000000..f94b866bd2 --- /dev/null +++ b/devtools/startup/AboutDevToolsToolboxRegistration.sys.mjs @@ -0,0 +1,37 @@ +/* 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/. */ + +// Register about:devtools-toolbox which allows to open a devtools toolbox +// in a Firefox tab or a custom html iframe in browser.html + +const { nsIAboutModule } = Ci; + +export function AboutDevtoolsToolbox() {} + +AboutDevtoolsToolbox.prototype = { + uri: Services.io.newURI("chrome://devtools/content/framework/toolbox.xhtml"), + classDescription: "about:devtools-toolbox", + classID: Components.ID("11342911-3135-45a8-8d71-737a2b0ad469"), + contractID: "@mozilla.org/network/protocol/about;1?what=devtools-toolbox", + + QueryInterface: ChromeUtils.generateQI([nsIAboutModule]), + + newChannel(uri, loadInfo) { + const chan = Services.io.newChannelFromURIWithLoadInfo(this.uri, loadInfo); + chan.owner = Services.scriptSecurityManager.getSystemPrincipal(); + return chan; + }, + + getURIFlags(uri) { + return ( + nsIAboutModule.ALLOW_SCRIPT | + nsIAboutModule.ENABLE_INDEXED_DB | + nsIAboutModule.HIDE_FROM_ABOUTABOUT + ); + }, + + getChromeURI(_uri) { + return this.uri; + }, +}; diff --git a/devtools/startup/DevToolsShim.sys.mjs b/devtools/startup/DevToolsShim.sys.mjs new file mode 100644 index 0000000000..41f874a1ca --- /dev/null +++ b/devtools/startup/DevToolsShim.sys.mjs @@ -0,0 +1,330 @@ +/* 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/. */ + +const lazy = {}; +ChromeUtils.defineLazyGetter(lazy, "DevToolsStartup", () => { + return Cc["@mozilla.org/devtools/startup-clh;1"].getService( + Ci.nsICommandLineHandler + ).wrappedJSObject; +}); + +// 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; +}); + +const DEVTOOLS_POLICY_DISABLED_PREF = "devtools.policy.disabled"; + +function removeItem(array, callback) { + const index = array.findIndex(callback); + if (index >= 0) { + array.splice(index, 1); + } +} + +/** + * DevToolsShim is a singleton that provides a set of helpers to interact with DevTools, + * that work whether Devtools are enabled or not. + * + * It can be used to start listening to devtools events before DevTools are ready. As soon + * as DevTools are ready, the DevToolsShim will forward all the requests received until + * then to the real DevTools instance. + */ +export const DevToolsShim = { + _gDevTools: null, + listeners: [], + + get telemetry() { + if (!this._telemetry) { + this._telemetry = new lazy.Telemetry(); + this._telemetry.setEventRecordingEnabled(true); + } + return this._telemetry; + }, + + /** + * Returns true if DevTools are enabled. This now only depends on the policy. + * TODO: Merge isEnabled and isDisabledByPolicy. + */ + isEnabled() { + return !this.isDisabledByPolicy(); + }, + + /** + * Returns true if the devtools are completely disabled and can not be enabled. All + * entry points should return without throwing, initDevTools should never be called. + */ + isDisabledByPolicy() { + return Services.prefs.getBoolPref(DEVTOOLS_POLICY_DISABLED_PREF, false); + }, + + /** + * Check if DevTools have already been initialized. + * + * @return {Boolean} true if DevTools are initialized. + */ + isInitialized() { + return !!this._gDevTools; + }, + + /** + * Returns the array of the existing toolboxes. This method is part of the compatibility + * layer for webextensions. + * + * @return {Array<Toolbox>} + * An array of toolboxes. + */ + getToolboxes() { + if (this.isInitialized()) { + return this._gDevTools.getToolboxes(); + } + + return []; + }, + + /** + * Register an instance of gDevTools. Should be called by DevTools during startup. + * + * @param {DevTools} a devtools instance (from client/framework/devtools) + */ + register(gDevTools) { + this._gDevTools = gDevTools; + this._onDevToolsRegistered(); + this._gDevTools.emit("devtools-registered"); + }, + + /** + * Unregister the current instance of gDevTools. Should be called by DevTools during + * shutdown. + */ + unregister() { + if (this.isInitialized()) { + this._gDevTools.emit("devtools-unregistered"); + this._gDevTools = null; + } + }, + + /** + * The following methods can be called before DevTools are initialized: + * - on + * - off + * + * If DevTools are not initialized when calling the method, DevToolsShim will call the + * appropriate method as soon as a gDevTools instance is registered. + */ + + /** + * This method is used by browser/components/extensions/ext-devtools.js for the events: + * - toolbox-ready + * - toolbox-destroyed + */ + on(event, listener) { + if (this.isInitialized()) { + this._gDevTools.on(event, listener); + } else { + this.listeners.push([event, listener]); + } + }, + + /** + * This method is currently only used by devtools code, but is kept here for consistency + * with on(). + */ + off(event, listener) { + if (this.isInitialized()) { + this._gDevTools.off(event, listener); + } else { + removeItem(this.listeners, ([e, l]) => e === event && l === listener); + } + }, + + /** + * Called from SessionStore.sys.mjs in mozilla-central when saving the current state. + * + * @param {Object} state + * A SessionStore state object that gets modified by reference + */ + saveDevToolsSession(state) { + if (!this.isInitialized()) { + return; + } + + this._gDevTools.saveDevToolsSession(state); + }, + + /** + * Called from SessionStore.sys.mjs in mozilla-central when restoring a previous session. + * Will always be called, even if the session does not contain DevTools related items. + */ + restoreDevToolsSession(session) { + if (!this.isEnabled()) { + return; + } + + const { browserConsole, browserToolbox } = session; + const hasDevToolsData = browserConsole || browserToolbox; + if (!hasDevToolsData) { + // Do not initialize DevTools unless there is DevTools specific data in the session. + return; + } + + this.initDevTools("SessionRestore"); + this._gDevTools.restoreDevToolsSession(session); + }, + + isDevToolsUser() { + return lazy.DevToolsStartup.isDevToolsUser(); + }, + + /** + * Called from nsContextMenu.js in mozilla-central when using the Inspect Accessibility + * context menu item. + * + * @param {XULTab} tab + * The browser tab on which inspect accessibility was used. + * @param {ElementIdentifier} domReference + * Identifier generated by ContentDOMReference. It is a unique pair of + * BrowsingContext ID and a numeric ID. + * @return {Promise} a promise that resolves when the accessible node is selected in the + * accessibility inspector or that resolves immediately if DevTools are not + * enabled. + */ + inspectA11Y(tab, domReference) { + if (!this.isEnabled()) { + return Promise.resolve(); + } + + // 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 `DevToolsStartup.initDevTools` into account. + const startTime = Cu.now(); + + this.initDevTools("ContextMenu"); + + return this._gDevTools.inspectA11Y(tab, domReference, startTime); + }, + + /** + * Called from nsContextMenu.js in mozilla-central when using the Inspect Element + * context menu item. + * + * @param {XULTab} tab + * The browser tab on which inspect node was used. + * @param {ElementIdentifier} domReference + * Identifier generated by ContentDOMReference. It is a unique pair of + * BrowsingContext ID and a numeric ID. + * @return {Promise} a promise that resolves when the node is selected in the inspector + * markup view or that resolves immediately if DevTools are not enabled. + */ + inspectNode(tab, domReference) { + if (!this.isEnabled()) { + return Promise.resolve(); + } + + // 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 `DevToolsStartup.initDevTools` into account. + const startTime = Cu.now(); + + this.initDevTools("ContextMenu"); + + return this._gDevTools.inspectNode(tab, domReference, startTime); + }, + + _onDevToolsRegistered() { + // Register all pending event listeners on the real gDevTools object. + for (const [event, listener] of this.listeners) { + this._gDevTools.on(event, listener); + } + + this.listeners = []; + }, + + /** + * Initialize DevTools via DevToolsStartup if needed. This method throws if DevTools are + * not enabled. + * + * @param {String} reason + * optional, if provided should be a valid entry point for DEVTOOLS_ENTRY_POINT + * in toolkit/components/telemetry/Histograms.json + */ + initDevTools(reason) { + if (!this.isEnabled()) { + throw new Error("DevTools are not enabled and can not be initialized."); + } + + if (reason) { + const window = Services.wm.getMostRecentWindow("navigator:browser"); + + this.telemetry.addEventProperty( + window, + "open", + "tools", + null, + "shortcut", + "" + ); + this.telemetry.addEventProperty( + window, + "open", + "tools", + null, + "entrypoint", + reason + ); + } + + if (!this.isInitialized()) { + lazy.DevToolsStartup.initDevTools(reason); + } + }, +}; + +/** + * Compatibility layer for webextensions. + * + * Those methods are called only after a DevTools webextension was loaded in DevTools, + * therefore DevTools should always be available when they are called. + */ +const webExtensionsMethods = [ + "createCommandsForTabForWebExtension", + "getTheme", + "openBrowserConsole", +]; + +/** + * Compatibility layer for other third parties. + */ +const otherToolMethods = [ + // gDevTools.showToolboxForTab is used by wptrunner to start devtools + // https://github.com/web-platform-tests/wpt + // And also, Quick Actions on URL bar. + "showToolboxForTab", + // Used for Quick Actions on URL bar. + "hasToolboxForTab", + // Used for Quick Actions test. + "getToolboxForTab", +]; + +for (const method of [...webExtensionsMethods, ...otherToolMethods]) { + DevToolsShim[method] = function () { + if (!this.isEnabled()) { + throw new Error( + "Could not call a DevToolsShim webextension method ('" + + method + + "'): DevTools are not initialized." + ); + } + + this.initDevTools(); + return this._gDevTools[method].apply(this._gDevTools, arguments); + }; +} diff --git a/devtools/startup/DevToolsStartup.sys.mjs b/devtools/startup/DevToolsStartup.sys.mjs new file mode 100644 index 0000000000..dd6be71337 --- /dev/null +++ b/devtools/startup/DevToolsStartup.sys.mjs @@ -0,0 +1,1408 @@ +/* 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 (subject, topic, data) { + 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(status) { + throw new Error("JSON Viewer's onSave failed in startPersistence"); + }, + }); + } + }, +}; diff --git a/devtools/startup/components.conf b/devtools/startup/components.conf new file mode 100644 index 0000000000..f88b9146da --- /dev/null +++ b/devtools/startup/components.conf @@ -0,0 +1,30 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# 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/. + +Classes = [] + +if buildconfig.substs['MOZ_DEVTOOLS'] == 'all': + Classes += [ + { + 'cid': '{9e9a9283-0ce9-4e4a-8f1c-ba129a032c32}', + 'contract_ids': ['@mozilla.org/devtools/startup-clh;1'], + 'esModule': 'resource:///modules/DevToolsStartup.sys.mjs', + 'constructor': 'DevToolsStartup', + 'categories': {'command-line-handler': 'm-devtools'}, + }, + { + 'cid': '{1060afaf-dc9e-43da-8646-23a2faf48493}', + 'contract_ids': ['@mozilla.org/network/protocol/about;1?what=debugging'], + 'esModule': 'resource:///modules/AboutDebuggingRegistration.sys.mjs', + 'constructor': 'AboutDebugging', + }, + { + 'cid': '{11342911-3135-45a8-8d71-737a2b0ad469}', + 'contract_ids': ['@mozilla.org/network/protocol/about;1?what=devtools-toolbox'], + 'esModule': 'resource:///modules/AboutDevToolsToolboxRegistration.sys.mjs', + 'constructor': 'AboutDevtoolsToolbox', + }, + ] diff --git a/devtools/startup/jar.mn b/devtools/startup/jar.mn new file mode 100644 index 0000000000..784b7c3ab7 --- /dev/null +++ b/devtools/startup/jar.mn @@ -0,0 +1,8 @@ +# 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/. + +devtools-startup.jar: +% content devtools-startup %content/ + + content/DevToolsShim.sys.mjs (DevToolsShim.sys.mjs) diff --git a/devtools/startup/locales/en-US/key-shortcuts.ftl b/devtools/startup/locales/en-US/key-shortcuts.ftl new file mode 100644 index 0000000000..bbc2c7ca1e --- /dev/null +++ b/devtools/startup/locales/en-US/key-shortcuts.ftl @@ -0,0 +1,38 @@ +# 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/. + +# Key pressed to open a toolbox with the default panel selected +devtools-commandkey-toggle-toolbox = I +# Alternative key pressed to open a toolbox with the default panel selected +devtools-commandkey-toggle-toolbox-f12 = VK_F12 +# Key pressed to open the Browser Toolbox, used for debugging Firefox itself +devtools-commandkey-browser-toolbox = I +# Key pressed to open the Browser Console, used for debugging Firefox itself +devtools-commandkey-browser-console = J +# Key pressed to toggle on the Responsive Design Mode +devtools-commandkey-responsive-design-mode = M +# Key pressed to open a toolbox with the inspector panel selected +devtools-commandkey-inspector = C +# Key pressed to open a toolbox with the web console panel selected +devtools-commandkey-webconsole = K +# Key pressed to open a toolbox with the debugger panel selected +devtools-commandkey-jsdebugger = Z +# Key pressed to open a toolbox with the network monitor panel selected +devtools-commandkey-netmonitor = E +# Key pressed to open a toolbox with the style editor panel selected +devtools-commandkey-styleeditor = VK_F7 +# Key pressed to open a toolbox with the performance panel selected +devtools-commandkey-performance = VK_F5 +# Key pressed to open a toolbox with the storage panel selected +devtools-commandkey-storage = VK_F9 +# Key pressed to open a toolbox with the DOM panel selected +devtools-commandkey-dom = W +# Key pressed to open a toolbox with the accessibility panel selected +devtools-commandkey-accessibility-f12 = VK_F12 +# Key pressed to start or stop the performance profiler +devtools-commandkey-profiler-start-stop = VK_1 +# Key pressed to capture a recorded performance profile +devtools-commandkey-profiler-capture = VK_2 +# Key pressed to toggle the JavaScript tracing +devtools-commandkey-javascript-tracing-toggle = VK_5 diff --git a/devtools/startup/locales/jar.mn b/devtools/startup/locales/jar.mn new file mode 100644 index 0000000000..56bbc39203 --- /dev/null +++ b/devtools/startup/locales/jar.mn @@ -0,0 +1,7 @@ +#filter substitution +# 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/. + +[localization] @AB_CD@.jar: + devtools/startup (%*.ftl) diff --git a/devtools/startup/locales/moz.build b/devtools/startup/locales/moz.build new file mode 100644 index 0000000000..d988c0ff9b --- /dev/null +++ b/devtools/startup/locales/moz.build @@ -0,0 +1,7 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# 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/. + +JAR_MANIFESTS += ["jar.mn"] diff --git a/devtools/startup/moz.build b/devtools/startup/moz.build new file mode 100644 index 0000000000..22ca205602 --- /dev/null +++ b/devtools/startup/moz.build @@ -0,0 +1,28 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# 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/. + +JAR_MANIFESTS += ["jar.mn"] + +# Register the startup components only for 'all' builds. +if CONFIG["MOZ_DEVTOOLS"] == "all": + EXTRA_JS_MODULES += [ + "AboutDebuggingRegistration.sys.mjs", + "AboutDevToolsToolboxRegistration.sys.mjs", + "DevToolsStartup.sys.mjs", + ] + + DIRS += [ + "locales", + ] + +XPCOM_MANIFESTS += [ + "components.conf", +] + +XPCSHELL_TESTS_MANIFESTS += ["tests/xpcshell/xpcshell.toml"] + +if CONFIG["MOZ_BUILD_APP"] != "mobile/android": + BROWSER_CHROME_MANIFESTS += ["tests/browser/browser.toml"] diff --git a/devtools/startup/tests/browser/browser.toml b/devtools/startup/tests/browser/browser.toml new file mode 100644 index 0000000000..ff9433663f --- /dev/null +++ b/devtools/startup/tests/browser/browser.toml @@ -0,0 +1,11 @@ +[DEFAULT] +tags = "devtools" +subsuite = "devtools" +support-files = [ + "command-line.html", + "command-line.js", +] + +["browser_command_line_urls.js"] + +["browser_shim_disable_devtools.js"] diff --git a/devtools/startup/tests/browser/browser_command_line_urls.js b/devtools/startup/tests/browser/browser_command_line_urls.js new file mode 100644 index 0000000000..4494d635c6 --- /dev/null +++ b/devtools/startup/tests/browser/browser_command_line_urls.js @@ -0,0 +1,154 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Test command line processing of URLs meant to be intepreted by DevTools. + */ + +/* eslint-env browser */ + +const { DevToolsStartup } = ChromeUtils.importESModule( + "resource:///modules/DevToolsStartup.sys.mjs" +); + +const { require } = ChromeUtils.importESModule( + "resource://devtools/shared/loader/Loader.sys.mjs" +); +const { gDevTools } = require("devtools/client/framework/devtools"); + +const URL_ROOT = "https://example.org/browser/devtools/startup/tests/browser/"; + +const startup = new DevToolsStartup(); +// The feature covered here only work when calling firefox from command line +// while it is already opened. So fake Firefox being already opened: +startup.initialized = true; + +add_task(async function ignoredUrls() { + const tabCount = gBrowser.tabs.length; + + // We explicitely try to ignore these URL which looks like line, but are passwords + sendUrlViaCommandLine("https://foo@user:123"); + sendUrlViaCommandLine("https://foo@user:123"); + sendUrlViaCommandLine("https://foo@123:456"); + + // The following is an invalid URL (domain with space) + sendUrlViaCommandLine("https://foo /index.html:123:456"); + + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + await new Promise(r => setTimeout(r, 1000)); + + is(tabCount, gBrowser.tabs.length); +}); + +/** + * With DevTools closed, but DevToolsStartup "initialized", + * the url will be ignored + */ +add_task(async function openingWithDevToolsClosed() { + const url = URL_ROOT + "command-line.html:5:2"; + + const tabCount = gBrowser.tabs.length; + const ignoredUrl = sendUrlViaCommandLine(url); + ok(ignoredUrl, "The url is ignored when no devtools are opened"); + + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + await new Promise(r => setTimeout(r, 1000)); + + is(tabCount, gBrowser.tabs.length); +}); + +/** + * With DevTools opened, but the source isn't in the debugged tab, + * the url will also be opened via view-source + */ +add_task(async function openingWithDevToolsButUnknownSource() { + const url = URL_ROOT + "command-line.html:5:2"; + + const tab = BrowserTestUtils.addTab( + gBrowser, + "data:text/html;charset=utf-8,<title>foo</title>" + ); + + const toolbox = await gDevTools.showToolboxForTab(gBrowser.selectedTab, { + toolId: "jsdebugger", + }); + + const newTabOpened = BrowserTestUtils.waitForNewTab(gBrowser); + sendUrlViaCommandLine(url); + const newTab = await newTabOpened; + is( + newTab.linkedBrowser.documentURI.spec, + "view-source:" + URL_ROOT + "command-line.html" + ); + + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () { + const selection = content.getSelection(); + Assert.equal( + selection.toString(), + " <title>Command line test page</title>", + "The 5th line is selected in view-source" + ); + }); + await gBrowser.removeTab(newTab); + + await toolbox.destroy(); + await gBrowser.removeTab(tab); +}); + +/** + * With DevTools opened, and the source is debugged by the debugger, + * the url will be opened in the debugger. + */ +add_task(async function openingWithDevToolsAndKnownSource() { + const url = URL_ROOT + "command-line.js:5:2"; + + const tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + URL_ROOT + "command-line.html" + ); + const toolbox = await gDevTools.showToolboxForTab(tab, { + toolId: "jsdebugger", + }); + + info("Open a first URL with line and column"); + sendUrlViaCommandLine(url); + + const dbg = toolbox.getPanel("jsdebugger"); + const selectedLocation = await BrowserTestUtils.waitForCondition(() => { + return dbg._selectors.getSelectedLocation(dbg._getState()); + }); + is(selectedLocation.source.url, URL_ROOT + "command-line.js"); + is(selectedLocation.line, 5); + is(selectedLocation.column, 1); + + info("Open another URL with only a line"); + const url2 = URL_ROOT + "command-line.js:6"; + sendUrlViaCommandLine(url2); + const selectedLocation2 = await BrowserTestUtils.waitForCondition(() => { + const location = dbg._selectors.getSelectedLocation(dbg._getState()); + return location.line == 6 ? location : false; + }); + is(selectedLocation2.source.url, URL_ROOT + "command-line.js"); + is(selectedLocation2.line, 6); + is(selectedLocation2.column, 0); + + await toolbox.destroy(); + await gBrowser.removeTab(tab); +}); + +// Fake opening an existing firefox instance with a URL +// passed via `-url` command line argument +function sendUrlViaCommandLine(url) { + const cmdLine = Cu.createCommandLine( + ["-url", url], + null, + Ci.nsICommandLine.STATE_REMOTE_EXPLICIT + ); + startup.handle(cmdLine); + + // Return true if DevToolsStartup ignored the url + // and let it be in the command line object. + return cmdLine.findFlag("url", false) != -1; +} diff --git a/devtools/startup/tests/browser/browser_shim_disable_devtools.js b/devtools/startup/tests/browser/browser_shim_disable_devtools.js new file mode 100644 index 0000000000..1a16902099 --- /dev/null +++ b/devtools/startup/tests/browser/browser_shim_disable_devtools.js @@ -0,0 +1,165 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/* eslint-env browser */ + +const { require } = ChromeUtils.importESModule( + "resource://devtools/shared/loader/Loader.sys.mjs" +); + +const { gDevTools } = require("devtools/client/framework/devtools"); + +async function simulateMenuOpen(menu) { + return new Promise(resolve => { + menu.addEventListener("popupshown", resolve, { once: true }); + menu.dispatchEvent(new MouseEvent("popupshowing")); + menu.dispatchEvent(new MouseEvent("popupshown")); + }); +} + +async function simulateMenuClosed(menu) { + return new Promise(resolve => { + menu.addEventListener("popuphidden", resolve, { once: true }); + menu.dispatchEvent(new MouseEvent("popuphiding")); + menu.dispatchEvent(new MouseEvent("popuphidden")); + }); +} + +/** + * Test that the preference devtools.policy.disabled disables entry points for devtools. + */ +add_task(async function () { + info( + "Disable DevTools entry points (does not apply to the already created window" + ); + await new Promise(resolve => { + const options = { set: [["devtools.policy.disabled", true]] }; + SpecialPowers.pushPrefEnv(options, resolve); + }); + + // In DEV_EDITION the browser starts with the developer-button in the toolbar. This + // applies to all new windows and forces creating keyboard shortcuts. The preference + // tested should not change without restart, but for the needs of the test, remove the + // developer-button from the UI before opening a new window. + if (AppConstants.MOZ_DEV_EDITION) { + CustomizableUI.removeWidgetFromArea("developer-button"); + } + + info( + "Open a new window, all window-specific hooks for DevTools will be disabled." + ); + const win = OpenBrowserWindow({ private: false }); + await waitForDelayedStartupFinished(win); + + info( + "Open a new tab on the new window to ensure the focus is on the new window" + ); + const tab = BrowserTestUtils.addTab( + win.gBrowser, + "data:text/html;charset=utf-8,<title>foo</title>" + ); + await BrowserTestUtils.browserLoaded(win.gBrowser.getBrowserForTab(tab)); + + info( + "Synthesize a DevTools shortcut, the toolbox should not open on this new window." + ); + synthesizeToggleToolboxKey(win); + + // There is no event to wait for here as this shortcut should have no effect. + /* eslint-disable mozilla/no-arbitrary-setTimeout */ + await new Promise(r => setTimeout(r, 1000)); + + is(gDevTools._toolboxesPerCommands.size, 0, "No toolbox has been opened"); + + info("Open the context menu for the content page."); + const contextMenu = win.document.getElementById("contentAreaContextMenu"); + const popupShownPromise = BrowserTestUtils.waitForEvent( + contextMenu, + "popupshown" + ); + EventUtils.synthesizeMouseAtCenter( + win.document.documentElement, + { type: "contextmenu", button: 2 }, + win + ); + await popupShownPromise; + + const inspectElementItem = contextMenu.querySelector(`#context-inspect`); + ok( + inspectElementItem.hidden, + "The inspect element item is hidden in the context menu" + ); + + info("Close the context menu"); + const onContextMenuHidden = BrowserTestUtils.waitForEvent( + contextMenu, + "popuphidden" + ); + contextMenu.hidePopup(); + await onContextMenuHidden; + + info("Open the menubar Tools menu"); + const toolsMenuPopup = win.document.getElementById("menu_ToolsPopup"); + const browserToolsMenu = win.document.getElementById("browserToolsMenu"); + ok( + !browserToolsMenu.hidden, + "The Browser Tools item of the tools menu is visible" + ); + + await simulateMenuOpen(toolsMenuPopup); + const subMenu = win.document.getElementById("menuWebDeveloperPopup"); + + info("Open the Browser Tools sub-menu"); + await simulateMenuOpen(subMenu); + + const visibleMenuItems = Array.from( + subMenu.querySelectorAll("menuitem") + ).filter(item => !item.hidden); + + const { menuitems } = require("devtools/client/menus"); + for (const devtoolsItem of menuitems) { + ok( + !visibleMenuItems.some(item => item.id === devtoolsItem.id), + "DevTools menu item is not visible in the Browser Tools menu" + ); + } + + info("Close out the menu popups"); + await simulateMenuClosed(subMenu); + await simulateMenuClosed(toolsMenuPopup); + + win.gBrowser.removeTab(tab); + + info("Close the test window"); + const winClosed = BrowserTestUtils.windowClosed(win); + win.BrowserTryToCloseWindow(); + await winClosed; +}); + +function waitForDelayedStartupFinished(win) { + return new Promise(resolve => { + Services.obs.addObserver(function observer(subject, topic) { + if (win == subject) { + Services.obs.removeObserver( + observer, + "browser-delayed-startup-finished" + ); + resolve(); + } + }, "browser-delayed-startup-finished"); + }); +} + +/** + * Helper to call the toggle devtools shortcut. + */ +function synthesizeToggleToolboxKey(win) { + info("Trigger the toogle toolbox shortcut"); + if (Services.appinfo.OS == "Darwin") { + EventUtils.synthesizeKey("i", { accelKey: true, altKey: true }, win); + } else { + EventUtils.synthesizeKey("i", { accelKey: true, shiftKey: true }, win); + } +} diff --git a/devtools/startup/tests/browser/command-line.html b/devtools/startup/tests/browser/command-line.html new file mode 100644 index 0000000000..c8f14b95bd --- /dev/null +++ b/devtools/startup/tests/browser/command-line.html @@ -0,0 +1,10 @@ +<!DOCTYPE html> +<html> +<head> + <meta charset="UTF-8"> + <title>Command line test page</title> +</head> +<body> + <script src="command-line.js"></script> +</body> +</html> diff --git a/devtools/startup/tests/browser/command-line.js b/devtools/startup/tests/browser/command-line.js new file mode 100644 index 0000000000..c15c83407b --- /dev/null +++ b/devtools/startup/tests/browser/command-line.js @@ -0,0 +1,3 @@ +"use strict"; + +console.log("command line script"); diff --git a/devtools/startup/tests/xpcshell/.eslintrc.js b/devtools/startup/tests/xpcshell/.eslintrc.js new file mode 100644 index 0000000000..6581a0bf32 --- /dev/null +++ b/devtools/startup/tests/xpcshell/.eslintrc.js @@ -0,0 +1,5 @@ +"use strict"; + +module.exports = { + extends: "../../../.eslintrc.xpcshell.js", +}; diff --git a/devtools/startup/tests/xpcshell/test_devtools_shim.js b/devtools/startup/tests/xpcshell/test_devtools_shim.js new file mode 100644 index 0000000000..6e0ef2789d --- /dev/null +++ b/devtools/startup/tests/xpcshell/test_devtools_shim.js @@ -0,0 +1,202 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { DevToolsShim } = ChromeUtils.importESModule( + "chrome://devtools-startup/content/DevToolsShim.sys.mjs" +); + +// Test the DevToolsShim + +/** + * Create a mocked version of DevTools that records all calls made to methods expected + * to be called by DevToolsShim. + */ +function createMockDevTools() { + const methods = [ + "on", + "off", + "emit", + "saveDevToolsSession", + "restoreDevToolsSession", + ]; + + const mock = { + callLog: {}, + }; + + for (const method of methods) { + // Create a stub for method, that only pushes its arguments in the inner callLog + mock[method] = function (...args) { + mock.callLog[method].push(args); + }; + mock.callLog[method] = []; + } + + return mock; +} + +/** + * Check if a given method was called an expected number of times, and finally check the + * arguments provided to the last call, if appropriate. + */ +function checkCalls(mock, method, length, lastArgs) { + Assert.strictEqual( + mock.callLog[method].length, + length, + "Devtools.on was called the expected number of times" + ); + + // If we don't want to check the last call or if the method was never called, bail out. + if (!lastArgs || length === 0) { + return; + } + + for (let i = 0; i < lastArgs.length; i++) { + const expectedArg = lastArgs[i]; + Assert.strictEqual( + mock.callLog[method][length - 1][i], + expectedArg, + `Devtools.${method} was called with the expected argument (index ${i})` + ); + } +} + +function test_register_unregister() { + ok(!DevToolsShim.isInitialized(), "DevTools are not initialized"); + + DevToolsShim.register(createMockDevTools()); + ok(DevToolsShim.isInitialized(), "DevTools are initialized"); + + DevToolsShim.unregister(); + ok(!DevToolsShim.isInitialized(), "DevTools are not initialized"); +} + +function test_on_is_forwarded_to_devtools() { + ok(!DevToolsShim.isInitialized(), "DevTools are not initialized"); + + function cb1() {} + function cb2() {} + const mock = createMockDevTools(); + + DevToolsShim.on("test_event", cb1); + DevToolsShim.register(mock); + checkCalls(mock, "on", 1, ["test_event", cb1]); + + DevToolsShim.on("other_event", cb2); + checkCalls(mock, "on", 2, ["other_event", cb2]); +} + +function test_off_called_before_registering_devtools() { + ok(!DevToolsShim.isInitialized(), "DevTools are not initialized"); + + function cb1() {} + const mock = createMockDevTools(); + + DevToolsShim.on("test_event", cb1); + DevToolsShim.off("test_event", cb1); + + DevToolsShim.register(mock); + checkCalls(mock, "on", 0); +} + +function test_off_called_before_with_bad_callback() { + ok(!DevToolsShim.isInitialized(), "DevTools are not initialized"); + + function cb1() {} + function cb2() {} + const mock = createMockDevTools(); + + DevToolsShim.on("test_event", cb1); + DevToolsShim.off("test_event", cb2); + + DevToolsShim.register(mock); + // on should still be called + checkCalls(mock, "on", 1, ["test_event", cb1]); + // Calls to off should not be held and forwarded. + checkCalls(mock, "off", 0); +} + +function test_events() { + ok(!DevToolsShim.isInitialized(), "DevTools are not initialized"); + + const mock = createMockDevTools(); + // Check emit was not called. + checkCalls(mock, "emit", 0); + + // Check emit is called once with the devtools-registered event. + DevToolsShim.register(mock); + checkCalls(mock, "emit", 1, ["devtools-registered"]); + + // Check emit is called once with the devtools-unregistered event. + DevToolsShim.unregister(); + checkCalls(mock, "emit", 2, ["devtools-unregistered"]); +} + +function test_restore_session_apis() { + // Backup method that will be updated for the test. + const initDevToolsBackup = DevToolsShim.initDevTools; + + // Create fake session objects to restore. + const sessionWithoutDevTools = {}; + const sessionWithDevTools = { + browserConsole: true, + }; + + Services.prefs.setBoolPref("devtools.policy.disabled", true); + ok(!DevToolsShim.isInitialized(), "DevTools are not initialized"); + ok(!DevToolsShim.isEnabled(), "DevTools are not enabled"); + + // Check that save & restore DevToolsSession don't initialize the tools and don't + // crash. + DevToolsShim.saveDevToolsSession({}); + DevToolsShim.restoreDevToolsSession(sessionWithDevTools); + ok(!DevToolsShim.isInitialized(), "DevTools are still not initialized"); + + Services.prefs.setBoolPref("devtools.policy.disabled", false); + ok(DevToolsShim.isEnabled(), "DevTools are enabled"); + ok(!DevToolsShim.isInitialized(), "DevTools are not initialized"); + + // Check that DevTools are not initialized when calling restoreDevToolsSession without + // DevTools related data. + DevToolsShim.restoreDevToolsSession(sessionWithoutDevTools); + ok(!DevToolsShim.isInitialized(), "DevTools are still not initialized"); + + const mock = createMockDevTools(); + DevToolsShim.initDevTools = () => { + // Next call to restoreDevToolsSession is expected to initialize DevTools, which we + // simulate here by registering our mock. + DevToolsShim.register(mock); + }; + + DevToolsShim.restoreDevToolsSession(sessionWithDevTools); + checkCalls(mock, "restoreDevToolsSession", 1, [sessionWithDevTools]); + + ok(DevToolsShim.isInitialized(), "DevTools are initialized"); + + DevToolsShim.saveDevToolsSession({}); + checkCalls(mock, "saveDevToolsSession", 1, []); + + // Restore initDevTools backup. + DevToolsShim.initDevTools = initDevToolsBackup; +} + +function run_test() { + test_register_unregister(); + DevToolsShim.unregister(); + + test_on_is_forwarded_to_devtools(); + DevToolsShim.unregister(); + + test_off_called_before_registering_devtools(); + DevToolsShim.unregister(); + + test_off_called_before_with_bad_callback(); + DevToolsShim.unregister(); + + test_restore_session_apis(); + DevToolsShim.unregister(); + + test_events(); +} diff --git a/devtools/startup/tests/xpcshell/xpcshell.toml b/devtools/startup/tests/xpcshell/xpcshell.toml new file mode 100644 index 0000000000..2e7e69614b --- /dev/null +++ b/devtools/startup/tests/xpcshell/xpcshell.toml @@ -0,0 +1,6 @@ +[DEFAULT] +tags = "devtools" +firefox-appdir = "browser" +skip-if = ["os == 'android'"] + +["test_devtools_shim.js"] |