diff options
Diffstat (limited to 'devtools/client/performance-new/popup')
-rw-r--r-- | devtools/client/performance-new/popup/README.md | 3 | ||||
-rw-r--r-- | devtools/client/performance-new/popup/background.jsm.js | 916 | ||||
-rw-r--r-- | devtools/client/performance-new/popup/menu-button.jsm.js | 331 | ||||
-rw-r--r-- | devtools/client/performance-new/popup/moz.build | 13 | ||||
-rw-r--r-- | devtools/client/performance-new/popup/panel.jsm.js | 346 |
5 files changed, 1609 insertions, 0 deletions
diff --git a/devtools/client/performance-new/popup/README.md b/devtools/client/performance-new/popup/README.md new file mode 100644 index 0000000000..78ef8e54c7 --- /dev/null +++ b/devtools/client/performance-new/popup/README.md @@ -0,0 +1,3 @@ +# Profiler Popup + +This directory collects the code that powers the profiler popup. See devtools/client/performance-new/README.md for more information. diff --git a/devtools/client/performance-new/popup/background.jsm.js b/devtools/client/performance-new/popup/background.jsm.js new file mode 100644 index 0000000000..3c05ac4731 --- /dev/null +++ b/devtools/client/performance-new/popup/background.jsm.js @@ -0,0 +1,916 @@ +/* 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/. */ +// @ts-check +"use strict"; + +/** + * This file contains all of the background logic for controlling the state and + * configuration of the profiler. It is in a JSM so that the logic can be shared + * with both the popup client, and the keyboard shortcuts. The shortcuts don't need + * access to any UI, and need to be loaded independent of the popup. + */ + +// The following are not lazily loaded as they are needed during initialization. + +const { createLazyLoaders } = ChromeUtils.import( + "resource://devtools/client/performance-new/typescript-lazy-load.jsm.js" +); +// For some reason TypeScript was giving me an error when de-structuring AppConstants. I +// suspect a bug in TypeScript was at play. +const AppConstants = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" +).AppConstants; + +/** + * @typedef {import("../@types/perf").RecordingSettings} RecordingSettings + * @typedef {import("../@types/perf").SymbolTableAsTuple} SymbolTableAsTuple + * @typedef {import("../@types/perf").Library} Library + * @typedef {import("../@types/perf").PerformancePref} PerformancePref + * @typedef {import("../@types/perf").ProfilerWebChannel} ProfilerWebChannel + * @typedef {import("../@types/perf").PageContext} PageContext + * @typedef {import("../@types/perf").PrefObserver} PrefObserver + * @typedef {import("../@types/perf").PrefPostfix} PrefPostfix + * @typedef {import("../@types/perf").Presets} Presets + * @typedef {import("../@types/perf").ProfilerViewMode} ProfilerViewMode + * @typedef {import("../@types/perf").MessageFromFrontend} MessageFromFrontend + * @typedef {import("../@types/perf").RequestFromFrontend} RequestFromFrontend + * @typedef {import("../@types/perf").ResponseToFrontend} ResponseToFrontend + * @typedef {import("../@types/perf").SymbolicationService} SymbolicationService + * @typedef {import("../@types/perf").ProfilerBrowserInfo} ProfilerBrowserInfo + * @typedef {import("../@types/perf").ProfileCaptureResult} ProfileCaptureResult + */ + +/** @type {PerformancePref["Entries"]} */ +const ENTRIES_PREF = "devtools.performance.recording.entries"; +/** @type {PerformancePref["Interval"]} */ +const INTERVAL_PREF = "devtools.performance.recording.interval"; +/** @type {PerformancePref["Features"]} */ +const FEATURES_PREF = "devtools.performance.recording.features"; +/** @type {PerformancePref["Threads"]} */ +const THREADS_PREF = "devtools.performance.recording.threads"; +/** @type {PerformancePref["ObjDirs"]} */ +const OBJDIRS_PREF = "devtools.performance.recording.objdirs"; +/** @type {PerformancePref["Duration"]} */ +const DURATION_PREF = "devtools.performance.recording.duration"; +/** @type {PerformancePref["Preset"]} */ +const PRESET_PREF = "devtools.performance.recording.preset"; +/** @type {PerformancePref["PopupFeatureFlag"]} */ +const POPUP_FEATURE_FLAG_PREF = "devtools.performance.popup.feature-flag"; +/* This will be used to observe all profiler-related prefs. */ +const PREF_PREFIX = "devtools.performance.recording."; + +// The version of the profiler WebChannel. +// This is reported from the STATUS_QUERY message, and identifies the +// capabilities of the WebChannel. The front-end can handle old WebChannel +// versions and has a full list of versions and capabilities here: +// https://github.com/firefox-devtools/profiler/blob/main/src/app-logic/web-channel.js +const CURRENT_WEBCHANNEL_VERSION = 1; + +const lazyRequire = {}; +// eslint-disable-next-line mozilla/lazy-getter-object-name +ChromeUtils.defineESModuleGetters(lazyRequire, { + require: "resource://devtools/shared/loader/Loader.sys.mjs", +}); +// Lazily load the require function, when it's needed. +// Avoid using ChromeUtils.defineESModuleGetters for now as: +// * we can't replace createLazyLoaders as we still load commonjs+jsm+esm +// It will be easier once we only load sys.mjs files. +// * we would need to find a way to accomodate typescript to this special function. +// @ts-ignore:next-line +function require(path) { + // @ts-ignore:next-line + return lazyRequire.require(path); +} + +// The following utilities are lazily loaded as they are not needed when controlling the +// global state of the profiler, and only are used during specific funcationality like +// symbolication or capturing a profile. +const lazy = createLazyLoaders({ + Utils: () => require("devtools/client/performance-new/utils"), + BrowserModule: () => require("devtools/client/performance-new/browser"), + RecordingUtils: () => + require("resource://devtools/shared/performance-new/recording-utils.js"), + CustomizableUI: () => + ChromeUtils.import("resource:///modules/CustomizableUI.jsm"), + PerfSymbolication: () => + ChromeUtils.import( + "resource://devtools/client/performance-new/symbolication.jsm.js" + ), + ProfilerMenuButton: () => + ChromeUtils.import( + "resource://devtools/client/performance-new/popup/menu-button.jsm.js" + ), +}); + +// The presets that we find in all interfaces are defined here. + +// The property l10nIds contain all FTL l10n IDs for these cases: +// - properties in "popup" are used in the popup's select box. +// - properties in "devtools" are used in other UIs (about:profiling and devtools panels). +// +// Properties for both cases have the same values, but because they're not used +// in the same way we need to duplicate them. +// Their values for the en-US locale are in the files: +// devtools/client/locales/en-US/perftools.ftl +// browser/locales/en-US/browser/appmenu.ftl + +/** @type {Presets} */ +const presets = { + "web-developer": { + entries: 128 * 1024 * 1024, + interval: 1, + features: ["screenshots", "js", "cpu"], + threads: ["GeckoMain", "Compositor", "Renderer", "DOM Worker"], + duration: 0, + profilerViewMode: "active-tab", + l10nIds: { + popup: { + label: "profiler-popup-presets-web-developer-label", + description: "profiler-popup-presets-web-developer-description", + }, + devtools: { + label: "perftools-presets-web-developer-label", + description: "perftools-presets-web-developer-description", + }, + }, + }, + "firefox-platform": { + entries: 128 * 1024 * 1024, + interval: 1, + features: ["screenshots", "js", "stackwalk", "cpu", "java", "processcpu"], + threads: [ + "GeckoMain", + "Compositor", + "Renderer", + "SwComposite", + "DOM Worker", + ], + duration: 0, + l10nIds: { + popup: { + label: "profiler-popup-presets-firefox-label", + description: "profiler-popup-presets-firefox-description", + }, + devtools: { + label: "perftools-presets-firefox-label", + description: "perftools-presets-firefox-description", + }, + }, + }, + graphics: { + entries: 128 * 1024 * 1024, + interval: 1, + features: ["stackwalk", "js", "cpu", "java", "processcpu"], + threads: [ + "GeckoMain", + "Compositor", + "Renderer", + "SwComposite", + "RenderBackend", + "SceneBuilder", + "WrWorker", + "CanvasWorkers", + ], + duration: 0, + l10nIds: { + popup: { + label: "profiler-popup-presets-graphics-label", + description: "profiler-popup-presets-graphics-description", + }, + devtools: { + label: "perftools-presets-graphics-label", + description: "perftools-presets-graphics-description", + }, + }, + }, + media: { + entries: 128 * 1024 * 1024, + interval: 1, + features: [ + "js", + "stackwalk", + "cpu", + "audiocallbacktracing", + "ipcmessages", + "processcpu", + ], + threads: [ + "cubeb", + "audio", + "BackgroundThreadPool", + "camera", + "capture", + "Compositor", + "decoder", + "GeckoMain", + "gmp", + "graph", + "grph", + "InotifyEventThread", + "IPDL Background", + "media", + "ModuleProcessThread", + "PacerThread", + "RemVidChild", + "RenderBackend", + "Renderer", + "Socket Thread", + "SwComposite", + "webrtc", + ], + duration: 0, + l10nIds: { + popup: { + label: "profiler-popup-presets-media-label", + description: "profiler-popup-presets-media-description2", + }, + devtools: { + label: "perftools-presets-media-label", + description: "perftools-presets-media-description2", + }, + }, + }, + networking: { + entries: 128 * 1024 * 1024, + interval: 1, + features: ["screenshots", "js", "stackwalk", "cpu", "java", "processcpu"], + threads: [ + "Compositor", + "DNS Resolver", + "DOM Worker", + "GeckoMain", + "Renderer", + "Socket Thread", + "StreamTrans", + "SwComposite", + "TRR Background", + ], + duration: 0, + l10nIds: { + popup: { + label: "profiler-popup-presets-networking-label", + description: "profiler-popup-presets-networking-description", + }, + devtools: { + label: "perftools-presets-networking-label", + description: "perftools-presets-networking-description", + }, + }, + }, + power: { + entries: 128 * 1024 * 1024, + interval: 10, + features: [ + "screenshots", + "js", + "stackwalk", + "cpu", + "processcpu", + "nostacksampling", + "ipcmessages", + "markersallthreads", + "power", + ], + threads: ["GeckoMain", "Renderer"], + duration: 0, + l10nIds: { + popup: { + label: "profiler-popup-presets-power-label", + description: "profiler-popup-presets-power-description", + }, + devtools: { + label: "perftools-presets-power-label", + description: "perftools-presets-power-description", + }, + }, + }, +}; + +/** + * Return the proper view mode for the Firefox Profiler front-end timeline by + * looking at the proper preset that is selected. + * Return value can be undefined when the preset is unknown or custom. + * @param {PageContext} pageContext + * @return {ProfilerViewMode | undefined} + */ +function getProfilerViewModeForCurrentPreset(pageContext) { + const prefPostfix = getPrefPostfix(pageContext); + const presetName = Services.prefs.getCharPref(PRESET_PREF + prefPostfix); + + if (presetName === "custom") { + return undefined; + } + + const preset = presets[presetName]; + if (!preset) { + console.error(`Unknown profiler preset was encountered: "${presetName}"`); + return undefined; + } + return preset.profilerViewMode; +} + +/** + * This function is called when the profile is captured with the shortcut + * keys, with the profiler toolbarbutton, or with the button inside the + * popup. + * @param {PageContext} pageContext + * @return {Promise<void>} + */ +async function captureProfile(pageContext) { + if (!Services.profiler.IsActive()) { + // The profiler is not active, ignore. + return; + } + if (Services.profiler.IsPaused()) { + // The profiler is already paused for capture, ignore. + return; + } + + // Pause profiler before we collect the profile, so that we don't capture + // more samples while the parent process waits for subprocess profiles. + Services.profiler.Pause(); + + /** + * @type {ProfileCaptureResult} + */ + const profileCaptureResult = await Services.profiler + .getProfileDataAsGzippedArrayBuffer() + .then( + profile => ({ type: "SUCCESS", profile }), + error => { + console.error(error); + return { type: "ERROR", error }; + } + ); + + const profilerViewMode = getProfilerViewModeForCurrentPreset(pageContext); + const sharedLibraries = Services.profiler.sharedLibraries; + const objdirs = getObjdirPrefValue(); + + const { createLocalSymbolicationService } = lazy.PerfSymbolication(); + const symbolicationService = createLocalSymbolicationService( + sharedLibraries, + objdirs + ); + + const { openProfilerTab } = lazy.BrowserModule(); + const browser = await openProfilerTab(profilerViewMode); + registerProfileCaptureForBrowser( + browser, + profileCaptureResult, + symbolicationService + ); + + Services.profiler.StopProfiler(); +} + +/** + * This function is called when the profiler is started with the shortcut + * keys, with the profiler toolbarbutton, or with the button inside the + * popup. + * @param {PageContext} pageContext + */ +function startProfiler(pageContext) { + const { + entries, + interval, + features, + threads, + duration, + } = getRecordingSettings(pageContext, Services.profiler.GetFeatures()); + + // Get the active Browser ID from browser. + const { getActiveBrowserID } = lazy.RecordingUtils(); + const activeTabID = getActiveBrowserID(); + + Services.profiler.StartProfiler( + entries, + interval, + features, + threads, + activeTabID, + duration + ); +} + +/** + * This function is called directly by devtools/startup/DevToolsStartup.jsm when + * using the shortcut keys to capture a profile. + * @type {() => void} + */ +function stopProfiler() { + Services.profiler.StopProfiler(); +} + +/** + * This function is called directly by devtools/startup/DevToolsStartup.jsm when + * using the shortcut keys to start and stop the profiler. + * @param {PageContext} pageContext + * @return {void} + */ +function toggleProfiler(pageContext) { + if (Services.profiler.IsPaused()) { + // The profiler is currently paused, which means that the user is already + // attempting to capture a profile. Ignore this request. + return; + } + if (Services.profiler.IsActive()) { + stopProfiler(); + } else { + startProfiler(pageContext); + } +} + +/** + * @param {PageContext} pageContext + */ +function restartProfiler(pageContext) { + stopProfiler(); + startProfiler(pageContext); +} + +/** + * @param {string} prefName + * @return {string[]} + */ +function _getArrayOfStringsPref(prefName) { + const text = Services.prefs.getCharPref(prefName); + return JSON.parse(text); +} + +/** + * The profiler recording workflow uses two different pref paths. One set of prefs + * is stored for local profiling, and another for remote profiling. This function + * decides which to use. The remote prefs have ".remote" appended to the end of + * their pref names. + * + * @param {PageContext} pageContext + * @returns {PrefPostfix} + */ +function getPrefPostfix(pageContext) { + switch (pageContext) { + case "devtools": + case "aboutprofiling": + case "aboutlogging": + // Don't use any postfix on the prefs. + return ""; + case "devtools-remote": + case "aboutprofiling-remote": + return ".remote"; + default: { + const { UnhandledCaseError } = lazy.Utils(); + throw new UnhandledCaseError(pageContext, "Page Context"); + } + } +} + +/** + * @param {string[]} objdirs + */ +function setObjdirPrefValue(objdirs) { + Services.prefs.setCharPref(OBJDIRS_PREF, JSON.stringify(objdirs)); +} + +/** + * Before Firefox 92, the objdir lists for local and remote profiling were + * stored in separate lists. In Firefox 92 those two prefs were merged into + * one. This function performs the migration. + */ +function migrateObjdirsPrefsIfNeeded() { + const OLD_REMOTE_OBJDIRS_PREF = OBJDIRS_PREF + ".remote"; + const remoteString = Services.prefs.getCharPref(OLD_REMOTE_OBJDIRS_PREF, ""); + if (remoteString === "") { + // No migration necessary. + return; + } + + const remoteList = JSON.parse(remoteString); + const localList = _getArrayOfStringsPref(OBJDIRS_PREF); + + // Merge the two lists, eliminating any duplicates. + const mergedList = [...new Set(localList.concat(remoteList))]; + setObjdirPrefValue(mergedList); + Services.prefs.clearUserPref(OLD_REMOTE_OBJDIRS_PREF); +} + +/** + * @returns {string[]} + */ +function getObjdirPrefValue() { + migrateObjdirsPrefsIfNeeded(); + return _getArrayOfStringsPref(OBJDIRS_PREF); +} + +/** + * @param {PageContext} pageContext + * @param {string[]} supportedFeatures + * @returns {RecordingSettings} + */ +function getRecordingSettings(pageContext, supportedFeatures) { + const objdirs = getObjdirPrefValue(); + const prefPostfix = getPrefPostfix(pageContext); + const presetName = Services.prefs.getCharPref(PRESET_PREF + prefPostfix); + + // First try to get the values from a preset. If the preset is "custom" or + // unrecognized, getRecordingSettingsFromPreset will return null and we will + // get the settings from individual prefs instead. + return ( + getRecordingSettingsFromPreset(presetName, supportedFeatures, objdirs) ?? + getRecordingSettingsFromPrefs(supportedFeatures, objdirs, prefPostfix) + ); +} + +/** + * @param {string} presetName + * @param {string[]} supportedFeatures + * @param {string[]} objdirs + * @return {RecordingSettings | null} + */ +function getRecordingSettingsFromPreset( + presetName, + supportedFeatures, + objdirs +) { + if (presetName === "custom") { + return null; + } + + const preset = presets[presetName]; + if (!preset) { + console.error(`Unknown profiler preset was encountered: "${presetName}"`); + return null; + } + + return { + presetName, + entries: preset.entries, + interval: preset.interval, + // Validate the features before passing them to the profiler. + features: preset.features.filter(feature => + supportedFeatures.includes(feature) + ), + threads: preset.threads, + objdirs, + duration: preset.duration, + }; +} + +/** + * @param {string[]} supportedFeatures + * @param {string[]} objdirs + * @param {PrefPostfix} prefPostfix + * @return {RecordingSettings} + */ +function getRecordingSettingsFromPrefs( + supportedFeatures, + objdirs, + prefPostfix +) { + // If you add a new preference here, please do not forget to update + // `revertRecordingSettings` as well. + + const entries = Services.prefs.getIntPref(ENTRIES_PREF + prefPostfix); + const intervalInMicroseconds = Services.prefs.getIntPref( + INTERVAL_PREF + prefPostfix + ); + const interval = intervalInMicroseconds / 1000; + const features = _getArrayOfStringsPref(FEATURES_PREF + prefPostfix); + const threads = _getArrayOfStringsPref(THREADS_PREF + prefPostfix); + const duration = Services.prefs.getIntPref(DURATION_PREF + prefPostfix); + + return { + presetName: "custom", + entries, + interval, + // Validate the features before passing them to the profiler. + features: features.filter(feature => supportedFeatures.includes(feature)), + threads, + objdirs, + duration, + }; +} + +/** + * @param {PageContext} pageContext + * @param {RecordingSettings} prefs + */ +function setRecordingSettings(pageContext, prefs) { + const prefPostfix = getPrefPostfix(pageContext); + Services.prefs.setCharPref(PRESET_PREF + prefPostfix, prefs.presetName); + Services.prefs.setIntPref(ENTRIES_PREF + prefPostfix, prefs.entries); + // The interval pref stores the value in microseconds for extra precision. + const intervalInMicroseconds = prefs.interval * 1000; + Services.prefs.setIntPref( + INTERVAL_PREF + prefPostfix, + intervalInMicroseconds + ); + Services.prefs.setCharPref( + FEATURES_PREF + prefPostfix, + JSON.stringify(prefs.features) + ); + Services.prefs.setCharPref( + THREADS_PREF + prefPostfix, + JSON.stringify(prefs.threads) + ); + setObjdirPrefValue(prefs.objdirs); +} + +const platform = AppConstants.platform; + +/** + * Revert the recording prefs for both local and remote profiling. + * @return {void} + */ +function revertRecordingSettings() { + for (const prefPostfix of ["", ".remote"]) { + Services.prefs.clearUserPref(PRESET_PREF + prefPostfix); + Services.prefs.clearUserPref(ENTRIES_PREF + prefPostfix); + Services.prefs.clearUserPref(INTERVAL_PREF + prefPostfix); + Services.prefs.clearUserPref(FEATURES_PREF + prefPostfix); + Services.prefs.clearUserPref(THREADS_PREF + prefPostfix); + Services.prefs.clearUserPref(DURATION_PREF + prefPostfix); + } + Services.prefs.clearUserPref(OBJDIRS_PREF); + Services.prefs.clearUserPref(POPUP_FEATURE_FLAG_PREF); +} + +/** + * Change the prefs based on a preset. This mechanism is used by the popup to + * easily switch between different settings. + * @param {string} presetName + * @param {PageContext} pageContext + * @param {string[]} supportedFeatures + * @return {void} + */ +function changePreset(pageContext, presetName, supportedFeatures) { + const prefPostfix = getPrefPostfix(pageContext); + const objdirs = getObjdirPrefValue(); + let recordingSettings = getRecordingSettingsFromPreset( + presetName, + supportedFeatures, + objdirs + ); + + if (!recordingSettings) { + // No recordingSettings were found for that preset. Most likely this means this + // is a custom preset, or it's one that we dont recognize for some reason. + // Get the preferences from the individual preference values. + Services.prefs.setCharPref(PRESET_PREF + prefPostfix, presetName); + recordingSettings = getRecordingSettings(pageContext, supportedFeatures); + } + + setRecordingSettings(pageContext, recordingSettings); +} + +/** + * Add an observer for the profiler-related preferences. + * @param {PrefObserver} observer + * @return {void} + */ +function addPrefObserver(observer) { + Services.prefs.addObserver(PREF_PREFIX, observer); +} + +/** + * Removes an observer for the profiler-related preferences. + * @param {PrefObserver} observer + * @return {void} + */ +function removePrefObserver(observer) { + Services.prefs.removeObserver(PREF_PREFIX, observer); +} + +/** + * This map stores information that is associated with a "profile capturing" + * action, so that we can look up this information for WebChannel messages + * from the profiler tab. + * Most importantly, this stores the captured profile. When the profiler tab + * requests the profile, we can respond to the message with the correct profile. + * This works even if the request happens long after the tab opened. It also + * works for an "old" tab even if new profiles have been captured since that + * tab was opened. + * Supporting tab refresh is important because the tab sometimes reloads itself: + * If an old version of the front-end is cached in the service worker, and the + * browser supplies a profile with a newer format version, then the front-end + * updates its service worker and reloads itself, so that the updated version + * can parse the profile. + * + * This is a WeakMap so that the profile can be garbage-collected when the tab + * is closed. + * + * @type {WeakMap<MockedExports.Browser, ProfilerBrowserInfo>} + */ +const infoForBrowserMap = new WeakMap(); + +/** + * This handler computes the response for any messages coming + * from the WebChannel from profiler.firefox.com. + * + * @param {RequestFromFrontend} request + * @param {MockedExports.Browser} browser - The tab's browser. + * @return {Promise<ResponseToFrontend>} + */ +async function getResponseForMessage(request, browser) { + switch (request.type) { + case "STATUS_QUERY": { + // The content page wants to know if this channel exists. It does, so respond + // back to the ping. + const { ProfilerMenuButton } = lazy.ProfilerMenuButton(); + return { + version: CURRENT_WEBCHANNEL_VERSION, + menuButtonIsEnabled: ProfilerMenuButton.isInNavbar(), + }; + } + case "ENABLE_MENU_BUTTON": { + const { ownerDocument } = browser; + if (!ownerDocument) { + throw new Error( + "Could not find the owner document for the current browser while enabling " + + "the profiler menu button" + ); + } + // Ensure the widget is enabled. + Services.prefs.setBoolPref(POPUP_FEATURE_FLAG_PREF, true); + + // Force the preset to be "firefox-platform" if we enable the menu button + // via web channel. If user goes through profiler.firefox.com to enable + // it, it means that either user is a platform developer or filing a bug + // report for performance engineers to look at. + const supportedFeatures = Services.profiler.GetFeatures(); + changePreset("aboutprofiling", "firefox-platform", supportedFeatures); + + // Enable the profiler menu button. + const { ProfilerMenuButton } = lazy.ProfilerMenuButton(); + ProfilerMenuButton.addToNavbar(ownerDocument); + + // Dispatch the change event manually, so that the shortcuts will also be + // added. + const { CustomizableUI } = lazy.CustomizableUI(); + CustomizableUI.dispatchToolboxEvent("customizationchange"); + + // Open the popup with a message. + ProfilerMenuButton.openPopup(ownerDocument); + + // There is no response data for this message. + return undefined; + } + case "GET_PROFILE": { + const infoForBrowser = infoForBrowserMap.get(browser); + if (infoForBrowser === undefined) { + throw new Error("Could not find a profile for this tab."); + } + const { profileCaptureResult } = infoForBrowser; + switch (profileCaptureResult.type) { + case "SUCCESS": + return profileCaptureResult.profile; + case "ERROR": + throw profileCaptureResult.error; + default: + const { UnhandledCaseError } = lazy.Utils(); + throw new UnhandledCaseError( + profileCaptureResult, + "profileCaptureResult" + ); + } + } + case "GET_SYMBOL_TABLE": { + const { debugName, breakpadId } = request; + const symbolicationService = getSymbolicationServiceForBrowser(browser); + return symbolicationService.getSymbolTable(debugName, breakpadId); + } + case "QUERY_SYMBOLICATION_API": { + const { path, requestJson } = request; + const symbolicationService = getSymbolicationServiceForBrowser(browser); + return symbolicationService.querySymbolicationApi(path, requestJson); + } + default: + console.error( + "An unknown message type was received by the profiler's WebChannel handler.", + request + ); + const { UnhandledCaseError } = lazy.Utils(); + throw new UnhandledCaseError(request, "WebChannel request"); + } +} + +/** + * Get the symbolicationService for the capture that opened this browser's + * tab, or a fallback service for browsers from tabs opened by the user. + * + * @param {MockedExports.Browser} browser + * @return {SymbolicationService} + */ +function getSymbolicationServiceForBrowser(browser) { + // We try to serve symbolication requests that come from tabs that we + // opened when a profile was captured, and for tabs that the user opened + // independently, for example because the user wants to load an existing + // profile from a file. + const infoForBrowser = infoForBrowserMap.get(browser); + if (infoForBrowser !== undefined) { + // We opened this tab when a profile was captured. Use the symbolication + // service for that capture. + return infoForBrowser.symbolicationService; + } + + // For the "foreign" tabs, we provide a fallback symbolication service so that + // we can find symbols for any libraries that are loaded in this process. This + // means that symbolication will work if the existing file has been captured + // from the same build. + const { createLocalSymbolicationService } = lazy.PerfSymbolication(); + return createLocalSymbolicationService( + Services.profiler.sharedLibraries, + getObjdirPrefValue() + ); +} + +/** + * This handler handles any messages coming from the WebChannel from profiler.firefox.com. + * + * @param {ProfilerWebChannel} channel + * @param {string} id + * @param {any} message + * @param {MockedExports.WebChannelTarget} target + */ +async function handleWebChannelMessage(channel, id, message, target) { + if (typeof message !== "object" || typeof message.type !== "string") { + console.error( + "An malformed message was received by the profiler's WebChannel handler.", + message + ); + return; + } + const messageFromFrontend = /** @type {MessageFromFrontend} */ (message); + const { requestId } = messageFromFrontend; + + try { + const response = await getResponseForMessage( + messageFromFrontend, + target.browser + ); + channel.send( + { + type: "SUCCESS_RESPONSE", + requestId, + response, + }, + target + ); + } catch (error) { + channel.send( + { + type: "ERROR_RESPONSE", + requestId, + error: `${error.name}: ${error.message}`, + }, + target + ); + } +} + +/** + * @param {MockedExports.Browser} browser - The tab's browser. + * @param {ProfileCaptureResult} profileCaptureResult - The Gecko profile. + * @param {SymbolicationService} symbolicationService - An object which implements the + * SymbolicationService interface, whose getSymbolTable method will be invoked + * when profiler.firefox.com sends GET_SYMBOL_TABLE WebChannel messages to us. This + * method should obtain a symbol table for the requested binary and resolve the + * returned promise with it. + */ +function registerProfileCaptureForBrowser( + browser, + profileCaptureResult, + symbolicationService +) { + infoForBrowserMap.set(browser, { + profileCaptureResult, + symbolicationService, + }); +} + +// Provide a fake module.exports for the JSM to be properly read by TypeScript. +/** @type {any} */ +var module = { exports: {} }; + +module.exports = { + presets, + captureProfile, + startProfiler, + stopProfiler, + restartProfiler, + toggleProfiler, + platform, + getRecordingSettings, + setRecordingSettings, + revertRecordingSettings, + changePreset, + handleWebChannelMessage, + registerProfileCaptureForBrowser, + addPrefObserver, + removePrefObserver, + getProfilerViewModeForCurrentPreset, +}; + +// Object.keys() confuses the linting which expects a static array expression. +// eslint-disable-next-line +var EXPORTED_SYMBOLS = Object.keys(module.exports); diff --git a/devtools/client/performance-new/popup/menu-button.jsm.js b/devtools/client/performance-new/popup/menu-button.jsm.js new file mode 100644 index 0000000000..434c19f0d1 --- /dev/null +++ b/devtools/client/performance-new/popup/menu-button.jsm.js @@ -0,0 +1,331 @@ +/* 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/. */ +// @ts-check +"use strict"; + +/** + * This file controls the enabling and disabling of the menu button for the profiler. + * Care should be taken to keep it minimal as it can be run with browser initialization. + */ + +// Provide an exports object for the JSM to be properly read by TypeScript. +/** @type {any} */ +var exports = {}; + +const { createLazyLoaders } = ChromeUtils.import( + "resource://devtools/client/performance-new/typescript-lazy-load.jsm.js" +); + +const lazy = createLazyLoaders({ + CustomizableUI: () => + ChromeUtils.import("resource:///modules/CustomizableUI.jsm"), + CustomizableWidgets: () => + ChromeUtils.import("resource:///modules/CustomizableWidgets.jsm"), + PopupPanel: () => + ChromeUtils.import( + "resource://devtools/client/performance-new/popup/panel.jsm.js" + ), + Background: () => + ChromeUtils.import( + "resource://devtools/client/performance-new/popup/background.jsm.js" + ), +}); + +const WIDGET_ID = "profiler-button"; + +/** + * Add the profiler button to the navbar. + * + * @param {ChromeDocument} document The browser's document. + * @return {void} + */ +function addToNavbar(document) { + const { CustomizableUI } = lazy.CustomizableUI(); + + CustomizableUI.addWidgetToArea(WIDGET_ID, CustomizableUI.AREA_NAVBAR); +} + +/** + * Remove the widget and place it in the customization palette. This will also + * disable the shortcuts. + * + * @return {void} + */ +function remove() { + const { CustomizableUI } = lazy.CustomizableUI(); + CustomizableUI.removeWidgetFromArea(WIDGET_ID); +} + +/** + * See if the profiler menu button is in the navbar, or other active areas. The + * placement is null when it's inactive in the customization palette. + * + * @return {boolean} + */ +function isInNavbar() { + const { CustomizableUI } = lazy.CustomizableUI(); + return Boolean(CustomizableUI.getPlacementOfWidget("profiler-button")); +} + +/** + * Opens the popup for the profiler. + * @param {Document} document + */ +function openPopup(document) { + // First find the button. + /** @type {HTMLButtonElement | null} */ + const button = document.querySelector("#profiler-button"); + if (!button) { + throw new Error("Could not find the profiler button."); + } + + // Sending a click event anywhere on the button could start the profiler + // instead of opening the popup. Sending a command event on a view widget + // will make CustomizableUI show the view. + const cmdEvent = document.createEvent("xulcommandevent"); + // @ts-ignore - Bug 1674368 + cmdEvent.initCommandEvent("command", true, true, button.ownerGlobal); + button.dispatchEvent(cmdEvent); +} + +/** + * This function creates the widget definition for the CustomizableUI. It should + * only be run if the profiler button is enabled. + * @param {(isEnabled: boolean) => void} toggleProfilerKeyShortcuts + * @return {void} + */ +function initialize(toggleProfilerKeyShortcuts) { + const { CustomizableUI } = lazy.CustomizableUI(); + const { CustomizableWidgets } = lazy.CustomizableWidgets(); + + const widget = CustomizableUI.getWidget(WIDGET_ID); + if (widget && widget.provider == CustomizableUI.PROVIDER_API) { + // This widget has already been created. + return; + } + + const viewId = "PanelUI-profiler"; + + /** + * This is mutable state that will be shared between panel displays. + * + * @type {import("devtools/client/performance-new/popup/panel.jsm.js").State} + */ + const panelState = { + cleanup: [], + isInfoCollapsed: true, + }; + + /** + * Handle when the customization changes for the button. This event is not + * very specific, and fires for any CustomizableUI widget. This event is + * pretty rare to fire, and only affects users of the profiler button, + * so it shouldn't have much overhead even if it runs a lot. + */ + function handleCustomizationChange() { + const isEnabled = isInNavbar(); + toggleProfilerKeyShortcuts(isEnabled); + + if (!isEnabled) { + // The profiler menu button is no longer in the navbar, make sure that the + // "intro-displayed" preference is reset. + /** @type {import("../@types/perf").PerformancePref["PopupIntroDisplayed"]} */ + const popupIntroDisplayedPref = + "devtools.performance.popup.intro-displayed"; + Services.prefs.setBoolPref(popupIntroDisplayedPref, false); + + // We stop the profiler when the button is removed for normal users, + // but we try to avoid interfering with profiling of automated tests. + if ( + Services.profiler.IsActive() && + (!Cu.isInAutomation || !Services.env.exists("MOZ_PROFILER_STARTUP")) + ) { + Services.profiler.StopProfiler(); + } + } + } + + const item = { + id: WIDGET_ID, + type: "button-and-view", + viewId, + l10nId: "profiler-popup-button-idle", + + onViewShowing: + /** + * @type {(event: { + * target: ChromeHTMLElement | XULElement, + * detail: { + * addBlocker: (blocker: Promise<void>) => void + * } + * }) => void} + */ + event => { + try { + // The popup logic is stored in a separate script so it doesn't have + // to be parsed at browser startup, and will only be lazily loaded + // when the popup is viewed. + const { initializePopup } = lazy.PopupPanel(); + + initializePopup(panelState, event.target); + } catch (error) { + // Surface any errors better in the console. + console.error(error); + } + }, + + /** + * @type {(event: { target: ChromeHTMLElement | XULElement }) => void} + */ + onViewHiding(event) { + // Clean-up the view. This removes all of the event listeners. + for (const fn of panelState.cleanup) { + fn(); + } + panelState.cleanup = []; + }, + + /** + * Perform any general initialization for this widget. This is called once per + * browser window. + * + * @type {(document: HTMLDocument) => void} + */ + onBeforeCreated: document => { + /** @type {import("../@types/perf").PerformancePref["PopupIntroDisplayed"]} */ + const popupIntroDisplayedPref = + "devtools.performance.popup.intro-displayed"; + + // Determine the state of the popup's info being collapsed BEFORE the view + // is shown, and update the collapsed state. This way the transition animation + // isn't run. + panelState.isInfoCollapsed = Services.prefs.getBoolPref( + popupIntroDisplayedPref + ); + if (!panelState.isInfoCollapsed) { + // We have displayed the intro, don't show it again by default. + Services.prefs.setBoolPref(popupIntroDisplayedPref, true); + } + + // Handle customization event changes. If the profiler is no longer in the + // navbar, then reset the popup intro preference. + const window = document.defaultView; + if (window) { + /** @type {any} */ (window).gNavToolbox.addEventListener( + "customizationchange", + handleCustomizationChange + ); + } + + toggleProfilerKeyShortcuts(isInNavbar()); + }, + + /** + * This method is used when we need to operate upon the button element itself. + * This is called once per browser window. + * + * @type {(node: ChromeHTMLElement) => void} + */ + onCreated: node => { + const document = node.ownerDocument; + const window = document?.defaultView; + if (!document || !window) { + console.error( + "Unable to find the document or the window of the profiler toolbar item." + ); + return; + } + + const firstButton = node.firstElementChild; + if (!firstButton) { + console.error( + "Unable to find the button element inside the profiler toolbar item." + ); + return; + } + + // Assign the null-checked button element to a new variable so that + // TypeScript doesn't require additional null checks in the functions + // below. + const buttonElement = firstButton; + + // This class is needed to show the subview arrow when our button + // is in the overflow menu. + buttonElement.classList.add("subviewbutton-nav"); + + function setButtonActive() { + document.l10n.setAttributes( + buttonElement, + "profiler-popup-button-recording" + ); + buttonElement.classList.toggle("profiler-active", true); + buttonElement.classList.toggle("profiler-paused", false); + } + function setButtonPaused() { + document.l10n.setAttributes( + buttonElement, + "profiler-popup-button-capturing" + ); + buttonElement.classList.toggle("profiler-active", false); + buttonElement.classList.toggle("profiler-paused", true); + } + function setButtonInactive() { + document.l10n.setAttributes( + buttonElement, + "profiler-popup-button-idle" + ); + buttonElement.classList.toggle("profiler-active", false); + buttonElement.classList.toggle("profiler-paused", false); + } + + if (Services.profiler.IsPaused()) { + setButtonPaused(); + } + if (Services.profiler.IsActive()) { + setButtonActive(); + } + + Services.obs.addObserver(setButtonActive, "profiler-started"); + Services.obs.addObserver(setButtonInactive, "profiler-stopped"); + Services.obs.addObserver(setButtonPaused, "profiler-paused"); + + window.addEventListener("unload", () => { + Services.obs.removeObserver(setButtonActive, "profiler-started"); + Services.obs.removeObserver(setButtonInactive, "profiler-stopped"); + Services.obs.removeObserver(setButtonPaused, "profiler-paused"); + }); + }, + + // @ts-ignore - Bug 1674368 + onCommand: event => { + if (Services.profiler.IsPaused()) { + // A profile is already being captured, ignore this event. + return; + } + const { startProfiler, captureProfile } = lazy.Background(); + if (Services.profiler.IsActive()) { + captureProfile("aboutprofiling"); + } else { + startProfiler("aboutprofiling"); + } + }, + }; + + CustomizableUI.createWidget(item); + CustomizableWidgets.push(item); +} + +const ProfilerMenuButton = { + initialize, + addToNavbar, + isInNavbar, + openPopup, + remove, +}; + +exports.ProfilerMenuButton = ProfilerMenuButton; + +// Object.keys() confuses the linting which expects a static array expression. +// eslint-disable-next-line +var EXPORTED_SYMBOLS = Object.keys(exports); diff --git a/devtools/client/performance-new/popup/moz.build b/devtools/client/performance-new/popup/moz.build new file mode 100644 index 0000000000..857cc3a3c7 --- /dev/null +++ b/devtools/client/performance-new/popup/moz.build @@ -0,0 +1,13 @@ +# 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/. + +DevToolsModules( + "background.jsm.js", + "menu-button.jsm.js", + "panel.jsm.js", +) + +with Files("**"): + BUG_COMPONENT = ("DevTools", "Performance Tools (Profiler/Timeline)") diff --git a/devtools/client/performance-new/popup/panel.jsm.js b/devtools/client/performance-new/popup/panel.jsm.js new file mode 100644 index 0000000000..9701697139 --- /dev/null +++ b/devtools/client/performance-new/popup/panel.jsm.js @@ -0,0 +1,346 @@ +/* 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/. */ +// @ts-check +"use strict"; + +/** + * This file controls the logic of the profiler popup view. + */ + +/** + * @typedef {ReturnType<typeof selectElementsInPanelview>} Elements + * @typedef {ReturnType<typeof createViewControllers>} ViewController + */ + +/** + * @typedef {Object} State - The mutable state of the popup. + * @property {Array<() => void>} cleanup - Functions to cleanup once the view is hidden. + * @property {boolean} isInfoCollapsed + */ + +const { createLazyLoaders } = ChromeUtils.import( + "resource://devtools/client/performance-new/typescript-lazy-load.jsm.js" +); + +const lazy = createLazyLoaders({ + PanelMultiView: () => + ChromeUtils.import("resource:///modules/PanelMultiView.jsm"), + Background: () => + ChromeUtils.import( + "resource://devtools/client/performance-new/popup/background.jsm.js" + ), +}); + +/** + * This function collects all of the selection of the elements inside of the panel. + * + * @param {XULElement} panelview + */ +function selectElementsInPanelview(panelview) { + const document = panelview.ownerDocument; + /** + * Get an element or throw an error if it's not found. This is more friendly + * for TypeScript. + * + * @param {string} id + * @return {HTMLElement} + */ + function getElementById(id) { + /** @type {HTMLElement | null} */ + // @ts-ignore - Bug 1674368 + const { PanelMultiView } = lazy.PanelMultiView(); + const element = PanelMultiView.getViewNode(document, id); + if (!element) { + throw new Error(`Could not find the element from the ID "${id}"`); + } + return element; + } + + // Forcefully cast the window to the type ChromeWindow. + /** @type {any} */ + const chromeWindowAny = document.defaultView; + /** @type {ChromeWindow} */ + const chromeWindow = chromeWindowAny; + + return { + document, + panelview, + window: chromeWindow, + inactive: getElementById("PanelUI-profiler-inactive"), + active: getElementById("PanelUI-profiler-active"), + presetDescription: getElementById("PanelUI-profiler-content-description"), + presetsEditSettings: getElementById( + "PanelUI-profiler-content-edit-settings" + ), + presetsMenuList: /** @type {MenuListElement} */ (getElementById( + "PanelUI-profiler-presets" + )), + header: getElementById("PanelUI-profiler-header"), + info: getElementById("PanelUI-profiler-info"), + menupopup: getElementById("PanelUI-profiler-presets-menupopup"), + infoButton: getElementById("PanelUI-profiler-info-button"), + learnMore: getElementById("PanelUI-profiler-learn-more"), + startRecording: getElementById("PanelUI-profiler-startRecording"), + stopAndDiscard: getElementById("PanelUI-profiler-stopAndDiscard"), + stopAndCapture: getElementById("PanelUI-profiler-stopAndCapture"), + settingsSection: getElementById("PanelUI-profiler-content-settings"), + contentRecording: getElementById("PanelUI-profiler-content-recording"), + }; +} + +/** + * This function returns an interface that can be used to control the view of the + * panel based on the current mutable State. + * + * @param {State} state + * @param {Elements} elements + */ +function createViewControllers(state, elements) { + return { + updateInfoCollapse() { + const { header, info, infoButton } = elements; + header.setAttribute( + "isinfocollapsed", + state.isInfoCollapsed ? "true" : "false" + ); + // @ts-ignore - Bug 1674368 + infoButton.checked = !state.isInfoCollapsed; + + if (state.isInfoCollapsed) { + const { height } = info.getBoundingClientRect(); + info.style.marginBlockEnd = `-${height}px`; + } else { + info.style.marginBlockEnd = "0"; + } + }, + + updatePresets() { + const { presets, getRecordingSettings } = lazy.Background(); + const { presetName } = getRecordingSettings( + "aboutprofiling", + Services.profiler.GetFeatures() + ); + const preset = presets[presetName]; + if (preset) { + elements.presetDescription.style.display = "block"; + elements.document.l10n.setAttributes( + elements.presetDescription, + preset.l10nIds.popup.description + ); + elements.presetsMenuList.value = presetName; + } else { + elements.presetDescription.style.display = "none"; + // We don't remove the l10n-id attribute as the element is hidden anyway. + // It will be updated again when it's displayed next time. + elements.presetsMenuList.value = "custom"; + } + }, + + updateProfilerState() { + if (Services.profiler.IsActive()) { + elements.inactive.hidden = true; + elements.active.hidden = false; + elements.settingsSection.hidden = true; + elements.contentRecording.hidden = false; + } else { + elements.inactive.hidden = false; + elements.active.hidden = true; + elements.settingsSection.hidden = false; + elements.contentRecording.hidden = true; + } + }, + + createPresetsList() { + // Check the DOM if the presets were built or not. We can't cache this value + // in the `State` object, as the `State` object will be removed if the + // button is removed from the toolbar, but the DOM changes will still persist. + if (elements.menupopup.getAttribute("presetsbuilt") === "true") { + // The presets were already built. + return; + } + + const { presets } = lazy.Background(); + const currentPreset = Services.prefs.getCharPref( + "devtools.performance.recording.preset" + ); + + const menuitems = Object.entries(presets).map(([id, preset]) => { + const { document, presetsMenuList } = elements; + const menuitem = document.createXULElement("menuitem"); + document.l10n.setAttributes(menuitem, preset.l10nIds.popup.label); + menuitem.setAttribute("value", id); + if (id === currentPreset) { + presetsMenuList.setAttribute("value", id); + } + return menuitem; + }); + + elements.menupopup.prepend(...menuitems); + elements.menupopup.setAttribute("presetsbuilt", "true"); + }, + + hidePopup() { + const panel = elements.panelview.closest("panel"); + if (!panel) { + throw new Error("Could not find the panel from the panelview."); + } + /** @type {any} */ (panel).hidePopup(); + }, + }; +} + +/** + * Perform all of the business logic to present the popup view once it is open. + * + * @param {State} state + * @param {Elements} elements + * @param {ViewController} view + */ +function initializeView(state, elements, view) { + view.createPresetsList(); + + state.cleanup.push(() => { + // The UI should be collapsed by default for the next time the popup + // is open. + state.isInfoCollapsed = true; + view.updateInfoCollapse(); + }); + + // Turn off all animations while initializing the popup. + elements.header.setAttribute("animationready", "false"); + + elements.window.requestAnimationFrame(() => { + // Allow the elements to layout once, the updateInfoCollapse implementation measures + // the size of the container. It needs to wait a second before the bounding box + // returns an actual size. + view.updateInfoCollapse(); + view.updateProfilerState(); + view.updatePresets(); + + // Now wait for another rAF, and turn the animations back on. + elements.window.requestAnimationFrame(() => { + elements.header.setAttribute("animationready", "true"); + }); + }); +} + +/** + * This function is in charge of settings all of the events handlers for the view. + * The handlers must also add themselves to the `state.cleanup` for them to be + * properly cleaned up once the view is destroyed. + * + * @param {State} state + * @param {Elements} elements + * @param {ViewController} view + */ +function addPopupEventHandlers(state, elements, view) { + const { + changePreset, + startProfiler, + stopProfiler, + captureProfile, + } = lazy.Background(); + + /** + * Adds a handler that automatically is removed once the panel is hidden. + * + * @param {HTMLElement} element + * @param {string} type + * @param {(event: Event) => void} handler + */ + function addHandler(element, type, handler) { + element.addEventListener(type, handler); + state.cleanup.push(() => { + element.removeEventListener(type, handler); + }); + } + + addHandler(elements.infoButton, "click", event => { + // Any button command event in the popup will cause it to close. Prevent this + // from happening on click. + event.preventDefault(); + + state.isInfoCollapsed = !state.isInfoCollapsed; + view.updateInfoCollapse(); + }); + + addHandler(elements.startRecording, "click", () => { + startProfiler("aboutprofiling"); + }); + + addHandler(elements.stopAndDiscard, "click", () => { + stopProfiler(); + }); + + addHandler(elements.stopAndCapture, "click", () => { + captureProfile("aboutprofiling"); + view.hidePopup(); + }); + + addHandler(elements.learnMore, "click", () => { + elements.window.openWebLinkIn("https://profiler.firefox.com/docs/", "tab"); + view.hidePopup(); + }); + + addHandler(elements.presetsMenuList, "command", () => { + changePreset( + "aboutprofiling", + elements.presetsMenuList.value, + Services.profiler.GetFeatures() + ); + view.updatePresets(); + }); + + addHandler(elements.presetsMenuList, "popuphidden", event => { + // Changing a preset makes the popup autohide, this handler stops the + // propagation of that event, so that only the menulist's popup closes, + // and not the rest of the popup. + event.stopPropagation(); + }); + + addHandler(elements.presetsMenuList, "click", event => { + // Clicking on a preset makes the popup autohide, this preventDefault stops + // the CustomizableUI from closing the popup. + event.preventDefault(); + }); + + addHandler(elements.presetsEditSettings, "click", () => { + elements.window.openTrustedLinkIn("about:profiling", "tab"); + view.hidePopup(); + }); + + // Update the view when the profiler starts/stops. + // These are all events that can affect the current state of the profiler. + const events = ["profiler-started", "profiler-stopped"]; + for (const event of events) { + Services.obs.addObserver(view.updateProfilerState, event); + state.cleanup.push(() => { + Services.obs.removeObserver(view.updateProfilerState, event); + }); + } +} + +/** + * Initialize everything needed for the popup to work fine. + * @param {State} panelState + * @param {XULElement} panelview + */ +function initializePopup(panelState, panelview) { + const panelElements = selectElementsInPanelview(panelview); + const panelviewControllers = createViewControllers(panelState, panelElements); + addPopupEventHandlers(panelState, panelElements, panelviewControllers); + initializeView(panelState, panelElements, panelviewControllers); +} + +// Provide an exports object for the JSM to be properly read by TypeScript. +/** @type {any} */ +var module = {}; + +module.exports = { + initializePopup, +}; + +// Object.keys() confuses the linting which expects a static array expression. +// eslint-disable-next-line +var EXPORTED_SYMBOLS = Object.keys(module.exports); |