зеркало из https://github.com/mozilla/gecko-dev.git
621 строка
19 KiB
JavaScript
621 строка
19 KiB
JavaScript
/* This Source Code Form is subject to the terms of the Mozilla Public
|
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
|
// @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 { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
|
|
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.import(
|
|
"resource://gre/modules/AppConstants.jsm"
|
|
).AppConstants;
|
|
|
|
/**
|
|
* @typedef {import("../@types/perf").RecordingStateFromPreferences} RecordingStateFromPreferences
|
|
* @typedef {import("../@types/perf").PopupBackgroundFeatures} PopupBackgroundFeatures
|
|
* @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").MessageFromFrontend} MessageFromFrontend
|
|
* @typedef {import("../@types/perf").PageContext} PageContext
|
|
* @typedef {import("../@types/perf").Presets} Presets
|
|
* @typedef {import("../@types/perf").ProfilerViewMode} ProfilerViewMode
|
|
*/
|
|
|
|
/** @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";
|
|
|
|
// Lazily load the require function, when it's needed.
|
|
ChromeUtils.defineModuleGetter(
|
|
this,
|
|
"require",
|
|
"resource://devtools/shared/Loader.jsm"
|
|
);
|
|
|
|
// 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({
|
|
OS: () => ChromeUtils.import("resource://gre/modules/osfile.jsm"),
|
|
Utils: () => require("devtools/client/performance-new/utils"),
|
|
BrowserModule: () => require("devtools/client/performance-new/browser"),
|
|
RecordingUtils: () =>
|
|
require("devtools/shared/performance-new/recording-utils"),
|
|
CustomizableUI: () =>
|
|
ChromeUtils.import("resource:///modules/CustomizableUI.jsm"),
|
|
PerfSymbolication: () =>
|
|
ChromeUtils.import(
|
|
"resource://devtools/client/performance-new/symbolication.jsm.js"
|
|
),
|
|
PreferenceManagement: () =>
|
|
require("devtools/client/performance-new/preference-management"),
|
|
ProfilerMenuButton: () =>
|
|
ChromeUtils.import(
|
|
"resource://devtools/client/performance-new/popup/menu-button.jsm.js"
|
|
),
|
|
});
|
|
|
|
// TODO - Bug 1681539. The presets still need to be localized.
|
|
|
|
/** @type {Presets} */
|
|
const presets = {
|
|
"web-developer": {
|
|
label: "Web Developer",
|
|
description:
|
|
"Recommended preset for most web app debugging, with low overhead.",
|
|
entries: 128 * 1024 * 1024,
|
|
interval: 1,
|
|
features: ["screenshots", "js"],
|
|
threads: ["GeckoMain", "Compositor", "Renderer", "DOM Worker"],
|
|
duration: 0,
|
|
profilerViewMode: "active-tab",
|
|
},
|
|
"firefox-platform": {
|
|
label: "Firefox Platform",
|
|
description: "Recommended preset for internal Firefox platform debugging.",
|
|
entries: 128 * 1024 * 1024,
|
|
interval: 1,
|
|
features: ["screenshots", "js", "leaf", "stackwalk", "java"],
|
|
threads: ["GeckoMain", "Compositor", "Renderer", "SwComposite"],
|
|
duration: 0,
|
|
},
|
|
"firefox-front-end": {
|
|
label: "Firefox Front-End",
|
|
description: "Recommended preset for internal Firefox front-end debugging.",
|
|
entries: 128 * 1024 * 1024,
|
|
interval: 1,
|
|
features: ["screenshots", "js", "leaf", "stackwalk", "java"],
|
|
threads: ["GeckoMain", "Compositor", "Renderer", "DOM Worker"],
|
|
duration: 0,
|
|
},
|
|
graphics: {
|
|
label: "Firefox Graphics",
|
|
description:
|
|
"Recommended preset for Firefox graphics performance investigation.",
|
|
entries: 128 * 1024 * 1024,
|
|
interval: 1,
|
|
features: ["leaf", "stackwalk", "js", "java"],
|
|
threads: [
|
|
"GeckoMain",
|
|
"Compositor",
|
|
"Renderer",
|
|
"SwComposite",
|
|
"RenderBackend",
|
|
"SceneBuilder",
|
|
"WrWorker",
|
|
],
|
|
duration: 0,
|
|
},
|
|
media: {
|
|
label: "Media",
|
|
description: "Recommended preset for diagnosing audio and video problems.",
|
|
entries: 128 * 1024 * 1024,
|
|
interval: 1,
|
|
features: ["js", "leaf", "stackwalk", "audiocallbacktracing"],
|
|
threads: [
|
|
"AsyncCubebTask",
|
|
"AudioIPC",
|
|
"Compositor",
|
|
"GeckoMain",
|
|
"GraphRunner",
|
|
"MediaDecoderStateMachine",
|
|
"MediaPDecoder",
|
|
"MediaSupervisor",
|
|
"MediaTimer",
|
|
"NativeAudioCallback",
|
|
"RenderBackend",
|
|
"Renderer",
|
|
"SwComposite",
|
|
],
|
|
duration: 0,
|
|
},
|
|
};
|
|
|
|
/**
|
|
* This Map caches the symbols from the shared libraries.
|
|
* @type {Map<string, Library>}
|
|
*/
|
|
const symbolCache = new Map();
|
|
|
|
/**
|
|
* @param {PageContext} pageContext
|
|
* @param {string} debugName
|
|
* @param {string} breakpadId
|
|
*/
|
|
async function getSymbolsFromThisBrowser(pageContext, debugName, breakpadId) {
|
|
if (symbolCache.size === 0) {
|
|
// Prime the symbols cache.
|
|
for (const lib of Services.profiler.sharedLibraries) {
|
|
symbolCache.set(`${lib.debugName}/${lib.breakpadId}`, lib);
|
|
}
|
|
}
|
|
|
|
const cachedLib = symbolCache.get(`${debugName}/${breakpadId}`);
|
|
if (!cachedLib) {
|
|
throw new Error(
|
|
`The library ${debugName} ${breakpadId} is not in the ` +
|
|
"Services.profiler.sharedLibraries list, so the local path for it is not known " +
|
|
"and symbols for it can not be obtained. This usually happens if a content " +
|
|
"process uses a library that's not used in the parent process - " +
|
|
"Services.profiler.sharedLibraries only knows about libraries in the " +
|
|
"parent process."
|
|
);
|
|
}
|
|
|
|
const lib = cachedLib;
|
|
const objdirs = getObjdirPrefValue(pageContext);
|
|
const { getSymbolTableMultiModal } = lazy.PerfSymbolication();
|
|
return getSymbolTableMultiModal(lib, objdirs);
|
|
}
|
|
|
|
/**
|
|
* 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 postfix = getPrefPostfix(pageContext);
|
|
const presetName = Services.prefs.getCharPref(PRESET_PREF + postfix);
|
|
|
|
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 directly by devtools/startup/DevToolsStartup.jsm when
|
|
* using the shortcut keys to capture a profile.
|
|
* @param {PageContext} pageContext
|
|
* @return {Promise<void>}
|
|
*/
|
|
async function captureProfile(pageContext) {
|
|
if (!Services.profiler.IsActive()) {
|
|
// The profiler is not active, ignore this shortcut.
|
|
return;
|
|
}
|
|
if (Services.profiler.IsPaused()) {
|
|
// The profiler is already paused for capture, ignore this shortcut.
|
|
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();
|
|
|
|
const profile = await Services.profiler
|
|
.getProfileDataAsGzippedArrayBuffer()
|
|
.catch(
|
|
/** @type {(e: any) => {}} */ e => {
|
|
console.error(e);
|
|
return {};
|
|
}
|
|
);
|
|
|
|
const profilerViewMode = getProfilerViewModeForCurrentPreset(pageContext);
|
|
const receiveProfile = lazy.BrowserModule().receiveProfile;
|
|
receiveProfile(profile, profilerViewMode, (debugName, breakpadId) => {
|
|
return getSymbolsFromThisBrowser(pageContext, debugName, breakpadId);
|
|
});
|
|
|
|
Services.profiler.StopProfiler();
|
|
}
|
|
|
|
/**
|
|
* This function is only called by devtools/startup/DevToolsStartup.jsm when
|
|
* starting the profiler using the shortcut keys, through toggleProfiler below.
|
|
* @param {PageContext} pageContext
|
|
*/
|
|
function startProfiler(pageContext) {
|
|
const { translatePreferencesToState } = lazy.PreferenceManagement();
|
|
const {
|
|
entries,
|
|
interval,
|
|
features,
|
|
threads,
|
|
duration,
|
|
} = translatePreferencesToState(
|
|
getRecordingPreferences(pageContext, Services.profiler.GetFeatures())
|
|
);
|
|
|
|
// Get the active BrowsingContext ID from browser.
|
|
const { getActiveBrowsingContextID } = lazy.RecordingUtils();
|
|
const activeBrowsingContextID = getActiveBrowsingContextID();
|
|
|
|
Services.profiler.StartProfiler(
|
|
entries,
|
|
interval,
|
|
features,
|
|
threads,
|
|
activeBrowsingContextID,
|
|
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);
|
|
}
|
|
|
|
/**
|
|
* @param {string} prefName
|
|
* @return {string[]}
|
|
*/
|
|
function _getArrayOfStringsHostPref(prefName) {
|
|
const text = Services.prefs.getStringPref(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 {string}
|
|
*/
|
|
function getPrefPostfix(pageContext) {
|
|
switch (pageContext) {
|
|
case "devtools":
|
|
case "aboutprofiling":
|
|
// 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 {PageContext} pageContext
|
|
* @returns {string[]}
|
|
*/
|
|
function getObjdirPrefValue(pageContext) {
|
|
const postfix = getPrefPostfix(pageContext);
|
|
return _getArrayOfStringsHostPref(OBJDIRS_PREF + postfix);
|
|
}
|
|
|
|
/**
|
|
* @param {PageContext} pageContext
|
|
* @param {string[]} supportedFeatures
|
|
* @returns {RecordingStateFromPreferences}
|
|
*/
|
|
function getRecordingPreferences(pageContext, supportedFeatures) {
|
|
const postfix = getPrefPostfix(pageContext);
|
|
|
|
// If you add a new preference here, please do not forget to update
|
|
// `revertRecordingPreferences` as well.
|
|
const objdirs = getObjdirPrefValue(pageContext);
|
|
const presetName = Services.prefs.getCharPref(PRESET_PREF + postfix);
|
|
|
|
// First try to get the values from a preset.
|
|
const recordingPrefs = getRecordingPrefsFromPreset(
|
|
presetName,
|
|
supportedFeatures,
|
|
objdirs
|
|
);
|
|
if (recordingPrefs) {
|
|
return recordingPrefs;
|
|
}
|
|
|
|
// Next use the preferences to get the values.
|
|
const entries = Services.prefs.getIntPref(ENTRIES_PREF + postfix);
|
|
const interval = Services.prefs.getIntPref(INTERVAL_PREF + postfix);
|
|
const features = _getArrayOfStringsPref(FEATURES_PREF + postfix);
|
|
const threads = _getArrayOfStringsPref(THREADS_PREF + postfix);
|
|
const duration = Services.prefs.getIntPref(DURATION_PREF + postfix);
|
|
|
|
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 {string} presetName
|
|
* @param {string[]} supportedFeatures
|
|
* @param {string[]} objdirs
|
|
* @return {RecordingStateFromPreferences | null}
|
|
*/
|
|
function getRecordingPrefsFromPreset(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,
|
|
// The interval is stored in preferences as microseconds, but the preset
|
|
// defines it in terms of milliseconds. Make the conversion here.
|
|
interval: preset.interval * 1000,
|
|
// 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 {PageContext} pageContext
|
|
* @param {RecordingStateFromPreferences} prefs
|
|
*/
|
|
function setRecordingPreferences(pageContext, prefs) {
|
|
const postfix = getPrefPostfix(pageContext);
|
|
Services.prefs.setCharPref(PRESET_PREF + postfix, prefs.presetName);
|
|
Services.prefs.setIntPref(ENTRIES_PREF + postfix, prefs.entries);
|
|
// The interval pref stores the value in microseconds for extra precision.
|
|
Services.prefs.setIntPref(INTERVAL_PREF + postfix, prefs.interval);
|
|
Services.prefs.setCharPref(
|
|
FEATURES_PREF + postfix,
|
|
JSON.stringify(prefs.features)
|
|
);
|
|
Services.prefs.setCharPref(
|
|
THREADS_PREF + postfix,
|
|
JSON.stringify(prefs.threads)
|
|
);
|
|
Services.prefs.setCharPref(
|
|
OBJDIRS_PREF + postfix,
|
|
JSON.stringify(prefs.objdirs)
|
|
);
|
|
}
|
|
|
|
const platform = AppConstants.platform;
|
|
|
|
/**
|
|
* Revert the recording prefs for both local and remote profiling.
|
|
* @return {void}
|
|
*/
|
|
function revertRecordingPreferences() {
|
|
for (const postfix of ["", ".remote"]) {
|
|
Services.prefs.clearUserPref(PRESET_PREF + postfix);
|
|
Services.prefs.clearUserPref(ENTRIES_PREF + postfix);
|
|
Services.prefs.clearUserPref(INTERVAL_PREF + postfix);
|
|
Services.prefs.clearUserPref(FEATURES_PREF + postfix);
|
|
Services.prefs.clearUserPref(THREADS_PREF + postfix);
|
|
Services.prefs.clearUserPref(OBJDIRS_PREF + postfix);
|
|
Services.prefs.clearUserPref(DURATION_PREF + postfix);
|
|
}
|
|
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 postfix = getPrefPostfix(pageContext);
|
|
const objdirs = _getArrayOfStringsHostPref(OBJDIRS_PREF + postfix);
|
|
let recordingPrefs = getRecordingPrefsFromPreset(
|
|
presetName,
|
|
supportedFeatures,
|
|
objdirs
|
|
);
|
|
|
|
if (!recordingPrefs) {
|
|
// No recordingPrefs 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 + postfix, presetName);
|
|
recordingPrefs = getRecordingPreferences(pageContext, supportedFeatures);
|
|
}
|
|
|
|
setRecordingPreferences(pageContext, recordingPrefs);
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
*/
|
|
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;
|
|
switch (messageFromFrontend.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();
|
|
channel.send(
|
|
{
|
|
type: "STATUS_RESPONSE",
|
|
menuButtonIsEnabled: ProfilerMenuButton.isInNavbar(),
|
|
requestId,
|
|
},
|
|
target
|
|
);
|
|
break;
|
|
}
|
|
case "ENABLE_MENU_BUTTON": {
|
|
const { ownerDocument } = target.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);
|
|
|
|
// 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);
|
|
|
|
// Respond back that we've done it.
|
|
channel.send(
|
|
{
|
|
type: "ENABLE_MENU_BUTTON_DONE",
|
|
requestId,
|
|
},
|
|
target
|
|
);
|
|
break;
|
|
}
|
|
default:
|
|
console.error(
|
|
"An unknown message type was received by the profiler's WebChannel handler.",
|
|
message
|
|
);
|
|
}
|
|
}
|
|
|
|
// Provide a fake module.exports for the JSM to be properly read by TypeScript.
|
|
/** @type {any} */ (this).module = { exports: {} };
|
|
|
|
module.exports = {
|
|
presets,
|
|
captureProfile,
|
|
startProfiler,
|
|
stopProfiler,
|
|
restartProfiler,
|
|
toggleProfiler,
|
|
platform,
|
|
getSymbolsFromThisBrowser,
|
|
getRecordingPreferences,
|
|
setRecordingPreferences,
|
|
revertRecordingPreferences,
|
|
changePreset,
|
|
handleWebChannelMessage,
|
|
};
|
|
|
|
// Object.keys() confuses the linting which expects a static array expression.
|
|
// eslint-disable-next-line
|
|
var EXPORTED_SYMBOLS = Object.keys(module.exports);
|