From 8a9ab8c13fde2663f087b1beba00e8144a77c643 Mon Sep 17 00:00:00 2001 From: adel-signal Date: Wed, 22 May 2024 17:28:01 -0700 Subject: [PATCH] Add calling tools to visualize ringrtc stats Co-authored-by: ayumi-signal --- .gitignore | 4 + ACKNOWLEDGMENTS.md | 30 + _locales/en/messages.json | 12 + app/main.ts | 62 ++ app/menu.ts | 5 + calling_tools.html | 75 +++ package.json | 1 + scripts/esbuild.js | 8 + scripts/generate-acknowledgments.js | 37 ++ stylesheets/webrtc_internals.css | 155 +++++ ts/services/calling.ts | 57 ++ ts/test-node/app/menu_test.ts | 3 + ts/types/menu.ts | 1 + ts/util/lint/license_comments.ts | 16 + ts/windows/callingtools/assert.js | 19 + ts/windows/callingtools/candidate_grid.js | 219 +++++++ ts/windows/callingtools/data_series.js | 133 ++++ ts/windows/callingtools/dump_creator.js | 170 +++++ .../peer_connection_update_table.js | 263 ++++++++ ts/windows/callingtools/preload.ts | 25 + ts/windows/callingtools/stats_graph_helper.js | 307 +++++++++ ts/windows/callingtools/stats_helper.js | 57 ++ .../callingtools/stats_rates_calculator.js | 609 ++++++++++++++++++ ts/windows/callingtools/stats_table.js | 219 +++++++ ts/windows/callingtools/tab_view.js | 115 ++++ .../callingtools/timeline_graph_view.js | 548 ++++++++++++++++ ts/windows/callingtools/user_media_table.js | 178 +++++ ts/windows/callingtools/util.js | 88 +++ ts/windows/callingtools/webrtc_internals.js | 508 +++++++++++++++ ts/windows/main/start.ts | 2 + 30 files changed, 3926 insertions(+) create mode 100644 calling_tools.html create mode 100644 stylesheets/webrtc_internals.css create mode 100644 ts/windows/callingtools/assert.js create mode 100644 ts/windows/callingtools/candidate_grid.js create mode 100644 ts/windows/callingtools/data_series.js create mode 100644 ts/windows/callingtools/dump_creator.js create mode 100644 ts/windows/callingtools/peer_connection_update_table.js create mode 100644 ts/windows/callingtools/preload.ts create mode 100644 ts/windows/callingtools/stats_graph_helper.js create mode 100644 ts/windows/callingtools/stats_helper.js create mode 100644 ts/windows/callingtools/stats_rates_calculator.js create mode 100644 ts/windows/callingtools/stats_table.js create mode 100644 ts/windows/callingtools/tab_view.js create mode 100644 ts/windows/callingtools/timeline_graph_view.js create mode 100644 ts/windows/callingtools/user_media_table.js create mode 100644 ts/windows/callingtools/util.js create mode 100644 ts/windows/callingtools/webrtc_internals.js diff --git a/.gitignore b/.gitignore index 2b241e42d2f4..b114e03513a1 100644 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,7 @@ js/components.js js/util_worker.js libtextsecure/components.js stylesheets/*.css +!stylesheets/webrtc_internals.css /storybook-static/ preload.bundle.* bundles/ @@ -37,6 +38,9 @@ build/ICUMessageParams.d.ts app/*.js ts/**/*.js ts/protobuf/*.d.ts +# allow js from callingtools +!ts/windows/callingtools/**/*.js +ts/windows/callingtools/preload.js # CSS Modules **/*.scss.d.ts diff --git a/ACKNOWLEDGMENTS.md b/ACKNOWLEDGMENTS.md index c0486c869813..832bc213f17b 100644 --- a/ACKNOWLEDGMENTS.md +++ b/ACKNOWLEDGMENTS.md @@ -3668,6 +3668,36 @@ Signal Desktop makes use of the following open source projects. OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +## Chromium WebRTC Internals Dashboard + + Copyright 2015 The Chromium Authors + + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions are + met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following disclaimer + in the documentation and/or other materials provided with the + distribution. + * Neither the name of Google LLC nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + + THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + ## Kyber Patent License diff --git a/_locales/en/messages.json b/_locales/en/messages.json index f5464816cde3..22a5a2150451 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -247,6 +247,10 @@ "messageformat": "Toggle Developer Tools", "description": "View menu command to show or hide the developer tools" }, + "icu:viewMenuOpenCallingDevTools": { + "messageformat": "Open Calling Developer Tools", + "description": "View menu command to open calling developer tools window" + }, "icu:menuSetupAsNewDevice": { "messageformat": "Set Up as New Device", "description": "When the application is not yet set up, menu option to start up the set up as fresh device" @@ -871,6 +875,14 @@ "messageformat": "Sharing screen", "description": "Title for screen sharing window" }, + "icu:callingDeveloperTools": { + "messageformat": "Calling Developer Tools", + "description": "Title for calling developer tools window" + }, + "icu:callingDeveloperToolsDescription": { + "messageformat": "This window is used during development to display diagnostics from ongoing calls.", + "description": "Description displayed for calling developer tools window" + }, "icu:speech": { "messageformat": "Speech", "description": "Item under the Edit menu, with 'start/stop speaking' items below it" diff --git a/app/main.ts b/app/main.ts index ff7632e1bbc9..6957dfef75a7 100644 --- a/app/main.ts +++ b/app/main.ts @@ -1237,6 +1237,67 @@ async function showScreenShareWindow(sourceName: string) { ); } +let callingDevToolsWindow: BrowserWindow | undefined; +async function showCallingDevToolsWindow() { + if (callingDevToolsWindow) { + callingDevToolsWindow.show(); + return; + } + + const options = { + height: 1200, + width: 1000, + alwaysOnTop: false, + autoHideMenuBar: true, + backgroundColor: '#ffffff', + darkTheme: false, + frame: true, + fullscreenable: true, + maximizable: true, + minimizable: true, + resizable: true, + show: false, + title: getResolvedMessagesLocale().i18n('icu:callingDeveloperTools'), + titleBarStyle: nonMainTitleBarStyle, + webPreferences: { + ...defaultWebPrefs, + nodeIntegration: false, + nodeIntegrationInWorker: false, + sandbox: true, + contextIsolation: true, + nativeWindowOpen: true, + preload: join(__dirname, '../bundles/callingtools/preload.js'), + }, + }; + + callingDevToolsWindow = new BrowserWindow(options); + + await handleCommonWindowEvents(callingDevToolsWindow); + + callingDevToolsWindow.once('closed', () => { + callingDevToolsWindow = undefined; + + mainWindow?.webContents.send('calling:set-rtc-stats-interval', null); + }); + + ipc.on('calling:set-rtc-stats-interval', (_, intervalMillis: number) => { + mainWindow?.webContents.send( + 'calling:set-rtc-stats-interval', + intervalMillis + ); + }); + + ipc.on('calling:rtc-stats-report', (_, report) => { + callingDevToolsWindow?.webContents.send('calling:rtc-stats-report', report); + }); + + await safeLoadURL( + callingDevToolsWindow, + await prepareFileUrl([__dirname, '../calling_tools.html']) + ); + callingDevToolsWindow.show(); +} + let aboutWindow: BrowserWindow | undefined; async function showAbout() { if (aboutWindow) { @@ -2054,6 +2115,7 @@ function setupMenu(options?: Partial) { setupAsStandalone, showAbout, showDebugLog: showDebugLogWindow, + showCallingDevTools: showCallingDevToolsWindow, showKeyboardShortcuts, showSettings: showSettingsWindow, showWindow, diff --git a/app/menu.ts b/app/menu.ts index 693254ba0c91..e52d8494e4be 100644 --- a/app/menu.ts +++ b/app/menu.ts @@ -35,6 +35,7 @@ export const createTemplate = ( forceUpdate, showAbout, showDebugLog, + showCallingDevTools, showKeyboardShortcuts, showSettings, openArtCreator, @@ -146,6 +147,10 @@ export const createTemplate = ( role: 'toggleDevTools' as const, label: i18n('icu:viewMenuToggleDevTools'), }, + { + label: i18n('icu:viewMenuOpenCallingDevTools'), + click: showCallingDevTools, + }, ] : []), ...(devTools && platform !== 'linux' diff --git a/calling_tools.html b/calling_tools.html new file mode 100644 index 000000000000..250ab268bd25 --- /dev/null +++ b/calling_tools.html @@ -0,0 +1,75 @@ + + + + + + + + + + + + + + +

+

+

+ + + + + + + + + + diff --git a/package.json b/package.json index 474f1d818b0d..856ffa7438a0 100644 --- a/package.json +++ b/package.json @@ -484,6 +484,7 @@ "screenShare.html", "settings.html", "permissions_popup.html", + "calling_tools.html", "debug_log.html", "loading.html", { diff --git a/scripts/esbuild.js b/scripts/esbuild.js index 8819b683c649..eb5c87152772 100644 --- a/scripts/esbuild.js +++ b/scripts/esbuild.js @@ -132,6 +132,13 @@ async function sandboxedEnv() { path.join(ROOT_DIR, 'ts', 'windows', 'permissions', 'app.tsx'), path.join(ROOT_DIR, 'ts', 'windows', 'screenShare', 'app.tsx'), path.join(ROOT_DIR, 'ts', 'windows', 'settings', 'app.tsx'), + path.join( + ROOT_DIR, + 'ts', + 'windows', + 'callingtools', + 'webrtc_internals.js' + ), ], }, preloadConfig: { @@ -142,6 +149,7 @@ async function sandboxedEnv() { path.join(ROOT_DIR, 'ts', 'windows', 'debuglog', 'preload.ts'), path.join(ROOT_DIR, 'ts', 'windows', 'loading', 'preload.ts'), path.join(ROOT_DIR, 'ts', 'windows', 'permissions', 'preload.ts'), + path.join(ROOT_DIR, 'ts', 'windows', 'callingtools', 'preload.ts'), path.join(ROOT_DIR, 'ts', 'windows', 'screenShare', 'preload.ts'), path.join(ROOT_DIR, 'ts', 'windows', 'settings', 'preload.ts'), ], diff --git a/scripts/generate-acknowledgments.js b/scripts/generate-acknowledgments.js index 65e61706bc5e..53a99ed6cebf 100644 --- a/scripts/generate-acknowledgments.js +++ b/scripts/generate-acknowledgments.js @@ -139,6 +139,42 @@ async function main() { } ); + const markdownForChromiumDashboard = [ + '## Chromium WebRTC Internals Dashboard', + '', + [ + 'Copyright 2015 The Chromium Authors', + '', + 'Redistribution and use in source and binary forms, with or without', + 'modification, are permitted provided that the following conditions are', + 'met:', + '', + ' * Redistributions of source code must retain the above copyright', + 'notice, this list of conditions and the following disclaimer.', + ' * Redistributions in binary form must reproduce the above', + 'copyright notice, this list of conditions and the following disclaimer', + 'in the documentation and/or other materials provided with the', + 'distribution.', + ' * Neither the name of Google LLC nor the names of its', + 'contributors may be used to endorse or promote products derived from', + 'this software without specific prior written permission.', + '', + 'THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS', + '"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT', + 'LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR', + 'A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT', + 'OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,', + 'SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT', + 'LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,', + 'DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY', + 'THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT', + '(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE', + 'OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.', + ] + .map(line => `\t${line}`) + .join('\n'), + ].join('\n'); + const unformattedOutput = [ '', '', @@ -147,6 +183,7 @@ async function main() { 'Signal Desktop makes use of the following open source projects.', '', markdownsForDependency.join('\n\n'), + markdownForChromiumDashboard, '', '## Kyber Patent License', '', diff --git a/stylesheets/webrtc_internals.css b/stylesheets/webrtc_internals.css new file mode 100644 index 000000000000..5cc60bc441cf --- /dev/null +++ b/stylesheets/webrtc_internals.css @@ -0,0 +1,155 @@ +/* Copyright 2013 The Chromium Authors + * Use of this source code is governed by a BSD-style license that can be + * found in the LICENSE file. */ + + + .peer-connection-dump-root { + font-size: 0.8em; + padding-bottom: 3px; + } + + .update-log-container { + float: left; + width: 50em; + overflow: auto; + } + + .update-log-failure { + background-color: #be2026; + } + + .stats-graph-container { + clear: both; + margin: 0.5em 0 0.5em 0; + } + + .stats-graph-sub-container { + float: left; + margin: 0.5em; + } + + .stats-graph-sub-container > div { + float: left; + } + + .stats-graph-sub-container > div:first-child { + float: none; + } + + .stats-table-container { + float: left; + padding: 0 0 0 0; + overflow: auto; + } + + .stats-table-container >div:first-child { + font-size: 0.8em; + font-weight: bold; + text-align: center; + padding: 0 0 1em 0; + } + + .stats-table-active-connection { + font-weight: bold; + } + + body { + font-family: 'Lucida Grande', sans-serif; + } + + table { + border: none; + margin: 0 1em 1em 0; + } + + td { + border: none; + font-size: 0.8em; + padding: 0 1em 0.5em 0; + min-width: 10em; + word-break: break-all; + } + + table > tr { + vertical-align: top; + } + + th { + border: none; + font-size: 0.8em; + padding: 0 0 0.5em 0; + } + + .tab-head { + background-color: rgb(220, 220, 220); + margin: 10px 2px 0 2px; + text-decoration: underline; + cursor: pointer; + display: inline-block; + overflow: hidden; + width: 20em; + height: 3em; + } + + .active-tab-head { + background-color: turquoise; + font-weight: bold; + } + + .tab-body { + border: 1px solid turquoise; + border-top-width: 3px; + padding: 0 10px 500px 10px; + display: none; + } + + .active-tab-body { + display: block; + } + + .user-media-request-div-class { + background-color: lightgray; + margin: 10px 0 10px 0; + } + + .user-media-request-div-class > div { + margin: 5px 0 5px 0; + } + + .dumps-info { + max-width: 60em; + } + + details[open] details summary { + background-color: rgb(220, 220, 220); + } + + .peerconnection-deprecations { + font-weight: bold; + } + + .candidategrid tr { + text-align: center; + word-break: break-word; + } + + .candidategrid-active { + font-weight: bold; + } + + .candidategrid-candidatepair { + background-color: #ccc; + } + + .candidategrid-candidatepair td:first-of-type { + text-align: left; + } + + .candidategrid-candidate { + background-color: #ddd; + } + + .candidategrid-candidate td:first-of-type { + text-align: right; + } + \ No newline at end of file diff --git a/ts/services/calling.ts b/ts/services/calling.ts index a4f3d9454944..451dc8cf5506 100644 --- a/ts/services/calling.ts +++ b/ts/services/calling.ts @@ -159,6 +159,7 @@ const RINGRTC_HTTP_METHOD_TO_OUR_HTTP_METHOD: Map< const CLEAN_EXPIRED_GROUP_CALL_RINGS_INTERVAL = 10 * durations.MINUTE; const ICE_SERVER_IS_IP_LIKE = /(turn|turns|stun):[.\d]+/; +const MAX_CALL_DEBUG_STATS_TABS = 5; // We send group call update messages to tell other clients to peek, which triggers // notifications, timeline messages, big green "Join" buttons, and so on. This enum @@ -346,6 +347,10 @@ export class CallingClass { private hadLocalVideoBeforePresenting?: boolean; + private currentRtcStatsInterval: number | null = null; + + private callDebugNumber: number = 0; + constructor() { this.videoCapturer = new GumVideoCapturer({ maxWidth: REQUESTED_VIDEO_WIDTH, @@ -381,6 +386,7 @@ export class CallingClass { this.handleSendCallMessageToGroup.bind(this); RingRTC.handleGroupCallRingUpdate = this.handleGroupCallRingUpdate.bind(this); + RingRTC.handleRtcStatsReport = this.handleRtcStatsReport.bind(this); this.attemptToGiveOurServiceIdToRingRtc(); window.Whisper.events.on('userChanged', () => { @@ -390,6 +396,12 @@ export class CallingClass { ipcRenderer.on('stop-screen-share', () => { reduxInterface.setPresenting(); }); + ipcRenderer.on( + 'calling:set-rtc-stats-interval', + (_, intervalMillis: number | null) => { + this.setAllRtcStatsInterval(intervalMillis); + } + ); drop(this.cleanExpiredGroupCallRingsAndLoop()); drop(this.cleanupStaleRingingCalls()); @@ -399,6 +411,16 @@ export class CallingClass { } } + private maybeUpdateRtcLogging(groupCall: GroupCall): void { + if (!this.currentRtcStatsInterval) { + return; + } + + groupCall.setRtcStatsInterval(this.currentRtcStatsInterval); + this.callDebugNumber = + (this.callDebugNumber + 1) % MAX_CALL_DEBUG_STATS_TABS; + } + private attemptToGiveOurServiceIdToRingRtc(): void { const ourAci = window.textsecure.storage.user.getAci(); if (!ourAci) { @@ -911,6 +933,7 @@ export class CallingClass { outerGroupCall.connect(); + this.maybeUpdateRtcLogging(outerGroupCall); this.syncGroupCallToRedux(conversationId, outerGroupCall, CallMode.Group); return outerGroupCall; @@ -966,6 +989,7 @@ export class CallingClass { outerGroupCall.connect(); + this.maybeUpdateRtcLogging(outerGroupCall); this.syncGroupCallToRedux(roomId, outerGroupCall, CallMode.Adhoc); return outerGroupCall; @@ -1513,6 +1537,27 @@ export class CallingClass { groupCall.react(value); } + // configures how often call stats are computed + public setAllRtcStatsInterval(intervalMillis: number | null): void { + if (this.currentRtcStatsInterval === intervalMillis) { + return; + } + this.currentRtcStatsInterval = intervalMillis; + + // GroupCall.setRtcStatsInterval resets to the default when interval == 0 + // so set it to 0 when intervalMillis is undefined + const statsInterval = intervalMillis ?? 0; + + for (const conversationId of Object.keys(this.callsLookup)) { + const groupCall = this.getGroupCall(conversationId); + if (!groupCall) { + continue; + } + log.info('Setting rtc stats interval:', conversationId, statsInterval); + groupCall.setRtcStatsInterval(statsInterval); + } + } + private syncGroupCallToRedux( conversationId: string, groupCall: GroupCall, @@ -2688,6 +2733,18 @@ export class CallingClass { } } + private async handleRtcStatsReport(reportJson: string) { + // assumes one active call + const conversationId = Object.keys(this.callsLookup)[0] ?? ''; + const callId = this.callDebugNumber; + + ipcRenderer.send('calling:rtc-stats-report', { + conversationId, + callId, + reportJson, + }); + } + private async handleSendHttpRequest( requestId: number, url: string, diff --git a/ts/test-node/app/menu_test.ts b/ts/test-node/app/menu_test.ts index 8e97503a4a4d..929c1831c439 100644 --- a/ts/test-node/app/menu_test.ts +++ b/ts/test-node/app/menu_test.ts @@ -22,6 +22,7 @@ const setupAsNewDevice = stub(); const setupAsStandalone = stub(); const showAbout = stub(); const showDebugLog = stub(); +const showCallingDevTools = stub(); const showKeyboardShortcuts = stub(); const showSettings = stub(); const showWindow = stub(); @@ -70,6 +71,7 @@ const getExpectedViewMenu = (): MenuItemConstructorOptions => ({ { label: 'Debug Log', click: showDebugLog }, { type: 'separator' }, { label: 'Toggle Developer Tools', role: 'toggleDevTools' }, + { label: 'Open Calling Developer Tools', click: showCallingDevTools }, { label: 'Force Update', click: forceUpdate }, ], }); @@ -227,6 +229,7 @@ describe('createTemplate', () => { setupAsStandalone, showAbout, showDebugLog, + showCallingDevTools, showKeyboardShortcuts, showSettings, showWindow, diff --git a/ts/types/menu.ts b/ts/types/menu.ts index dc627febe745..4196859cee24 100644 --- a/ts/types/menu.ts +++ b/ts/types/menu.ts @@ -25,6 +25,7 @@ export type MenuActionsType = Readonly<{ setupAsStandalone: () => unknown; showAbout: () => unknown; showDebugLog: () => unknown; + showCallingDevTools: () => unknown; showKeyboardShortcuts: () => unknown; showSettings: () => unknown; showWindow: () => unknown; diff --git a/ts/util/lint/license_comments.ts b/ts/util/lint/license_comments.ts index 188815855468..50b3170ccaf2 100644 --- a/ts/util/lint/license_comments.ts +++ b/ts/util/lint/license_comments.ts @@ -50,6 +50,22 @@ const FILES_TO_IGNORE = new Set( 'js/WebAudioRecorderMp3.js', 'sticker-creator/src/util/protos.d.ts', 'sticker-creator/src/util/protos.js', + // ignore calling developer tools licensing which use Chromium license + 'calling_tools.html', + 'ts/windows/callingtools/assert.js', + 'ts/windows/callingtools/candidate_grid.js', + 'ts/windows/callingtools/data_series.js', + 'ts/windows/callingtools/dump_creator.js', + 'ts/windows/callingtools/peer_connection_update_table.js', + 'ts/windows/callingtools/stats_graph_helper.js', + 'ts/windows/callingtools/stats_helper.js', + 'ts/windows/callingtools/stats_rates_calculator.js', + 'ts/windows/callingtools/stats_table.js', + 'ts/windows/callingtools/tab_view.js', + 'ts/windows/callingtools/timeline_graph_view.js', + 'ts/windows/callingtools/user_media_table.js', + 'ts/windows/callingtools/util.js', + 'ts/windows/callingtools/webrtc_internals.js', ].map( // This makes sure the files are correct on Windows. path.normalize diff --git a/ts/windows/callingtools/assert.js b/ts/windows/callingtools/assert.js new file mode 100644 index 000000000000..8a74c7187b59 --- /dev/null +++ b/ts/windows/callingtools/assert.js @@ -0,0 +1,19 @@ +// Derived from Chromium WebRTC Internals Dashboard - see Acknowledgements for full license details +export function assert(value, message) { + if (value) { + return; + } + throw new Error("Assertion failed" + (message ? `: ${message}` : "")); + } + export function assertInstanceof(value, type, message) { + if (value instanceof type) { + return; + } + throw new Error( + message || `Value ${value} is not of type ${type.name || typeof type}`, + ); + } + export function assertNotReached(message = "Unreachable code hit") { + assert(false, message); + } + \ No newline at end of file diff --git a/ts/windows/callingtools/candidate_grid.js b/ts/windows/callingtools/candidate_grid.js new file mode 100644 index 000000000000..a5586d4acbc8 --- /dev/null +++ b/ts/windows/callingtools/candidate_grid.js @@ -0,0 +1,219 @@ +// Derived from Chromium WebRTC Internals Dashboard - see Acknowledgements for full license details + +/** + * Creates a ICE candidate grid. + * @param {Element} peerConnectionElement + */ + +import {$} from './util.js'; +/** + * A helper function for appending a child element to |parent|. + * Copied from webrtc_internals.js + * + * @param {!Element} parent The parent element. + * @param {string} tag The child element tag. + * @param {string} text The textContent of the new DIV. + * @return {!Element} the new DIV element. + */ +function appendChildWithText(parent, tag, text) { + const child = document.createElement(tag); + child.textContent = text; + parent.appendChild(child); + return child; +} + +export function createIceCandidateGrid(peerConnectionElement) { + const container = document.createElement('details'); + appendChildWithText(container, 'summary', 'ICE candidate grid'); + + const table = document.createElement('table'); + table.id = 'grid-' + peerConnectionElement.id; + table.className = 'candidategrid'; + container.appendChild(table); + + const tableHeader = document.createElement('tr'); + table.append(tableHeader); + + // For candidate pairs. + appendChildWithText(tableHeader, 'th', 'Candidate (pair) id'); + // [1] is used for both candidate pairs and individual candidates. + appendChildWithText(tableHeader, 'th', 'State / Candidate type'); + // For individual candidates. + appendChildWithText(tableHeader, 'th', 'Network type / address'); + appendChildWithText(tableHeader, 'th', 'Port'); + appendChildWithText(tableHeader, 'th', 'Protocol / candidate type'); + appendChildWithText(tableHeader, 'th', '(Pair) Priority'); + + // For candidate pairs. + appendChildWithText(tableHeader, 'th', 'Bytes sent / received'); + appendChildWithText(tableHeader, 'th', + 'STUN requests sent / responses received'); + appendChildWithText(tableHeader, 'th', + 'STUN requests received / responses sent'); + appendChildWithText(tableHeader, 'th', 'RTT'); + appendChildWithText(tableHeader, 'th', 'Last data sent / received'); + appendChildWithText(tableHeader, 'th', 'Last update'); + + peerConnectionElement.appendChild(container); +} + +/** + * Creates or returns a table row in the ICE candidate grid. + * @param {string} peerConnectionElement id + * @param {string} stat object id + * @param {type} type of the row + */ +function findOrCreateGridRow(peerConnectionElementId, statId, type) { + const elementId = 'grid-' + peerConnectionElementId + + '-' + statId + '-' + type; + let row = document.getElementById(elementId); + if (!row) { + row = document.createElement('tr'); + row.id = elementId; + for (let i = 0; i < 12; i++) { + row.appendChild(document.createElement('td')); + } + $('grid-' + peerConnectionElementId).appendChild(row); + } + return row; +} + +/** + * Updates a table row in the ICE candidate grid. + * @param {string} peerConnectionElement id + * @param {boolean} whether the pair is the selected pair of a transport + * (displayed bold) + * @param {object} candidate pair stats report + * @param {Map} full map of stats + */ +function appendRow(peerConnectionElement, active, candidatePair, stats) { + const pairRow = findOrCreateGridRow(peerConnectionElement.id, + candidatePair.id, 'candidatepair'); + pairRow.classList.add('candidategrid-candidatepair') + if (active) { + pairRow.classList.add('candidategrid-active'); + } + // Set transport-specific fields. + pairRow.children[0].innerText = candidatePair.id; + pairRow.children[1].innerText = candidatePair.state; + // Show (pair) priority as hex. + pairRow.children[5].innerText = + '0x' + parseInt(candidatePair.priority, 10).toString(16); + pairRow.children[6].innerText = + candidatePair.bytesSent + ' / ' + candidatePair.bytesReceived; + pairRow.children[7].innerText = candidatePair.requestsSent + ' / ' + + candidatePair.responsesReceived; + pairRow.children[8].innerText = candidatePair.requestsReceived + ' / ' + + candidatePair.responsesSent; + pairRow.children[9].innerText = + candidatePair.currentRoundTripTime !== undefined ? + candidatePair.currentRoundTripTime + 's' : ''; + if (candidatePair.lastPacketSentTimestamp) { + pairRow.children[10].innerText = + (new Date(candidatePair.lastPacketSentTimestamp)) + .toLocaleTimeString() + ' / ' + + (new Date(candidatePair.lastPacketReceivedTimestamp)) + .toLocaleTimeString(); + } + pairRow.children[11].innerText = (new Date()).toLocaleTimeString(); + + // Local candidate. + const localRow = findOrCreateGridRow(peerConnectionElement.id, + candidatePair.id, 'local'); + localRow.className = 'candidategrid-candidate' + const localCandidate = stats.get(candidatePair.localCandidateId); + ['id', 'type', 'address', 'port', 'candidateType', + 'priority'].forEach((stat, index) => { + // `relayProtocol` is only set for local relay candidates. + if (stat == 'candidateType' && localCandidate.relayProtocol) { + localRow.children[index].innerText = localCandidate[stat] + + '(' + localCandidate.relayProtocol + ')'; + if (localCandidate.url) { + localRow.children[index].innerText += '\n' + localCandidate.url; + } + } else if (stat === 'priority') { + const priority = parseInt(localCandidate[stat], 10) & 0xFFFFFFFF; + localRow.children[index].innerText = '0x' + priority.toString(16) + + // RFC 5245 - 4.1.2.1. + // priority = (2^24)*(type preference) + + // (2^8)*(local preference) + + // (2^0)*(256 - component ID) + '\n' + (priority >> 24) + + ' | ' + ((priority >> 8) & 0xFFFF) + + ' | ' + (priority & 0xFF); + } else if (stat === 'address') { + localRow.children[index].innerText = localCandidate[stat] || '(not set)'; + } else { + localRow.children[index].innerText = localCandidate[stat]; + } + }); + // `networkType` is only known for the local candidate so put it into the + // pair row above the address. Also highlight VPN adapters. + pairRow.children[2].innerText = localCandidate.networkType; + if (localCandidate['vpn'] === true) { + pairRow.children[2].innerText += ' (VPN)'; + } + // `protocol` must always be the same for the pair + // so put it into the pair row above the candidate type. + // Add `tcpType` for local candidates. + pairRow.children[4].innerText = localCandidate.protocol; + if (localCandidate.tcpType) { + pairRow.children[4].innerText += ' ' + localCandidate.tcpType; + } + + // Remote candidate. + const remoteRow = findOrCreateGridRow(peerConnectionElement.id, + candidatePair.id, 'remote'); + remoteRow.className = 'candidategrid-candidate' + const remoteCandidate = stats.get(candidatePair.remoteCandidateId); + ['id', 'type', 'address', 'port', 'candidateType', + 'priority'].forEach((stat, index) => { + if (stat === 'priority') { + remoteRow.children[index].innerText = '0x' + + parseInt(remoteCandidate[stat], 10).toString(16); + } else if (stat === 'address') { + remoteRow.children[index].innerText = remoteCandidate[stat] || + '(not set)'; + } else { + remoteRow.children[index].innerText = remoteCandidate[stat]; + } + }); + return pairRow; +} + +/** + * Updates the (spec) ICE candidate grid. + * @param {Element} peerConnectionElement + * @param {Map} stats reconstructed stats object. + */ +export function updateIceCandidateGrid(peerConnectionElement, stats) { + const container = $('grid-' + peerConnectionElement.id); + // Remove the active/bold marker from all rows. + container.childNodes.forEach(row => { + row.classList.remove('candidategrid-active'); + }); + let activePairIds = []; + // Find the active transport(s), then find its candidate pair + // and display it first. Note that previously selected pairs continue to be + // shown since rows are not removed. + stats.forEach(transportReport => { + if (transportReport.type !== 'transport') { + return; + } + if (!transportReport.selectedCandidatePairId) { + return; + } + activePairIds.push(transportReport.selectedCandidatePairId); + appendRow(peerConnectionElement, true, + stats.get(transportReport.selectedCandidatePairId), stats); + }); + + // Then iterate over the other candidate pairs. + stats.forEach(report => { + if (report.type !== 'candidate-pair' || activePairIds.includes(report.id)) { + return; + } + appendRow(peerConnectionElement, false, report, stats); + }); +} + diff --git a/ts/windows/callingtools/data_series.js b/ts/windows/callingtools/data_series.js new file mode 100644 index 000000000000..0fd5b0195167 --- /dev/null +++ b/ts/windows/callingtools/data_series.js @@ -0,0 +1,133 @@ +// Derived from Chromium WebRTC Internals Dashboard - see Acknowledgements for full license details + +// The maximum number of data points buffered for each stats. Old data points +// will be shifted out when the buffer is full. +export const MAX_STATS_DATA_POINT_BUFFER_SIZE = 1000; + +/** + * A TimelineDataSeries collects an ordered series of (time, value) pairs, + * and converts them to graph points. It also keeps track of its color and + * current visibility state. + * It keeps MAX_STATS_DATA_POINT_BUFFER_SIZE data points at most. Old data + * points will be dropped when it reaches this size. + */ +export class TimelineDataSeries { + constructor(statsType) { + // List of DataPoints in chronological order. + this.dataPoints_ = []; + + // Default color. Should always be overridden prior to display. + this.color_ = 'red'; + // Whether or not the data series should be drawn. + this.isVisible_ = true; + + this.cacheStartTime_ = null; + this.cacheStepSize_ = 0; + this.cacheValues_ = []; + this.statsType_ = statsType; + } + + /** + * @override + */ + toJSON() { + if (this.dataPoints_.length < 1) { + return {}; + } + + const values = []; + for (let i = 0; i < this.dataPoints_.length; ++i) { + values.push(this.dataPoints_[i].value); + } + return { + startTime: this.dataPoints_[0].time, + endTime: this.dataPoints_[this.dataPoints_.length - 1].time, + statsType: this.statsType_, + values: JSON.stringify(values), + }; + } + + /** + * Adds a DataPoint to |this| with the specified time and value. + * DataPoints are assumed to be received in chronological order. + */ + addPoint(timeTicks, value) { + const time = new Date(timeTicks); + this.dataPoints_.push(new DataPoint(time, value)); + + if (this.dataPoints_.length > MAX_STATS_DATA_POINT_BUFFER_SIZE) { + this.dataPoints_.shift(); + } + } + + isVisible() { + return this.isVisible_; + } + + show(isVisible) { + this.isVisible_ = isVisible; + } + + getColor() { + return this.color_; + } + + setColor(color) { + this.color_ = color; + } + + getCount() { + return this.dataPoints_.length; + } + /** + * Returns a list containing the values of the data series at |count| + * points, starting at |startTime|, and |stepSize| milliseconds apart. + * Caches values, so showing/hiding individual data series is fast. + */ + getValues(startTime, stepSize, count) { + // Use cached values, if we can. + if (this.cacheStartTime_ === startTime && + this.cacheStepSize_ === stepSize && + this.cacheValues_.length === count) { + return this.cacheValues_; + } + + // Do all the work. + this.cacheValues_ = this.getValuesInternal_(startTime, stepSize, count); + this.cacheStartTime_ = startTime; + this.cacheStepSize_ = stepSize; + + return this.cacheValues_; + } + + /** + * Returns the cached |values| in the specified time period. + */ + getValuesInternal_(startTime, stepSize, count) { + const values = []; + let nextPoint = 0; + let currentValue = 0; + let time = startTime; + for (let i = 0; i < count; ++i) { + while (nextPoint < this.dataPoints_.length && + this.dataPoints_[nextPoint].time < time) { + currentValue = this.dataPoints_[nextPoint].value; + ++nextPoint; + } + values[i] = currentValue; + time += stepSize; + } + return values; + } +} + +/** + * A single point in a data series. Each point has a time, in the form of + * milliseconds since the Unix epoch, and a numeric value. + */ +class DataPoint { + constructor(time, value) { + this.time = time; + this.value = value; + } +} diff --git a/ts/windows/callingtools/dump_creator.js b/ts/windows/callingtools/dump_creator.js new file mode 100644 index 000000000000..4314ca65dabe --- /dev/null +++ b/ts/windows/callingtools/dump_creator.js @@ -0,0 +1,170 @@ +// Derived from Chromium WebRTC Internals Dashboard - see Acknowledgements for full license details + +import {$} from './util.js'; + +/** A list of getUserMedia requests. */ +export const userMediaRequests = []; +/** A map from peer connection id to the PeerConnectionRecord. */ +export const peerConnectionDataStore = {}; + +// Also duplicating on window since tests access these from C++. +window.userMediaRequests = userMediaRequests; +window.peerConnectionDataStore = peerConnectionDataStore; + +/** + * Provides the UI for dump creation. + */ +export class DumpCreator { + /** + * @param {Element} containerElement The parent element of the dump creation + * UI. + */ + constructor(containerElement) { + /** + * The root element of the dump creation UI. + * @type {Element} + * @private + */ + // Signal change, remove unused diagnostic UI + } + + createDumpRoot(containerElement) { + this.dumpRoot_ = document.createElement('details'); + + this.dumpRoot_.className = 'peer-connection-dump-root'; + containerElement.appendChild(this.dumpRoot_); + const summary = document.createElement('summary'); + this.dumpRoot_.appendChild(summary); + summary.textContent = 'Create a WebRTC-Internals dump'; + const content = document.createElement('div'); + this.dumpRoot_.appendChild(content); + content.appendChild($('dump-template').content.cloneNode(true)); + content.getElementsByTagName('a')[0].addEventListener( + 'click', this.onDownloadData_.bind(this)); + } + + createAudioRecordingRoot(containerElement) { + this.audioRoot_ = document.createElement('details'); + + this.audioRoot_.className = 'peer-connection-dump-root'; + containerElement.appendChild(this.audioRoot_); + const summary = document.createElement('summary'); + this.audioRoot_.appendChild(summary); + summary.textContent = 'Create diagnostic audio recordings'; + const content = document.createElement('div'); + this.audioRoot_.appendChild(content); + content.appendChild($('audio-recording-template').content.cloneNode(true)); + content.getElementsByTagName('input')[0].addEventListener( + 'click', this.onAudioDebugRecordingsChanged_.bind(this)); + + } + + createPacketRecordingRoot(containerElement) { + this.packetRoot_ = document.createElement('details'); + + this.packetRoot_.className = 'peer-connection-dump-root'; + containerElement.appendChild(this.packetRoot_); + const summary = document.createElement('summary'); + this.packetRoot_.appendChild(summary); + summary.textContent = 'Create diagnostic packet recordings'; + const content = document.createElement('div'); + this.packetRoot_.appendChild(content); + content.appendChild($('packet-recording-template').content.cloneNode(true)); + content.getElementsByTagName('input')[0].addEventListener( + 'click', this.onEventLogRecordingsChanged_.bind(this)); + } + + // Mark the diagnostic audio recording checkbox checked. + setAudioDebugRecordingsCheckbox() { + this.audioRoot_.getElementsByTagName('input')[0].checked = true; + } + + // Mark the diagnostic audio recording checkbox unchecked. + clearAudioDebugRecordingsCheckbox() { + this.audioRoot_.getElementsByTagName('input')[0].checked = false; + } + + // Mark the event log recording checkbox checked. + setEventLogRecordingsCheckbox() { + this.packetRoot_.getElementsByTagName('input')[0].checked = true; + } + + // Mark the event log recording checkbox unchecked. + clearEventLogRecordingsCheckbox() { + this.packetRoot_.getElementsByTagName('input')[0].checked = false; + } + + // Mark the event log recording checkbox as mutable/immutable. + setEventLogRecordingsCheckboxMutability(mutable) { + this.packetRoot_.getElementsByTagName('input')[0].disabled = !mutable; + if (!mutable) { + const label = this.packetRoot_.getElementsByTagName('label')[0]; + label.style = 'color:red;'; + label.textContent = + ' WebRTC event logging\'s state was set by a command line flag.'; + } + } + + /** + * Downloads the PeerConnection updates and stats data as a file. + * + * @private + */ + async onDownloadData_(event) { + const useCompression = this.dumpRoot_.getElementsByTagName('input')[0].checked; + const dumpObject = { + 'getUserMedia': userMediaRequests, + 'PeerConnections': peerConnectionDataStore, + 'UserAgent': navigator.userAgent, + }; + const textBlob = + new Blob([JSON.stringify(dumpObject, null, 1)], {type: 'octet/stream'}); + let url; + if (useCompression) { + const compressionStream = new CompressionStream('gzip'); + const binaryStream = textBlob.stream().pipeThrough(compressionStream); + const binaryBlob = await new Response(binaryStream).blob(); + url = URL.createObjectURL(binaryBlob); + // Since this is async we can't use the default event and need to click + // again (while avoiding an infinite loop). + const anchor = document.createElement('a'); + anchor.download = 'webrtc_internals_dump.gz' + anchor.href = url; + anchor.click(); + return; + } + url = URL.createObjectURL(textBlob); + const anchor = this.dumpRoot_.getElementsByTagName('a')[0]; + anchor.download = 'webrtc_internals_dump.txt' + anchor.href = url; + // The default action of the anchor will download the url. + } + + /** + * Handles the event of toggling the audio debug recordings state. + * + * @private + */ + onAudioDebugRecordingsChanged_() { + const enabled = this.audioRoot_.getElementsByTagName('input')[0].checked; + if (enabled) { + // chrome.send('enableAudioDebugRecordings'); + } else { + // chrome.send('disableAudioDebugRecordings'); + } + } + + /** + * Handles the event of toggling the event log recordings state. + * + * @private + */ + onEventLogRecordingsChanged_() { + const enabled = this.packetRoot_.getElementsByTagName('input')[0].checked; + if (enabled) { + // chrome.send('enableEventLogRecordings'); + } else { + // chrome.send('disableEventLogRecordings'); + } + } +} diff --git a/ts/windows/callingtools/peer_connection_update_table.js b/ts/windows/callingtools/peer_connection_update_table.js new file mode 100644 index 000000000000..179316594939 --- /dev/null +++ b/ts/windows/callingtools/peer_connection_update_table.js @@ -0,0 +1,263 @@ +// Derived from Chromium WebRTC Internals Dashboard - see Acknowledgements for full license details + +import {$} from './util.js'; + +const MAX_NUMBER_OF_STATE_CHANGES_DISPLAYED = 10; +const MAX_NUMBER_OF_EXPANDED_MEDIASECTIONS = 10; +/** + * The data of a peer connection update. + * @param {number} pid The id of the renderer. + * @param {number} lid The id of the peer conneciton inside a renderer. + * @param {string} type The type of the update. + * @param {string} value The details of the update. + */ +class PeerConnectionUpdateEntry { + constructor(pid, lid, type, value) { + /** + * @type {number} + */ + this.pid = pid; + + /** + * @type {number} + */ + this.lid = lid; + + /** + * @type {string} + */ + this.type = type; + + /** + * @type {string} + */ + this.value = value; + } +} + +/** + * Maintains the peer connection update log table. + */ +export class PeerConnectionUpdateTable { + constructor() { + /** + * @type {string} + * @const + * @private + */ + this.UPDATE_LOG_ID_SUFFIX_ = '-update-log'; + + /** + * @type {string} + * @const + * @private + */ + this.UPDATE_LOG_CONTAINER_CLASS_ = 'update-log-container'; + + /** + * @type {string} + * @const + * @private + */ + this.UPDATE_LOG_TABLE_CLASS = 'update-log-table'; + } + + /** + * Adds the update to the update table as a new row. The type of the update + * is set to the summary of the cell; clicking the cell will reveal or hide + * the details as the content of a TextArea element. + * + * @param {!Element} peerConnectionElement The root element. + * @param {!PeerConnectionUpdateEntry} update The update to add. + */ + addPeerConnectionUpdate(peerConnectionElement, update) { + const tableElement = this.ensureUpdateContainer_(peerConnectionElement); + + const row = document.createElement('tr'); + tableElement.firstChild.appendChild(row); + + const time = new Date(parseFloat(update.time)); + const timeItem = document.createElement('td'); + timeItem.textContent = time.toLocaleString(); + row.appendChild(timeItem); + + // `type` is a display variant of update.type which does not get serialized + // into the JSON dump. + let type = update.type; + + if (update.value.length === 0) { + const typeItem = document.createElement('td'); + typeItem.textContent = type; + row.appendChild(typeItem); + return; + } + + if (update.type === 'icecandidate' || update.type === 'addIceCandidate') { + const parts = update.value.split(', '); + type += '(' + parts[0] + ', ' + parts[1]; // show sdpMid/sdpMLineIndex. + const candidateParts = parts[2].substr(11).split(' '); + if (candidateParts && candidateParts[7]) { // show candidate type. + type += ', type: ' + candidateParts[7]; + } + type += ')'; + } else if ( + update.type === 'createOfferOnSuccess' || + update.type === 'createAnswerOnSuccess') { + this.setLastOfferAnswer_(tableElement, update); + } else if (update.type === 'setLocalDescription') { + const lastOfferAnswer = this.getLastOfferAnswer_(tableElement); + if (update.value.startsWith('type: rollback')) { + this.setLastOfferAnswer_(tableElement, {value: undefined}) + } else if (lastOfferAnswer && update.value !== lastOfferAnswer) { + type += ' (munged)'; + } + } else if (update.type === 'setConfiguration') { + // Update the configuration that is displayed at the top. + peerConnectionElement.firstChild.children[2].textContent = update.value; + } else if (['transceiverAdded', + 'transceiverModified'].includes(update.type)) { + // Show the transceiver index. + const indexLine = update.value.split('\n', 3)[2]; + if (indexLine.startsWith('getTransceivers()[')) { + type += ' ' + indexLine.substring(17, indexLine.length - 2); + } + const kindLine = update.value.split('\n', 5)[4].trim(); + if (kindLine.startsWith('kind:')) { + type += ', ' + kindLine.substring(6, kindLine.length - 2); + } + } else if (['iceconnectionstatechange', 'connectionstatechange', + 'signalingstatechange'].includes(update.type)) { + const fieldName = { + 'iceconnectionstatechange' : 'iceconnectionstate', + 'connectionstatechange' : 'connectionstate', + 'signalingstatechange' : 'signalingstate', + }[update.type]; + const el = peerConnectionElement.getElementsByClassName(fieldName)[0]; + const numberOfEvents = el.textContent.split(' => ').length; + if (numberOfEvents < MAX_NUMBER_OF_STATE_CHANGES_DISPLAYED) { + el.textContent += ' => ' + update.value; + } else if (numberOfEvents >= MAX_NUMBER_OF_STATE_CHANGES_DISPLAYED) { + el.textContent += ' => ...'; + } + } + + const summaryItem = $('summary-template').content.cloneNode(true); + const summary = summaryItem.querySelector('summary'); + summary.textContent = type; + row.appendChild(summaryItem); + + const valueContainer = document.createElement('pre'); + const details = row.cells[1].childNodes[0]; + details.appendChild(valueContainer); + + // Highlight ICE/DTLS failures and failure callbacks. + if ((update.type === 'iceconnectionstatechange' && + update.value === 'failed') || + (update.type === 'connectionstatechange' && + update.value === 'failed') || + update.type.indexOf('OnFailure') !== -1 || + update.type === 'addIceCandidateFailed') { + valueContainer.parentElement.classList.add('update-log-failure'); + } + + // RTCSessionDescription is serialized as 'type: , sdp:' + if (update.value.indexOf(', sdp:') !== -1) { + const [type, sdp] = update.value.substr(6).split(', sdp: '); + if (type === 'rollback') { + // Rollback has no SDP. + summary.textContent += ' (type: "rollback")'; + } else { + // Create a copy-to-clipboard button. + const copyBtn = document.createElement('button'); + copyBtn.textContent = 'Copy description to clipboard'; + copyBtn.onclick = () => { + navigator.clipboard.writeText(JSON.stringify({type, sdp})); + }; + valueContainer.appendChild(copyBtn); + + // Fold the SDP sections. + const sections = sdp.split('\nm=') + .map((part, index) => (index > 0 ? + 'm=' + part : part).trim() + '\r\n'); + summary.textContent += + ' (type: "' + type + '", ' + sections.length + ' sections)'; + sections.forEach(section => { + const lines = section.trim().split('\n'); + // Extract the mid attribute. + const mid = lines + .filter(line => line.startsWith('a=mid:')) + .map(line => line.substr(6))[0]; + const sectionDetails = document.createElement('details'); + // Fold by default for large SDP. + sectionDetails.open = + sections.length <= MAX_NUMBER_OF_EXPANDED_MEDIASECTIONS; + sectionDetails.textContent = lines.slice(1).join('\n'); + + const sectionSummary = document.createElement('summary'); + sectionSummary.textContent = + lines[0].trim() + + ' (' + (lines.length - 1) + ' more lines)' + + (mid ? ' mid=' + mid : ''); + sectionDetails.appendChild(sectionSummary); + + valueContainer.appendChild(sectionDetails); + }); + } + } else { + valueContainer.textContent = update.value; + } + } + + /** + * Makes sure the update log table of the peer connection is created. + * + * @param {!Element} peerConnectionElement The root element. + * @return {!Element} The log table element. + * @private + */ + ensureUpdateContainer_(peerConnectionElement) { + const tableId = peerConnectionElement.id + this.UPDATE_LOG_ID_SUFFIX_; + + // Disable getElementById restriction here, since |tableId| is not always + // a valid selector. + // eslint-disable-next-line no-restricted-properties + let tableElement = document.getElementById(tableId); + if (!tableElement) { + const tableContainer = document.createElement('div'); + tableContainer.className = this.UPDATE_LOG_CONTAINER_CLASS_; + peerConnectionElement.appendChild(tableContainer); + + tableElement = document.createElement('table'); + tableElement.className = this.UPDATE_LOG_TABLE_CLASS; + tableElement.id = tableId; + tableElement.border = 1; + tableContainer.appendChild(tableElement); + tableElement.appendChild( + $('time-event-template').content.cloneNode(true)); + } + return tableElement; + } + + /** + * Store the last createOfferOnSuccess/createAnswerOnSuccess to compare to + * setLocalDescription and visualize SDP munging. + * + * @param {!Element} tableElement The peerconnection update element. + * @param {!PeerConnectionUpdateEntry} update The update to add. + * @private + */ + setLastOfferAnswer_(tableElement, update) { + tableElement['data-lastofferanswer'] = update.value; + } + + /** + * Retrieves the last createOfferOnSuccess/createAnswerOnSuccess to compare + * to setLocalDescription and visualize SDP munging. + * + * @param {!Element} tableElement The peerconnection update element. + * @private + */ + getLastOfferAnswer_(tableElement) { + return tableElement['data-lastofferanswer']; + } +} diff --git a/ts/windows/callingtools/preload.ts b/ts/windows/callingtools/preload.ts new file mode 100644 index 000000000000..fa1ebd0a5d34 --- /dev/null +++ b/ts/windows/callingtools/preload.ts @@ -0,0 +1,25 @@ +// Copyright 2024 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { contextBridge, ipcRenderer } from 'electron'; +import type { Event } from 'electron/renderer'; +import { MinimalSignalContext } from '../minimalContext'; + +type RtcStatsReport = { + conversationId: string; + callId: string; + reportJson: string; +}; + +const Signal = { + CallingToolsProps: { + onRtcStatsReport: ( + callback: (event: Event, value: RtcStatsReport) => void + ) => ipcRenderer.on('calling:rtc-stats-report', callback), + setRtcStatsInterval: (intervalMillis: number) => { + ipcRenderer.send('calling:set-rtc-stats-interval', intervalMillis); + }, + }, +}; +contextBridge.exposeInMainWorld('Signal', Signal); +contextBridge.exposeInMainWorld('SignalContext', MinimalSignalContext); diff --git a/ts/windows/callingtools/stats_graph_helper.js b/ts/windows/callingtools/stats_graph_helper.js new file mode 100644 index 000000000000..e9e4ad02afa3 --- /dev/null +++ b/ts/windows/callingtools/stats_graph_helper.js @@ -0,0 +1,307 @@ +// Derived from Chromium WebRTC Internals Dashboard - see Acknowledgements for full license details + +// +// This file contains helper methods to draw the stats timeline graphs. +// Each graph represents a series of stats report for a PeerConnection, +// e.g. 1234-0-ssrc-abcd123-bytesSent is the graph for the series of bytesSent +// for ssrc-abcd123 of PeerConnection 0 in process 1234. +// The graphs are drawn as CANVAS, grouped per report type per PeerConnection. +// Each group has an expand/collapse button and is collapsed initially. +// + +import {$} from './util.js'; + +import {TimelineDataSeries} from './data_series.js'; +import {peerConnectionDataStore} from './dump_creator.js'; +import {generateStatsLabel} from './stats_helper.js'; +import {TimelineGraphView} from './timeline_graph_view.js'; + +const STATS_GRAPH_CONTAINER_HEADING_CLASS = 'stats-graph-container-heading'; + +function isReportBlocklisted(report) { + // Codec stats reflect what has been negotiated. They don't contain + // information that is useful in graphs. + if (report.type === 'codec') { + return true; + } + // Unused data channels can stay in "connecting" indefinitely and their + // counters stay zero. + if (report.type === 'data-channel' && + readReportStat(report, 'state') === 'connecting') { + return true; + } + // The same is true for transports and "new". + if (report.type === 'transport' && + readReportStat(report, 'dtlsState') === 'new') { + return true; + } + // Local and remote candidates don't change over time and there are several of + // them. + if (report.type === 'local-candidate' || report.type === 'remote-candidate') { + return true; + } + return false; +} + +function readReportStat(report, stat) { + const values = report.stats.values; + for (let i = 0; i < values.length; i += 2) { + if (values[i] === stat) { + return values[i + 1]; + } + } + return undefined; +} + +function isStatBlocklisted(report, statName) { + // The priority does not change over time on its own; plotting uninteresting. + if (report.type === 'candidate-pair' && statName === 'priority') { + return true; + } + // The mid/rid and ssrcs associated with a sender/receiver do not change + // over time; plotting uninteresting. + if (['inbound-rtp', 'outbound-rtp'].includes(report.type) && + ['mid', 'rid', 'ssrc', 'rtxSsrc', 'fecSsrc'].includes(statName)) { + return true; + } + return false; +} + +const graphViews = {}; +// Export on |window| since tests access this directly from C++. +window.graphViews = graphViews; +const graphElementsByPeerConnectionId = new Map(); + +// Returns number parsed from |value|, or NaN. +function getNumberFromValue(name, value) { + if (isNaN(value)) { + return NaN; + } + return parseFloat(value); +} + +// Adds the stats report |report| to the timeline graph for the given +// |peerConnectionElement|. +export function drawSingleReport( + peerConnectionElement, report) { + const reportType = report.type; + const reportId = report.id; + const stats = report.stats; + if (!stats || !stats.values) { + return; + } + + const childrenBefore = peerConnectionElement.hasChildNodes() ? + Array.from(peerConnectionElement.childNodes) : + []; + + for (let i = 0; i < stats.values.length - 1; i = i + 2) { + const rawLabel = stats.values[i]; + const rawDataSeriesId = reportId + '-' + rawLabel; + const rawValue = getNumberFromValue(rawLabel, stats.values[i + 1]); + if (isNaN(rawValue)) { + // We do not draw non-numerical values, but still want to record it in the + // data series. + addDataSeriesPoints( + peerConnectionElement, reportType, rawDataSeriesId, rawLabel, + [stats.timestamp], [stats.values[i + 1]]); + continue; + } + let finalDataSeriesId = rawDataSeriesId; + let finalLabel = rawLabel; + let finalValue = rawValue; + + // Updates the final dataSeries to draw. + addDataSeriesPoints( + peerConnectionElement, reportType, finalDataSeriesId, finalLabel, + [stats.timestamp], [finalValue]); + + if (isReportBlocklisted(report) || isStatBlocklisted(report, rawLabel)) { + // We do not want to draw certain reports but still want to + // record them in the data series. + continue; + } + + // Updates the graph. + const graphType = finalLabel; + const graphViewId = + peerConnectionElement.id + '-' + reportId + '-' + graphType; + + if (!graphViews[graphViewId]) { + graphViews[graphViewId] = + createStatsGraphView(peerConnectionElement, report, graphType); + const searchParameters = new URLSearchParams(window.location.search); + if (searchParameters.has('statsInterval')) { + const statsInterval = Math.max( + parseInt(searchParameters.get('statsInterval'), 10), + 100); + if (isFinite(statsInterval)) { + graphViews[graphViewId].setScale(statsInterval); + } + } + const date = new Date(stats.timestamp); + graphViews[graphViewId].setDateRange(date, date); + } + // Ensures the stats graph title is up-to-date. + ensureStatsGraphContainer(peerConnectionElement, report); + // Adds the new dataSeries to the graphView. We have to do it here to cover + // both the simple and compound graph cases. + const dataSeries = + peerConnectionDataStore[peerConnectionElement.id].getDataSeries( + finalDataSeriesId); + if (!graphViews[graphViewId].hasDataSeries(dataSeries)) { + graphViews[graphViewId].addDataSeries(dataSeries); + } + graphViews[graphViewId].updateEndDate(); + } + // Add a synthetic data series for the timestamp. + addDataSeriesPoints( + peerConnectionElement, reportType, reportId + '-timestamp', + reportId + '-timestamp', [stats.timestamp], [stats.timestamp]); + + const childrenAfter = peerConnectionElement.hasChildNodes() ? + Array.from(peerConnectionElement.childNodes) : + []; + for (let i = 0; i < childrenAfter.length; ++i) { + if (!childrenBefore.includes(childrenAfter[i])) { + let graphElements = + graphElementsByPeerConnectionId.get(peerConnectionElement.id); + if (!graphElements) { + graphElements = []; + graphElementsByPeerConnectionId.set( + peerConnectionElement.id, graphElements); + } + graphElements.push(childrenAfter[i]); + } + } +} + +export function removeStatsReportGraphs(peerConnectionElement) { + const graphElements = + graphElementsByPeerConnectionId.get(peerConnectionElement.id); + if (graphElements) { + for (let i = 0; i < graphElements.length; ++i) { + peerConnectionElement.removeChild(graphElements[i]); + } + graphElementsByPeerConnectionId.delete(peerConnectionElement.id); + } + Object.keys(graphViews).forEach(key => { + if (key.startsWith(peerConnectionElement.id)) { + delete graphViews[key]; + } + }); +} + +// Makes sure the TimelineDataSeries with id |dataSeriesId| is created, +// and adds the new data points to it. |times| is the list of timestamps for +// each data point, and |values| is the list of the data point values. +function addDataSeriesPoints( + peerConnectionElement, reportType, dataSeriesId, label, times, values) { + let dataSeries = + peerConnectionDataStore[peerConnectionElement.id].getDataSeries( + dataSeriesId); + if (!dataSeries) { + dataSeries = new TimelineDataSeries(reportType); + peerConnectionDataStore[peerConnectionElement.id].setDataSeries( + dataSeriesId, dataSeries); + } + for (let i = 0; i < times.length; ++i) { + dataSeries.addPoint(times[i], values[i]); + } +} + +// Ensures a div container to the stats graph for a peerConnectionElement is +// created as a child of the |peerConnectionElement|. +function ensureStatsGraphTopContainer(peerConnectionElement) { + const containerId = peerConnectionElement.id + '-graph-container'; + let container = document.getElementById(containerId); + if (!container) { + container = document.createElement('div'); + container.id = containerId; + container.className = 'stats-graph-container'; + const label = document.createElement('label'); + label.innerText = 'Filter statistics graphs by type including '; + container.appendChild(label); + const input = document.createElement('input'); + input.placeholder = 'separate multiple values by `,`'; + input.size = 25; + input.oninput = (e) => filterStats(e, container); + container.appendChild(input); + + peerConnectionElement.appendChild(container); + } + return container; +} + +// Ensures a div container to the stats graph for a single set of data is +// created as a child of the |peerConnectionElement|'s graph container. +function ensureStatsGraphContainer(peerConnectionElement, report) { + const topContainer = ensureStatsGraphTopContainer(peerConnectionElement); + const containerId = peerConnectionElement.id + '-' + report.type + '-' + + report.id + '-graph-container'; + // Disable getElementById restriction here, since |containerId| is not always + // a valid selector. + // eslint-disable-next-line no-restricted-properties + let container = document.getElementById(containerId); + if (!container) { + container = document.createElement('details'); + container.id = containerId; + container.className = 'stats-graph-container'; + container.attributes['data-statsType'] = report.type; + + peerConnectionElement.appendChild(container); + container.appendChild($('summary-span-template').content.cloneNode(true)); + container.firstChild.firstChild.className = + STATS_GRAPH_CONTAINER_HEADING_CLASS; + topContainer.appendChild(container); + } + // Update the label all the time to account for new information. + container.firstChild.firstChild.textContent = 'Stats graphs for ' + + generateStatsLabel(report); + return container; +} + +// Creates the container elements holding a timeline graph +// and the TimelineGraphView object. +function createStatsGraphView(peerConnectionElement, report, statsName) { + const topContainer = + ensureStatsGraphContainer(peerConnectionElement, report); + + const graphViewId = + peerConnectionElement.id + '-' + report.id + '-' + statsName; + const divId = graphViewId + '-div'; + const canvasId = graphViewId + '-canvas'; + const container = document.createElement('div'); + container.className = 'stats-graph-sub-container'; + + topContainer.appendChild(container); + const canvasDiv = $('container-template').content.cloneNode(true); + canvasDiv.querySelectorAll('div')[0].textContent = statsName; + canvasDiv.querySelectorAll('div')[1].id = divId; + canvasDiv.querySelector('canvas').id = canvasId; + container.appendChild(canvasDiv); + return new TimelineGraphView(divId, canvasId); +} + +/** + * Apply a filter to the stats graphs + * @param event InputEvent from the filter input field. + * @param container stats table container element. + * @private + */ +function filterStats(event, container) { + const filter = event.target.value; + const filters = filter.split(','); + container.childNodes.forEach(node => { + if (node.nodeName !== 'DETAILS') { + return; + } + const statsType = node.attributes['data-statsType']; + if (!filter || filters.includes(statsType) || + filters.find(f => statsType.includes(f))) { + node.style.display = 'block'; + } else { + node.style.display = 'none'; + } + }); +} diff --git a/ts/windows/callingtools/stats_helper.js b/ts/windows/callingtools/stats_helper.js new file mode 100644 index 000000000000..0c41d67851de --- /dev/null +++ b/ts/windows/callingtools/stats_helper.js @@ -0,0 +1,57 @@ +// Derived from Chromium WebRTC Internals Dashboard - see Acknowledgements for full license details + +/** + * @param {!Object} statsValues The object containing stats, an + * array [key1, val1, key2, val2, ...] so searching a certain + * key needs to ensure it does not collide with a value. + */ +function generateLabel(key, statsValues) { + let label = ''; + const statIndex = statsValues.findIndex((value, index) => { + return value === key && index % 2 === 0; + }); + if (statIndex !== -1) { + label += key + '=' + statsValues[statIndex + 1]; + } + return label; +} + +/** + * Formats the display text used for a stats type that is shown + * in the stats table or the stats graph. + * + * @param {!Object} report The object containing stats, which is the object + * containing timestamp and values, which is an array of strings, whose + * even index entry is the name of the stat, and the odd index entry is + * the value. + */ +export function generateStatsLabel(report) { + let label = report.type + ' ('; + let labels = []; + if (['outbound-rtp', 'remote-outbound-rtp', 'inbound-rtp', + 'remote-inbound-rtp'].includes(report.type) && report.stats.values) { + labels = ['kind', 'mid', 'rid', 'ssrc', 'rtxSsrc', 'fecSsrc', + 'scalabilityMode', + 'encoderImplementation', 'decoderImplementation', + 'powerEfficientEncoder', 'powerEfficientDecoder', + '[codec]']; + } else if (['local-candidate', 'remote-candidate'].includes(report.type)) { + labels = ['candidateType', 'tcpType', 'relayProtocol']; + } else if (report.type === 'codec') { + labels = ['mimeType', 'payloadType']; + } else if (['media-playout', 'media-source'].includes(report.type)) { + labels = ['kind']; + } else if (report.type === 'candidate-pair') { + labels = ['state']; + } else if (report.type === 'transport') { + labels = ['iceState', 'dtlsState']; + } + labels = labels + .map(stat => generateLabel(stat, report.stats.values)) + .filter(label => !!label); + if (labels.length) { + label += labels.join(', ') + ', '; + } + label += 'id=' + report.id + ')'; + return label; +} diff --git a/ts/windows/callingtools/stats_rates_calculator.js b/ts/windows/callingtools/stats_rates_calculator.js new file mode 100644 index 000000000000..8549fab7415c --- /dev/null +++ b/ts/windows/callingtools/stats_rates_calculator.js @@ -0,0 +1,609 @@ +// Derived from Chromium WebRTC Internals Dashboard - see Acknowledgements for full license details + +const CalculatorModifier = Object.freeze({ + kNone: Object.freeze({postfix: '', multiplier: 1}), + kMillisecondsFromSeconds: + Object.freeze({postfix: '_in_ms', multiplier: 1000}), + kBytesToBits: Object.freeze({bitrate: true, multiplier: 8}), +}); + +class Metric { + constructor(name, value) { + this.name = name; + this.value = value; + } + + toString() { + return '{"' + this.name + '":"' + this.value + '"}'; + } +} + +// Represents a companion dictionary to an RTCStats object of an RTCStatsReport. +// The CalculatedStats object contains additional metrics associated with the +// original RTCStats object. Typically, the RTCStats object contains +// accumulative counters, but in chrome://webrc-internals/ we also want graphs +// for the average rate over the last second, so we have CalculatedStats +// containing calculated Metrics. +class CalculatedStats { + constructor(id) { + this.id = id; + // A map Original Name -> Array of Metrics, where Original Name refers to + // the name of the metric in the original RTCStats object, and the Metrics + // are calculated metrics. For example, if the original RTCStats report + // contains framesReceived, and from that we've calculated + // [framesReceived/s] and [framesReceived-framesDecoded], then there will be + // a mapping from "framesReceived" to an array of two Metric objects, + // "[framesReceived/s]" and "[framesReceived-framesDecoded]". + this.calculatedMetricsByOriginalName = new Map(); + } + + addCalculatedMetric(originalName, metric) { + let calculatedMetrics = + this.calculatedMetricsByOriginalName.get(originalName); + if (!calculatedMetrics) { + calculatedMetrics = []; + this.calculatedMetricsByOriginalName.set(originalName, calculatedMetrics); + } + calculatedMetrics.push(metric); + } + + // Gets the calculated metrics associated with |originalName| in the order + // that they were added, or an empty list if there are no associated metrics. + getCalculatedMetrics(originalName) { + const calculatedMetrics = + this.calculatedMetricsByOriginalName.get(originalName); + if (!calculatedMetrics) { + return []; + } + return calculatedMetrics; + } + + toString() { + let str = '{id:"' + this.id + '"'; + for (const originalName of this.calculatedMetricsByOriginalName.keys()) { + const calculatedMetrics = + this.calculatedMetricsByOriginalName.get(originalName); + str += ',' + originalName + ':['; + for (let i = 0; i < calculatedMetrics.length; i++) { + str += calculatedMetrics[i].toString(); + if (i + 1 < calculatedMetrics.length) { + str += ','; + } + str += ']'; + } + } + str += '}'; + return str; + } +} + +// Contains the metrics of an RTCStatsReport, as well as calculated metrics +// associated with metrics from the original report. Convertible to and from the +// "internal reports" format used by webrtc_internals.js to pass stats from C++ +// to JavaScript. +export class StatsReport { + constructor() { + // Represents an RTCStatsReport. It is a Map RTCStats.id -> RTCStats. + // https://w3c.github.io/webrtc-pc/#dom-rtcstatsreport + this.statsById = new Map(); + // RTCStats.id -> CalculatedStats + this.calculatedStatsById = new Map(); + } + + // |internalReports| is an array, each element represents an RTCStats object, + // but the format is a little different from the spec. This is the format: + // { + // id: "string", + // type: "string", + // stats: { + // timestamp: , + // values: ["member1", value1, "member2", value2...] + // } + // } + static fromInternalsReportList(internalReports) { + const result = new StatsReport(); + internalReports.forEach(internalReport => { + if (!internalReport.stats || !internalReport.stats.values) { + return; // continue; + } + const stats = { + id: internalReport.id, + type: internalReport.type, + timestamp: internalReport.stats.timestamp / 1000.0 // ms -> s + }; + const values = internalReport.stats.values; + for (let i = 0; i < values.length; i += 2) { + // Metric "name: value". + stats[values[i]] = values[i + 1]; + } + result.statsById.set(stats.id, stats); + }); + return result; + } + + toInternalsReportList() { + const result = []; + for (const stats of this.statsById.values()) { + const internalReport = { + id: stats.id, + type: stats.type, + stats: { + timestamp: stats.timestamp * 1000.0, // s -> ms + values: [] + } + }; + Object.keys(stats).forEach(metricName => { + if (metricName === 'id' || metricName === 'type' || + metricName === 'timestamp') { + return; // continue; + } + internalReport.stats.values.push(metricName); + internalReport.stats.values.push(stats[metricName]); + const calculatedMetrics = + this.getCalculatedMetrics(stats.id, metricName); + calculatedMetrics.forEach(calculatedMetric => { + internalReport.stats.values.push(calculatedMetric.name); + // Treat calculated metrics that are undefined as 0 to ensure graphs + // can be created anyway. + internalReport.stats.values.push( + calculatedMetric.value ? calculatedMetric.value : 0); + }); + }); + result.push(internalReport); + } + return result; + } + + toString() { + let str = ''; + for (const stats of this.statsById.values()) { + if (str !== '') { + str += ','; + } + str += JSON.stringify(stats); + } + let str2 = ''; + for (const stats of this.calculatedStatsById.values()) { + if (str2 !== '') { + str2 += ','; + } + str2 += stats.toString(); + } + return '[original:' + str + '],calculated:[' + str2 + ']'; + } + + get(id) { + return this.statsById.get(id); + } + + getByType(type) { + const result = []; + for (const stats of this.statsById.values()) { + if (stats.type === type) { + result.push(stats); + } + } + return result; + } + + addCalculatedMetric(id, insertAtOriginalMetricName, name, value) { + let calculatedStats = this.calculatedStatsById.get(id); + if (!calculatedStats) { + calculatedStats = new CalculatedStats(id); + this.calculatedStatsById.set(id, calculatedStats); + } + calculatedStats.addCalculatedMetric( + insertAtOriginalMetricName, new Metric(name, value)); + } + + getCalculatedMetrics(id, originalMetricName) { + const calculatedStats = this.calculatedStatsById.get(id); + return calculatedStats ? + calculatedStats.getCalculatedMetrics(originalMetricName) : + []; + } +} + +// Shows a `DOMHighResTimeStamp` as a human readable date time. +// The metric must be a time value in milliseconds with Unix epoch as time +// origin. +class DateCalculator { + constructor(metric) { + this.metric = metric; + } + getCalculatedMetricName() { + return '[' + this.metric + ']'; + } + calculate(id, previousReport, currentReport) { + const timestamp = currentReport.get(id)[this.metric]; + const date = new Date(timestamp); + return date.toLocaleString(); + } +} + +// Calculates the rate "delta accumulative / delta samples" and returns it. If +// a rate cannot be calculated, such as the metric is missing in the current +// or previous report, undefined is returned. +class RateCalculator { + constructor( + accumulativeMetric, samplesMetric, modifier = CalculatorModifier.kNone) { + this.accumulativeMetric = accumulativeMetric; + this.samplesMetric = samplesMetric; + this.modifier = modifier; + } + + getCalculatedMetricName() { + const accumulativeMetric = this.modifier.bitrate ? + this.accumulativeMetric + '_in_bits' : + this.accumulativeMetric; + if (this.samplesMetric === 'timestamp') { + return '[' + accumulativeMetric + '/s]'; + } + return '[' + accumulativeMetric + '/' + this.samplesMetric + + this.modifier.postfix + ']'; + } + + calculate(id, previousReport, currentReport) { + return RateCalculator.calculateRate( + id, previousReport, currentReport, this.accumulativeMetric, + this.samplesMetric) * + this.modifier.multiplier; + } + + static calculateRate( + id, previousReport, currentReport, accumulativeMetric, samplesMetric) { + if (!previousReport || !currentReport) { + return undefined; + } + const previousStats = previousReport.get(id); + const currentStats = currentReport.get(id); + if (!previousStats || !currentStats) { + return undefined; + } + const deltaTime = currentStats.timestamp - previousStats.timestamp; + if (deltaTime <= 0) { + return undefined; + } + // Try to convert whatever the values are to numbers. This gets around the + // fact that some types that are not supported by base::Value (e.g. uint32, + // int64, uint64 and double) are passed as strings. + const previousValue = Number(previousStats[accumulativeMetric]); + const currentValue = Number(currentStats[accumulativeMetric]); + if (typeof previousValue !== 'number' || typeof currentValue !== 'number') { + return undefined; + } + const previousSamples = Number(previousStats[samplesMetric]); + const currentSamples = Number(currentStats[samplesMetric]); + if (typeof previousSamples !== 'number' || + typeof currentSamples !== 'number') { + return undefined; + } + const deltaValue = currentValue - previousValue; + const deltaSamples = currentSamples - previousSamples; + return deltaValue / deltaSamples; + } +} + +// Looks up codec and payload type from a codecId reference, constructing an +// informative string about which codec is used. +class CodecCalculator { + getCalculatedMetricName() { + return '[codec]'; + } + + calculate(id, previousReport, currentReport) { + const targetStats = currentReport.get(id); + const codecStats = currentReport.get(targetStats.codecId); + if (!codecStats) { + return undefined; + } + // If mimeType is 'video/VP8' then codec is 'VP8'. + const codec = + codecStats.mimeType.substr(codecStats.mimeType.indexOf('/') + 1); + + let fmtpLine = ''; + if (codecStats.sdpFmtpLine) { + fmtpLine = ', ' + codecStats.sdpFmtpLine; + } + return codec + ' (' + codecStats.payloadType + fmtpLine + ')'; + } +} + +// Calculates "RMS" audio level, which is the average audio level between the +// previous and current report, in the interval [0,1]. Calculated per: +// https://w3c.github.io/webrtc-stats/#dom-rtcinboundrtpstreamstats-totalaudioenergy +class AudioLevelRmsCalculator { + getCalculatedMetricName() { + return '[Audio_Level_in_RMS]'; + } + + calculate(id, previousReport, currentReport) { + const averageAudioLevelSquared = RateCalculator.calculateRate( + id, previousReport, currentReport, 'totalAudioEnergy', + 'totalSamplesDuration'); + return Math.sqrt(averageAudioLevelSquared); + } +} + +// Calculates "metricA - SUM(otherMetrics)", only looking at the current report. +class DifferenceCalculator { + constructor(metricA, ...otherMetrics) { + this.metricA = metricA; + this.otherMetrics = otherMetrics; + } + + getCalculatedMetricName() { + return '[' + this.metricA + '-' + this.otherMetrics.join('-') + ']'; + } + + calculate(id, previousReport, currentReport) { + const currentStats = currentReport.get(id); + return parseInt(currentStats[this.metricA], 10) + - this.otherMetrics.map(metric => parseInt(currentStats[metric], 10)) + .reduce((a, b) => a + b, 0); + } +} + +// Calculates the standard deviation from a totalSquaredSum, totalSum, and +// totalCount. If the standard deviation cannot be calculated, such as the +// metric is missing in the current or previous report, undefined is returned. +class StandardDeviationCalculator { + constructor(totalSquaredSumMetric, totalSumMetric, totalCount, label) { + this.totalSquaredSumMetric = totalSquaredSumMetric; + this.totalSumMetric = totalSumMetric; + this.totalCount = totalCount; + this.label = label; + } + + getCalculatedMetricName() { + return '[' + this.label + 'StDev_in_ms]'; + } + + calculate(id, previousReport, currentReport) { + return StandardDeviationCalculator.calculateStandardDeviation( + id, previousReport, currentReport, this.totalSquaredSumMetric, + this.totalSumMetric, this.totalCount); + } + + static calculateStandardDeviation( + id, previousReport, currentReport, totalSquaredSumMetric, totalSumMetric, + totalCount) { + if (!previousReport || !currentReport) { + return undefined; + } + const previousStats = previousReport.get(id); + const currentStats = currentReport.get(id); + if (!previousStats || !currentStats) { + return undefined; + } + const deltaCount = + Number(currentStats[totalCount]) - Number(previousStats[totalCount]); + if (deltaCount <= 0) { + return undefined; + } + // Try to convert whatever the values are to numbers. This gets around the + // fact that some types that are not supported by base::Value (e.g. uint32, + // int64, uint64 and double) are passed as strings. + const previousSquaredSumValue = + Number(previousStats[totalSquaredSumMetric]); + const currentSquaredSumValue = Number(currentStats[totalSquaredSumMetric]); + if (typeof previousSquaredSumValue !== 'number' || + typeof currentSquaredSumValue !== 'number') { + return undefined; + } + const previousSumValue = Number(previousStats[totalSumMetric]); + const currentSumValue = Number(currentStats[totalSumMetric]); + if (typeof previousSumValue !== 'number' || + typeof currentSumValue !== 'number') { + return undefined; + } + + const deltaSquaredSum = currentSquaredSumValue - previousSquaredSumValue; + const deltaSum = currentSumValue - previousSumValue; + const variance = + (deltaSquaredSum - Math.pow(deltaSum, 2) / deltaCount) / deltaCount; + if (variance < 0) { + return undefined; + } + return 1000 * Math.sqrt(variance); + } +} + +// Keeps track of previous and current stats report and calculates all +// calculated metrics. +export class StatsRatesCalculator { + constructor() { + this.previousReport = null; + this.currentReport = null; + this.statsCalculators = [ + { + type: 'data-channel', + metricCalculators: { + messagesSent: new RateCalculator('messagesSent', 'timestamp'), + messagesReceived: new RateCalculator('messagesReceived', 'timestamp'), + bytesSent: new RateCalculator( + 'bytesSent', 'timestamp', CalculatorModifier.kBytesToBits), + bytesReceived: new RateCalculator( + 'bytesReceived', 'timestamp', CalculatorModifier.kBytesToBits), + }, + }, + { + type: 'media-source', + metricCalculators: { + totalAudioEnergy: new AudioLevelRmsCalculator(), + }, + }, + { + type: 'outbound-rtp', + metricCalculators: { + bytesSent: new RateCalculator( + 'bytesSent', 'timestamp', CalculatorModifier.kBytesToBits), + headerBytesSent: new RateCalculator( + 'headerBytesSent', 'timestamp', CalculatorModifier.kBytesToBits), + retransmittedBytesSent: new RateCalculator( + 'retransmittedBytesSent', 'timestamp', + CalculatorModifier.kBytesToBits), + packetsSent: new RateCalculator('packetsSent', 'timestamp'), + retransmittedPacketsSent: + new RateCalculator('retransmittedPacketsSent', 'timestamp'), + totalPacketSendDelay: new RateCalculator( + 'totalPacketSendDelay', 'packetsSent', + CalculatorModifier.kMillisecondsFromSeconds), + framesEncoded: new RateCalculator('framesEncoded', 'timestamp'), + framesSent: new RateCalculator('framesSent', 'timestamp'), + totalEncodedBytesTarget: new RateCalculator( + 'totalEncodedBytesTarget', 'timestamp', + CalculatorModifier.kBytesToBits), + totalEncodeTime: new RateCalculator( + 'totalEncodeTime', 'framesEncoded', + CalculatorModifier.kMillisecondsFromSeconds), + qpSum: new RateCalculator('qpSum', 'framesEncoded'), + codecId: new CodecCalculator(), + }, + }, + { + type: 'inbound-rtp', + metricCalculators: { + bytesReceived: new RateCalculator( + 'bytesReceived', 'timestamp', CalculatorModifier.kBytesToBits), + headerBytesReceived: new RateCalculator( + 'headerBytesReceived', 'timestamp', + CalculatorModifier.kBytesToBits), + retransmittedBytesReceived: new RateCalculator( + 'retransmittedBytesReceived', 'timestamp', + CalculatorModifier.kBytesToBits), + fecBytesReceived: new RateCalculator( + 'fecBytesReceived', 'timestamp', + CalculatorModifier.kBytesToBits), + packetsReceived: new RateCalculator('packetsReceived', 'timestamp'), + packetsDiscarded: new RateCalculator('packetsDiscarded', 'timestamp'), + retransmittedPacketsReceived: + new RateCalculator('retransmittedPacketsReceived', 'timestamp'), + fecPacketsReceived: + new RateCalculator('fecPacketsReceived', 'timestamp'), + fecPacketsDiscarded: + new RateCalculator('fecPacketsDiscarded', 'timestamp'), + framesReceived: [ + new RateCalculator('framesReceived', 'timestamp'), + new DifferenceCalculator('framesReceived', + 'framesDecoded', 'framesDropped'), + ], + framesDecoded: new RateCalculator('framesDecoded', 'timestamp'), + keyFramesDecoded: new RateCalculator('keyFramesDecoded', 'timestamp'), + totalDecodeTime: new RateCalculator( + 'totalDecodeTime', 'framesDecoded', + CalculatorModifier.kMillisecondsFromSeconds), + totalInterFrameDelay: new RateCalculator( + 'totalInterFrameDelay', 'framesDecoded', + CalculatorModifier.kMillisecondsFromSeconds), + totalSquaredInterFrameDelay: new StandardDeviationCalculator( + 'totalSquaredInterFrameDelay', 'totalInterFrameDelay', + 'framesDecoded', 'interFrameDelay'), + totalSamplesReceived: + new RateCalculator('totalSamplesReceived', 'timestamp'), + concealedSamples: [ + new RateCalculator('concealedSamples', 'timestamp'), + new RateCalculator('concealedSamples', 'totalSamplesReceived'), + ], + silentConcealedSamples: + new RateCalculator('silentConcealedSamples', 'timestamp'), + insertedSamplesForDeceleration: + new RateCalculator('insertedSamplesForDeceleration', 'timestamp'), + removedSamplesForAcceleration: + new RateCalculator('removedSamplesForAcceleration', 'timestamp'), + qpSum: new RateCalculator('qpSum', 'framesDecoded'), + codecId: new CodecCalculator(), + totalAudioEnergy: new AudioLevelRmsCalculator(), + jitterBufferDelay: new RateCalculator( + 'jitterBufferDelay', 'jitterBufferEmittedCount', + CalculatorModifier.kMillisecondsFromSeconds), + jitterBufferTargetDelay: new RateCalculator( + 'jitterBufferTargetDelay', 'jitterBufferEmittedCount', + CalculatorModifier.kMillisecondsFromSeconds), + jitterBufferMinimumDelay: new RateCalculator( + 'jitterBufferMinimumDelay', 'jitterBufferEmittedCount', + CalculatorModifier.kMillisecondsFromSeconds), + lastPacketReceivedTimestamp: new DateCalculator( + 'lastPacketReceivedTimestamp'), + estimatedPlayoutTimestamp: new DateCalculator( + 'estimatedPlayoutTimestamp'), + totalProcessingDelay: new RateCalculator( + 'totalProcessingDelay', 'framesDecoded', + CalculatorModifier.kMillisecondsFromSeconds), + totalAssemblyTime: new RateCalculator( + 'totalAssemblyTime', 'framesAssembledFromMultiplePackets', + CalculatorModifier.kMillisecondsFromSeconds), + }, + }, + { + type: 'remote-outbound-rtp', + metricCalculators: { + remoteTimestamp: new DateCalculator('remoteTimestamp'), + }, + }, + { + type: 'transport', + metricCalculators: { + bytesSent: new RateCalculator( + 'bytesSent', 'timestamp', CalculatorModifier.kBytesToBits), + bytesReceived: new RateCalculator( + 'bytesReceived', 'timestamp', CalculatorModifier.kBytesToBits), + packetsSent: new RateCalculator( + 'packetsSent', 'timestamp'), + packetsReceived: new RateCalculator( + 'packetsReceived', 'timestamp'), + }, + }, + { + type: 'candidate-pair', + metricCalculators: { + bytesSent: new RateCalculator( + 'bytesSent', 'timestamp', CalculatorModifier.kBytesToBits), + bytesReceived: new RateCalculator( + 'bytesReceived', 'timestamp', CalculatorModifier.kBytesToBits), + packetsSent: new RateCalculator( + 'packetsSent', 'timestamp'), + packetsReceived: new RateCalculator( + 'packetsReceived', 'timestamp'), + totalRoundTripTime: + new RateCalculator('totalRoundTripTime', 'responsesReceived'), + lastPacketReceivedTimestamp: new DateCalculator( + 'lastPacketReceivedTimestamp'), + lastPacketSentTimestamp: new DateCalculator( + 'lastPacketSentTimestamp'), + }, + }, + ]; + } + + addStatsReport(report) { + this.previousReport = this.currentReport; + this.currentReport = report; + this.updateCalculatedMetrics_(); + } + + // Updates all "calculated metrics", which are metrics derived from standard + // values, such as converting total counters (e.g. bytesSent) to rates (e.g. + // bytesSent/s). + updateCalculatedMetrics_() { + this.statsCalculators.forEach(statsCalculator => { + this.currentReport.getByType(statsCalculator.type).forEach(stats => { + Object.keys(statsCalculator.metricCalculators) + .forEach(originalMetric => { + let metricCalculators = + statsCalculator.metricCalculators[originalMetric]; + if (!Array.isArray(metricCalculators)) { + metricCalculators = [metricCalculators]; + } + metricCalculators.forEach(metricCalculator => { + this.currentReport.addCalculatedMetric( + stats.id, originalMetric, + metricCalculator.getCalculatedMetricName(), + metricCalculator.calculate( + stats.id, this.previousReport, this.currentReport)); + }); + }); + }); + }); + } +} diff --git a/ts/windows/callingtools/stats_table.js b/ts/windows/callingtools/stats_table.js new file mode 100644 index 000000000000..bcf98c177327 --- /dev/null +++ b/ts/windows/callingtools/stats_table.js @@ -0,0 +1,219 @@ +// Derived from Chromium WebRTC Internals Dashboard - see Acknowledgements for full license details + +import {$} from './util.js'; + +import {generateStatsLabel} from './stats_helper.js'; + +/** + * Maintains the stats table. + */ +export class StatsTable { + constructor() {} + + /** + * Adds |report| to the stats table of |peerConnectionElement|. + * + * @param {!Element} peerConnectionElement The root element. + * @param {!Object} report The object containing stats, which is the object + * containing timestamp and values, which is an array of strings, whose + * even index entry is the name of the stat, and the odd index entry is + * the value. + */ + addStatsReport(peerConnectionElement, report) { + const statsTable = this.ensureStatsTable_(peerConnectionElement, report); + + // Update the label since information may have changed. + statsTable.parentElement.firstElementChild.innerText = + generateStatsLabel(report); + + if (report.stats) { + this.addStatsToTable_( + statsTable, report.stats.timestamp, report.stats.values); + } + } + + clearStatsLists(peerConnectionElement) { + const containerId = peerConnectionElement.id + '-table-container'; + // Disable getElementById restriction here, since |containerId| is not + // always a valid selector. + // eslint-disable-next-line no-restricted-properties + const container = document.getElementById(containerId); + if (container) { + peerConnectionElement.removeChild(container); + this.ensureStatsTableContainer_(peerConnectionElement); + } + } + + /** + * Ensure the DIV container for the stats tables is created as a child of + * |peerConnectionElement|. + * + * @param {!Element} peerConnectionElement The root element. + * @return {!Element} The stats table container. + * @private + */ + ensureStatsTableContainer_(peerConnectionElement) { + const containerId = peerConnectionElement.id + '-table-container'; + // Disable getElementById restriction here, since |containerId| is not + // always a valid selector. + // eslint-disable-next-line no-restricted-properties + let container = document.getElementById(containerId); + if (!container) { + container = document.createElement('div'); + container.id = containerId; + container.className = 'stats-table-container'; + const head = document.createElement('div'); + head.textContent = 'Stats Tables'; + container.appendChild(head); + const label = document.createElement('label'); + label.innerText = 'Filter statistics by type including '; + container.appendChild(label); + const input = document.createElement('input'); + input.placeholder = 'separate multiple values by `,`'; + input.size = 25; + input.oninput = (e) => this.filterStats(e, container); + container.appendChild(input); + peerConnectionElement.appendChild(container); + } + return container; + } + + /** + * Ensure the stats table for track specified by |report| of PeerConnection + * |peerConnectionElement| is created. + * + * @param {!Element} peerConnectionElement The root element. + * @param {!Object} report The object containing stats, which is the object + * containing timestamp and values, which is an array of strings, whose + * even index entry is the name of the stat, and the odd index entry is + * the value. + * @return {!Element} The stats table element. + * @private + */ + ensureStatsTable_(peerConnectionElement, report) { + const tableId = peerConnectionElement.id + '-table-' + report.id; + // Disable getElementById restriction here, since |tableId| is not + // always a valid selector. + // eslint-disable-next-line no-restricted-properties + let table = document.getElementById(tableId); + if (!table) { + const container = this.ensureStatsTableContainer_(peerConnectionElement); + const details = document.createElement('details'); + details.attributes['data-statsType'] = report.type; + container.appendChild(details); + + const summary = document.createElement('summary'); + summary.textContent = generateStatsLabel(report); + details.appendChild(summary); + + table = document.createElement('table'); + details.appendChild(table); + table.id = tableId; + table.border = 1; + + table.appendChild($('trth-template').content.cloneNode(true)); + table.rows[0].cells[0].textContent = 'Statistics ' + report.id; + } + return table; + } + + /** + * Update |statsTable| with |time| and |statsData|. + * + * @param {!Element} statsTable Which table to update. + * @param {number} time The number of milliseconds since epoch. + * @param {Array} statsData An array of stats name and value pairs. + * @private + */ + addStatsToTable_(statsTable, time, statsData) { + const definedMetrics = new Set(); + for (let i = 0; i < statsData.length - 1; i = i + 2) { + definedMetrics.add(statsData[i]); + } + // For any previously reported metric that is no longer defined, replace its + // now obsolete value with the magic string "(removed)". + const metricsContainer = statsTable.firstChild; + for (let i = 0; i < metricsContainer.children.length; ++i) { + const metricElement = metricsContainer.children[i]; + // `metricElement` IDs have the format `bla-bla-bla-bla-${metricName}`. + let metricName = + metricElement.id.substring(metricElement.id.lastIndexOf('-') + 1); + if (metricName.endsWith(']')) { + // Computed metrics may contain the '-' character (e.g. + // `DifferenceCalculator` based metrics) in which case `metricName` will + // not have been parsed correctly. Instead look for starting '['. + metricName = + metricElement.id.substring(metricElement.id.indexOf('[')); + } + if (metricName && metricName != 'timestamp' && + !definedMetrics.has(metricName)) { + this.updateStatsTableRow_(statsTable, metricName, '(removed)'); + } + } + // Add or update all "metric: value" that have a defined value. + const date = new Date(time); + this.updateStatsTableRow_(statsTable, 'timestamp', date.toLocaleString()); + for (let i = 0; i < statsData.length - 1; i = i + 2) { + this.updateStatsTableRow_(statsTable, statsData[i], statsData[i + 1]); + } + } + + /** + * Update the value column of the stats row of |rowName| to |value|. + * A new row is created is this is the first report of this stats. + * + * @param {!Element} statsTable Which table to update. + * @param {string} rowName The name of the row to update. + * @param {string} value The new value to set. + * @private + */ + updateStatsTableRow_(statsTable, rowName, value) { + const trId = statsTable.id + '-' + rowName; + // Disable getElementById restriction here, since |trId| is not always + // a valid selector. + // eslint-disable-next-line no-restricted-properties + let trElement = document.getElementById(trId); + const activeConnectionClass = 'stats-table-active-connection'; + if (!trElement) { + trElement = document.createElement('tr'); + trElement.id = trId; + statsTable.firstChild.appendChild(trElement); + const item = $('td2-template').content.cloneNode(true); + item.querySelector('td').textContent = rowName; + trElement.appendChild(item); + } + trElement.cells[1].textContent = value; + + // Highlights the table for the active connection. + if (rowName === 'googActiveConnection') { + if (value === true) { + statsTable.parentElement.classList.add(activeConnectionClass); + } else { + statsTable.parentElement.classList.remove(activeConnectionClass); + } + } + } + + /** + * Apply a filter to the stats table + * @param event InputEvent from the filter input field. + * @param container stats table container element. + * @private + */ + filterStats(event, container) { + const filter = event.target.value; + const filters = filter.split(','); + container.childNodes.forEach(node => { + if (node.nodeName !== 'DETAILS') { + return; + } + const statsType = node.attributes['data-statsType']; + if (!filter || filters.includes(statsType) || + filters.find(f => statsType.includes(f))) { + node.style.display = 'block'; + } else { + node.style.display = 'none'; + } + }); + } +} diff --git a/ts/windows/callingtools/tab_view.js b/ts/windows/callingtools/tab_view.js new file mode 100644 index 000000000000..daf5449366d6 --- /dev/null +++ b/ts/windows/callingtools/tab_view.js @@ -0,0 +1,115 @@ +// Derived from Chromium WebRTC Internals Dashboard - see Acknowledgements for full license details + +// Creates a simple object containing the tab head and body elements. +class TabDom { + constructor(h, b) { + this.head = h; + this.body = b; + } +} + +/** + * A TabView provides the ability to create tabs and switch between tabs. It's + * responsible for creating the DOM and managing the visibility of each tab. + * The first added tab is active by default and the others hidden. + */ +export class TabView { + /** + * @param {Element} root The root DOM element containing the tabs. + */ + constructor(root) { + this.root_ = root; + this.ACTIVE_TAB_HEAD_CLASS_ = 'active-tab-head'; + this.ACTIVE_TAB_BODY_CLASS_ = 'active-tab-body'; + this.TAB_HEAD_CLASS_ = 'tab-head'; + this.TAB_BODY_CLASS_ = 'tab-body'; + + /** + * A mapping for an id to the tab elements. + * @type {!Object} + * @private + */ + this.tabElements_ = {}; + + this.headBar_ = null; + this.activeTabId_ = null; + this.initializeHeadBar_(); + } + + /** + * Adds a tab with the specified id and title. + * @param {string} id + * @param {string} title + * @return {!Element} The tab body element. + */ + addTab(id, title) { + if (this.tabElements_[id]) { + throw 'Tab already exists: ' + id; + } + + const head = document.createElement('span'); + head.className = this.TAB_HEAD_CLASS_; + head.textContent = title; + head.title = title; + this.headBar_.appendChild(head); + head.addEventListener('click', this.switchTab_.bind(this, id)); + + const body = document.createElement('div'); + body.className = this.TAB_BODY_CLASS_; + body.id = id; + this.root_.appendChild(body); + + this.tabElements_[id] = new TabDom(head, body); + + if (!this.activeTabId_) { + this.switchTab_(id); + } + return this.tabElements_[id].body; + } + + /** Removes the tab. @param {string} id */ + removeTab(id) { + if (!this.tabElements_[id]) { + return; + } + this.tabElements_[id].head.parentNode.removeChild( + this.tabElements_[id].head); + this.tabElements_[id].body.parentNode.removeChild( + this.tabElements_[id].body); + + delete this.tabElements_[id]; + if (this.activeTabId_ === id) { + this.switchTab_(Object.keys(this.tabElements_)[0]); + } + } + + /** + * Switches the specified tab into view. + * + * @param {string} activeId The id the of the tab that should be switched to + * active state. + * @private + */ + switchTab_(activeId) { + if (this.activeTabId_ && this.tabElements_[this.activeTabId_]) { + this.tabElements_[this.activeTabId_].body.classList.remove( + this.ACTIVE_TAB_BODY_CLASS_); + this.tabElements_[this.activeTabId_].head.classList.remove( + this.ACTIVE_TAB_HEAD_CLASS_); + } + this.activeTabId_ = activeId; + if (this.tabElements_[activeId]) { + this.tabElements_[activeId].body.classList.add( + this.ACTIVE_TAB_BODY_CLASS_); + this.tabElements_[activeId].head.classList.add( + this.ACTIVE_TAB_HEAD_CLASS_); + } + } + + /** Initializes the bar containing the tab heads. */ + initializeHeadBar_() { + this.headBar_ = document.createElement('div'); + this.root_.appendChild(this.headBar_); + this.headBar_.style.textAlign = 'center'; + } +} diff --git a/ts/windows/callingtools/timeline_graph_view.js b/ts/windows/callingtools/timeline_graph_view.js new file mode 100644 index 000000000000..7dbe6e1c21eb --- /dev/null +++ b/ts/windows/callingtools/timeline_graph_view.js @@ -0,0 +1,548 @@ +// Derived from Chromium WebRTC Internals Dashboard - see Acknowledgements for full license details + +// Maximum number of labels placed vertically along the sides of the graph. +const MAX_VERTICAL_LABELS = 6; + +// Vertical spacing between labels and between the graph and labels. +const LABEL_VERTICAL_SPACING = 4; +// Horizontal spacing between vertically placed labels and the edges of the +// graph. +const LABEL_HORIZONTAL_SPACING = 3; +// Horizintal spacing between two horitonally placed labels along the bottom +// of the graph. +const LABEL_LABEL_HORIZONTAL_SPACING = 25; + +// Length of ticks, in pixels, next to y-axis labels. The x-axis only has +// one set of labels, so it can use lines instead. +const Y_AXIS_TICK_LENGTH = 10; + +const GRID_COLOR = '#CCC'; +const TEXT_COLOR = '#000'; +const BACKGROUND_COLOR = '#FFF'; + +const MAX_DECIMAL_PRECISION = 3; + +/** + * A TimelineGraphView displays a timeline graph on a canvas element. + */ +export class TimelineGraphView { + constructor(divId, canvasId) { + this.scrollbar_ = {position_: 0, range_: 0}; + + // Disable getElementById restriction here, since |divId| and |canvasId| are + // not always valid selectors. + // eslint-disable-next-line no-restricted-properties + this.graphDiv_ = document.getElementById(divId); + // eslint-disable-next-line no-restricted-properties + this.canvas_ = document.getElementById(canvasId); + + // Set the range and scale of the graph. Times are in milliseconds since + // the Unix epoch. + + // All measurements we have must be after this time. + this.startTime_ = 0; + // The current rightmost position of the graph is always at most this. + this.endTime_ = 1; + + this.graph_ = null; + + // Horizontal scale factor, in terms of milliseconds per pixel. + this.scale_ = 1000; + + // Initialize the scrollbar. + this.updateScrollbarRange_(true); + } + + setScale(scale) { + this.scale_ = scale; + } + + // Returns the total length of the graph, in pixels. + getLength_() { + const timeRange = this.endTime_ - this.startTime_; + // Math.floor is used to ignore the last partial area, of length less + // than this.scale_. + return Math.floor(timeRange / this.scale_); + } + + /** + * Returns true if the graph is scrolled all the way to the right. + */ + graphScrolledToRightEdge_() { + return this.scrollbar_.position_ === this.scrollbar_.range_; + } + + /** + * Update the range of the scrollbar. If |resetPosition| is true, also + * sets the slider to point at the rightmost position and triggers a + * repaint. + */ + updateScrollbarRange_(resetPosition) { + let scrollbarRange = this.getLength_() - this.canvas_.width; + if (scrollbarRange < 0) { + scrollbarRange = 0; + } + + // If we've decreased the range to less than the current scroll position, + // we need to move the scroll position. + if (this.scrollbar_.position_ > scrollbarRange) { + resetPosition = true; + } + + this.scrollbar_.range_ = scrollbarRange; + if (resetPosition) { + this.scrollbar_.position_ = scrollbarRange; + this.repaint(); + } + } + + /** + * Sets the date range displayed on the graph, switches to the default + * scale factor, and moves the scrollbar all the way to the right. + */ + setDateRange(startDate, endDate) { + this.startTime_ = startDate.getTime(); + this.endTime_ = endDate.getTime(); + + // Safety check. + if (this.endTime_ <= this.startTime_) { + this.startTime_ = this.endTime_ - 1; + } + + this.updateScrollbarRange_(true); + } + + /** + * Updates the end time at the right of the graph to be the current time. + * Specifically, updates the scrollbar's range, and if the scrollbar is + * all the way to the right, keeps it all the way to the right. Otherwise, + * leaves the view as-is and doesn't redraw anything. + */ + updateEndDate(opt_date) { + this.endTime_ = opt_date || (new Date()).getTime(); + this.updateScrollbarRange_(this.graphScrolledToRightEdge_()); + } + + getStartDate() { + return new Date(this.startTime_); + } + + /** + * Replaces the current TimelineDataSeries with |dataSeries|. + */ + setDataSeries(dataSeries) { + // Simply recreates the Graph. + this.graph_ = new Graph(); + for (let i = 0; i < dataSeries.length; ++i) { + this.graph_.addDataSeries(dataSeries[i]); + } + this.repaint(); + } + + /** + * Adds |dataSeries| to the current graph. + */ + addDataSeries(dataSeries) { + if (!this.graph_) { + this.graph_ = new Graph(); + } + this.graph_.addDataSeries(dataSeries); + this.repaint(); + } + + /** + * Draws the graph on |canvas_| when visible. + */ + repaint() { + if (this.canvas_.offsetParent === null) { + return; // do not repaint graphs that are not visible. + } + + this.repaintTimerRunning_ = false; + + const width = this.canvas_.width; + let height = this.canvas_.height; + const context = this.canvas_.getContext('2d'); + + // Clear the canvas. + context.fillStyle = BACKGROUND_COLOR; + context.fillRect(0, 0, width, height); + + // Try to get font height in pixels. Needed for layout. + const fontHeightString = context.font.match(/([0-9]+)px/)[1]; + const fontHeight = parseInt(fontHeightString); + + // Safety check, to avoid drawing anything too ugly. + if (fontHeightString.length === 0 || fontHeight <= 0 || + fontHeight * 4 > height || width < 50) { + return; + } + + // Save current transformation matrix so we can restore it later. + context.save(); + + // The center of an HTML canvas pixel is technically at (0.5, 0.5). This + // makes near straight lines look bad, due to anti-aliasing. This + // translation reduces the problem a little. + context.translate(0.5, 0.5); + + // Figure out what time values to display. + let position = this.scrollbar_.position_; + // If the entire time range is being displayed, align the right edge of + // the graph to the end of the time range. + if (this.scrollbar_.range_ === 0) { + position = this.getLength_() - this.canvas_.width; + } + const visibleStartTime = this.startTime_ + position * this.scale_; + + // Make space at the bottom of the graph for the time labels, and then + // draw the labels. + const textHeight = height; + height -= fontHeight + LABEL_VERTICAL_SPACING; + this.drawTimeLabels(context, width, height, textHeight, visibleStartTime); + + // Draw outline of the main graph area. + context.strokeStyle = GRID_COLOR; + context.strokeRect(0, 0, width - 1, height - 1); + + if (this.graph_) { + // Layout graph and have them draw their tick marks. + this.graph_.layout( + width, height, fontHeight, visibleStartTime, this.scale_); + this.graph_.drawTicks(context); + + // Draw the lines of all graphs, and then draw their labels. + this.graph_.drawLines(context); + this.graph_.drawLabels(context); + } + + // Restore original transformation matrix. + context.restore(); + } + + /** + * Draw time labels below the graph. Takes in start time as an argument + * since it may not be |startTime_|, when we're displaying the entire + * time range. + */ + drawTimeLabels(context, width, height, textHeight, startTime) { + // Draw the labels 1 minute apart. + const timeStep = 1000 * 60; + + // Find the time for the first label. This time is a perfect multiple of + // timeStep because of how UTC times work. + let time = Math.ceil(startTime / timeStep) * timeStep; + + context.textBaseline = 'bottom'; + context.textAlign = 'center'; + context.fillStyle = TEXT_COLOR; + context.strokeStyle = GRID_COLOR; + + // Draw labels and vertical grid lines. + while (true) { + const x = Math.round((time - startTime) / this.scale_); + if (x >= width) { + break; + } + const text = (new Date(time)).toLocaleTimeString(); + context.fillText(text, x, textHeight); + context.beginPath(); + context.lineTo(x, 0); + context.lineTo(x, height); + context.stroke(); + time += timeStep; + } + } + + getDataSeriesCount() { + if (this.graph_) { + return this.graph_.dataSeries_.length; + } + return 0; + } + + hasDataSeries(dataSeries) { + if (this.graph_) { + return this.graph_.hasDataSeries(dataSeries); + } + return false; + } +} + +/** + * A Label is the label at a particular position along the y-axis. + */ +class Label { + constructor(height, text) { + this.height = height; + this.text = text; + } +} + +/** + * A Graph is responsible for drawing all the TimelineDataSeries that have + * the same data type. Graphs are responsible for scaling the values, laying + * out labels, and drawing both labels and lines for its data series. + */ +class Graph { + constructor() { + this.dataSeries_ = []; + + // Cached properties of the graph, set in layout. + this.width_ = 0; + this.height_ = 0; + this.fontHeight_ = 0; + this.startTime_ = 0; + this.scale_ = 0; + + // The lowest/highest values adjusted by the vertical label step size + // in the displayed range of the graph. Used for scaling and setting + // labels. Set in layoutLabels. + this.min_ = 0; + this.max_ = 0; + + // Cached text of equally spaced labels. Set in layoutLabels. + this.labels_ = []; + } + + addDataSeries(dataSeries) { + this.dataSeries_.push(dataSeries); + } + + hasDataSeries(dataSeries) { + for (let i = 0; i < this.dataSeries_.length; ++i) { + if (this.dataSeries_[i] === dataSeries) { + return true; + } + } + return false; + } + + /** + * Returns a list of all the values that should be displayed for a given + * data series, using the current graph layout. + */ + getValues(dataSeries) { + if (!dataSeries.isVisible()) { + return null; + } + return dataSeries.getValues(this.startTime_, this.scale_, this.width_); + } + + /** + * Updates the graph's layout. In particular, both the max value and + * label positions are updated. Must be called before calling any of the + * drawing functions. + */ + layout(width, height, fontHeight, startTime, scale) { + this.width_ = width; + this.height_ = height; + this.fontHeight_ = fontHeight; + this.startTime_ = startTime; + this.scale_ = scale; + + // Find largest value. + let max = 0; + let min = 0; + for (let i = 0; i < this.dataSeries_.length; ++i) { + const values = this.getValues(this.dataSeries_[i]); + if (!values) { + continue; + } + for (let j = 0; j < values.length; ++j) { + if (values[j] > max) { + max = values[j]; + } else if (values[j] < min) { + min = values[j]; + } + } + } + + this.layoutLabels_(min, max); + } + + /** + * Lays out labels and sets |max_|/|min_|, taking the time units into + * consideration. |maxValue| is the actual maximum value, and + * |max_| will be set to the value of the largest label, which + * will be at least |maxValue|. Similar for |min_|. + */ + layoutLabels_(minValue, maxValue) { + if (maxValue - minValue < 1024) { + this.layoutLabelsBasic_(minValue, maxValue, MAX_DECIMAL_PRECISION); + return; + } + + // Find appropriate units to use. + const units = ['', 'k', 'M', 'G', 'T', 'P']; + // Units to use for labels. 0 is '1', 1 is K, etc. + // We start with 1, and work our way up. + let unit = 1; + minValue /= 1024; + maxValue /= 1024; + while (units[unit + 1] && maxValue - minValue >= 1024) { + minValue /= 1024; + maxValue /= 1024; + ++unit; + } + + // Calculate labels. + this.layoutLabelsBasic_(minValue, maxValue, MAX_DECIMAL_PRECISION); + + // Append units to labels. + for (let i = 0; i < this.labels_.length; ++i) { + this.labels_[i] += ' ' + units[unit]; + } + + // Convert |min_|/|max_| back to unit '1'. + this.min_ *= Math.pow(1024, unit); + this.max_ *= Math.pow(1024, unit); + } + + /** + * Same as layoutLabels_, but ignores units. |maxDecimalDigits| is the + * maximum number of decimal digits allowed. The minimum allowed + * difference between two adjacent labels is 10^-|maxDecimalDigits|. + */ + layoutLabelsBasic_(minValue, maxValue, maxDecimalDigits) { + this.labels_ = []; + const range = maxValue - minValue; + // No labels if the range is 0. + if (range === 0) { + this.min_ = this.max_ = maxValue; + return; + } + + // The maximum number of equally spaced labels allowed. |fontHeight_| + // is doubled because the top two labels are both drawn in the same + // gap. + const minLabelSpacing = 2 * this.fontHeight_ + LABEL_VERTICAL_SPACING; + + // The + 1 is for the top label. + let maxLabels = 1 + this.height_ / minLabelSpacing; + if (maxLabels < 2) { + maxLabels = 2; + } else if (maxLabels > MAX_VERTICAL_LABELS) { + maxLabels = MAX_VERTICAL_LABELS; + } + + // Initial try for step size between consecutive labels. + let stepSize = Math.pow(10, -maxDecimalDigits); + // Number of digits to the right of the decimal of |stepSize|. + // Used for formatting label strings. + let stepSizeDecimalDigits = maxDecimalDigits; + + // Pick a reasonable step size. + while (true) { + // If we use a step size of |stepSize| between labels, we'll need: + // + // Math.ceil(range / stepSize) + 1 + // + // labels. The + 1 is because we need labels at both at 0 and at + // the top of the graph. + + // Check if we can use steps of size |stepSize|. + if (Math.ceil(range / stepSize) + 1 <= maxLabels) { + break; + } + // Check |stepSize| * 2. + if (Math.ceil(range / (stepSize * 2)) + 1 <= maxLabels) { + stepSize *= 2; + break; + } + // Check |stepSize| * 5. + if (Math.ceil(range / (stepSize * 5)) + 1 <= maxLabels) { + stepSize *= 5; + break; + } + stepSize *= 10; + if (stepSizeDecimalDigits > 0) { + --stepSizeDecimalDigits; + } + } + + // Set the min/max so it's an exact multiple of the chosen step size. + this.max_ = Math.ceil(maxValue / stepSize) * stepSize; + this.min_ = Math.floor(minValue / stepSize) * stepSize; + + // Create labels. + for (let label = this.max_; label >= this.min_; label -= stepSize) { + this.labels_.push(label.toFixed(stepSizeDecimalDigits)); + } + } + + /** + * Draws tick marks for each of the labels in |labels_|. + */ + drawTicks(context) { + const x1 = this.width_ - 1; + const x2 = this.width_ - 1 - Y_AXIS_TICK_LENGTH; + + context.fillStyle = GRID_COLOR; + context.beginPath(); + for (let i = 1; i < this.labels_.length - 1; ++i) { + // The rounding is needed to avoid ugly 2-pixel wide anti-aliased + // lines. + const y = Math.round(this.height_ * i / (this.labels_.length - 1)); + context.moveTo(x1, y); + context.lineTo(x2, y); + } + context.stroke(); + } + + /** + * Draws a graph line for each of the data series. + */ + drawLines(context) { + // Factor by which to scale all values to convert them to a number from + // 0 to height - 1. + let scale = 0; + const bottom = this.height_ - 1; + if (this.max_) { + scale = bottom / (this.max_ - this.min_); + } + + // Draw in reverse order, so earlier data series are drawn on top of + // subsequent ones. + for (let i = this.dataSeries_.length - 1; i >= 0; --i) { + const values = this.getValues(this.dataSeries_[i]); + if (!values) { + continue; + } + context.strokeStyle = this.dataSeries_[i].getColor(); + context.beginPath(); + for (let x = 0; x < values.length; ++x) { + // The rounding is needed to avoid ugly 2-pixel wide anti-aliased + // horizontal lines. + context.lineTo(x, bottom - Math.round((values[x] - this.min_) * scale)); + } + context.stroke(); + } + } + + /** + * Draw labels in |labels_|. + */ + drawLabels(context) { + if (this.labels_.length === 0) { + return; + } + const x = this.width_ - LABEL_HORIZONTAL_SPACING; + + // Set up the context. + context.fillStyle = TEXT_COLOR; + context.textAlign = 'right'; + + // Draw top label, which is the only one that appears below its tick + // mark. + context.textBaseline = 'top'; + context.fillText(this.labels_[0], x, 0); + + // Draw all the other labels. + context.textBaseline = 'bottom'; + const step = (this.height_ - 1) / (this.labels_.length - 1); + for (let i = 1; i < this.labels_.length; ++i) { + context.fillText(this.labels_[i], x, step * i); + } + } +} diff --git a/ts/windows/callingtools/user_media_table.js b/ts/windows/callingtools/user_media_table.js new file mode 100644 index 000000000000..9a1da6fdf815 --- /dev/null +++ b/ts/windows/callingtools/user_media_table.js @@ -0,0 +1,178 @@ +// Derived from Chromium WebRTC Internals Dashboard - see Acknowledgements for full license details + +import {$} from './util.js'; +const USER_MEDIA_TAB_ID = 'user-media-tab-id'; + +/** + * A helper function for appending a child element to |parent|. + * + * @param {!Element} parent The parent element. + * @param {string} tag The child element tag. + * @param {string} text The textContent of the new DIV. + * @return {!Element} the new DIV element. + */ + function appendChildWithText(parent, tag, text) { + const child = document.createElement(tag); + child.textContent = text; + parent.appendChild(child); + return child; +} + +export class UserMediaTable { + /** + * @param {Object} tabView the TabView object to add the user media tab to. + */ + constructor(tabView) { + this.tabView = tabView; + } + + /** + * Populate the tab view with a getUserMedia/getDisplayMedia tab. + */ + createTab() { + const container = this.tabView.addTab(USER_MEDIA_TAB_ID, + 'getUserMedia/getDisplayMedia'); + // Create the filter input field and label. + appendChildWithText(container, 'label', 'Filter by origin including '); + const input = document.createElement('input'); + input.size = 30; + input.oninput = this.filterUserMedia.bind(this); + container.appendChild(input); + } + + /** + * Apply a filter to the user media table. + * @param event InputEvent from the filter input field. + * @private + */ + filterUserMedia(event) { + const filter = event.target.value; + const requests = $(USER_MEDIA_TAB_ID).childNodes; + for (let i = 0; i < requests.length; ++i) { + if (!requests[i]['data-origin']) { + continue; + } + if (requests[i]['data-origin'].includes(filter)) { + requests[i].style.display = 'block'; + } else { + requests[i].style.display = 'none'; + } + } + } + + /** + * Adds a getUserMedia/getDisplayMedia request. + * @param {!Object} data The object containing rid {number}, pid {number}, + * origin {string}, request_id {number}, request_type {string}, + * audio {string}, video {string}. + */ + addMedia(data) { + if (!$(USER_MEDIA_TAB_ID)) { + this.createTab(); + } + + const requestDiv = document.createElement('div'); + requestDiv.className = 'user-media-request-div-class'; + requestDiv.id = ['gum', data.rid, data.pid, data.request_id].join('-'); + requestDiv['data-rid'] = data.rid; + requestDiv['data-origin'] = data.origin; + // Insert new getUserMedia calls at the top. + $(USER_MEDIA_TAB_ID).insertBefore(requestDiv, + $(USER_MEDIA_TAB_ID).firstChild); + + appendChildWithText(requestDiv, 'div', 'Caller origin: ' + data.origin); + appendChildWithText(requestDiv, 'div', 'Caller process id: ' + data.pid); + + const el = appendChildWithText(requestDiv, 'span', + data.request_type + ' call'); + el.style.fontWeight = 'bold'; + appendChildWithText(el, 'div', 'Time: ' + + (new Date(data.timestamp).toTimeString())) + .style.fontWeight = 'normal'; + if (data.audio !== undefined) { + appendChildWithText(el, 'div', 'Audio constraints: ' + + (data.audio || 'true')) + .style.fontWeight = 'normal'; + } + if (data.video !== undefined) { + appendChildWithText(el, 'div', 'Video constraints: ' + + (data.video || 'true')) + .style.fontWeight = 'normal'; + } + } + + /** + * Update a getUserMedia/getDisplayMedia request with a result or error. + * + * @param {!Object} data The object containing rid {number}, pid {number}, + * request_id {number}, request_type {string}. + * For results there is also the + * stream_id {string}, audio_track_info {string} and + * video_track_info {string}. + * For errors the error {string} and + * error_message {string} fields are set. + */ + updateMedia(data) { + if (!$(USER_MEDIA_TAB_ID)) { + this.createTab(); + } + + const requestDiv = document.getElementById( + ['gum', data.rid, data.pid, data.request_id].join('-')); + if (!requestDiv) { + console.error('Could not update ' + data.request_type + ' request', data); + return; + } + + if (data.error) { + const el = appendChildWithText(requestDiv, 'span', 'Error'); + el.style.fontWeight = 'bold'; + appendChildWithText(el, 'div', 'Time: ' + + (new Date(data.timestamp).toTimeString())) + .style.fontWeight = 'normal'; + appendChildWithText(el, 'div', 'Error: ' + data.error) + .style.fontWeight = 'normal'; + appendChildWithText(el, 'div', 'Error message: ' + data.error_message) + .style.fontWeight = 'normal'; + return; + } + + const el = appendChildWithText(requestDiv, 'span', + data.request_type + ' result'); + el.style.fontWeight = 'bold'; + appendChildWithText(el, 'div', 'Time: ' + + (new Date(data.timestamp).toTimeString())) + .style.fontWeight = 'normal'; + appendChildWithText(el, 'div', 'Stream id: ' + data.stream_id) + .style.fontWeight = 'normal'; + if (data.audio_track_info) { + appendChildWithText(el, 'div', 'Audio track: ' + data.audio_track_info) + .style.fontWeight = 'normal'; + } + if (data.video_track_info) { + appendChildWithText(el, 'div', 'Video track: ' + data.video_track_info) + .style.fontWeight = 'normal'; + } + } + + /** + * Removes the getUserMedia/getDisplayMedia requests from the specified |rid|. + * + * @param {!Object} data The object containing rid {number}, the render id. + */ + removeMediaForRenderer(data) { + const requests = $(USER_MEDIA_TAB_ID).childNodes; + for (let i = 0; i < requests.length; ++i) { + if (!requests[i]['data-origin']) { + continue; + } + if (requests[i]['data-rid'] === data.rid) { + $(USER_MEDIA_TAB_ID).removeChild(requests[i]); + } + } + // Remove the tab when only the search field and its label are left. + if ($(USER_MEDIA_TAB_ID).childNodes.length === 2) { + this.tabView.removeTab(USER_MEDIA_TAB_ID); + } + } +} diff --git a/ts/windows/callingtools/util.js b/ts/windows/callingtools/util.js new file mode 100644 index 000000000000..f75d8e93e3c4 --- /dev/null +++ b/ts/windows/callingtools/util.js @@ -0,0 +1,88 @@ +// Derived from Chromium WebRTC Internals Dashboard - see Acknowledgements for full license details + +import {assert} from "./assert.js"; +export function $(id) { + const el = document.querySelector(`#${id}`); + if (el) { + assert(el instanceof HTMLElement); + return el + } + return null +} +export function getRequiredElement(id) { + const el = document.querySelector(`#${id}`); + assert(el); + assert(el instanceof HTMLElement); + return el +} +export function getDeepActiveElement() { + let a = document.activeElement; + while (a && a.shadowRoot && a.shadowRoot.activeElement) { + a = a.shadowRoot.activeElement + } + return a +} +export function isRTL() { + return document.documentElement.dir === "rtl" +} +export function appendParam(url, key, value) { + const param = encodeURIComponent(key) + "=" + encodeURIComponent(value); + if (url.indexOf("?") === -1) { + return url + "?" + param + } + return url + "&" + param +} +export function ensureTransitionEndEvent(el, timeOut) { + if (timeOut === undefined) { + const style = getComputedStyle(el); + timeOut = parseFloat(style.transitionDuration) * 1e3; + timeOut += 50 + } + let fired = false; + el.addEventListener("transitionend", (function f() { + el.removeEventListener("transitionend", f); + fired = true + } + )); + window.setTimeout((function() { + if (!fired) { + el.dispatchEvent(new CustomEvent("transitionend",{ + bubbles: true, + composed: true + })) + } + } + ), timeOut) +} +export function htmlEscape(original) { + return original.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """).replace(/'/g, "'") +} +export function quoteString(str) { + return str.replace(/([\\\.\+\*\?\[\^\]\$\(\)\{\}\=\!\<\>\|\:])/g, "\\$1") +} +export function listenOnce(target, eventNames, callback) { + const eventNamesArray = Array.isArray(eventNames) ? eventNames : eventNames.split(/ +/); + const removeAllAndCallCallback = function(event) { + eventNamesArray.forEach((function(eventName) { + target.removeEventListener(eventName, removeAllAndCallCallback, false) + } + )); + return callback(event) + }; + eventNamesArray.forEach((function(eventName) { + target.addEventListener(eventName, removeAllAndCallCallback, false) + } + )) +} +export function hasKeyModifiers(e) { + return !!(e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) +} +export function isUndoKeyboardEvent(event) { + if (event.key !== "z") { + return false + } + const excludedModifiers = [event.altKey, event.shiftKey, event.ctrlKey]; + let targetModifier = event.ctrlKey; + targetModifier = event.metaKey; + return targetModifier && !excludedModifiers.some((modifier=>modifier)) +} diff --git a/ts/windows/callingtools/webrtc_internals.js b/ts/windows/callingtools/webrtc_internals.js new file mode 100644 index 000000000000..24c685d8281c --- /dev/null +++ b/ts/windows/callingtools/webrtc_internals.js @@ -0,0 +1,508 @@ +// Derived from Chromium WebRTC Internals Dashboard - see Acknowledgements for full license details + +import {$} from './util.js'; + +import {createIceCandidateGrid, updateIceCandidateGrid} from './candidate_grid.js'; +import {MAX_STATS_DATA_POINT_BUFFER_SIZE} from './data_series.js'; +import {DumpCreator, peerConnectionDataStore, userMediaRequests} from './dump_creator.js'; +import {PeerConnectionUpdateTable} from './peer_connection_update_table.js'; +import {drawSingleReport, removeStatsReportGraphs} from './stats_graph_helper.js'; +import {StatsRatesCalculator, StatsReport} from './stats_rates_calculator.js'; +import {StatsTable} from './stats_table.js'; +import {TabView} from './tab_view.js'; +import {UserMediaTable} from './user_media_table.js'; +import { i18n } from '../sandboxedInit.js'; + +let tabView = null; +let peerConnectionUpdateTable = null; +let statsTable = null; +let userMediaTable = null; +let dumpCreator = null; +let requestedStatsInterval = 2000; + +// Start Signal Change +let stats_queue = []; + +function onRtcStatsReport(event, report) { + const rs = JSON.parse(report.reportJson); + const mungedReports = rs.map(r => ({ + id: r.id, + type: r.type, + stats: { + timestamp: r.timestamp / 1000, + values: Object + .keys(r) + .filter(k => !['id', 'type', 'timestamp'].includes(k)) + .reduce((acc, k) => { + acc.push(k, r[k]); + return acc; + }, []), + }, + })); + + // fake since we can only have 1 call going at a time + // pid should be related to call ID + // lid is only one peer connection per call currently + stats_queue.push( + { + pid: 100, + rid: report.conversationId, + lid: report.callId, + reports: mungedReports, + } + ) +} +window.Signal.CallingToolsProps.onRtcStatsReport(onRtcStatsReport); +// End Signal Change + +const searchParameters = new URLSearchParams(window.location.search); + +/** Maps from id (see getPeerConnectionId) to StatsRatesCalculator. */ +const statsRatesCalculatorById = new Map(); + +/** A simple class to store the updates and stats data for a peer connection. */ + /** @constructor */ +class PeerConnectionRecord { + constructor() { + /** @private */ + this.record_ = { + pid: -1, + constraints: {}, + rtcConfiguration: [], + stats: {}, + updateLog: [], + url: '', + }; + } + + /** @override */ + toJSON() { + return this.record_; + } + + /** + * Adds the initialization info of the peer connection. + * @param {number} pid The pid of the process hosting the peer connection. + * @param {string} url The URL of the web page owning the peer connection. + * @param {Array} rtcConfiguration + * @param {!Object} constraints Media constraints. + */ + initialize(pid, url, rtcConfiguration, constraints) { + this.record_.pid = pid; + this.record_.url = url; + this.record_.rtcConfiguration = rtcConfiguration; + this.record_.constraints = constraints; + } + + resetStats() { + this.record_.stats = {}; + } + + /** + * @param {string} dataSeriesId The TimelineDataSeries identifier. + * @return {!TimelineDataSeries} + */ + getDataSeries(dataSeriesId) { + return this.record_.stats[dataSeriesId]; + } + + /** + * @param {string} dataSeriesId The TimelineDataSeries identifier. + * @param {!TimelineDataSeries} dataSeries The TimelineDataSeries to set to. + */ + setDataSeries(dataSeriesId, dataSeries) { + this.record_.stats[dataSeriesId] = dataSeries; + } + + /** + * @param {!Object} update The object contains keys "time", "type", and + * "value". + */ + addUpdate(update) { + const time = new Date(parseFloat(update.time)); + this.record_.updateLog.push({ + time: time.toLocaleString(), + type: update.type, + value: update.value, + }); + } +} + +function addMedia(data) { + userMediaRequests.push(data); + userMediaTable.addMedia(data) +} + +function updateMedia(data) { + userMediaRequests.push(data); + userMediaTable.updateMedia(data); + +} + +function removeMediaForRenderer(data) { + for (let i = userMediaRequests.length - 1; i >= 0; --i) { + if (userMediaRequests[i].rid === data.rid) { + userMediaRequests.splice(i, 1); + } + } + userMediaTable.removeMediaForRenderer(data); +} + +function setRtcStatsInterval() { + window.Signal.CallingToolsProps.setRtcStatsInterval(requestedStatsInterval); +} + +function initialize() { + let placeholderTitle = i18n('icu:callingDeveloperTools'); + let placeholderDescription = i18n('icu:callingDeveloperToolsDescription'); + $('placeholder-title').innerText = placeholderTitle; + $('placeholder-description').innerText = placeholderDescription; + + dumpCreator = new DumpCreator($('content-root')); + + tabView = new TabView($('content-root')); + peerConnectionUpdateTable = new PeerConnectionUpdateTable(); + statsTable = new StatsTable(); + userMediaTable = new UserMediaTable(tabView, userMediaRequests); + + let processStatsInterval = 1000; + window.setInterval(processStats, processStatsInterval); + setRtcStatsInterval(2000); +} +document.addEventListener('DOMContentLoaded', initialize); + +/** + * Sends a request to the browser to get peer connection statistics from the + * standard getStats() API (promise-based). + */ +function processStats() { + // Start Signal Change + for(let i = 0; i < 10 && stats_queue.length > 0; i++) { + addStandardStats(stats_queue.shift()); + } + // End Signal Change +} + +/** + * A helper function for getting a peer connection element id. + * + * @param {!Object} data The object containing the rid and lid of the + * peer connection. + * @return {string} The peer connection element id. + */ +function getPeerConnectionId(data) { + return data.rid + '-' + data.lid; +} + +/** + * A helper function for appending a child element to |parent|. + * + * @param {!Element} parent The parent element. + * @param {string} tag The child element tag. + * @param {string} text The textContent of the new DIV. + * @return {!Element} the new DIV element. + */ +function appendChildWithText(parent, tag, text) { + const child = document.createElement(tag); + child.textContent = text; + parent.appendChild(child); + return child; +} + +/** + * Helper for adding a peer connection update. + * + * @param {Element} peerConnectionElement + * @param {!PeerConnectionUpdateEntry} update The peer connection update data. + */ +function addPeerConnectionUpdate(peerConnectionElement, update) { + + peerConnectionUpdateTable.addPeerConnectionUpdate( + peerConnectionElement, update); + peerConnectionDataStore[peerConnectionElement.id].addUpdate(update); +} + + +/** Browser message handlers. */ + + +/** + * Removes all information about a peer connection. + * Use ?keepRemovedConnections url parameter to prevent the removal. + * + * @param {!Object} data The object containing the rid and lid of a peer + * connection. + */ +function removePeerConnection(data) { + // Disable getElementById restriction here, since |getPeerConnectionId| does + // not return valid selectors. + // eslint-disable-next-line no-restricted-properties + + const element = document.getElementById(getPeerConnectionId(data)); + if (element && !searchParameters.has('keepRemovedConnections')) { + delete peerConnectionDataStore[element.id]; + tabView.removeTab(element.id); + } +} + +/** + * Adds a peer connection. + * + * @param {!Object} data The object containing the rid, lid, pid, url, + * rtcConfiguration, and constraints of a peer connection. + */ +function addPeerConnection(data) { + const id = getPeerConnectionId(data); + + if (!peerConnectionDataStore[id]) { + peerConnectionDataStore[id] = new PeerConnectionRecord(); + } + peerConnectionDataStore[id].initialize( + data.pid, data.url, data.rtcConfiguration, data.constraints); + + // Disable getElementById restriction here, since |id| is not always + // a valid selector. + // eslint-disable-next-line no-restricted-properties + let peerConnectionElement = document.getElementById(id); + if (!peerConnectionElement) { + const details = `[ rid: ${data.rid}, lid: ${data.lid}, pid: ${data.pid} ]`; + peerConnectionElement = tabView.addTab(id, data.url + " " + details); + } + + const p = document.createElement('p'); + appendChildWithText(p, 'span', data.url); + appendChildWithText(p, 'span', ', '); + appendChildWithText(p, 'span', data.rtcConfiguration); + if (data.constraints !== '') { + appendChildWithText(p, 'span', ', '); + appendChildWithText(p, 'span', data.constraints); + } + peerConnectionElement.appendChild(p); + + // Show deprecation notices as a list. + // Note: data.rtcConfiguration is not in JSON format and may + // not be defined in tests. + const deprecationNotices = document.createElement('ul'); + if (data.rtcConfiguration) { + deprecationNotices.className = 'peerconnection-deprecations'; + } + peerConnectionElement.appendChild(deprecationNotices); + + const iceConnectionStates = document.createElement('div'); + iceConnectionStates.textContent = 'ICE connection state: new'; + iceConnectionStates.className = 'iceconnectionstate'; + peerConnectionElement.appendChild(iceConnectionStates); + + const connectionStates = document.createElement('div'); + connectionStates.textContent = 'Connection state: new'; + connectionStates.className = 'connectionstate'; + peerConnectionElement.appendChild(connectionStates); + + const signalingStates = document.createElement('div'); + signalingStates.textContent = 'Signaling state: new'; + signalingStates.className = 'signalingstate'; + peerConnectionElement.appendChild(signalingStates); + + const candidatePair = document.createElement('div'); + candidatePair.textContent = 'ICE Candidate pair: '; + candidatePair.className = 'candidatepair'; + candidatePair.appendChild(document.createElement('span')); + peerConnectionElement.appendChild(candidatePair); + + createIceCandidateGrid(peerConnectionElement); + return peerConnectionElement; +} + + +/** + * Adds a peer connection update. + * + * @param {!PeerConnectionUpdateEntry} data The peer connection update data. + */ +function updatePeerConnection(data) { + // Disable getElementById restriction here, since |getPeerConnectionId| does + // not return valid selectors. + const peerConnectionElement = + // eslint-disable-next-line no-restricted-properties + document.getElementById(getPeerConnectionId(data)); + addPeerConnectionUpdate(peerConnectionElement, data); +} + + +/** + * Adds the information of all peer connections created so far. + * + * @param {Array} data An array of the information of all peer + * connections. Each array item contains rid, lid, pid, url, + * rtcConfiguration, constraints, and an array of updates as the log. + */ +function updateAllPeerConnections(data) { + for (let i = 0; i < data.length; ++i) { + const peerConnection = addPeerConnection(data[i]); + + const log = data[i].log; + if (!log) { + continue; + } + for (let j = 0; j < log.length; ++j) { + addPeerConnectionUpdate(peerConnection, log[j]); + } + } + processStats(); +} + +/** + * Handles the report of stats originating from the standard getStats() API. + * + * @param {!Object} data The object containing rid, lid, and reports, where + * reports is an array of stats reports. Each report contains id, type, + * and stats, where stats is the object containing timestamp and values, + * which is an array of strings, whose even index entry is the name of the + * stat, and the odd index entry is the value. + */ +function addStandardStats(data) { + // Disable getElementById restriction here, since |getPeerConnectionId| does + // not return valid selectors. + // eslint-disable-next-line no-restricted-properties + let peerConnectionElement = + // eslint-disable-next-line no-restricted-properties + document.getElementById(getPeerConnectionId(data)); + if (!peerConnectionElement) { + // fake the add peer event + peerConnectionElement = addPeerConnection({ + connected: false, + isOpen: true, + lid: data.lid, + rid: data.rid, + rtcConfiguration: "{ iceServers: [], iceTransportPolicy: all, bundlePolicy: balanced, rtcpMuxPolicy: require, iceCandidatePoolSize: 0 }", + url: "groupcall" + }); + // eslint-disable-next-line no-restricted-properties + if(!peerConnectionElement) { + console.error("Failed to create peerConnection Element"); + } + } + + const pcId = getPeerConnectionId(data); + let statsRatesCalculator = statsRatesCalculatorById.get(pcId); + if (!statsRatesCalculator) { + statsRatesCalculator = new StatsRatesCalculator(); + statsRatesCalculatorById.set(pcId, statsRatesCalculator); + } + // This just changes the reports from their array format into an object format, then adds it to statsByAdd + const r = StatsReport.fromInternalsReportList(data.reports); + statsRatesCalculator.addStatsReport(r); + data.reports = statsRatesCalculator.currentReport.toInternalsReportList(); + for (let i = 0; i < data.reports.length; ++i) { + const report = data.reports[i]; + statsTable.addStatsReport(peerConnectionElement, report); + drawSingleReport(peerConnectionElement, report); + } + // Determine currently connected candidate pair. + const stats = r.statsById; + + let activeCandidatePair = null; + let remoteCandidate = null; + let localCandidate = null; + + // Get the first active candidate pair. This ignores the rare case of + // non-bundled connections. + stats.forEach(report => { + if (report.type === 'transport' && !activeCandidatePair) { + activeCandidatePair = stats.get(report.selectedCandidatePairId); + } + }); + + const candidateElement = peerConnectionElement + .getElementsByClassName('candidatepair')[0].firstElementChild; + if (activeCandidatePair) { + if (activeCandidatePair.remoteCandidateId) { + remoteCandidate = stats.get(activeCandidatePair.remoteCandidateId); + } + if (activeCandidatePair.localCandidateId) { + localCandidate = stats.get(activeCandidatePair.localCandidateId); + } + candidateElement.innerText = ''; + if (localCandidate && remoteCandidate) { + if (localCandidate.address && + localCandidate.address.indexOf(':') !== -1) { + // Show IPv6 in [] + candidateElement.innerText +='[' + localCandidate.address + ']'; + } else { + candidateElement.innerText += localCandidate.address || '(not set)'; + } + candidateElement.innerText += ':' + localCandidate.port + ' <=> '; + + if (remoteCandidate.address && + remoteCandidate.address.indexOf(':') !== -1) { + // Show IPv6 in [] + candidateElement.innerText +='[' + remoteCandidate.address + ']'; + } else { + candidateElement.innerText += remoteCandidate.address || '(not set)'; + } + candidateElement.innerText += ':' + remoteCandidate.port; + } + // Mark active local-candidate, remote candidate and candidate pair + // bold in the table. + // Disable getElementById restriction here, since |peerConnectionElement| + // doesn't always have a valid selector ID. + const statsContainer = + // eslint-disable-next-line no-restricted-properties + document.getElementById(peerConnectionElement.id + '-table-container'); + const activeConnectionClass = 'stats-table-active-connection'; + statsContainer.childNodes.forEach(node => { + if (node.nodeName !== 'DETAILS' || !node.children[1]) { + return; + } + const ids = [ + peerConnectionElement.id + '-table-' + activeCandidatePair.id, + peerConnectionElement.id + '-table-' + localCandidate.id, + peerConnectionElement.id + '-table-' + remoteCandidate.id, + ]; + if (ids.includes(node.children[1].id)) { + node.firstElementChild.classList.add(activeConnectionClass); + } else { + node.firstElementChild.classList.remove(activeConnectionClass); + } + }); + // Mark active candidate-pair graph bold. + const statsGraphContainers = peerConnectionElement + .getElementsByClassName('stats-graph-container'); + for (let i = 0; i < statsGraphContainers.length; i++) { + const node = statsGraphContainers[i]; + if (node.nodeName !== 'DETAILS') { + continue; + } + if (!node.id.startsWith(pcId + '-candidate-pair')) { + continue; + } + if (node.id === pcId + '-candidate-pair-' + activeCandidatePair.id + + '-graph-container') { + node.firstElementChild.classList.add(activeConnectionClass); + } else { + node.firstElementChild.classList.remove(activeConnectionClass); + } + } + } else { + candidateElement.innerText = '(not connected)'; + } + + updateIceCandidateGrid(peerConnectionElement, r.statsById); +} + +/** + * Notification that the audio debug recordings file selection dialog was + * cancelled, i.e. recordings have not been enabled. + */ +function audioDebugRecordingsFileSelectionCancelled() { + dumpCreator.clearAudioDebugRecordingsCheckbox(); +} + + +/** + * Notification that the event log recordings file selection dialog was + * cancelled, i.e. recordings have not been enabled. + */ +function eventLogRecordingsFileSelectionCancelled() { + dumpCreator.clearEventLogRecordingsCheckbox(); +} diff --git a/ts/windows/main/start.ts b/ts/windows/main/start.ts index c685bb2748ea..1c3808eb5821 100644 --- a/ts/windows/main/start.ts +++ b/ts/windows/main/start.ts @@ -87,6 +87,8 @@ if (!isProduction(window.SignalContext.getVersion())) { window.Signal.Services.calling._iceServerOverride = override; }, + setRtcStatsInterval: (intervalMillis: number) => + window.Signal.Services.calling.setAllRtcStatsInterval(intervalMillis), sqlCall: (name: string, ...args: ReadonlyArray) => ipcInvoke(name, args), ...(window.SignalContext.config.ciMode === 'benchmark'