diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-19 00:47:55 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-19 00:47:55 +0000 |
commit | 26a029d407be480d791972afb5975cf62c9360a6 (patch) | |
tree | f435a8308119effd964b339f76abb83a57c29483 /toolkit/content/aboutwebrtc | |
parent | Initial commit. (diff) | |
download | firefox-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.css | 268 | ||||
-rw-r--r-- | toolkit/content/aboutwebrtc/aboutWebrtc.html | 35 | ||||
-rw-r--r-- | toolkit/content/aboutwebrtc/aboutWebrtc.mjs | 1957 | ||||
-rw-r--r-- | toolkit/content/aboutwebrtc/configurationList.mjs | 118 | ||||
-rw-r--r-- | toolkit/content/aboutwebrtc/copyButton.mjs | 83 | ||||
-rw-r--r-- | toolkit/content/aboutwebrtc/disclosure.mjs | 95 | ||||
-rw-r--r-- | toolkit/content/aboutwebrtc/graph.mjs | 186 | ||||
-rw-r--r-- | toolkit/content/aboutwebrtc/graphdb.mjs | 211 |
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 }; |