diff options
Diffstat (limited to 'toolkit/content/aboutwebrtc')
-rw-r--r-- | toolkit/content/aboutwebrtc/aboutWebrtc.css | 173 | ||||
-rw-r--r-- | toolkit/content/aboutwebrtc/aboutWebrtc.html | 24 | ||||
-rw-r--r-- | toolkit/content/aboutwebrtc/aboutWebrtc.js | 1248 |
3 files changed, 1445 insertions, 0 deletions
diff --git a/toolkit/content/aboutwebrtc/aboutWebrtc.css b/toolkit/content/aboutwebrtc/aboutWebrtc.css new file mode 100644 index 0000000000..a1270b41a6 --- /dev/null +++ b/toolkit/content/aboutwebrtc/aboutWebrtc.css @@ -0,0 +1,173 @@ +/* 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 { + 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); +} + +.section-heading > * { + display: inline-block; +} + +.section-heading > button { + margin-inline: 1em; +} + +.peer-connection > h3 { + background-color: var(--in-content-box-info-background); + padding: 4px; +} + +.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; + } +} + +.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; +} diff --git a/toolkit/content/aboutwebrtc/aboutWebrtc.html b/toolkit/content/aboutwebrtc/aboutWebrtc.html new file mode 100644 index 0000000000..14ffab8c5f --- /dev/null +++ b/toolkit/content/aboutwebrtc/aboutWebrtc.html @@ -0,0 +1,24 @@ +<!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.js" + defer="defer"></script> + <link rel="localization" href="toolkit/about/aboutWebrtc.ftl"/> +</head> +<body id="body"> + <div id="controls" class="no-print"></div> + <div id="content"></div> +</body> +</html> diff --git a/toolkit/content/aboutwebrtc/aboutWebrtc.js b/toolkit/content/aboutwebrtc/aboutWebrtc.js new file mode 100644 index 0000000000..707cf537d5 --- /dev/null +++ b/toolkit/content/aboutwebrtc/aboutWebrtc.js @@ -0,0 +1,1248 @@ +/* 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/. */ +"use strict"; + +const { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); + +ChromeUtils.defineESModuleGetters(this, { + FileUtils: "resource://gre/modules/FileUtils.sys.mjs", +}); +XPCOMUtils.defineLazyServiceGetter( + this, + "FilePicker", + "@mozilla.org/filepicker;1", + "nsIFilePicker" +); + +const WGI = WebrtcGlobalInformation; + +const LOGFILE_NAME_DEFAULT = "aboutWebrtc.html"; +const WEBRTC_TRACE_ALL = 65535; + +async function getStats() { + const { reports } = await new Promise(r => WGI.getAllStats(r)); + return [...reports].sort((a, b) => b.timestamp - a.timestamp); +} + +const getLog = () => new Promise(r => WGI.getLogging("", r)); + +const renderElement = (name, options, l10n_id, l10n_args) => { + let elem = Object.assign(document.createElement(name), options); + if (l10n_id) { + document.l10n.setAttributes(elem, l10n_id, l10n_args); + } + return elem; +}; + +const renderText = (name, textContent, options) => + renderElement(name, Object.assign({ textContent }, options)); + +const renderElements = (name, options, list) => { + const element = renderElement(name, options); + element.append(...list); + return element; +}; + +// 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" }, + ]); + 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 = FileUtils.openAtomicFileOutputStream( + FilePicker.file, + FileUtils.MODE_WRONLY | FileUtils.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 { + FileUtils.closeAtomicFileOutputStream(fout); + for (const node of noPrintList) { + node.style.removeProperty("display"); + } + } + this.message = "about-webrtc-save-page-msg"; + this.messageArgs = { path: FilePicker.file.path }; + this.update(); + } +} + +class DebugMode extends Control { + constructor() { + super(); + this.messageHeader = "about-webrtc-debug-mode-msg-label"; + + if (WGI.debugLevel > 0) { + this.setState(true); + } else { + this.label = "about-webrtc-debug-mode-off-state-label"; + } + } + + setState(state) { + this.label = state + ? "about-webrtc-debug-mode-on-state-label" + : "about-webrtc-debug-mode-off-state-label"; + try { + const file = Services.prefs.getCharPref("media.webrtc.debug.log_file"); + this.message = state + ? "about-webrtc-debug-mode-on-state-msg" + : "about-webrtc-debug-mode-off-state-msg"; + this.messageArgs = { path: file }; + } catch (e) { + this.message = null; + } + return state; + } + + onClick() { + this.setState((WGI.debugLevel = WGI.debugLevel ? 0 : WEBRTC_TRACE_ALL)); + this.update(); + } +} + +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-off-state-msg"; + this.messageArgs = { path: file }; + } else { + this.message = "about-webrtc-aec-logging-on-state-msg"; + } + } catch (e) { + this.message = null; + } + } + + onClick() { + 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 gBrowser = + window.ownerGlobal.browsingContext.topChromeWindow.gBrowser; + for (const tab of gBrowser.visibleTabs) { + if (tab.linkedBrowser && tab.linkedBrowser.browserId == this.browserId) { + gBrowser.selectedTab = tab; + return; + } + } + this.ctrl.disabled = true; + } +} + +(async () => { + // Setup. Retrieve reports & log while page loads. + const haveReports = getStats(); + const haveLog = getLog(); + 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 DebugMode().render()); + add(new AecLogging().render()); + // Add the autorefresh checkbox and its label + const autorefresh = document.createElement("input"); + Object.assign(autorefresh, { + type: "checkbox", + id: "autorefresh", + checked: true, + }); + const autorefreshLabel = document.createElement("label"); + document.l10n.setAttributes( + autorefreshLabel, + "about-webrtc-auto-refresh-label" + ); + + const ctrls = document.querySelector("#controls"); + ctrls.append(renderElements("div", { className: "controls" }, [ctrl, msg])); + ctrls.appendChild(autorefresh); + ctrls.appendChild(autorefreshLabel); + } + + // Render pcs and log + let reports = await haveReports; + let log = await haveLog; + + reports.sort((a, b) => a.browserId - b.browserId); + + let peerConnections = renderElement("div"); + let connectionLog = renderElement("div"); + let userPrefs = renderElement("div"); + + const content = document.querySelector("#content"); + content.append(peerConnections, connectionLog, userPrefs); + + // This does not handle the auto-refresh, only the manual refreshes needed + // for certain user actions, and the initial population of the data + function refresh() { + const pcDiv = renderElements("div", { className: "stats" }, [ + renderElements("span", { className: "section-heading" }, [ + renderElement("h3", {}, "about-webrtc-stats-heading"), + renderElement( + "button", + { + className: "no-print", + onclick: async () => { + WGI.clearAllStats(); + reports = await getStats(); + refresh(); + }, + }, + "about-webrtc-stats-clear" + ), + ]), + ...reports.map(renderPeerConnection), + ]); + const logDiv = renderElements("div", { className: "log" }, [ + renderElements("span", { className: "section-heading" }, [ + renderElement("h3", {}, "about-webrtc-log-heading"), + renderElement( + "button", + { + className: "no-print", + onclick: async () => { + WGI.clearLogging(); + log = await getLog(); + refresh(); + }, + }, + "about-webrtc-log-clear" + ), + ]), + ]); + if (log.length) { + const div = renderFoldableSection(logDiv, { + showMsg: "about-webrtc-log-show-msg", + hideMsg: "about-webrtc-log-hide-msg", + }); + div.append(...log.map(line => renderText("p", line))); + logDiv.append(div); + } + // Replace previous info + peerConnections.replaceWith(pcDiv); + connectionLog.replaceWith(logDiv); + userPrefs.replaceWith((userPrefs = renderUserPrefs())); + + peerConnections = pcDiv; + connectionLog = logDiv; + } + refresh(); + + async function translate(element) { + const frag = document.createDocumentFragment(); + frag.append(element); + await document.l10n.translateFragment(frag); + return frag; + } + + window.setInterval( + async history => { + // Only refresh if the autorefresh checkbox is checked + if (!document.getElementById("autorefresh").checked) { + return; + } + const reports = await getStats(); + + const translateSection = async (report, id, renderFunc) => { + const element = document.getElementById(`${id}: ${report.pcid}`); + const result = + element && (await translate(renderFunc(report, history))); + return { element, translated: result }; + }; + + const sections = ( + await Promise.all( + reports.flatMap(report => [ + translateSection(report, "ice-stats", renderICEStats), + translateSection(report, "rtp-stats", renderRTPStats), + 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(); + }, + 500, + {} + ); +})(); + +function renderPeerConnection(report) { + const { pcid, browserId, closed, timestamp, configuration } = report; + + const pcDiv = renderElement("div", { className: "peer-connection" }); + { + const id = pcid.match(/id=(\S+)/)[1]; + const url = pcid.match(/url=([^)]+)/)[1]; + const now = new Date(timestamp); + + pcDiv.append( + closed + ? renderElement("h3", {}, "about-webrtc-connection-closed", { + "browser-id": browserId, + id, + url, + now, + }) + : renderElement("h3", {}, "about-webrtc-connection-open", { + "browser-id": browserId, + id, + url, + now, + }) + ); + pcDiv.append(new ShowTab(browserId).render()[0]); + } + { + const section = renderFoldableSection(pcDiv); + section.append( + renderElements("div", {}, [ + renderElement( + "span", + { + className: "info-label", + }, + "about-webrtc-peerconnection-id-label" + ), + renderText("span", pcid, { className: "info-body" }), + ]), + renderConfiguration(configuration), + renderRTPStats(report), + renderICEStats(report), + renderSDPStats(report), + renderBandwidthStats(report), + renderFrameRateStats(report) + ); + pcDiv.append(section); + } + return pcDiv; +} + +function renderSDPStats({ offerer, localSdp, remoteSdp, sdpHistory }) { + const trimNewlines = sdp => sdp.replaceAll("\r\n", "\n"); + + const statsDiv = renderElements("div", {}, [ + renderElement("h4", {}, "about-webrtc-sdp-heading"), + renderElement( + "h5", + {}, + offerer + ? "about-webrtc-local-sdp-heading-offer" + : "about-webrtc-local-sdp-heading-answer" + ), + renderText("pre", trimNewlines(localSdp)), + renderElement( + "h5", + {}, + offerer + ? "about-webrtc-remote-sdp-heading-answer" + : "about-webrtc-remote-sdp-heading-offer" + ), + renderText("pre", trimNewlines(remoteSdp)), + renderElement("h4", {}, "about-webrtc-sdp-history-heading"), + ]); + + // All SDP in sequential order. Add onclick handler to scroll the associated + // SDP into view below. + for (const { isLocal, timestamp } of sdpHistory) { + const histDiv = renderElement("div", {}); + const text = renderElement( + "h5", + { className: "sdp-history-link" }, + isLocal + ? "about-webrtc-sdp-set-at-timestamp-local" + : "about-webrtc-sdp-set-at-timestamp-remote", + { timestamp } + ); + text.onclick = () => { + const elem = document.getElementById("sdp-history: " + timestamp); + if (elem) { + elem.scrollIntoView(); + } + }; + histDiv.append(text); + statsDiv.append(histDiv); + } + + // Render the SDP into separate columns for local and remote. + const section = renderElement("div", { className: "sdp-history" }); + const localDiv = renderElements("div", {}, [ + renderElement("h4", {}, "about-webrtc-local-sdp-heading"), + ]); + const remoteDiv = renderElements("div", {}, [ + renderElement("h4", {}, "about-webrtc-remote-sdp-heading"), + ]); + + let first = NaN; + for (const { isLocal, timestamp, sdp, errors } of sdpHistory) { + if (isNaN(first)) { + first = timestamp; + } + const histDiv = isLocal ? localDiv : remoteDiv; + histDiv.append( + renderElement( + "h5", + { id: "sdp-history: " + timestamp }, + "about-webrtc-sdp-set-timestamp", + { timestamp, "relative-timestamp": timestamp - first } + ) + ); + if (errors.length) { + histDiv.append( + renderElement("h5", {}, "about-webrtc-sdp-parsing-errors-heading") + ); + } + for (const { lineNumber, error } of errors) { + histDiv.append(renderElement("br"), `${lineNumber}: ${error}`); + } + histDiv.append(renderText("pre", trimNewlines(sdp))); + } + section.append(localDiv, remoteDiv); + statsDiv.append(section); + return statsDiv; +} + +function renderBandwidthStats(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(report) { + const statsDiv = renderElement("div", { id: "frame-stats: " + report.pcid }); + report.videoFrameHistories.forEach(history => { + const stats = history.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": history.trackIdentifier, + }), + table + ); + }); + + return statsDiv; +} + +function renderRTPStats(report, history) { + 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); + } + + // Render stats set + return renderElements("div", { id: "rtp-stats: " + report.pcid }, [ + renderElement("h4", {}, "about-webrtc-rtp-stats-heading"), + ...rtpStats.map(stat => { + const { ssrc, remoteId, remoteRtpStats } = stat; + const div = renderElements("div", {}, [ + renderText("h5", `SSRC ${ssrc}`), + renderCodecStats(stat), + renderTransportStats(stat, true, history), + ]); + if (remoteId && remoteRtpStats) { + div.append(renderTransportStats(remoteRtpStats, 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, + history +) { + if (history) { + if (history[id] === undefined) { + history[id] = {}; + } + } + + const estimateKBps = (timestamp, lastTimestamp, bytes, lastBytes) => { + if (!timestamp || !lastTimestamp || !bytes || !lastBytes) { + return "0.0"; + } + const elapsedTime = timestamp - 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 && history) { + s += ` , ${estimateKBps( + timestamp, + history[id].lastTimestamp, + bytesReceived, + history[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) { + 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 && history) { + s += `, ${estimateKBps( + timestamp, + history[id].lastTimestamp, + bytesSent, + history[id].lastBytesSent + )} KBps`; + } + s += ")"; + elements.push(renderText("span", s)); + } + } + + // Update history + if (history) { + history[id].lastBytesReceived = bytesReceived; + history[id].lastBytesSent = bytesSent; + history[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(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), + ]; + + return renderElements("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"), + ]) + )), + ]); +} + +function renderICEStats(report) { + const iceDiv = renderElements("div", { id: "ice-stats: " + report.pcid }, [ + renderElement("h4", {}, "about-webrtc-ice-stats-heading"), + ]); + + // 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 + ) + ); + + // Render raw ICECandidate section + { + const section = renderElements("div", {}, [ + renderElement("h4", {}, "about-webrtc-raw-candidates-heading"), + ]); + const foldSection = renderFoldableSection(section, { + showMsg: "about-webrtc-raw-cand-show-msg", + hideMsg: "about-webrtc-raw-cand-hide-msg", + }); + + // render raw candidates + foldSection.append( + renderElements("div", {}, [ + renderRawIceTable( + "about-webrtc-raw-local-candidate", + report.rawLocalCandidates + ), + renderRawIceTable( + "about-webrtc-raw-remote-candidate", + report.rawRemoteCandidates + ), + ]) + ); + section.append(foldSection); + iceDiv.append(section); + } + return iceDiv; +} + +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}`; +} + +function renderUserPrefs() { + const getPref = key => { + switch (Services.prefs.getPrefType(key)) { + case Services.prefs.PREF_BOOL: + return Services.prefs.getBoolPref(key); + case Services.prefs.PREF_INT: + return Services.prefs.getIntPref(key); + case Services.prefs.PREF_STRING: + return Services.prefs.getStringPref(key); + } + return ""; + }; + const prefs = [ + "media.peerconnection", + "media.navigator", + "media.getusermedia", + "media.gmp-gmpopenh264.enabled", + ]; + const renderPref = p => renderText("p", `${p}: ${getPref(p)}`); + const display = prefs + .flatMap(Services.prefs.getChildList) + .filter(Services.prefs.prefHasUserValue) + .map(renderPref); + return renderElements( + "div", + { + id: "prefs", + className: "prefs", + style: display.length ? "" : "visibility:hidden", + }, + [ + renderElements("span", { className: "section-heading" }, [ + renderElement( + "h3", + {}, + "about-webrtc-custom-webrtc-configuration-heading" + ), + ]), + ...display, + ] + ); +} + +function renderFoldableSection(parent, options = {}) { + const section = renderElement("div"); + if (parent) { + const ctrl = renderElements("div", { className: "section-ctrl no-print" }, [ + new FoldEffect(section, options).render(), + ]); + parent.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-show-msg", + hideMsg = "about-webrtc-fold-hide-msg", + } = {} + ) { + Object.assign(this, { target, showMsg, hideMsg }); + } + + render() { + this.target.classList.add("fold-target"); + this.trigger = renderElement("div", { className: "fold-trigger" }); + this.trigger.classList.add(this.showMsg, this.hideMsg); + 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); + } + } +} |