summaryrefslogtreecommitdiffstats
path: root/toolkit/content/aboutwebrtc
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-19 00:47:55 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-19 00:47:55 +0000
commit26a029d407be480d791972afb5975cf62c9360a6 (patch)
treef435a8308119effd964b339f76abb83a57c29483 /toolkit/content/aboutwebrtc
parentInitial commit. (diff)
downloadfirefox-26a029d407be480d791972afb5975cf62c9360a6.tar.xz
firefox-26a029d407be480d791972afb5975cf62c9360a6.zip
Adding upstream version 124.0.1.upstream/124.0.1
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'toolkit/content/aboutwebrtc')
-rw-r--r--toolkit/content/aboutwebrtc/aboutWebrtc.css268
-rw-r--r--toolkit/content/aboutwebrtc/aboutWebrtc.html35
-rw-r--r--toolkit/content/aboutwebrtc/aboutWebrtc.mjs1957
-rw-r--r--toolkit/content/aboutwebrtc/configurationList.mjs118
-rw-r--r--toolkit/content/aboutwebrtc/copyButton.mjs83
-rw-r--r--toolkit/content/aboutwebrtc/disclosure.mjs95
-rw-r--r--toolkit/content/aboutwebrtc/graph.mjs186
-rw-r--r--toolkit/content/aboutwebrtc/graphdb.mjs211
8 files changed, 2953 insertions, 0 deletions
diff --git a/toolkit/content/aboutwebrtc/aboutWebrtc.css b/toolkit/content/aboutwebrtc/aboutWebrtc.css
new file mode 100644
index 0000000000..9d87aded81
--- /dev/null
+++ b/toolkit/content/aboutwebrtc/aboutWebrtc.css
@@ -0,0 +1,268 @@
+/* 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/. */
+
+body {
+ margin: 8px;
+}
+
+table {
+ font-family: monospace;
+ border: 1px solid var(--in-content-border-color);
+ border-spacing: 0;
+ margin-block: 1em;
+}
+
+.controls {
+ font-size: 1.1em;
+ display: inline-block;
+ margin: 0 0.5em;
+}
+
+.control {
+ margin: 0.5em 0;
+}
+
+.message > p {
+ margin: 4px;
+}
+
+.log p,
+.prefs p {
+ font-family: monospace;
+ padding-inline-start: 2em;
+ text-indent: -2em;
+ margin-block: 2px;
+}
+
+#content > div,
+#mediactx > div {
+ padding: 1em 2em;
+ margin: 1em 0;
+ border: 1px solid var(--in-content-box-border-color);
+ border-radius: 10px;
+ background-color: var(--in-content-box-background);
+}
+
+.autorefresh {
+ font-size: var(--font-size-small);
+ margin-inline-end: 0.5em;
+}
+
+.section-heading {
+ display: flex;
+ align-items: center;
+
+ > h3,
+ > h4 {
+ margin-inline-end: 1em;
+ }
+
+ > .fold-trigger {
+ margin-inline-end: 1em;
+ }
+
+ > button {
+ margin-inline: 1em;
+ }
+}
+
+.fold-target {
+ border-inline-start: 1px solid var(--in-content-border-color);
+ padding-inline-start: 1em;
+
+ .section-body > & {
+ display: block;
+ }
+}
+
+.peer-connection > h3 {
+ background-color: var(--in-content-box-info-background);
+ padding: 4px;
+}
+
+h3 > span {
+ margin-inline-end: 0.5em;
+}
+
+.peer-connection > button {
+ margin-inline-start: 0;
+}
+
+.peer-connection table {
+ width: 100%;
+ text-align: center;
+}
+
+.peer-connection table th {
+ font-weight: bold;
+}
+
+.peer-connection table th,
+.peer-connection table td {
+ padding: 0.4em;
+ border: 1px solid var(--in-content-border-color);
+}
+
+.peer-connection table tr:nth-child(odd) {
+ background-color: var(--in-content-box-background-odd);
+}
+
+.peer-connection table caption {
+ text-align: start;
+}
+
+.peer-connection table.raw-candidate {
+ text-align: match-parent;
+}
+
+.bottom-border td {
+ border-bottom: 2px solid currentColor;
+}
+
+.peer-connection-config div {
+ margin-inline: 1em;
+ padding: 4px;
+ border: 1px solid var(--in-content-border-color);
+}
+
+.peer-connection-config div:nth-child(odd) {
+ background-color: var(--in-content-box-background-odd);
+}
+
+/* The pale colour scheme is taken from:
+ https://personal.sron.nl/~pault/#sec:qualitative */
+.ice-trickled {
+ background-color: #cceeff; /* pale cyan */
+}
+.ice-succeeded {
+ background-color: #ccddaa; /* pale green */
+}
+.ice-failed {
+ background-color: #ffcccc; /* pale red */
+}
+.ice-cancelled {
+ background-color: #eeeebb; /* pale yellow */
+}
+.ice-trickled,
+.ice-succeeded,
+.ice-failed,
+.ice-cancelled {
+ color: black
+}
+
+.info-label {
+ font-weight: bold;
+}
+
+.info-body,
+.stat-label {
+ padding-inline-start: 0.5em;
+}
+
+.section-ctrl {
+ margin: 1em 1.5em;
+}
+
+div.fold-trigger {
+ color: var(--blue-60);
+ cursor: pointer;
+}
+
+@media screen {
+ .fold-closed {
+ display: none !important;
+ }
+}
+
+@media print {
+ .no-print {
+ display: none !important;
+ }
+}
+
+.tab-pane {
+ display: none;
+}
+
+.active-tab-pane {
+ border: 1px solid var(--in-content-border-color);
+ display: block;
+}
+
+.tab-button {
+ color: var(--in-content-button-text-color);
+}
+
+.active-tab-button {
+ color: var(--in-content-button-text-color-active);
+ background: var(--in-content-button-background-active);
+}
+
+.sdp-history {
+ display: flex;
+ height: 400px;
+}
+
+.sdp-history-link {
+ text-decoration: underline;
+}
+
+.sdp-history h5 {
+ background-color: var(--in-content-box-info-background);
+}
+
+.sdp-history div {
+ border: 1px solid var(--in-content-border-color);
+ padding: 1em;
+ width: 50%;
+ overflow: scroll;
+}
+
+.line-graph {
+ border: 1px solid var(--in-content-border-color);
+ margin-inline: 1px;
+ padding: 1px;
+}
+
+.svg-graph {
+ border: 1px solid var(--in-content-border-color);
+ margin-inline: 1px;
+}
+
+.copy-button-base {
+ padding-inline-end: 0.25em;
+}
+
+.copy-button {
+ visibility: hidden;
+}
+
+.prefList > li {
+ list-style-type: none;
+ margin: 0;
+ padding: 0;
+}
+
+.pathDisplay {
+ margin-inline-end: 1em;
+}
+
+.subsection-heading > h4 > span {
+ margin-inline-end: 0.5em;
+}
+
+.prefList > li:hover > .copy-button,
+.subsection-heading:hover > h4 > .copy-button {
+ visibility: visible;
+}
+
+.copy-button-fade-out {
+ opacity: 0;
+ transition: opacity 0.5s;
+}
+
+.copy-button-fade-in {
+ opacity: 1;
+ transition: opacity 0.5s;
+}
diff --git a/toolkit/content/aboutwebrtc/aboutWebrtc.html b/toolkit/content/aboutwebrtc/aboutWebrtc.html
new file mode 100644
index 0000000000..67a202f17f
--- /dev/null
+++ b/toolkit/content/aboutwebrtc/aboutWebrtc.html
@@ -0,0 +1,35 @@
+<!DOCTYPE html>
+
+<!-- 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/. -->
+
+<html>
+ <head>
+ <meta
+ http-equiv="Content-Security-Policy"
+ content="default-src chrome:; object-src 'none'"
+ />
+ <meta charset="utf-8" />
+ <meta name="color-scheme" content="light dark" />
+ <title data-l10n-id="about-webrtc-document-title"></title>
+ <link rel="stylesheet" href="chrome://global/skin/in-content/common.css" />
+ <link
+ rel="stylesheet"
+ type="text/css"
+ media="all"
+ href="chrome://global/content/aboutwebrtc/aboutWebrtc.css"
+ />
+ <script
+ src="chrome://global/content/aboutwebrtc/aboutWebrtc.mjs"
+ defer="defer"
+ type="module"
+ ></script>
+ <link rel="localization" href="toolkit/about/aboutWebrtc.ftl" />
+ </head>
+ <body id="body">
+ <div id="content"></div>
+ <div id="mediactx"></div>
+ <div id="controls" class="no-print"></div>
+ </body>
+</html>
diff --git a/toolkit/content/aboutwebrtc/aboutWebrtc.mjs b/toolkit/content/aboutwebrtc/aboutWebrtc.mjs
new file mode 100644
index 0000000000..3c41a4aa66
--- /dev/null
+++ b/toolkit/content/aboutwebrtc/aboutWebrtc.mjs
@@ -0,0 +1,1957 @@
+/* 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/. */
+
+import { GraphImpl } from "chrome://global/content/aboutwebrtc/graph.mjs";
+import { GraphDb } from "chrome://global/content/aboutwebrtc/graphdb.mjs";
+import { Disclosure } from "chrome://global/content/aboutwebrtc/disclosure.mjs";
+import { ConfigurationList } from "chrome://global/content/aboutwebrtc/configurationList.mjs";
+import { CopyButton } from "./copyButton.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ FileUtils: "resource://gre/modules/FileUtils.sys.mjs",
+});
+
+function makeFilePickerService() {
+ const fpContractID = "@mozilla.org/filepicker;1";
+ const fpIID = Ci.nsIFilePicker;
+ return Cc[fpContractID].createInstance(fpIID);
+}
+
+const WGI = WebrtcGlobalInformation;
+
+const LOGFILE_NAME_DEFAULT = "aboutWebrtc.html";
+
+class Renderer {
+ // Long function names preserved until code can be uniformly moved to new names
+ renderElement(eleName, options, l10n_id, l10n_args) {
+ let elem = Object.assign(document.createElement(eleName), options);
+ if (l10n_id) {
+ document.l10n.setAttributes(elem, l10n_id, l10n_args);
+ }
+ return elem;
+ }
+ elem() {
+ return this.renderElement(...arguments);
+ }
+ text(eleName, textContent, options) {
+ return this.renderElement(eleName, { textContent, ...options });
+ }
+ renderElements(eleName, options, list) {
+ const element = renderElement(eleName, options);
+ element.append(...list);
+ return element;
+ }
+ elems() {
+ return this.renderElements(...arguments);
+ }
+}
+
+// Proxies a Renderer instance to provide some meta programming methods to make
+// adding elements more readable, e.g. elemRenderer.elem_h4(...) instead of
+// elemRenderer.elem("h4", ...).
+const elemRenderer = new Proxy(new Renderer(), {
+ get(target, prop, receiver) {
+ // Function prefixes to proxy.
+ const proxied = {
+ elem_: (...args) => target.elem(...args),
+ elems_: (...args) => target.elems(...args),
+ text_: (...args) => target.text(...args),
+ };
+ for (let [prefix, func] of Object.entries(proxied)) {
+ if (prop.startsWith(prefix) && prop.length > prefix.length) {
+ return (...args) => func(prop.substring(prefix.length), ...args);
+ }
+ }
+ // Pass non-matches to the base object
+ return Reflect.get(...arguments);
+ },
+});
+
+let graphData = [];
+let mostRecentReports = {};
+let sdpHistories = [];
+let historyTsMemoForPcid = {};
+let sdpHistoryTsMemoForPcid = {};
+
+function clearStatsHistory() {
+ graphData = [];
+ mostRecentReports = {};
+ sdpHistories = [];
+ historyTsMemoForPcid = {};
+ sdpHistoryTsMemoForPcid = {};
+}
+
+function appendReportToHistory(report) {
+ appendSdpHistory(report);
+ mostRecentReports[report.pcid] = report;
+ if (graphData[report.pcid] === undefined) {
+ graphData[report.pcid] ??= new GraphDb(report);
+ } else {
+ graphData[report.pcid].insertReportData(report);
+ }
+}
+
+function appendSdpHistory({ pcid, sdpHistory: newHistory }) {
+ sdpHistories[pcid] ??= [];
+ let storedHistory = sdpHistories[pcid];
+ newHistory.forEach(entry => {
+ const { timestamp } = entry;
+ if (!storedHistory.length || storedHistory.at(-1).timestamp < timestamp) {
+ storedHistory.push(entry);
+ sdpHistoryTsMemoForPcid[pcid] = timestamp;
+ }
+ });
+}
+
+function recentStats() {
+ return Object.values(mostRecentReports);
+}
+
+// Returns the sdpHistory for a given stats report
+function getSdpHistory({ pcid, timestamp: a }) {
+ sdpHistories[pcid] ??= [];
+ return sdpHistories[pcid].filter(({ timestamp: b }) => a >= b);
+}
+
+function appendStats(allStats) {
+ allStats.forEach(appendReportToHistory);
+}
+
+function getAndUpdateStatsTsMemoForPcid(pcid) {
+ historyTsMemoForPcid[pcid] = mostRecentReports[pcid]?.timestamp;
+ return historyTsMemoForPcid[pcid] || null;
+}
+
+function getSdpTsMemoForPcid(pcid) {
+ return sdpHistoryTsMemoForPcid[pcid] || null;
+}
+
+const REQUEST_FULL_REFRESH = true;
+const REQUEST_UPDATE_ONLY_REFRESH = false;
+
+async function getStats(requestFullRefresh) {
+ if (
+ requestFullRefresh ||
+ !Services.prefs.getBoolPref("media.aboutwebrtc.hist.enabled")
+ ) {
+ // Upon clearing the history we need to get all the stats to rebuild what
+ // will become the skeleton of the page.hg wip
+ const { reports } = await new Promise(r => WGI.getAllStats(r));
+ appendStats(reports);
+ return reports.sort((a, b) => b.timestamp - a.timestamp);
+ }
+ const pcids = await new Promise(r => WGI.getStatsHistoryPcIds(r));
+ await Promise.all(
+ [...pcids].map(pcid =>
+ new Promise(r =>
+ WGI.getStatsHistorySince(
+ r,
+ pcid,
+ getAndUpdateStatsTsMemoForPcid(pcid),
+ getSdpTsMemoForPcid(pcid)
+ )
+ ).then(r => {
+ appendStats(r.reports);
+ r.sdpHistories.forEach(hist => appendSdpHistory(hist));
+ })
+ )
+ );
+ let recent = recentStats();
+ return recent.sort((a, b) => b.timestamp - a.timestamp);
+}
+
+const renderElement = (eleName, options, l10n_id, l10n_args) =>
+ elemRenderer.elem(eleName, options, l10n_id, l10n_args);
+
+const renderText = (eleName, textContent, options) =>
+ elemRenderer.text(eleName, textContent, options);
+
+const renderElements = (eleName, options, list) =>
+ elemRenderer.elems(eleName, options, list);
+
+// Button control classes
+
+class Control {
+ label = null;
+ message = null;
+ messageArgs = null;
+ messageHeader = null;
+
+ render() {
+ this.ctrl = renderElement(
+ "button",
+ { onclick: () => this.onClick() },
+ this.label
+ );
+ this.msg = renderElement("p");
+ this.update();
+ return [this.ctrl, this.msg];
+ }
+
+ update() {
+ document.l10n.setAttributes(this.ctrl, this.label);
+ this.msg.textContent = "";
+ if (this.message) {
+ this.msg.append(
+ renderElement(
+ "span",
+ {
+ className: "info-label",
+ },
+ this.messageHeader
+ ),
+ renderElement(
+ "span",
+ {
+ className: "info-body",
+ },
+ this.message,
+ this.messageArgs
+ )
+ );
+ }
+ }
+}
+
+class SavePage extends Control {
+ constructor() {
+ super();
+ this.messageHeader = "about-webrtc-save-page-label";
+ this.label = "about-webrtc-save-page-label";
+ }
+
+ async onClick() {
+ FoldEffect.expandAll();
+ let [dialogTitle] = await document.l10n.formatValues([
+ { id: "about-webrtc-save-page-dialog-title" },
+ ]);
+ let FilePicker = makeFilePickerService();
+ const lazyFileUtils = lazy.FileUtils;
+ FilePicker.init(window, dialogTitle, FilePicker.modeSave);
+ FilePicker.defaultString = LOGFILE_NAME_DEFAULT;
+ const rv = await new Promise(r => FilePicker.open(r));
+ if (rv != FilePicker.returnOK && rv != FilePicker.returnReplace) {
+ return;
+ }
+ const fout = lazyFileUtils.openAtomicFileOutputStream(
+ FilePicker.file,
+ lazyFileUtils.MODE_WRONLY | lazyFileUtils.MODE_CREATE
+ );
+ const content = document.querySelector("#content");
+ const noPrintList = [...content.querySelectorAll(".no-print")];
+ for (const node of noPrintList) {
+ node.style.setProperty("display", "none");
+ }
+ try {
+ fout.write(content.outerHTML, content.outerHTML.length);
+ } finally {
+ lazyFileUtils.closeAtomicFileOutputStream(fout);
+ for (const node of noPrintList) {
+ node.style.removeProperty("display");
+ }
+ }
+ this.message = "about-webrtc-save-page-complete-msg";
+ this.messageArgs = { path: FilePicker.file.path };
+ this.update();
+ }
+}
+
+class EnableLogging extends Control {
+ constructor() {
+ super();
+ this.label = "about-webrtc-enable-logging-label";
+ this.message = null;
+ }
+
+ onClick() {
+ this.update();
+ window.open("about:logging?preset=webrtc");
+ }
+}
+
+class AecLogging extends Control {
+ constructor() {
+ super();
+ this.messageHeader = "about-webrtc-aec-logging-msg-label";
+
+ if (WGI.aecDebug) {
+ this.setState(true);
+ } else {
+ this.label = "about-webrtc-aec-logging-off-state-label";
+ this.message = null;
+ }
+ }
+
+ setState(state) {
+ this.label = state
+ ? "about-webrtc-aec-logging-on-state-label"
+ : "about-webrtc-aec-logging-off-state-label";
+ try {
+ if (!state) {
+ const file = WGI.aecDebugLogDir;
+ this.message = "about-webrtc-aec-logging-toggled-off-state-msg";
+ this.messageArgs = { path: file };
+ } else {
+ this.message = "about-webrtc-aec-logging-toggled-on-state-msg";
+ }
+ } catch (e) {
+ this.message = null;
+ }
+ }
+
+ onClick() {
+ if (Services.env.get("MOZ_DISABLE_CONTENT_SANDBOX") != "1") {
+ this.message = "about-webrtc-aec-logging-unavailable-sandbox";
+ } else {
+ this.setState((WGI.aecDebug = !WGI.aecDebug));
+ }
+ this.update();
+ }
+}
+
+class ShowTab extends Control {
+ constructor(browserId) {
+ super();
+ this.label = "about-webrtc-show-tab-label";
+ this.message = null;
+ this.browserId = browserId;
+ }
+
+ onClick() {
+ const globalBrowser =
+ window.ownerGlobal.browsingContext.topChromeWindow.gBrowser;
+ for (const tab of globalBrowser.visibleTabs) {
+ if (tab.linkedBrowser && tab.linkedBrowser.browserId == this.browserId) {
+ globalBrowser.selectedTab = tab;
+ return;
+ }
+ }
+ this.ctrl.disabled = true;
+ }
+}
+
+(async () => {
+ // Setup. Retrieve reports & log while page loads.
+
+ const primarySections = [];
+ let peerConnections = renderElement("div");
+ let connectionLog = renderElement("div");
+ let userModifiedConfigView = renderElement("div");
+
+ const content = document.querySelector("#content");
+ content.append(peerConnections, connectionLog, userModifiedConfigView);
+ await new Promise(r => (window.onload = r));
+ {
+ const ctrl = renderElement("div", { className: "control" });
+ const msg = renderElement("div", { className: "message" });
+ const add = ([control, message]) => {
+ ctrl.appendChild(control);
+ msg.appendChild(message);
+ };
+ add(new SavePage().render());
+ add(new EnableLogging().render());
+ add(new AecLogging().render());
+
+ const ctrls = document.querySelector("#controls");
+ ctrls.append(renderElements("div", { className: "controls" }, [ctrl, msg]));
+
+ const mediactx = document.querySelector("#mediactx");
+ const mediaCtxSection = await renderMediaCtx(elemRenderer);
+ primarySections.push(mediaCtxSection);
+ mediactx.append(mediaCtxSection.view());
+ }
+
+ // This does not handle the auto-refresh, only the manual refreshes needed
+ // for certain user actions, and the initial population of the data
+ async function refresh() {
+ const pcSection = await renderPeerConnectionSection();
+ primarySections.push(pcSection);
+ const pcDiv = pcSection.view();
+ const connectionLogSection = await renderConnectionLog();
+ primarySections.push(connectionLogSection);
+ const logDiv = connectionLogSection.view();
+
+ // Replace previous info
+ peerConnections.replaceWith(pcDiv);
+ connectionLog.replaceWith(logDiv);
+ const userModifiedConfigSection = await renderUserPrefSection();
+ primarySections.push(userModifiedConfigSection);
+ userModifiedConfigView.replaceWith(userModifiedConfigSection.view());
+ peerConnections = pcDiv;
+ connectionLog = logDiv;
+ }
+ refresh();
+
+ const INTERVAL_MS = 250;
+ const HALF_INTERVAL_MS = INTERVAL_MS / 2;
+ // This handles autorefresh and forced refresh, not initial document loading
+ async function autorefresh() {
+ const startTime = performance.now();
+ await Promise.all(primarySections.map(s => s.autoUpdate()));
+ const elapsed = performance.now() - startTime;
+ // Using half the refresh interval as
+ const timeout =
+ elapsed > HALF_INTERVAL_MS ? INTERVAL_MS : INTERVAL_MS - elapsed;
+ return timeout;
+ }
+ let timeout = INTERVAL_MS;
+ while (true) {
+ timeout = await autorefresh();
+ await new Promise(r => setTimeout(r, timeout));
+ }
+})();
+
+const peerConnectionAutoRefreshState = {
+ /** @type HTMLInputElement */
+ primaryCheckbox: undefined,
+ /** @type [HTMLInputElement] */
+ secondaryCheckboxes: [],
+
+ secondaryClicked() {
+ const { checkedBoxes, uncheckedBoxes } = this.secondaryCheckboxes
+ .filter(cb => !cb.hidden)
+ .reduce(
+ (sums, { checked }) => {
+ if (checked) {
+ sums.checkedBoxes += 1;
+ } else {
+ sums.uncheckedBoxes += 1;
+ }
+ return sums;
+ },
+ {
+ checkedBoxes: 0,
+ uncheckedBoxes: 0,
+ }
+ );
+ // Stay checked unless all secondary boxes are unchecked
+ this.primaryCheckbox.checked = checkedBoxes > 0;
+ // Display an indeterminate state when there are both checked and unchecked boxes
+ this.primaryCheckbox.indeterminate = checkedBoxes && uncheckedBoxes;
+ },
+ primaryClicked() {
+ for (const cb of this.secondaryCheckboxes.filter(c => !c.hidden)) {
+ cb.checked = this.primaryCheckbox.checked;
+ }
+ this.primaryCheckbox.indeterminate = false;
+ },
+};
+
+function renderCopyTextToClipboardButton(rndr, id, l10n_id, getTextFn) {
+ return rndr.elem_button(
+ {
+ id: `copytextbutton-${id}`,
+ onclick() {
+ navigator.clipboard.writeText(getTextFn());
+ },
+ },
+ l10n_id
+ );
+}
+
+async function renderPeerConnectionSection() {
+ // Render pcs and log
+ let reports = await getStats();
+ let needsFullUpdate = REQUEST_UPDATE_ONLY_REFRESH;
+ reports.sort((a, b) => a.browserId - b.browserId);
+
+ // Used by the renderTransportStats function to calculate stat deltas
+ const hist = {};
+
+ // Adding a pcid to this list will cause the stats for that list to be refreshed
+ // on the next update interval. This is useful for one time refreshes like the
+ // "Refresh" button. The list is cleared at the end of each refresh interval.
+ const forceRefreshList = [];
+
+ const openPeerConnectionReports = reports.filter(r => !r.closed);
+ const closedPeerConnectionReports = reports.filter(r => r.closed);
+ const closedPCSection = document.createElement("div");
+ if (closedPeerConnectionReports.length) {
+ const closedPeerConnectionDisclosure = renderFoldableSection(
+ closedPCSection,
+ {
+ showMsg: "about-webrtc-closed-peerconnection-disclosure-show-msg",
+ hideMsg: "about-webrtc-closed-peerconnection-disclosure-hide-msg",
+ startsCollapsed: [...openPeerConnectionReports].size,
+ }
+ );
+ closedPCSection.append(closedPeerConnectionDisclosure);
+ closedPeerConnectionDisclosure.append(
+ ...closedPeerConnectionReports.map(r =>
+ renderPeerConnection(r, () => forceRefreshList.push(r.pcid))
+ )
+ );
+ }
+
+ const primarySection = await PrimarySection.make({
+ headingL10nId: "about-webrtc-peerconnections-section-heading",
+ disclosureShowL10nId: "about-webrtc-peerconnections-section-show-msg",
+ disclosureHideL10nId: "about-webrtc-peerconnections-section-hide-msg",
+ autoRefreshPref: "media.aboutwebrtc.auto_refresh.peerconnection_section",
+ renderFn: async () => {
+ const body = document.createElement("div");
+ body.append(
+ ...openPeerConnectionReports.map(r =>
+ renderPeerConnection(r, () => forceRefreshList.push(r.pcid))
+ ),
+ closedPCSection
+ );
+ return body;
+ },
+ // Creates the filling for the disclosure
+ updateFn: async section => {
+ let statsReports = await getStats(needsFullUpdate);
+ needsFullUpdate = REQUEST_UPDATE_ONLY_REFRESH;
+
+ async function translate(element) {
+ const frag = document.createDocumentFragment();
+ frag.append(element);
+ await document.l10n.translateFragment(frag);
+ return frag;
+ }
+
+ const translateSection = async (report, id, renderFunc) => {
+ const element = document.getElementById(`${id}: ${report.pcid}`);
+ const result =
+ element && (await translate(renderFunc(elemRenderer, report, hist)));
+ return { element, translated: result };
+ };
+
+ const sections = (
+ await Promise.all(
+ // Add filter to check the refreshEnabledPcids
+ statsReports
+ .filter(
+ ({ pcid }) =>
+ document.getElementById(`autorefresh-${pcid}`)?.checked ||
+ forceRefreshList.includes(pcid)
+ )
+ .flatMap(report => [
+ translateSection(
+ report,
+ "pc-heading",
+ renderPeerConnectionHeading
+ ),
+ translateSection(report, "ice-stats", renderICEStats),
+ translateSection(
+ report,
+ "ice-raw-stats-fold",
+ renderRawICEStatsFold
+ ),
+ translateSection(report, "rtp-stats", renderRTPStats),
+ translateSection(report, "sdp-stats", renderSDPStats),
+ translateSection(report, "bandwidth-stats", renderBandwidthStats),
+ translateSection(report, "frame-stats", renderFrameRateStats),
+ ])
+ )
+ ).filter(({ element }) => element);
+ document.l10n.pauseObserving();
+ for (const { element, translated } of sections) {
+ element.replaceWith(translated);
+ }
+ document.l10n.resumeObserving();
+ while (forceRefreshList.length) {
+ forceRefreshList.pop();
+ }
+ },
+ // Updates the contents.
+ headerElementsFn: async () => {
+ const clearStatsButton = document.createElement("button");
+ Object.assign(clearStatsButton, {
+ className: "no-print",
+ onclick: async () => {
+ WGI.clearAllStats();
+ clearStatsHistory();
+ needsFullUpdate = REQUEST_FULL_REFRESH;
+ primarySection.updateFn();
+ },
+ });
+ document.l10n.setAttributes(clearStatsButton, "about-webrtc-stats-clear");
+ return [clearStatsButton];
+ },
+ });
+ peerConnectionAutoRefreshState.primaryCheckbox = primarySection.autorefresh;
+ let originalOnChange = primarySection.autorefresh.onchange;
+ primarySection.autorefresh.onchange = () => {
+ originalOnChange();
+ peerConnectionAutoRefreshState.primaryClicked();
+ };
+ return primarySection;
+}
+
+function renderSubsectionHeading(l10n_id, copyFunc) {
+ const heading = document.createElement("div");
+ heading.className = "subsection-heading";
+ const h4 = document.createElement("h4");
+ const text = document.createElement("span");
+ document.l10n.setAttributes(text, l10n_id);
+ h4.appendChild(text);
+ if (copyFunc != undefined) {
+ const copyButton = new CopyButton(copyFunc);
+ h4.appendChild(copyButton.element);
+ }
+ heading.appendChild(h4);
+ return heading;
+}
+
+function renderPeerConnection(report, forceRefreshFn) {
+ const rndr = elemRenderer;
+ const { pcid, configuration } = report;
+ const pcStats = report.peerConnectionStats[0];
+
+ const pcDiv = renderElement("div", { className: "peer-connection" });
+ pcDiv.append(renderPeerConnectionTools(rndr, report, forceRefreshFn));
+ {
+ const section = renderFoldableSection(pcDiv);
+ section.append(
+ renderElements("div", {}, [
+ renderElement(
+ "span",
+ {
+ className: "info-label",
+ },
+ "about-webrtc-peerconnection-id-label"
+ ),
+ renderText("span", pcid, { className: "info-body" }),
+ rndr.elems_p({}, [
+ rndr.elem_span(
+ { className: "info-label" },
+ "about-webrtc-data-channels-opened-label"
+ ),
+ rndr.text_span(pcStats.dataChannelsOpened, {
+ className: "info-body",
+ }),
+ ]),
+ rndr.elems_p({}, [
+ rndr.elem_span(
+ { className: "info-label" },
+ "about-webrtc-data-channels-closed-label"
+ ),
+ rndr.text_span(pcStats.dataChannelsClosed, {
+ className: "info-body",
+ }),
+ ]),
+ renderConfiguration(rndr, configuration),
+ ]),
+ renderRTPStats(rndr, report),
+ renderICEStats(rndr, report),
+ renderRawICEStats(rndr, report),
+ renderSDPStats(rndr, report),
+ renderBandwidthStats(rndr, report),
+ renderFrameRateStats(rndr, report)
+ );
+ pcDiv.append(section);
+ }
+ return pcDiv;
+}
+
+function renderPeerConnectionMediaSummary(rndr, report) {
+ // Takes a codecId value and returns a corresponding codec stats object
+ const getCodecById = aId => report.codecStats.find(({ id }) => id == aId);
+
+ // Find all the codecs used by send streams
+ const sendCodecs = new Set(
+ [...report.outboundRtpStreamStats]
+ .filter(({ codecId }) => codecId)
+ .map(({ codecId }) => getCodecById(codecId).mimeType)
+ .sort()
+ );
+
+ // Find all the codecs used by receive streams
+ const recvCodecs = new Set(
+ [...report.inboundRtpStreamStats]
+ .filter(({ codecId }) => codecId)
+ .map(({ codecId }) => getCodecById(codecId).mimeType)
+ .sort()
+ );
+
+ // Take all the codecs that appear in both the send and receive codec lists
+ const sendRecvCodecs = new Set(
+ [...sendCodecs, ...recvCodecs].filter(
+ c => sendCodecs.has(c) && recvCodecs.has(c)
+ )
+ );
+
+ // Remove the common codecs from the send and receive codec lists.
+ // sendCodecs will now contain send only codecs
+ // receiveCodecs will now contain receive only codecs
+ sendRecvCodecs.forEach(c => {
+ sendCodecs.delete(c);
+ recvCodecs.delete(c);
+ });
+
+ const formatter = new Intl.ListFormat("en", {
+ style: "short",
+ type: "conjunction",
+ });
+
+ // Create a label with the codecs common to send and receive streams
+ const sendRecvSpan = sendRecvCodecs.size
+ ? [
+ rndr.elem_span({}, "about-webrtc-short-send-receive-direction", {
+ codecs: formatter.format(sendRecvCodecs),
+ }),
+ ]
+ : [];
+
+ // Do the same for send only codecs
+ const sendSpan = sendCodecs.size
+ ? [
+ rndr.elem_span({}, "about-webrtc-short-send-direction", {
+ codecs: formatter.format(sendCodecs),
+ }),
+ ]
+ : [];
+
+ // Do the same for receive only codecs
+ const recvSpan = recvCodecs.size
+ ? [
+ rndr.elem_span({}, "about-webrtc-short-receive-direction", {
+ codecs: formatter.format(recvCodecs),
+ }),
+ ]
+ : [];
+
+ return [...sendRecvSpan, ...sendSpan, ...recvSpan];
+}
+
+function renderPeerConnectionHeading(rndr, report) {
+ const { pcid, timestamp, closed: isClosed, browserId } = report;
+ const id = pcid.match(/id=(\S+)/)[1];
+ const url = pcid.match(/url=([^)]+)/)[1];
+ const now = new Date(timestamp);
+ return isClosed
+ ? rndr.elems_div(
+ {
+ id: `pc-heading: ${pcid}`,
+ class: "pc-heading",
+ },
+ [
+ rndr.elems_h3({}, [
+ rndr.elem_span({}, "about-webrtc-connection-closed", {
+ "browser-id": browserId,
+ id,
+ url,
+ now,
+ }),
+ ...renderPeerConnectionMediaSummary(rndr, report),
+ ]),
+ ]
+ )
+ : rndr.elems_div(
+ {
+ id: `pc-heading: ${pcid}`,
+ class: "pc-heading",
+ },
+ [
+ rndr.elems_h3({}, [
+ rndr.elem_span({}, "about-webrtc-connection-open", {
+ "browser-id": browserId,
+ id,
+ url,
+ now,
+ }),
+ ...renderPeerConnectionMediaSummary(rndr, report),
+ ]),
+ ]
+ );
+}
+
+function renderPeerConnectionTools(rndr, report, forceRefreshFn) {
+ const { pcid, browserId } = report;
+ const id = pcid.match(/id=(\S+)/)[1];
+ const copyHistButton = !Services.prefs.getBoolPref(
+ "media.aboutwebrtc.hist.enabled"
+ )
+ ? []
+ : [
+ rndr.elem_button(
+ {
+ id: `copytextbutton-hist-${id}`,
+ onclick() {
+ WGI.getStatsHistorySince(
+ hist =>
+ navigator.clipboard.writeText(JSON.stringify(hist, null, 2)),
+ pcid
+ );
+ },
+ },
+ "about-webrtc-copy-report-history-button"
+ ),
+ ];
+ const autorefreshButton = rndr.elem_input({
+ id: `autorefresh-${pcid}`,
+ className: "autorefresh",
+ type: "checkbox",
+ hidden: report.closed,
+ checked: Services.prefs.getBoolPref(
+ "media.aboutwebrtc.auto_refresh.peerconnection_section"
+ ),
+ onchange: () => peerConnectionAutoRefreshState.secondaryClicked(),
+ });
+ peerConnectionAutoRefreshState.secondaryCheckboxes.push(autorefreshButton);
+ const forceRefreshButton = rndr.elem_button(
+ {
+ id: `force-refresh-pc-${id}`,
+ onclick() {
+ forceRefreshFn();
+ },
+ },
+ "about-webrtc-force-refresh-button"
+ );
+ const autorefreshLabel = rndr.elem_label(
+ {
+ className: "autorefresh",
+ hidden: autorefreshButton.hidden,
+ },
+ "about-webrtc-auto-refresh-label"
+ );
+ return renderElements("div", { id: "pc-tools: " + pcid }, [
+ renderPeerConnectionHeading(rndr, report),
+ new ShowTab(browserId).render()[0],
+ renderCopyTextToClipboardButton(
+ rndr,
+ report.pcid,
+ "about-webrtc-copy-report-button",
+ () => JSON.stringify({ ...report }, null, 2)
+ ),
+ ...copyHistButton,
+ forceRefreshButton,
+ autorefreshButton,
+ autorefreshLabel,
+ ]);
+}
+
+const trimNewlines = sdp => sdp.replaceAll("\r\n", "\n");
+
+const tabElementProps = (element, elemSubId, pcid) => ({
+ className:
+ elemSubId != "answer"
+ ? `tab-${element}`
+ : `tab-${element} active-tab-${element}`,
+ id: `tab_${element}_${elemSubId}_${pcid}`,
+});
+
+const renderSDPTab = (rndr, sdp, props) =>
+ rndr.elems("div", props, [rndr.text("pre", trimNewlines(sdp))]);
+
+const renderSDPHistoryTab = (rndr, hist, props) => {
+ // All SDP in sequential order. Add onclick handler to scroll the associated
+ // SDP into view below.
+ let first = Math.min(...hist.map(({ timestamp }) => timestamp));
+ const parts = hist.map(({ isLocal, timestamp, sdp, errors: errs }) => {
+ let errorsSubSect = () => [
+ rndr.elem_h5({}, "about-webrtc-sdp-parsing-errors-heading"),
+ ...errs.map(({ lineNumber: n, error: e }) => rndr.text_br(`${n}: ${e}`)),
+ ];
+
+ let sdpSection = [
+ rndr.elem_h5({}, "about-webrtc-sdp-set-timestamp", {
+ timestamp,
+ "relative-timestamp": timestamp - first,
+ }),
+ ...(errs && errs.length ? errorsSubSect() : []),
+ rndr.text_pre(trimNewlines(sdp)),
+ ];
+
+ return {
+ link: rndr.elems_div({}, [
+ rndr.elem_h5(
+ {
+ className: "sdp-history-link",
+ onclick: () => sdpSection[0].scrollIntoView(),
+ },
+ isLocal
+ ? "about-webrtc-sdp-set-at-timestamp-local"
+ : "about-webrtc-sdp-set-at-timestamp-remote",
+ { timestamp }
+ ),
+ ]),
+ ...(isLocal ? { local: sdpSection } : { remote: sdpSection }),
+ };
+ });
+
+ return rndr.elems_div(props, [
+ // Render the links
+ rndr.elems_div(
+ {},
+ parts.map(({ link }) => link)
+ ),
+ rndr.elems_div({ className: "sdp-history" }, [
+ // Render the SDP into separate columns for local and remote.
+ rndr.elems_div({}, [
+ rndr.elem_h4({}, "about-webrtc-local-sdp-heading"),
+ ...parts.filter(({ local }) => local).flatMap(({ local }) => local),
+ ]),
+ rndr.elems_div({}, [
+ rndr.elem_h4({}, "about-webrtc-remote-sdp-heading"),
+ ...parts.filter(({ remote }) => remote).flatMap(({ remote }) => remote),
+ ]),
+ ]),
+ ]);
+};
+
+function renderSDPStats(rndr, { offerer, pcid, timestamp }) {
+ // Get the most recent (as of timestamp) local and remote SDPs from the
+ // history
+ const sdpEntries = getSdpHistory({ pcid, timestamp });
+ const localSdp = sdpEntries.findLast(({ isLocal }) => isLocal)?.sdp || "";
+ const remoteSdp = sdpEntries.findLast(({ isLocal }) => !isLocal)?.sdp || "";
+
+ const sdps = offerer
+ ? { offer: localSdp, answer: remoteSdp }
+ : { offer: remoteSdp, answer: localSdp };
+
+ const sdpLabels = offerer
+ ? { offer: "local", answer: "remote" }
+ : { offer: "remote", answer: "local" };
+
+ sdpLabels.l10n = {
+ offer: offerer
+ ? "about-webrtc-local-sdp-heading-offer"
+ : "about-webrtc-remote-sdp-heading-offer",
+ answer: offerer
+ ? "about-webrtc-remote-sdp-heading-answer"
+ : "about-webrtc-local-sdp-heading-answer",
+ history: "about-webrtc-sdp-history-heading",
+ };
+
+ const tabPaneProps = elemSubId => tabElementProps("pane", elemSubId, pcid);
+
+ const panes = {
+ answer: renderSDPTab(rndr, sdps.answer, tabPaneProps("answer")),
+ offer: renderSDPTab(rndr, sdps.offer, tabPaneProps("offer")),
+ history: renderSDPHistoryTab(
+ rndr,
+ getSdpHistory({ pcid, timestamp }),
+ tabPaneProps("history")
+ ),
+ };
+
+ // Creates the properties and l10n label for tab buttons
+ const tabButtonProps = (elemSubId, pane) => [
+ {
+ ...tabElementProps("button", elemSubId, pcid),
+ onclick({ currentTarget: t }) {
+ const flipPane = c => c.classList.toggle("active-tab-pane", c == pane);
+ Object.values(panes).forEach(flipPane);
+ const selButton = c => c.classList.toggle("active-tab-button", c == t);
+ [...t.parentElement.children].forEach(selButton);
+ },
+ },
+ sdpLabels.l10n[elemSubId],
+ ];
+
+ const sdpDiv = renderSubsectionHeading("about-webrtc-sdp-heading", () =>
+ JSON.stringify(
+ {
+ offer: {
+ side: sdpLabels.offer,
+ sdp: sdps.offer.split("\r\n"),
+ },
+ answer: {
+ side: sdpLabels.answer,
+ sdp: sdps.answer.split("\r\n"),
+ },
+ },
+ null,
+ 2
+ )
+ );
+ const outer = document.createElement("div", { id: "sdp-stats" + pcid });
+ outer.appendChild(sdpDiv);
+ let foldSection = renderFoldableSection(outer, {
+ showMsg: "about-webrtc-show-msg-sdp",
+ hideMsg: "about-webrtc-hide-msg-sdp",
+ });
+ foldSection.append(
+ rndr.elems_div({ className: "tab-buttons" }, [
+ ...Object.entries(panes).map(([elemSubId, pane]) =>
+ rndr.elem_button(...tabButtonProps(elemSubId, pane))
+ ),
+ ...Object.values(panes),
+ ])
+ );
+ outer.append(foldSection);
+ return outer;
+}
+
+function renderBandwidthStats(rndr, report) {
+ const statsDiv = renderElement("div", {
+ id: "bandwidth-stats: " + report.pcid,
+ });
+ const table = renderSimpleTable(
+ "",
+ [
+ "about-webrtc-track-identifier",
+ "about-webrtc-send-bandwidth-bytes-sec",
+ "about-webrtc-receive-bandwidth-bytes-sec",
+ "about-webrtc-max-padding-bytes-sec",
+ "about-webrtc-pacer-delay-ms",
+ "about-webrtc-round-trip-time-ms",
+ ],
+ report.bandwidthEstimations.map(stat => [
+ stat.trackIdentifier,
+ stat.sendBandwidthBps,
+ stat.receiveBandwidthBps,
+ stat.maxPaddingBps,
+ stat.pacerDelayMs,
+ stat.rttMs,
+ ])
+ );
+ statsDiv.append(
+ renderElement("h4", {}, "about-webrtc-bandwidth-stats-heading"),
+ table
+ );
+ return statsDiv;
+}
+
+function renderFrameRateStats(rndr, report) {
+ const statsDiv = renderElement("div", { id: "frame-stats: " + report.pcid });
+ report.videoFrameHistories.forEach(hist => {
+ const stats = hist.entries.map(stat => {
+ stat.elapsed = stat.lastFrameTimestamp - stat.firstFrameTimestamp;
+ if (stat.elapsed < 1) {
+ stat.elapsed = "0.00";
+ }
+ stat.elapsed = (stat.elapsed / 1_000).toFixed(3);
+ if (stat.elapsed && stat.consecutiveFrames) {
+ stat.avgFramerate = (stat.consecutiveFrames / stat.elapsed).toFixed(2);
+ } else {
+ stat.avgFramerate = "0.00";
+ }
+ return stat;
+ });
+
+ const table = renderSimpleTable(
+ "",
+ [
+ "about-webrtc-width-px",
+ "about-webrtc-height-px",
+ "about-webrtc-consecutive-frames",
+ "about-webrtc-time-elapsed",
+ "about-webrtc-estimated-framerate",
+ "about-webrtc-rotation-degrees",
+ "about-webrtc-first-frame-timestamp",
+ "about-webrtc-last-frame-timestamp",
+ "about-webrtc-local-receive-ssrc",
+ "about-webrtc-remote-send-ssrc",
+ ],
+ stats.map(stat =>
+ [
+ stat.width,
+ stat.height,
+ stat.consecutiveFrames,
+ stat.elapsed,
+ stat.avgFramerate,
+ stat.rotationAngle,
+ stat.firstFrameTimestamp,
+ stat.lastFrameTimestamp,
+ stat.localSsrc,
+ stat.remoteSsrc || "?",
+ ].map(entry => (Object.is(entry, undefined) ? "<<undefined>>" : entry))
+ )
+ );
+
+ statsDiv.append(
+ renderElement("h4", {}, "about-webrtc-frame-stats-heading", {
+ "track-identifier": hist.trackIdentifier,
+ }),
+ table
+ );
+ });
+
+ return statsDiv;
+}
+
+function renderRTPStats(rndr, report, hist) {
+ const rtpStats = [
+ ...(report.inboundRtpStreamStats || []),
+ ...(report.outboundRtpStreamStats || []),
+ ];
+ const remoteRtpStats = [
+ ...(report.remoteInboundRtpStreamStats || []),
+ ...(report.remoteOutboundRtpStreamStats || []),
+ ];
+
+ // Generate an id-to-streamStat index for each remote streamStat. This will
+ // be used next to link the remote to its local side.
+ const remoteRtpStatsMap = {};
+ for (const stat of remoteRtpStats) {
+ remoteRtpStatsMap[stat.id] = stat;
+ }
+
+ // If a streamStat has a remoteId attribute, create a remoteRtpStats
+ // attribute that references the remote streamStat entry directly.
+ // That is, the index generated above is merged into the returned list.
+ for (const stat of rtpStats.filter(s => "remoteId" in s)) {
+ stat.remoteRtpStats = remoteRtpStatsMap[stat.remoteId];
+ }
+ for (const stat of rtpStats.filter(s => "codecId" in s)) {
+ stat.codecStat = report.codecStats.find(({ id }) => id == stat.codecId);
+ }
+ const graphsByStat = stat =>
+ (graphData[report.pcid]?.getGraphDataById(stat.id) || []).map(gd => {
+ // For some (remote) graphs data comes in slowly.
+ // Those graphs can be larger to show trends.
+ const histSecs = gd.getConfig().histSecs;
+ const width = (histSecs > 30 ? histSecs / 3 : 15) * 20;
+ const height = 100;
+ const graph = new GraphImpl(width, height);
+ graph.startTime = () => stat.timestamp - histSecs * 1000;
+ graph.stopTime = () => stat.timestamp;
+ if (gd.subKey == "packetsLost") {
+ const oldMaxColor = graph.maxColor;
+ graph.maxColor = data => (data.value == 0 ? "red" : oldMaxColor(data));
+ }
+ // Get a bit more history for averages (20%)
+ const dataSet = gd.getDataSetSince(
+ graph.startTime() - histSecs * 0.2 * 1000
+ );
+ return graph.drawSparseValues(dataSet, gd.subKey, gd.getConfig());
+ });
+ // Render stats set
+ return renderElements(
+ "div",
+ { id: "rtp-stats: " + report.pcid, className: "rtp-stats" },
+ [
+ renderSubsectionHeading("about-webrtc-rtp-stats-heading", () =>
+ JSON.stringify([...rtpStats, ...remoteRtpStats], null, 2)
+ ),
+ ...rtpStats.map(stat => {
+ const { ssrc, remoteId, remoteRtpStats: rtcpStats } = stat;
+ const remoteGraphs = rtcpStats
+ ? [
+ rndr.elems_div({}, [
+ rndr.text_h6(rtcpStats.type),
+ ...graphsByStat(rtcpStats),
+ ]),
+ ]
+ : [];
+ const mime = stat?.codecStat?.mimeType?.concat(" - ") || "";
+ const div = renderElements("div", {}, [
+ rndr.text_h5(`${mime}SSRC ${ssrc}`),
+ rndr.elems_div({}, [rndr.text_h6(stat.type), ...graphsByStat(stat)]),
+ ...remoteGraphs,
+ renderCodecStats(stat),
+ renderTransportStats(stat, true, hist),
+ ]);
+ if (remoteId && rtcpStats) {
+ div.append(renderTransportStats(rtcpStats, false));
+ }
+ return div;
+ }),
+ ]
+ );
+}
+
+function renderCodecStats({
+ codecStat,
+ framesEncoded,
+ framesDecoded,
+ framesDropped,
+ discardedPackets,
+ packetsReceived,
+}) {
+ let elements = [];
+
+ if (codecStat) {
+ elements.push(
+ renderText("span", `${codecStat.payloadType} ${codecStat.mimeType}`, {})
+ );
+ if (framesEncoded !== undefined || framesDecoded !== undefined) {
+ elements.push(
+ renderElement(
+ "span",
+ { className: "stat-label" },
+ "about-webrtc-frames",
+ {
+ frames: framesEncoded || framesDecoded || 0,
+ }
+ )
+ );
+ }
+ if (codecStat.channels !== undefined) {
+ elements.push(
+ renderElement(
+ "span",
+ { className: "stat-label" },
+ "about-webrtc-channels",
+ {
+ channels: codecStat.channels,
+ }
+ )
+ );
+ }
+ elements.push(
+ renderText(
+ "span",
+ ` ${codecStat.clockRate} ${codecStat.sdpFmtpLine || ""}`,
+ {}
+ )
+ );
+ }
+ if (framesDropped !== undefined) {
+ elements.push(
+ renderElement(
+ "span",
+ { className: "stat-label" },
+ "about-webrtc-dropped-frames-label"
+ )
+ );
+ elements.push(renderText("span", ` ${framesDropped}`, {}));
+ }
+ if (discardedPackets !== undefined) {
+ elements.push(
+ renderElement(
+ "span",
+ { className: "stat-label" },
+ "about-webrtc-discarded-packets-label"
+ )
+ );
+ elements.push(renderText("span", ` ${discardedPackets}`, {}));
+ }
+ if (elements.length) {
+ if (packetsReceived !== undefined) {
+ elements.unshift(
+ renderElement("span", {}, "about-webrtc-decoder-label"),
+ renderText("span", ": ")
+ );
+ } else {
+ elements.unshift(
+ renderElement("span", {}, "about-webrtc-encoder-label"),
+ renderText("span", ": ")
+ );
+ }
+ }
+ return renderElements("div", {}, elements);
+}
+
+function renderTransportStats(
+ {
+ id,
+ timestamp,
+ type,
+ packetsReceived,
+ bytesReceived,
+ packetsLost,
+ jitter,
+ roundTripTime,
+ packetsSent,
+ bytesSent,
+ },
+ local,
+ hist
+) {
+ if (hist) {
+ if (hist[id] === undefined) {
+ hist[id] = {};
+ }
+ }
+
+ const estimateKBps = (curTimestamp, lastTimestamp, bytes, lastBytes) => {
+ if (!curTimestamp || !lastTimestamp || !bytes || !lastBytes) {
+ return "0.0";
+ }
+ const elapsedTime = curTimestamp - lastTimestamp;
+ if (elapsedTime <= 0) {
+ return "0.0";
+ }
+ return ((bytes - lastBytes) / elapsedTime).toFixed(1);
+ };
+
+ let elements = [];
+
+ if (local) {
+ elements.push(
+ renderElement("span", {}, "about-webrtc-type-local"),
+ renderText("span", ": ")
+ );
+ } else {
+ elements.push(
+ renderElement("span", {}, "about-webrtc-type-remote"),
+ renderText("span", ": ")
+ );
+ }
+
+ const time = new Date(timestamp).toTimeString();
+ elements.push(renderText("span", `${time} ${type}`));
+
+ if (packetsReceived) {
+ elements.push(
+ renderElement(
+ "span",
+ { className: "stat-label" },
+ "about-webrtc-received-label",
+ {
+ packets: packetsReceived,
+ }
+ )
+ );
+
+ if (bytesReceived) {
+ let s = ` (${(bytesReceived / 1024).toFixed(2)} Kb`;
+ if (local && hist) {
+ s += ` , ${estimateKBps(
+ timestamp,
+ hist[id].lastTimestamp,
+ bytesReceived,
+ hist[id].lastBytesReceived
+ )} KBps`;
+ }
+ s += ")";
+ elements.push(renderText("span", s));
+ }
+
+ elements.push(
+ renderElement(
+ "span",
+ { className: "stat-label" },
+ "about-webrtc-lost-label",
+ {
+ packets: packetsLost,
+ }
+ )
+ );
+ elements.push(
+ renderElement(
+ "span",
+ { className: "stat-label" },
+ "about-webrtc-jitter-label",
+ {
+ jitter,
+ }
+ )
+ );
+
+ if (roundTripTime !== undefined) {
+ elements.push(renderText("span", ` RTT: ${roundTripTime * 1000} ms`));
+ }
+ } else if (packetsSent) {
+ elements.push(
+ renderElement(
+ "span",
+ { className: "stat-label" },
+ "about-webrtc-sent-label",
+ {
+ packets: packetsSent,
+ }
+ )
+ );
+ if (bytesSent) {
+ let s = ` (${(bytesSent / 1024).toFixed(2)} Kb`;
+ if (local && hist) {
+ s += `, ${estimateKBps(
+ timestamp,
+ hist[id].lastTimestamp,
+ bytesSent,
+ hist[id].lastBytesSent
+ )} KBps`;
+ }
+ s += ")";
+ elements.push(renderText("span", s));
+ }
+ }
+
+ // Update history
+ if (hist) {
+ hist[id].lastBytesReceived = bytesReceived;
+ hist[id].lastBytesSent = bytesSent;
+ hist[id].lastTimestamp = timestamp;
+ }
+
+ return renderElements("div", {}, elements);
+}
+
+function renderRawIceTable(caption, candidates) {
+ const table = renderSimpleTable(
+ "",
+ [caption],
+ [...new Set(candidates.sort())].filter(i => i).map(i => [i])
+ );
+ table.className = "raw-candidate";
+ return table;
+}
+
+function renderConfiguration(rndr, c) {
+ const provided = "about-webrtc-configuration-element-provided";
+ const notProvided = "about-webrtc-configuration-element-not-provided";
+
+ // Create the text for a configuration field
+ const cfg = (obj, key) => [
+ renderElement("br"),
+ `${key}: `,
+ key in obj ? obj[key] : renderElement("i", {}, notProvided),
+ ];
+
+ // Create the text for a fooProvided configuration field
+ const pro = (obj, key) => [
+ renderElement("br"),
+ `${key}(`,
+ renderElement("i", {}, provided),
+ `/`,
+ renderElement("i", {}, notProvided),
+ `): `,
+ renderElement("i", {}, obj[`${key}Provided`] ? provided : notProvided),
+ ];
+
+ const confDiv = rndr.elem_div({ display: "contents" });
+ let disclosure = renderFoldableSection(confDiv, {
+ showMsg: "about-webrtc-pc-configuration-show-msg",
+ hideMsg: "about-webrtc-pc-configuration-hide-msg",
+ });
+ disclosure.append(
+ rndr.elems_div({ classList: "peer-connection-config" }, [
+ "RTCConfiguration",
+ ...cfg(c, "bundlePolicy"),
+ ...cfg(c, "iceTransportPolicy"),
+ ...pro(c, "peerIdentity"),
+ ...cfg(c, "sdpSemantics"),
+ renderElement("br"),
+ "iceServers: ",
+ ...(!c.iceServers
+ ? [renderElement("i", {}, notProvided)]
+ : c.iceServers.map(i =>
+ renderElements("div", {}, [
+ `urls: ${JSON.stringify(i.urls)}`,
+ ...pro(i, "credential"),
+ ...pro(i, "userName"),
+ ])
+ )),
+ ])
+ );
+ confDiv.append(disclosure);
+ return confDiv;
+}
+
+function renderICEStats(rndr, report) {
+ const iceDiv = renderElements("div", { id: "ice-stats: " + report.pcid }, [
+ renderSubsectionHeading("about-webrtc-ice-stats-heading", () =>
+ JSON.stringify(
+ [...report.iceCandidateStats, ...report.iceCandidatePairStats],
+ null,
+ 2
+ )
+ ),
+ ]);
+
+ // Render ICECandidate table
+ {
+ const caption = renderElement(
+ "caption",
+ { className: "no-print" },
+ "about-webrtc-trickle-caption-msg"
+ );
+
+ // Generate ICE stats
+ const stats = [];
+ {
+ // Create an index based on candidate ID for each element in the
+ // iceCandidateStats array.
+ const candidates = {};
+ for (const candidate of report.iceCandidateStats) {
+ candidates[candidate.id] = candidate;
+ }
+
+ // a method to see if a given candidate id is in the array of tickled
+ // candidates.
+ const isTrickled = candidateId =>
+ report.trickledIceCandidateStats.some(({ id }) => id == candidateId);
+
+ // A component may have a remote or local candidate address or both.
+ // Combine those with both; these will be the peer candidates.
+ const matched = {};
+
+ for (const {
+ localCandidateId,
+ remoteCandidateId,
+ componentId,
+ state,
+ priority,
+ nominated,
+ selected,
+ bytesSent,
+ bytesReceived,
+ } of report.iceCandidatePairStats) {
+ const local = candidates[localCandidateId];
+ if (local) {
+ const stat = {
+ ["local-candidate"]: candidateToString(local),
+ componentId,
+ state,
+ priority,
+ nominated,
+ selected,
+ bytesSent,
+ bytesReceived,
+ };
+ matched[local.id] = true;
+ if (isTrickled(local.id)) {
+ stat["local-trickled"] = true;
+ }
+
+ const remote = candidates[remoteCandidateId];
+ if (remote) {
+ stat["remote-candidate"] = candidateToString(remote);
+ matched[remote.id] = true;
+ if (isTrickled(remote.id)) {
+ stat["remote-trickled"] = true;
+ }
+ }
+ stats.push(stat);
+ }
+ }
+
+ // sort (group by) componentId first, then bytesSent if available, else by
+ // priority
+ stats.sort((a, b) => {
+ if (a.componentId != b.componentId) {
+ return a.componentId - b.componentId;
+ }
+ return b.bytesSent
+ ? b.bytesSent - (a.bytesSent || 0)
+ : (b.priority || 0) - (a.priority || 0);
+ });
+ }
+ // Render ICE stats
+ // don't use |stat.x || ""| here because it hides 0 values
+ const statsTable = renderSimpleTable(
+ caption,
+ [
+ "about-webrtc-ice-state",
+ "about-webrtc-nominated",
+ "about-webrtc-selected",
+ "about-webrtc-local-candidate",
+ "about-webrtc-remote-candidate",
+ "about-webrtc-ice-component-id",
+ "about-webrtc-priority",
+ "about-webrtc-ice-pair-bytes-sent",
+ "about-webrtc-ice-pair-bytes-received",
+ ],
+ stats.map(stat =>
+ [
+ stat.state,
+ stat.nominated,
+ stat.selected,
+ stat["local-candidate"],
+ stat["remote-candidate"],
+ stat.componentId,
+ stat.priority,
+ stat.bytesSent,
+ stat.bytesReceived,
+ ].map(entry => (Object.is(entry, undefined) ? "" : entry))
+ )
+ );
+
+ // after rendering the table, we need to change the class name for each
+ // candidate pair's local or remote candidate if it was trickled.
+ let index = 0;
+ for (const {
+ state,
+ nominated,
+ selected,
+ "local-trickled": localTrickled,
+ "remote-trickled": remoteTrickled,
+ } of stats) {
+ // look at statsTable row index + 1 to skip column headers
+ const { cells } = statsTable.rows[++index];
+ cells[0].className = `ice-${state}`;
+ if (nominated) {
+ cells[1].className = "ice-succeeded";
+ }
+ if (selected) {
+ cells[2].className = "ice-succeeded";
+ }
+ if (localTrickled) {
+ cells[3].className = "ice-trickled";
+ }
+ if (remoteTrickled) {
+ cells[4].className = "ice-trickled";
+ }
+ }
+
+ // if the current row's component id changes, mark the bottom of the
+ // previous row with a thin, black border to differentiate the
+ // component id grouping.
+ let previousRow;
+ for (const row of statsTable.rows) {
+ if (previousRow) {
+ if (previousRow.cells[5].innerHTML != row.cells[5].innerHTML) {
+ previousRow.className = "bottom-border";
+ }
+ }
+ previousRow = row;
+ }
+ iceDiv.append(statsTable);
+ }
+ // restart/rollback counts.
+ iceDiv.append(
+ renderIceMetric("about-webrtc-ice-restart-count-label", report.iceRestarts),
+ renderIceMetric(
+ "about-webrtc-ice-rollback-count-label",
+ report.iceRollbacks
+ )
+ );
+ return iceDiv;
+}
+
+function renderRawICEStats(rndr, report) {
+ const iceDiv = renderElements("div", { id: "ice-stats: " + report.pcid }, [
+ renderSubsectionHeading("about-webrtc-raw-candidates-heading", () =>
+ JSON.stringify(
+ [...report.rawLocalCandidates, ...report.rawRemoteCandidates],
+ null,
+ 2
+ )
+ ),
+ ]);
+ // Render raw ICECandidate section
+ {
+ const foldSection = renderFoldableSection(iceDiv, {
+ showMsg: "about-webrtc-raw-cand-section-show-msg",
+ hideMsg: "about-webrtc-raw-cand-section-hide-msg",
+ });
+
+ // render raw candidates
+ foldSection.append(renderRawICEStatsFold(rndr, report));
+ iceDiv.append(foldSection);
+ }
+ return iceDiv;
+}
+
+function renderRawICEStatsFold(rndr, report) {
+ return renderElements("div", { id: "ice-raw-stats-fold: " + report.pcid }, [
+ renderRawIceTable(
+ "about-webrtc-raw-local-candidate",
+ report.rawLocalCandidates
+ ),
+ renderRawIceTable(
+ "about-webrtc-raw-remote-candidate",
+ report.rawRemoteCandidates
+ ),
+ ]);
+}
+
+function renderIceMetric(label, value) {
+ return renderElements("div", {}, [
+ renderElement("span", { className: "info-label" }, label),
+ renderText("span", value, { className: "info-body" }),
+ ]);
+}
+
+function candidateToString({
+ type,
+ address,
+ port,
+ protocol,
+ candidateType,
+ relayProtocol,
+ proxied,
+} = {}) {
+ if (!type) {
+ return "*";
+ }
+ if (relayProtocol) {
+ candidateType = `${candidateType}-${relayProtocol}`;
+ }
+ proxied = type == "local-candidate" ? ` [${proxied}]` : "";
+ return `${address}:${port}/${protocol}(${candidateType})${proxied}`;
+}
+
+async function renderConnectionLog() {
+ const getLog = () => new Promise(r => WGI.getLogging("", r));
+ const logView = document.createElement("div");
+ const displayLogs = logLines => {
+ logView.replaceChildren();
+ logView.append(
+ ...logLines.map(line => {
+ const e = document.createElement("p");
+ e.textContent = line;
+ return e;
+ })
+ );
+ };
+ const clearLogsButton = document.createElement("button");
+
+ Object.assign(clearLogsButton, {
+ className: "no-print",
+ onclick: async () => {
+ await WGI.clearLogging();
+ displayLogs(await getLog());
+ },
+ });
+ document.l10n.setAttributes(clearLogsButton, "about-webrtc-log-clear");
+ return PrimarySection.make({
+ headingL10nId: "about-webrtc-log-heading",
+ disclosureShowL10nId: "about-webrtc-log-section-show-msg",
+ disclosureHideL10nId: "about-webrtc-log-section-hide-msg",
+ autoRefreshPref: "media.aboutwebrtc.auto_refresh.connection_log_section",
+ renderFn: async () => {
+ displayLogs(await getLog());
+ return logView;
+ },
+ updateFn: async () => {
+ displayLogs(await getLog());
+ },
+ headerElementsFn: async () => [clearLogsButton],
+ });
+}
+
+const PREFERENCES = {
+ branches: [
+ "media.aboutwebrtc",
+ "media.peerconnection",
+ "media.navigator",
+ "media.getusermedia",
+ "media.gmp-gmpopenh264.enabled",
+ ],
+ hidden: [
+ "media.aboutwebrtc.auto_refresh.peerconnection_section",
+ "media.aboutwebrtc.auto_refresh.connection_log_section",
+ "media.aboutwebrtc.auto_refresh.user_modified_config_section",
+ "media.aboutwebrtc.auto_refresh.media_ctx_section",
+ ],
+};
+
+async function renderUserPrefSection() {
+ const getConfigPaths = () => {
+ return PREFERENCES.branches
+ .flatMap(Services.prefs.getChildList)
+ .filter(Services.prefs.prefHasUserValue)
+ .filter(p => !PREFERENCES.hidden.includes(p));
+ };
+ const prefList = new ConfigurationList(getConfigPaths());
+ return PrimarySection.make({
+ headingL10nId: "about-webrtc-user-modified-configuration-heading",
+ disclosureShowL10nId: "about-webrtc-user-modified-configuration-show-msg",
+ disclosureHideL10nId: "about-webrtc-user-modified-configuration-hide-msg",
+ autoRefreshPref:
+ "media.aboutwebrtc.auto_refresh.user_modified_config_section",
+ renderFn: () => prefList.view(),
+ updateFn: () => {
+ prefList.setPrefPaths(getConfigPaths());
+ prefList.update();
+ },
+ });
+}
+
+function renderFoldableSection(parentElem, options = {}) {
+ const section = renderElement("div");
+ if (parentElem) {
+ const ctrl = renderElements("div", { className: "section-ctrl no-print" }, [
+ new FoldEffect(section, options).render(),
+ ]);
+ parentElem.append(ctrl);
+ }
+ return section;
+}
+
+function renderSimpleTable(caption, headings, data) {
+ const heads = headings.map(text => renderElement("th", {}, text));
+ const renderCell = text => renderText("td", text);
+
+ return renderElements("table", {}, [
+ caption,
+ renderElements("tr", {}, heads),
+ ...data.map(line => renderElements("tr", {}, line.map(renderCell))),
+ ]);
+}
+
+class FoldEffect {
+ constructor(
+ target,
+ {
+ showMsg = "about-webrtc-fold-default-show-msg",
+ hideMsg = "about-webrtc-fold-default-hide-msg",
+ startsCollapsed = true,
+ } = {}
+ ) {
+ Object.assign(this, { target, showMsg, hideMsg, startsCollapsed });
+ }
+
+ render() {
+ this.target.classList.add("fold-target");
+ this.trigger = renderElement("div", { className: "fold-trigger" });
+ this.trigger.classList.add("heading-medium", this.showMsg, this.hideMsg);
+ if (this.startsCollapsed) {
+ this.collapse();
+ }
+ this.trigger.onclick = () => {
+ if (this.target.classList.contains("fold-closed")) {
+ this.expand();
+ } else {
+ this.collapse();
+ }
+ };
+ return this.trigger;
+ }
+
+ expand() {
+ this.target.classList.remove("fold-closed");
+ document.l10n.setAttributes(this.trigger, this.hideMsg);
+ }
+
+ collapse() {
+ this.target.classList.add("fold-closed");
+ document.l10n.setAttributes(this.trigger, this.showMsg);
+ }
+
+ static expandAll() {
+ for (const target of document.getElementsByClassName("fold-closed")) {
+ target.classList.remove("fold-closed");
+ }
+ for (const trigger of document.getElementsByClassName("fold-trigger")) {
+ const hideMsg = trigger.classList[2];
+ document.l10n.setAttributes(trigger, hideMsg);
+ }
+ }
+
+ static collapseAll() {
+ for (const target of document.getElementsByClassName("fold-target")) {
+ target.classList.add("fold-closed");
+ }
+ for (const trigger of document.getElementsByClassName("fold-trigger")) {
+ const showMsg = trigger.classList[1];
+ document.l10n.setAttributes(trigger, showMsg);
+ }
+ }
+}
+
+class PrimarySection {
+ /** @returns {Promise<PrimarySection>} */
+ static async make({
+ headingL10nId,
+ disclosureShowL10nId,
+ disclosureHideL10nId,
+ autoRefreshPref,
+ renderFn = async () => {}, // Creates the filling for the disclosure
+ updateFn = async section => {}, // Updates the contents.
+ headerElementsFn = async () => [], // Accessory elements for the heading
+ }) {
+ const newSect = new PrimarySection();
+ Object.assign(newSect, {
+ autoRefreshPref,
+ renderFn,
+ updateFn,
+ headerElementsFn,
+ });
+
+ // Top level of the section
+ const sectionContainer = document.createElement("div");
+ // Section heading is always visible and contains the disclosure control,
+ // the section title, the autorefresh button, and any accessory elements.
+ const sectionHeading = document.createElement("div");
+ sectionHeading.className = "section-heading";
+ sectionContainer.appendChild(sectionHeading);
+ // The section body is the portion that contains the disclosure body
+ // container.
+ const sectionBody = document.createElement("div");
+ sectionBody.className = "section-body";
+ sectionContainer.appendChild(sectionBody);
+
+ const disclosure = new Disclosure({
+ showMsg: disclosureShowL10nId,
+ hideMsg: disclosureHideL10nId,
+ });
+ sectionHeading.appendChild(disclosure.control());
+
+ const heading = document.createElement("h3");
+ document.l10n.setAttributes(heading, headingL10nId);
+ sectionHeading.append(heading);
+
+ const autorefresh = document.createElement("input");
+ Object.assign(autorefresh, {
+ type: "checkbox",
+ class: "autorefresh",
+ id: autoRefreshPref,
+ checked: Services.prefs.getBoolPref(autoRefreshPref),
+ onchange: () =>
+ Services.prefs.setBoolPref(autoRefreshPref, autorefresh.checked),
+ });
+ newSect.autorefresh = autorefresh;
+ newSect.autorefreshPrefState = newSect.autorefresh.checked;
+ const autorefreshLabel = document.createElement("label");
+ autorefreshLabel.className = "autorefresh";
+ autorefreshLabel.htmlFor = autorefresh.id;
+ document.l10n.setAttributes(
+ autorefreshLabel,
+ "about-webrtc-auto-refresh-label"
+ );
+ sectionHeading.append(autorefresh, autorefreshLabel);
+
+ let rendered = await renderFn();
+ if (rendered) {
+ disclosure.view().appendChild(rendered);
+ }
+ sectionBody.append(disclosure.view());
+
+ let headerElements = (await newSect.headerElementsFn(newSect)) || [];
+ sectionHeading.append(...headerElements);
+
+ newSect.section = sectionContainer;
+ return newSect;
+ }
+ view() {
+ return this.section;
+ }
+ async update() {
+ return this.updateFn(this);
+ }
+ async autoUpdate() {
+ let prefState = Services.prefs.getBoolPref(this.autoRefreshPref);
+ if (prefState != this.autorefreshPrefState) {
+ this.autorefreshPrefState = prefState;
+ this.autorefresh.checked = prefState;
+ }
+ if (this.autorefresh.checked || this.autorefresh.indeterminate) {
+ return this.updateFn(this);
+ }
+ return null;
+ }
+}
+
+async function renderMediaCtx(rndr) {
+ const ctx = WGI.getMediaContext();
+ const prefs = [
+ "media.peerconnection.video.vp9_enabled",
+ "media.peerconnection.video.vp9_preferred",
+ "media.navigator.video.h264.level",
+ "media.navigator.video.h264.max_mbps",
+ "media.navigator.video.h264.max_mbps",
+ "media.navigator.video.max_fs",
+ "media.navigator.video.max_fr",
+ "media.navigator.video.use_tmmbr",
+ "media.navigator.video.use_remb",
+ "media.navigator.video.use_transport_cc",
+ "media.navigator.audio.use_fec",
+ "media.navigator.video.red_ulpfec_enabled",
+ ];
+
+ const confList = new ConfigurationList(prefs);
+ const hasH264Hardware = rndr.text_p(
+ `hasH264Hardware: ${ctx.hasH264Hardware}`
+ );
+ hasH264Hardware.dataset.value = ctx.hasH264Hardware;
+ const renderFn = async () =>
+ rndr.elems_div({}, [hasH264Hardware, rndr.elem_hr(), confList.view()]);
+ const updateFn = async section => {
+ const newCtx = WGI.getMediaContext();
+ if (hasH264Hardware.dataset.value != newCtx.hasH264Hardware) {
+ hasH264Hardware.dataset.value = newCtx.hasH264Hardware;
+ hasH264Hardware.textContent = `hasH264Hardware: ${newCtx.hasH264Hardware}`;
+ }
+ confList.update();
+ };
+
+ return PrimarySection.make({
+ headingL10nId: "about-webrtc-media-context-heading",
+ disclosureShowL10nId: "about-webrtc-media-context-show-msg",
+ disclosureHideL10nId: "about-webrtc-media-context-hide-msg",
+ autoRefreshPref: "media.aboutwebrtc.auto_refresh.media_ctx_section",
+ renderFn,
+ updateFn,
+ });
+}
diff --git a/toolkit/content/aboutwebrtc/configurationList.mjs b/toolkit/content/aboutwebrtc/configurationList.mjs
new file mode 100644
index 0000000000..d9c209b9df
--- /dev/null
+++ b/toolkit/content/aboutwebrtc/configurationList.mjs
@@ -0,0 +1,118 @@
+/* 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/. */
+
+import { CopyButton } from "chrome://global/content/aboutwebrtc/copyButton.mjs";
+
+function getPref(path) {
+ switch (Services.prefs.getPrefType(path)) {
+ case Services.prefs.PREF_BOOL:
+ return Services.prefs.getBoolPref(path);
+ case Services.prefs.PREF_INT:
+ return Services.prefs.getIntPref(path);
+ case Services.prefs.PREF_STRING:
+ return Services.prefs.getStringPref(path);
+ }
+ return "";
+}
+
+/*
+ * This provides a visual list of configuration settings given an array of
+ * configuration paths. To change the list one can call setPrefPaths.
+ */
+class ConfigurationList {
+ constructor(aPreferencePaths) {
+ this.list = document.createElement("list");
+ this.list.classList.add("prefList");
+ this.setPrefPaths(aPreferencePaths);
+ }
+
+ /** @return {Element} */
+ view() {
+ return this.list;
+ }
+
+ /**
+ * @return {Element[]}
+ */
+ getPrefListItems() {
+ return [...this.list.children].flatMap(e =>
+ e.dataset.prefPath !== undefined ? [e] : []
+ );
+ }
+
+ /**
+ * @return {string[]}
+ */
+ getPrefPaths() {
+ return [...this.getPrefListItems()].map(e => e.dataset.prefPath);
+ }
+
+ // setPrefPaths adds and removes list items from the list and updates
+ // existing elements
+
+ setPrefPaths(aPreferencePaths) {
+ const currentPaths = this.getPrefPaths();
+ // Take the difference of the two arrays of preferences. There are three
+ // groups of paths: those removed from the current list, those to remain
+ // in the current list, and those to be added.
+ const { kept: keptPaths, removed: removedPaths } = currentPaths.reduce(
+ (acc, p) => {
+ if (aPreferencePaths.includes(p)) {
+ acc.kept.push(p);
+ } else {
+ acc.removed.push(p);
+ }
+ return acc;
+ },
+ { removed: [], kept: [] }
+ );
+
+ const addedPaths = aPreferencePaths.filter(p => !keptPaths.includes(p));
+
+ // Remove items
+ this.getPrefListItems()
+ .filter(e => removedPaths.includes(e.dataset.prefPath))
+ .forEach(e => e.remove() /* Remove from DOM*/);
+
+ const addItemForPath = path => {
+ const item = document.createElement("li");
+ item.dataset.prefPath = path;
+
+ item.appendChild(new CopyButton(() => path).element);
+
+ const pathSpan = document.createElement("span");
+ pathSpan.textContent = path;
+ pathSpan.classList.add(["pathDisplay"]);
+ item.appendChild(pathSpan);
+
+ const valueSpan = document.createElement("span");
+ valueSpan.classList.add(["valueDisplay"]);
+ item.appendChild(valueSpan);
+
+ this.list.appendChild(item);
+ };
+
+ // Add items
+ addedPaths.forEach(addItemForPath);
+
+ // Update all pref values
+ this.updatePrefValues();
+ }
+
+ updatePrefValues() {
+ for (const e of this.getPrefListItems()) {
+ const value = getPref(e.dataset.prefPath);
+ const valueSpan = e.getElementsByClassName("valueDisplay").item(0);
+ if ("prefPath" in e.dataset) {
+ valueSpan.textContent = value;
+ }
+ }
+ }
+
+ update() {
+ this.updatePrefValues();
+ }
+}
+
+export { ConfigurationList };
diff --git a/toolkit/content/aboutwebrtc/copyButton.mjs b/toolkit/content/aboutwebrtc/copyButton.mjs
new file mode 100644
index 0000000000..f39210c709
--- /dev/null
+++ b/toolkit/content/aboutwebrtc/copyButton.mjs
@@ -0,0 +1,83 @@
+/* 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 creates a button that can be used to copy text to the clipboard.
+ * Whenever the button is pressed the getCopyContentsFn passed into the
+ * constructor is called and the resulting text is copied. It uses CSS
+ * transitions to perform a short animation.
+ */
+class CopyButton {
+ constructor(getCopyContentsFn) {
+ const button = document.createElement("span");
+ button.textContent = String.fromCodePoint(0x1f4cb);
+ button.classList.add("copy-button", "copy-button-base");
+ button.onclick = () => {
+ if (!button.classList.contains("copy-button")) {
+ return;
+ }
+
+ const handleAnimation = async () => {
+ const switchFadeDirection = () => {
+ if (button.classList.contains("copy-button-fade-out")) {
+ // We just faded out so let's fade in
+ button.classList.toggle("copy-button-fade-out");
+ button.classList.toggle("copy-button-fade-in");
+ } else {
+ // We just faded in so let's fade out
+ button.classList.toggle("copy-button-fade-out");
+ button.classList.toggle("copy-button-fade-in");
+ }
+ };
+
+ // Fade out clipboard icon
+ // Fade out the clipboard character
+ button.classList.toggle("copy-button-fade-out");
+ // Wait for CSS transition to end
+ await new Promise(r => (button.ontransitionend = r));
+
+ // Fade in checkmark icon
+ // This is the start of fade in.
+ // Switch to the checkmark character
+ button.textContent = String.fromCodePoint(0x2705);
+ // Trigger CSS fade in transition
+ switchFadeDirection();
+ // Wait for CSS transition to end
+ await new Promise(r => (button.ontransitionend = r));
+
+ // Fade out clipboard icon
+ // Trigger CSS fade out transition
+ switchFadeDirection();
+ // Wait for CSS transition to end
+ await new Promise(r => (button.ontransitionend = r));
+
+ // Fade in clipboard icon
+ // This is the start of fade in.
+ // Switch to the clipboard character
+ button.textContent = String.fromCodePoint(0x1f4cb);
+ // Trigger CSS fade in transition
+ switchFadeDirection();
+ // Wait for CSS transition to end
+ await new Promise(r => (button.ontransitionend = r));
+
+ // Remove fade
+ button.classList.toggle("copy-button-fade-in");
+ // Re-enable clicks and hidding when parent div has lost :hover
+ button.classList.add("copy-button");
+ };
+
+ // Note the fade effect is handled in the CSS, we just need to swap
+ // between the different CSS classes. This returns a promise that waits
+ // for the current fade to end, starts the next fade, then resolves.
+
+ navigator.clipboard.writeText(getCopyContentsFn());
+ // Prevent animation from disappearing when parent div losses :hover,
+ // and prevent additional clicks until the animation finishes.
+ button.classList.remove("copy-button");
+ handleAnimation(); // runs unawaited
+ };
+ this.element = button;
+ }
+}
+export { CopyButton };
diff --git a/toolkit/content/aboutwebrtc/disclosure.mjs b/toolkit/content/aboutwebrtc/disclosure.mjs
new file mode 100644
index 0000000000..6460f6523d
--- /dev/null
+++ b/toolkit/content/aboutwebrtc/disclosure.mjs
@@ -0,0 +1,95 @@
+/* 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 localization = new Localization(["toolkit/about/aboutWebrtc.ftl"], true);
+
+/*
+ * A disclosure area that has localized tooltips for expanding and collapsing
+ * the area.
+ */
+class Disclosure {
+ constructor({
+ showMsg = "about-webrtc-fold-default-show-msg",
+ hideMsg = "about-webrtc-fold-default-hide-msg",
+ startsCollapsed = true,
+ } = {}) {
+ Object.assign(this, { showMsg, hideMsg, startsCollapsed });
+ this.target = document.createElement("div");
+ this.target.classList.add("fold-target");
+ this.trigger = document.createElement("div");
+ this.trigger.className = "fold-trigger";
+ this.trigger.classList.add(
+ "heading-medium",
+ "no-print",
+ this.showMsg,
+ this.hideMsg
+ );
+ this.message = document.createElement("span");
+
+ if (this.startsCollapsed) {
+ this.collapse();
+ } else {
+ this.expand();
+ }
+ this.trigger.onclick = () => {
+ if (this.target.classList.contains("fold-closed")) {
+ this.expand();
+ } else {
+ this.collapse();
+ }
+ };
+ }
+
+ /** @return {Element} */
+ control() {
+ return this.trigger;
+ }
+
+ /** @return {Element} */
+ view() {
+ return this.target;
+ }
+
+ expand() {
+ this.target.classList.remove("fold-closed");
+ this.control().textContent = String.fromCodePoint(0x25bc);
+ this.control().setAttribute(
+ "title",
+ localization.formatValueSync(this.hideMsg)
+ );
+ document.l10n.setAttributes(this.message, this.hideMsg);
+ }
+
+ collapse() {
+ this.target.classList.add("fold-closed");
+ this.trigger.textContent = String.fromCodePoint(0x25b6);
+ this.control().setAttribute(
+ "title",
+ localization.formatValueSync(this.showMsg)
+ );
+ document.l10n.setAttributes(this.message, this.showMsg);
+ }
+
+ static expandAll() {
+ for (const target of document.getElementsByClassName("fold-closed")) {
+ target.classList.remove("fold-closed");
+ }
+ for (const trigger of document.getElementsByClassName("fold-trigger")) {
+ const hideMsg = trigger.classList[2];
+ document.l10n.setAttributes(trigger, hideMsg);
+ }
+ }
+
+ static collapseAll() {
+ for (const target of document.getElementsByClassName("fold-target")) {
+ target.classList.add("fold-closed");
+ }
+ for (const trigger of document.getElementsByClassName("fold-trigger")) {
+ const showMsg = trigger.classList[1];
+ document.l10n.setAttributes(trigger, showMsg);
+ }
+ }
+}
+
+export { Disclosure };
diff --git a/toolkit/content/aboutwebrtc/graph.mjs b/toolkit/content/aboutwebrtc/graph.mjs
new file mode 100644
index 0000000000..f2c93f0709
--- /dev/null
+++ b/toolkit/content/aboutwebrtc/graph.mjs
@@ -0,0 +1,186 @@
+/* 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/. */
+
+function compStyle(property) {
+ return getComputedStyle(window.document.body).getPropertyValue(property);
+}
+
+function toHumanReadable(num, fpDecimals) {
+ const prefixes = [..." kMGTPEYZYRQ"];
+ const inner = (curr, remainingPrefixes) => {
+ return Math.abs(curr >= 1000)
+ ? inner(curr / 1000, remainingPrefixes.slice(1, -1))
+ : [curr.toFixed(fpDecimals), remainingPrefixes[0].trimEnd()];
+ };
+ return inner(num, prefixes);
+}
+
+class GraphImpl {
+ constructor(width, height) {
+ this.width = width;
+ this.height = height;
+ }
+
+ // The returns the earliest time to graph
+ startTime = dataSet => (dataSet.earliest() || { time: 0 }).time;
+
+ // Returns the latest time to graph
+ stopTime = dataSet => (dataSet.latest() || { time: 0 }).time;
+
+ // The default background color
+ bgColor = () => compStyle("--in-content-page-background");
+ // The color to use for value graph lines
+ valueLineColor = () => "grey";
+ // The color to use for average graph lines and text
+ averageLineColor = () => "green";
+ // The color to use for the max value
+ maxColor = ({ time, value }) => "grey";
+ // The color to use for the min value
+ minColor = ({ time, value }) => "grey";
+ // Title color
+ titleColor = title => compStyle("--in-content-page-color");
+ // The color to use for a data point at a time.
+ // The destination x coordinate and graph width are also provided.
+ datumColor = ({ time, value, x, width }) => "red";
+
+ // Returns an SVG element that needs to be inserted into the DOM for display
+ drawSparseValues = (dataSet, title, config) => {
+ const { width, height } = this;
+ // Clear the canvas
+ const bgColor = this.bgColor();
+ const mkSvgElem = type =>
+ document.createElementNS("http://www.w3.org/2000/svg", type);
+ const svgText = (x, y, text, color, subclass) => {
+ const txt = mkSvgElem("text");
+ txt.setAttribute("x", x);
+ txt.setAttribute("y", y);
+ txt.setAttribute("stroke", bgColor);
+ txt.setAttribute("fill", color);
+ txt.setAttribute("paint-order", "stroke");
+ txt.textContent = text;
+ txt.classList.add(["graph-text", ...[subclass]].join("-"));
+ return txt;
+ };
+ const svg = mkSvgElem("svg");
+ svg.setAttribute("viewBox", `0 0 ${width} ${height}`);
+ svg.setAttribute("version", "1.1");
+ svg.setAttribute("width", width);
+ svg.setAttribute("height", height);
+ svg.classList.add("svg-graph");
+ const rect = mkSvgElem("rect");
+ rect.setAttribute("fill", bgColor);
+ rect.setAttribute("width", width);
+ rect.setAttribute("height", height);
+ svg.appendChild(rect);
+
+ if (config.toRate) {
+ dataSet = dataSet.toRateDataSet();
+ }
+
+ const startTime = this.startTime(dataSet);
+ const stopTime = this.stopTime(dataSet);
+ let timeFilter = ({ time }) => time >= startTime && time <= stopTime;
+
+ let avgDataSet = { dataPoints: [] };
+ if (!config.noAvg) {
+ avgDataSet = dataSet.toRollingAverageDataSet(config.avgPoints);
+ }
+
+ let filtered = dataSet.filter(timeFilter);
+ if (filtered.dataPoints == []) {
+ return svg;
+ }
+
+ let range = filtered.dataRange();
+ if (range === undefined) {
+ return svg;
+ }
+ let { min: rangeMin, max: rangeMax } = range;
+
+ // Adjust the _display_ range to lift flat lines towards the center
+ if (rangeMin == rangeMax) {
+ rangeMin = rangeMin - 1;
+ rangeMax = rangeMax + 1;
+ }
+ const yFactor = (height - 26) / (1 + rangeMax - rangeMin);
+ const yPos = ({ value }) =>
+ this.height - 1 - (value - rangeMin) * yFactor - 13;
+ const xFactor = width / (1 + stopTime - startTime);
+ const xPos = ({ time }) => (time - startTime) * xFactor;
+
+ const toPathStr = dataPoints =>
+ [...dataPoints]
+ .map(
+ (datum, index) => `${index ? "L" : "M"}${xPos(datum)} ${yPos(datum)}`
+ )
+ .join(" ");
+ const valuePath = mkSvgElem("path");
+ valuePath.setAttribute("d", toPathStr(filtered.dataPoints));
+ valuePath.setAttribute("stroke", this.valueLineColor());
+ valuePath.setAttribute("fill", "none");
+ svg.appendChild(valuePath);
+
+ const avgPath = mkSvgElem("path");
+ avgPath.setAttribute("d", toPathStr(avgDataSet.dataPoints));
+ avgPath.setAttribute("stroke", this.averageLineColor());
+ avgPath.setAttribute("fill", "none");
+ svg.appendChild(avgPath);
+ const fixed = num => num.toFixed(config.fixedPointDecimals);
+ const formatValue = value =>
+ config.toHuman
+ ? toHumanReadable(value, config.fixedPointDecimals).join("")
+ : fixed(value);
+
+ // Draw rolling average text
+ avgDataSet.dataPoints.slice(-1).forEach(({ value }) => {
+ svg.appendChild(
+ svgText(
+ 5,
+ height - 4,
+ `AVG: ${formatValue(value)}`,
+ this.averageLineColor(),
+ "avg"
+ )
+ );
+ });
+
+ // Draw title text
+ if (title) {
+ svg.appendChild(
+ svgText(
+ 5,
+ 12,
+ `${title}${config.toRate ? "/s" : ""}`,
+ this.titleColor(this),
+ "title"
+ )
+ );
+ }
+
+ // Draw max value text
+ const maxText = svgText(
+ width - 5,
+ 12,
+ `Max: ${formatValue(range.max)}`,
+ this.maxColor(range.max),
+ "max"
+ );
+ maxText.setAttribute("text-anchor", "end");
+ svg.appendChild(maxText);
+
+ // Draw min value text
+ const minText = svgText(
+ width - 5,
+ height - 4,
+ `Min: ${formatValue(range.min)}`,
+ this.minColor(range.min),
+ "min"
+ );
+ minText.setAttribute("text-anchor", "end");
+ svg.appendChild(minText);
+ return svg;
+ };
+}
+
+export { GraphImpl };
diff --git a/toolkit/content/aboutwebrtc/graphdb.mjs b/toolkit/content/aboutwebrtc/graphdb.mjs
new file mode 100644
index 0000000000..2d20f334a5
--- /dev/null
+++ b/toolkit/content/aboutwebrtc/graphdb.mjs
@@ -0,0 +1,211 @@
+/* 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 CHECK_RTC_STATS_COLLECTION = [
+ "inboundRtpStreamStats",
+ "outboundRtpStreamStats",
+ "remoteInboundRtpStreamStats",
+ "remoteOutboundRtpStreamStats",
+];
+
+const DEFAULT_PROPS = {
+ avgPoints: 10,
+ histSecs: 15,
+ toRate: false,
+ noAvg: false,
+ fixedPointDecimals: 2,
+ toHuman: false,
+};
+
+const REMOTE_RTP_PROPS = "avgPoints=2;histSecs=90";
+const GRAPH_KEYS = [
+ "inbound-rtp.framesPerSecond;noAvg",
+ "inbound-rtp.packetsReceived;toRate",
+ "inbound-rtp.packetsLost;toRate",
+ "inbound-rtp.jitter;fixedPointDecimals=4",
+ `remote-inbound-rtp.roundTripTime;${REMOTE_RTP_PROPS}`,
+ `remote-inbound-rtp.packetsReceived;toRate;${REMOTE_RTP_PROPS}`,
+ "outbound-rtp.packetsSent;toRate",
+ "outbound-rtp.framesSent;toRate",
+ "outbound-rtp.frameHeight;noAvg",
+ "outbound-rtp.frameWidth;noAvg",
+ "outbound-rtp.nackCount",
+ "outbound-rtp.pliCount",
+ "outbound-rtp.firCount",
+ `remote-outbound-rtp.bytesSent;toHuman;toRate;${REMOTE_RTP_PROPS}`,
+ `remote-outbound-rtp.packetsSent;toRate;${REMOTE_RTP_PROPS}`,
+]
+ .map(k => k.split(".", 2))
+ .reduce((mapOfArr, [k, rest]) => {
+ mapOfArr[k] ??= [];
+ const [subKey, ...conf] = rest.split(";");
+ let config = conf.reduce((c, v) => {
+ let [configName, ...configVal] = v.split("=", 2);
+ c[configName] = !configVal.length ? true : configVal[0];
+ return c;
+ }, {});
+ mapOfArr[k].push({ subKey, config });
+ return mapOfArr;
+ }, {});
+
+// Sliding window iterator of size n (where: n >= 1) over the array.
+// Only returns full windows.
+// Returns [] if n > array.length.
+// eachN(['a','b','c','d','e'], 3) will yield the following values:
+// ['a','b','c'], ['b','c','d'], and ['c','d','e']
+const eachN = (array, n) => {
+ return {
+ // Index state
+ index: 0,
+ // Iteration function
+ next() {
+ let slice = array.slice(this.index, this.index + n);
+ this.index++;
+ // Done is true _AFTER_ the last value has returned.
+ // When done is true, value is ignored.
+ return { value: slice, done: slice.length < n };
+ },
+ [Symbol.iterator]() {
+ return this;
+ },
+ };
+};
+
+const msToSec = ms => 1000 * ms;
+
+//
+// A subset of the graph data
+//
+class GraphDataSet {
+ constructor(dataPoints) {
+ this.dataPoints = dataPoints;
+ }
+
+ // The latest
+ latest = () => (this.dataPoints ? this.dataPoints.slice(-1)[0] : undefined);
+
+ earliest = () => (this.dataPoints ? this.dataPoints[0] : undefined);
+
+ // The returns the earliest time to graph
+ startTime = () => (this.earliest() || { time: 0 }).time;
+
+ // Returns the latest time to graph
+ stopTime = () => (this.latest() || { time: 0 }).time;
+
+ // Elapsed time within the display window
+ elapsed = () =>
+ this.dataPoints ? this.latest().time - this.earliest().time : 0;
+
+ // Return a new data set that has been filtered
+ filter = fn => new GraphDataSet([...this.dataPoints].filter(fn));
+
+ // The range of values in the set or or undefined if the set is empty
+ dataRange = () =>
+ this.dataPoints.reduce(
+ ({ min, max }, { value }) => ({
+ min: Math.min(min, value),
+ max: Math.max(max, value),
+ }),
+ this.dataPoints.length
+ ? { min: this.dataPoints[0].value, max: this.dataPoints[0].value }
+ : undefined
+ );
+
+ // Get the rates between points. By definition the rates will have
+ // one fewer data points.
+ toRateDataSet = () =>
+ new GraphDataSet(
+ [...eachN(this.dataPoints, 2)].map(([a, b]) => ({
+ // Time mid point
+ time: (b.time + a.time) / 2,
+ value: msToSec(b.value - a.value) / (b.time - a.time),
+ }))
+ );
+
+ average = samples =>
+ samples.reduce(
+ ({ time, value }, { time: t, value: v }) => ({
+ time: time + t / samples.length,
+ value: value + v / samples.length,
+ }),
+ { time: 0, value: 0 }
+ );
+
+ toRollingAverageDataSet = sampleSize =>
+ new GraphDataSet([...eachN(this.dataPoints, sampleSize)].map(this.average));
+}
+
+class GraphData {
+ constructor(id, key, subKey, config) {
+ this.id = id;
+ this.key = key;
+ this.subKey = subKey;
+ this.data = [];
+ this.config = Object.assign({}, DEFAULT_PROPS, config);
+ }
+
+ setValueForTime(dataPoint) {
+ this.data = this.data.filter(({ time: t }) => t != dataPoint.time);
+ this.data.push(dataPoint);
+ }
+
+ getValuesSince = time => this.data.filter(dp => dp.time > time);
+
+ getDataSetSince = time =>
+ new GraphDataSet(this.data.filter(dp => dp.time > time));
+
+ getConfig = () => this.config;
+
+ // Cull old data, but keep twice the window size for average computation
+ cullData = timeNow =>
+ (this.data = this.data.filter(
+ ({ time }) => time + msToSec(this.config.histSecs * 2) > timeNow
+ ));
+}
+
+class GraphDb {
+ constructor(report) {
+ this.graphDatas = new Map();
+ this.insertReportData(report);
+ }
+
+ mkStoreKey = ({ id, key, subKey }) => `${key}.${id}.${subKey}`;
+
+ insertDataPoint(id, key, subKey, config, time, value) {
+ let storeKey = this.mkStoreKey({ id, key, subKey });
+ let data =
+ this.graphDatas.get(storeKey) || new GraphData(id, key, subKey, config);
+ data.setValueForTime({ time, value });
+ data.cullData(time);
+ this.graphDatas.set(storeKey, data);
+ }
+
+ insertReportData(report) {
+ if (report.timestamp == this.lastReportTimestamp) {
+ return;
+ }
+ this.lastReportTimestamp = report.timestamp;
+ CHECK_RTC_STATS_COLLECTION.forEach(listName => {
+ (report[listName] || []).forEach(stats => {
+ (GRAPH_KEYS[stats.type] || []).forEach(({ subKey, config }) => {
+ if (stats[subKey] !== undefined) {
+ this.insertDataPoint(
+ stats.id,
+ stats.type,
+ subKey,
+ config,
+ stats.timestamp,
+ stats[subKey]
+ );
+ }
+ });
+ });
+ });
+ }
+
+ getGraphDataById = id =>
+ [...this.graphDatas.values()].filter(gd => gd.id == id);
+}
+
+export { GraphDb };