Calls web: Report a problem sheet, 30-second quality reports, Devices toggle and holding-screen disclosure (spec 15.26)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
10 files changed,
+1732
−13
Makefile+1 −0internal/calls/handlers_room.go+5 −0internal/calls/static/calls.css+56 −0internal/calls/static/report.js+355 −0internal/calls/static/room.js+726 −13internal/calls/templates/join.html+10 −0internal/calls/templates/partials/report.html+48 −0internal/calls/templates/room.html+11 −0test/browser/call.mjs+139 −0test/browser/room-quality.test.mjs+381 −0
diff --git a/Makefile b/Makefile| index d44b73b..01b91f6 100644 |
| --- a/Makefile |
| +++ b/Makefile |
| @@ -157,6 +157,7 @@ browser: |
| node --test test/browser/room-layout.test.mjs |
| node --test test/browser/room-window.test.mjs |
| node --test test/browser/room-lane.test.mjs |
| + node --test test/browser/room-quality.test.mjs |
| node --test test/browser/media.test.mjs |
| node --test test/browser/audio.test.mjs |
| node --test test/browser/devices.test.mjs |
diff --git a/internal/calls/handlers_room.go b/internal/calls/handlers_room.go| index fef57a3..fd61ea1 100644 |
| --- a/internal/calls/handlers_room.go |
| +++ b/internal/calls/handlers_room.go |
| @@ -6,6 +6,8 @@ import ( |
| "github.com/go-chi/chi/v5" |
| + "amadan.net/rastrillo/rastrillo" |
| + |
| "amadan.net/oficina/calls/internal/calls/hub" |
| ) |
| @@ -78,6 +80,9 @@ func (a *app) room(w http.ResponseWriter, r *http.Request) { |
| // between the two failed-tile sentences. The list the |
| // server actually mints at /ice is the authority. |
| "Floor": !a.relayConfigured(), "Relay": a.relayConfigured(), "Boot": a.hub.Boot(), |
| + // The build a web quality or problem report names as its |
| + // client's version (spec §15.26): the page is the client. |
| + "Version": rastrillo.BuildVersion, |
| }) |
| data["Menu"] = a.roomMenuFor(data["Frame"].(Frame)) |
| render(w, "room", data) |
diff --git a/internal/calls/static/calls.css b/internal/calls/static/calls.css| index da75362..a91b888 100644 |
| --- a/internal/calls/static/calls.css |
| +++ b/internal/calls/static/calls.css |
| @@ -1431,3 +1431,59 @@ body:has(> .calls-window) { background: var(--rst-bg); } |
| .calls-window #controls .calls-sheet[data-open] > button, |
| .calls-window #controls .calls-sheet[data-open] > form > button { min-block-size: 64px; } |
| } |
| + |
| +/* ── "Report a problem" (spec §15.26) ── |
| + |
| + report.js's sheet: a modal <dialog> over the call or the holding |
| + screen, in the page's own theme, which inside the call window is the |
| + window's dark one (color-scheme: dark is inherited into the top |
| + layer). Centred by the browser, and never wider than the screen less |
| + a margin, so a phone gets the whole width. */ |
| +.calls-report { |
| + box-sizing: border-box; |
| + inline-size: min(28rem, calc(100vw - 32px)); |
| + max-block-size: calc(100dvh - 32px); |
| + overflow: auto; |
| + padding: var(--rst-sp-4); |
| + border: 1px solid var(--rst-line); |
| + border-radius: 16px; |
| + background: var(--rst-surface); |
| + color: var(--rst-text); |
| + box-shadow: var(--rst-shadow-pop); |
| +} |
| +.calls-report::backdrop { background: rgb(0 0 0 / 0.55); } |
| +.calls-report-form { margin: 0; } |
| +.calls-report-title { margin: 0 0 var(--rst-sp-3); font-size: 1.0625rem; font-weight: 650; } |
| +.calls-report textarea { box-sizing: border-box; inline-size: 100%; resize: vertical; } |
| +.calls-report-disclosure { margin: var(--rst-sp-3) 0 0; color: var(--rst-text-muted); font-size: var(--rst-fs-sm); } |
| +.calls-report-error { margin: var(--rst-sp-3) 0 0; color: var(--rst-tone-negative-fg); font-size: var(--rst-fs-sm); } |
| +.calls-report-actions { |
| + display: flex; |
| + flex-wrap: wrap; |
| + justify-content: flex-end; |
| + gap: var(--rst-sp-2); |
| + margin-block-start: var(--rst-sp-4); |
| +} |
| +.calls-report-done { border-radius: var(--rst-radius-sm); outline-offset: 4px; } |
| +.calls-report-done p { margin: 0 0 var(--rst-sp-2); } |
| +.calls-report-thanks { font-weight: 600; } |
| +/* One click selects the whole reference, for copying by hand. */ |
| +.calls-report-ref { |
| + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; |
| + letter-spacing: 0.06em; |
| + -webkit-user-select: all; |
| + user-select: all; |
| +} |
| +.calls-report-copied { margin: 0; min-block-size: 1.2em; color: var(--rst-text-muted); font-size: var(--rst-fs-sm); } |
| +/* Beside the way out of a call that is over, read like its links. */ |
| +.calls-report-after { |
| + padding: 0; |
| + border: 0; |
| + background: none; |
| + color: inherit; |
| + font: inherit; |
| + text-decoration: underline; |
| + cursor: pointer; |
| +} |
| +.calls-window .calls-status .calls-report-after { margin-inline-start: 8px; color: #fff; font-weight: 600; } |
| +.calls-quality-row .calls-raw-help { margin-block-start: var(--rst-sp-1); } |
diff --git a/internal/calls/static/report.js b/internal/calls/static/report.js| new file mode 100644 |
| index 0000000..fc31a73 |
| --- /dev/null |
| +++ b/internal/calls/static/report.js |
| @@ -0,0 +1,355 @@ |
| +// report.js: "Report a problem" (spec §15.26, 2026-10-03) — the sheet, |
| +// what kind of client is reporting, and the offer on the holding screen |
| +// for ten minutes after leaving a call. |
| +// |
| +// The room (room.js) opens the sheet from its More menu, from the |
| +// Devices panel beside "Copy call diagnostics", and from the words that |
| +// say a call is over; it hands over the report token from its `hello` |
| +// and the call's diagnostics with the people named by short peer id. |
| +// Leaving the call navigates to the holding screen, and the page that |
| +// held both is gone, so room.js leaves them here, in this tab's |
| +// sessionStorage, for as long as the server will still take a report |
| +// (hub.ReportAfterLeave); the holding screen offers the sheet with them. |
| +// |
| +// What is sent is what the sheet says: the note, if any, and the |
| +// diagnostics — connection states and counts, no audio, no video, no |
| +// names. Nothing is sent until Send is pressed. |
| +// |
| +// House rules as room.js's: no library, no inline script, nothing built |
| +// with innerHTML. |
| +(() => { |
| + 'use strict'; |
| + |
| + // ---- the rules, lifted by name in test/browser/room-quality.test.mjs ---- |
| + |
| + // reportClient is protocol.ReportClient for this browser: enums, and |
| + // versions as digits and dots — never the user agent string itself. |
| + // `ua` is navigator.userAgent; `hints` what navigator.userAgentData |
| + // says ({platform, platformVersion}), where it says anything — Chromium |
| + // freezes the operating system's version in the user agent string, and |
| + // only the hints carry the real one; `version` is the server's build |
| + // (rastrillo.BuildVersion); `touch` is navigator.maxTouchPoints, the |
| + // one way to tell an iPad asking for the desktop site from a Mac. |
| + // Anything the server would refuse is left out instead. |
| + function reportClient(ua, hints, version, touch) { |
| + const VERSION = /^[0-9A-Za-z][0-9A-Za-z.+-]{0,23}$/; |
| + const NUMERIC = /^[0-9]{1,4}([._][0-9]{1,6}){0,3}$/; |
| + const c = { app: 'web' }; |
| + if (typeof version === 'string' && VERSION.test(version)) c.version = version; |
| + const s = String(ua || ''); |
| + const dotted = (m) => (m ? m.slice(1).filter((x) => x !== undefined && x !== '').join('.') : ''); |
| + let m = null; |
| + let browser = 'other'; |
| + let browserVersion = ''; |
| + if ((m = s.match(/\bEdg(?:e|A|iOS)?\/(\d{1,4})(?:\.(\d{1,6}))?/))) { |
| + browser = 'edge'; |
| + } else if ((m = s.match(/\b(?:Firefox|FxiOS)\/(\d{1,4})(?:\.(\d{1,6}))?/))) { |
| + browser = 'firefox'; |
| + } else if (/\b(?:OPR|OPiOS|SamsungBrowser|YaBrowser|Vivaldi)\//.test(s)) { |
| + m = null; |
| + } else if ((m = s.match(/\b(?:HeadlessChrome|Chrome|CriOS|Chromium)\/(\d{1,4})(?:\.(\d{1,6}))?/))) { |
| + browser = 'chrome'; |
| + } else if ((m = s.match(/\bVersion\/(\d{1,4})(?:\.(\d{1,6}))?.*\bSafari\//))) { |
| + browser = 'safari'; |
| + } |
| + browserVersion = dotted(m); |
| + c.browser = browser; |
| + if (NUMERIC.test(browserVersion)) c.browserVersion = browserVersion; |
| + |
| + let os = 'other'; |
| + let osVersion = ''; |
| + const ios = s.match(/\bOS (\d{1,4})_(\d{1,6})(?:_(\d{1,6}))?/); |
| + if (/\biPad\b/.test(s) || (/\bMacintosh\b/.test(s) && touch > 1)) { |
| + os = 'ipados'; |
| + osVersion = dotted(ios); |
| + } else if (/\b(?:iPhone|iPod)\b/.test(s)) { |
| + os = 'ios'; |
| + osVersion = dotted(ios); |
| + } else if (/\bAndroid\b/.test(s)) { |
| + os = 'android'; |
| + osVersion = dotted(s.match(/\bAndroid (\d{1,4})(?:\.(\d{1,6}))?(?:\.(\d{1,6}))?/)); |
| + } else if (/\bCrOS\b/.test(s)) { |
| + os = 'chromeos'; |
| + } else if (/\bMacintosh\b|\bMac OS X\b/.test(s)) { |
| + // The user agent string's macOS version has said 10_15_7 since |
| + // 2020 whatever the machine runs: not a version, so not sent. |
| + os = 'macos'; |
| + } else if (/\bWindows\b/.test(s)) { |
| + os = 'windows'; |
| + } else if (/\bLinux\b|\bX11\b/.test(s)) { |
| + os = 'linux'; |
| + } |
| + const pv = hints && typeof hints.platformVersion === 'string' ? hints.platformVersion : ''; |
| + if (pv && ['macos', 'windows', 'chromeos', 'android', 'linux'].includes(os)) { |
| + const parts = pv.split('.').slice(0, 3).join('.'); |
| + if (NUMERIC.test(parts)) osVersion = parts; |
| + } |
| + c.os = os; |
| + if (NUMERIC.test(osVersion)) c.osVersion = osVersion; |
| + return c; |
| + } |
| + |
| + // problemAnswer is what the sheet says for the server's answer to a |
| + // problem report: the reference to quote, or the sentence for why not. |
| + function problemAnswer(status, data) { |
| + if (status === 200 && data && typeof data.ref === 'string' && /^[0-9A-Z]{8}$/.test(data.ref)) { |
| + return { ref: data.ref, text: '' }; |
| + } |
| + if (status === 429) return { ref: '', text: 'Too many reports from this call — try again in a minute.' }; |
| + if (status === 404) return { ref: '', text: 'This call can no longer be reported.' }; |
| + return { ref: '', text: 'The report could not be sent.' }; |
| + } |
| + |
| + // problemBody is protocol.ProblemReport as JSON, at most `max` bytes: |
| + // the note to its thousand characters, and the diagnostics cut short |
| + // from the end — the state of the call comes first and the oldest of |
| + // the screen's log last — with a line saying so, if the whole would not |
| + // fit the server's 64 KB. |
| + function problemBody(note, diagnostics, client, max) { |
| + const size = (x) => (typeof TextEncoder === 'function' ? new TextEncoder().encode(x).length : x.length * 3); |
| + const n = Array.from(String(note || '')).slice(0, 1000).join(''); |
| + let d = String(diagnostics || ''); |
| + let body = JSON.stringify({ note: n, diagnostics: d, client }); |
| + while (size(body) > max && d.length > 0) { |
| + d = d.slice(0, Math.floor(d.length * 0.9)); |
| + body = JSON.stringify({ note: n, diagnostics: d + '\n[cut short to fit]', client }); |
| + } |
| + return body; |
| + } |
| + |
| + // ---- this browser ---- |
| + |
| + const LAST_KEY = 'calls.lastCall'; |
| + // hub.ReportAfterLeave: how long the server takes a report for after |
| + // the participation ends. |
| + const AFTER_LEAVE = 10 * 60 * 1000; |
| + const NOTE_MAX = 1000; |
| + const BODY_MAX = 64 * 1024 - 512; |
| + |
| + const $ = (id) => document.getElementById(id); |
| + const sheet = $('report-sheet'); |
| + |
| + // The operating system's real version, where the browser will say it. |
| + // Asked once, at load, and used by every report after it answers. |
| + let hints = null; |
| + try { |
| + const uad = navigator.userAgentData; |
| + if (uad) { |
| + hints = { platform: uad.platform || '' }; |
| + if (typeof uad.getHighEntropyValues === 'function') { |
| + uad.getHighEntropyValues(['platformVersion']).then((h) => { |
| + hints = { platform: h.platform || hints.platform, platformVersion: h.platformVersion || '' }; |
| + }, () => {}); |
| + } |
| + } |
| + } catch { |
| + hints = null; |
| + } |
| + |
| + function client() { |
| + const version = (sheet && sheet.dataset.callsVersion) || ''; |
| + return reportClient(navigator.userAgent, hints, version, navigator.maxTouchPoints || 0); |
| + } |
| + |
| + // remember is room.js's, at leave: what the holding screen needs to |
| + // offer the sheet for the next ten minutes. |
| + function remember(rec) { |
| + try { |
| + sessionStorage.setItem(LAST_KEY, JSON.stringify({ |
| + sqid: rec.sqid, token: rec.token, at: Date.now(), diagnostics: rec.diagnostics, client: rec.client, |
| + })); |
| + } catch { |
| + // Private browsing, or full: the holding screen simply has no offer. |
| + } |
| + } |
| + |
| + function recall(sqid) { |
| + let rec = null; |
| + try { |
| + rec = JSON.parse(sessionStorage.getItem(LAST_KEY) || 'null'); |
| + } catch { |
| + rec = null; |
| + } |
| + if (!rec || typeof rec.token !== 'string' || typeof rec.at !== 'number') return null; |
| + if (Date.now() - rec.at >= AFTER_LEAVE) { |
| + try { sessionStorage.removeItem(LAST_KEY); } catch { /* nothing to forget */ } |
| + return null; |
| + } |
| + return rec.sqid === sqid ? rec : null; |
| + } |
| + |
| + // ---- the sheet ---- |
| + |
| + const form = $('report-form'); |
| + const noteEl = $('report-note'); |
| + const hint = $('report-note-hint'); |
| + const ask = $('report-ask'); |
| + const error = $('report-error'); |
| + const send = $('report-send'); |
| + const cancel = $('report-cancel'); |
| + const done = $('report-done'); |
| + const doneTitle = $('report-done-title'); |
| + const refEl = $('report-ref'); |
| + const copy = $('report-copy'); |
| + const copied = $('report-copied'); |
| + const closeButton = $('report-close'); |
| + |
| + let current = null; // the open sheet's options |
| + let round = 0; // which opening a POST belongs to |
| + |
| + function hintText() { |
| + const left = NOTE_MAX - (noteEl ? noteEl.value.length : 0); |
| + return 'Optional. ' + left.toLocaleString('en-GB') + ' of 1,000 characters left.'; |
| + } |
| + |
| + function show(which) { |
| + ask.hidden = which !== 'ask'; |
| + done.hidden = which !== 'done'; |
| + } |
| + |
| + function close() { |
| + if (!sheet) return; |
| + if (typeof sheet.close === 'function' && sheet.open) sheet.close(); |
| + else sheet.removeAttribute('open'); |
| + } |
| + |
| + function open(opts) { |
| + if (!sheet || !form) return; |
| + round += 1; |
| + current = opts; |
| + noteEl.value = ''; |
| + hint.textContent = hintText(); |
| + error.hidden = true; |
| + error.textContent = ''; |
| + send.disabled = false; |
| + send.textContent = 'Send'; |
| + copied.textContent = ''; |
| + show('ask'); |
| + if (typeof sheet.showModal === 'function') { |
| + if (!sheet.open) sheet.showModal(); |
| + } else { |
| + sheet.setAttribute('open', ''); |
| + } |
| + noteEl.focus(); |
| + } |
| + |
| + async function submit() { |
| + const opts = current; |
| + if (!opts) return; |
| + const mine = round; |
| + send.disabled = true; |
| + send.textContent = 'Sending…'; |
| + error.hidden = true; |
| + const token = typeof opts.token === 'function' ? opts.token() : opts.token; |
| + let answer; |
| + if (!token) { |
| + answer = problemAnswer(404, null); |
| + } else { |
| + const body = problemBody(noteEl.value, opts.diagnostics(), opts.client(), BODY_MAX); |
| + try { |
| + const res = await fetch('/m/' + encodeURIComponent(opts.sqid) + '/problem', { |
| + method: 'POST', |
| + credentials: 'same-origin', |
| + headers: { 'Content-Type': 'application/json', 'X-Calls-Report': token }, |
| + body, |
| + }); |
| + let data = null; |
| + try { |
| + data = await res.json(); |
| + } catch { |
| + data = null; |
| + } |
| + answer = problemAnswer(res.status, data); |
| + } catch { |
| + answer = problemAnswer(0, null); |
| + } |
| + } |
| + // Closed, or opened again, while that was in flight: the answer |
| + // belongs to a sheet nobody is looking at. |
| + if (mine !== round || !sheet.hasAttribute('open')) return; |
| + send.disabled = false; |
| + send.textContent = 'Send'; |
| + if (answer.ref) { |
| + refEl.textContent = answer.ref; |
| + show('done'); |
| + doneTitle.focus(); |
| + return; |
| + } |
| + error.textContent = answer.text; |
| + error.hidden = false; |
| + } |
| + |
| + if (sheet && form) { |
| + hint.textContent = hintText(); |
| + noteEl.addEventListener('input', () => { |
| + hint.textContent = hintText(); |
| + }); |
| + form.addEventListener('submit', (ev) => { |
| + ev.preventDefault(); |
| + submit().catch(() => { |
| + error.textContent = problemAnswer(0, null).text; |
| + error.hidden = false; |
| + send.disabled = false; |
| + send.textContent = 'Send'; |
| + }); |
| + }); |
| + cancel.addEventListener('click', close); |
| + closeButton.addEventListener('click', close); |
| + copy.addEventListener('click', () => { |
| + const ref = refEl.textContent || ''; |
| + const ok = () => { copied.textContent = 'Reference copied.'; }; |
| + const failed = () => { copied.textContent = 'Select the reference to copy it.'; }; |
| + try { |
| + navigator.clipboard.writeText(ref).then(ok, failed); |
| + } catch { |
| + failed(); |
| + } |
| + }); |
| + // The sheet's keys are its own. Escape closes it and nothing behind |
| + // it (the call window closes its popovers on Escape too), and Space |
| + // on the sheet is never the room's mute. |
| + sheet.addEventListener('keydown', (ev) => { |
| + if (ev.key === 'Escape') { |
| + ev.preventDefault(); |
| + close(); |
| + } |
| + ev.stopPropagation(); |
| + }); |
| + // The keyboard goes back to what opened the sheet — or, when that was |
| + // in a panel that has since closed, to the button the panel hung from. |
| + sheet.addEventListener('close', () => { |
| + round += 1; |
| + const back = current && current.returnFocus; |
| + current = null; |
| + if (back && back.isConnected && !back.hidden && typeof back.focus === 'function') back.focus(); |
| + }); |
| + } |
| + |
| + // ---- the holding screen, after leaving ---- |
| + |
| + const last = $('report-last'); |
| + const lastOpen = $('report-last-open'); |
| + const devices = $('devices'); |
| + if (last && lastOpen && devices) { |
| + const rec = recall(devices.dataset.sqid || ''); |
| + if (rec && sheet) { |
| + last.hidden = false; |
| + lastOpen.addEventListener('click', () => { |
| + open({ |
| + sqid: rec.sqid, |
| + token: () => (Date.now() - rec.at < AFTER_LEAVE ? rec.token : ''), |
| + diagnostics: () => String(rec.diagnostics || ''), |
| + client: () => (rec.client && rec.client.app === 'web' ? rec.client : client()), |
| + returnFocus: lastOpen, |
| + }); |
| + }); |
| + // Gone when the server would no longer take it. |
| + setTimeout(() => { |
| + if (document.activeElement === lastOpen) lastOpen.blur(); |
| + last.hidden = true; |
| + }, Math.max(0, AFTER_LEAVE - (Date.now() - rec.at))); |
| + } |
| + } |
| + |
| + window.CallsReport = { client, open, remember }; |
| +})(); |
diff --git a/internal/calls/static/room.js b/internal/calls/static/room.js| index 75abb55..279221c 100644 |
| --- a/internal/calls/static/room.js |
| +++ b/internal/calls/static/room.js |
| @@ -30,6 +30,9 @@ |
| // §15.19 media state who is muted and whose camera is off: our own |
| // reported on the broadcast lane, everyone else's |
| // read from the roster and `media` |
| +// §15.26 reports the anonymous quality report every thirty |
| +// seconds and at leave, and "Report a problem" |
| +// (report.js's sheet) with the diagnostics |
| // |
| // House rules this file is written under: no library, no inline script |
| // (the page's CSP is `default-src 'self'`), nothing built with |
| @@ -72,6 +75,15 @@ |
| const shareStop = $('share-stop'); |
| const shareRecoveryEl = $('share-recovery'); |
| const copyDiagnostics = $('copy-diagnostics'); |
| + // §15.26: "Report a problem", in the Devices panel beside Copy call |
| + // diagnostics and in the More menu, and the person's own switch for |
| + // the automatic reports. |
| + const reportProblem = $('report-problem'); |
| + const reportProblemMenu = $('report-problem-menu'); |
| + const moreToggle = $('call-more-toggle'); |
| + const morePanel = $('call-more'); |
| + const qualityRow = $('quality-row'); |
| + const qualityInput = qualityRow ? qualityRow.querySelector('input[type="checkbox"]') : null; |
| // §15.19: the tile marks, rendered by the server so they are the |
| // vendored badge and the app's own icons rather than a copy of either, |
| // and the polite region that says a change out loud. #status is no |
| @@ -322,6 +334,28 @@ |
| // CONNECTING_CAP, after which §15.20's and §15.23's words take over. |
| const CONNECTING_GRACE = 1000; |
| const CONNECTING_CAP = 20000; |
| + // §15.26 (2026-10-03), the automatic quality report: one every |
| + // QUALITY_EVERY while admitted, and one more at leave. The gate |
| + // shortens the cadence through window.meet.qualityEveryForTest, never |
| + // below QUALITY_EVERY_MIN. QUALITY_KEY is the person's own switch |
| + // ("Send anonymous quality reports"), stored as "off" when off. |
| + // The bounds are protocol/quality.go's, so a report is clamped here to |
| + // what the server would accept rather than refused there. |
| + const QUALITY_EVERY = 30000; |
| + const QUALITY_EVERY_MIN = 1000; |
| + const QUALITY_KEY = 'calls.qualityReports'; |
| + const QUALITY_MAX_PEERS = 4; |
| + const QUALITY_MAX_SECONDS = 600; |
| + const QUALITY_MAX_MILLIS = 600000; |
| + const QUALITY_MAX_COUNT = 1e9; |
| + const QUALITY_MAX_KBPS = 1e6; |
| + const QUALITY_MAX_PIXELS = 16384; |
| + const QUALITY_MAX_FPS = 240; |
| + const QUALITY_MAX_BODY = 16384; |
| + // The longest stretch between two looks of the event watch that is |
| + // counted as time a notice was showing: a suspended tab's timers stop, |
| + // and the gap when they start again is not a notice someone saw. |
| + const QUALITY_LOOK_MAX = 1000; |
| // §5.4, sending: "Optimise shared screen for". What a screen gives up |
| // first when the uplink or the encoder cannot carry all of it. The |
| // first is the default; sharePlan says what each one means. |
| @@ -397,6 +431,11 @@ |
| let shareResharedAt = 0; |
| const shareCounts = { keyframes: 0, rebuilds: 0, reshares: 0, failures: 0 }; |
| const screenLines = []; |
| + // §15.26: the same ladder and mending, per peer, taken by each quality |
| + // report (peer id -> counts). shareCounts above is the whole call's, |
| + // for the diagnostics. |
| + const shareCountsBy = new Map(); |
| + const reattachesBy = new Map(); |
| // §15.23: when the stream went down (0 while it is up), and the watch. |
| let streamDownSince = 0; |
| const reconnect = reconnectRecord(); |
| @@ -488,6 +527,24 @@ |
| let renewing = false; // a renewal fetch is in flight |
| let renewDue = false; // the current run is a renewal, not a first fetch |
| let iceWarningEl = null; |
| + // §15.26: the report token the latest `hello` carried — the authority |
| + // for both report endpoints, kept after leaving because it goes on |
| + // working for ten minutes after — and the quality report's own state: |
| + // when the current window began (0 until admitted), each connection's |
| + // reading at the last report (peer id -> {pc, reading}), the event |
| + // watch, the output device's kind as last enumerated, and what has |
| + // been sent. A 204 is the server's kill switch, and stops the reports |
| + // for the rest of this page. |
| + let reportToken = ''; |
| + let qualityEvery = QUALITY_EVERY; |
| + let qualityFrom = 0; |
| + const qualityPrev = new Map(); |
| + const qualityEvents = qualityWatchRecord(); |
| + let qualityOutput = 'unknown'; |
| + let qualityServerOff = false; |
| + let qualityLocalOff = false; // the switch, where there is no storage to keep it in |
| + let qualityFinalSent = false; |
| + const quality = { sent: 0, accepted: 0, status: 0 }; |
| // How many rosters this tab has applied. Nothing in the page reads |
| // it: it is the one fact a gate cannot infer from the DOM or from |
| // `peers`, because a connection can be closed locally without any |
| @@ -1450,6 +1507,10 @@ |
| const fresh = !state.peer; |
| state.peer = msg.peer || null; |
| state.boot = msg.boot || state.boot; |
| + // §15.26: a resumed stream's hello carries the same token, and a new |
| + // participation's a new one; either way the latest is the one that |
| + // works. A server from before reports sends none, and nothing is sent. |
| + if (typeof msg.report === 'string' && /^[0-9a-f]{32}$/.test(msg.report)) reportToken = msg.report; |
| root.dataset.peer = state.peer || ''; |
| if (fresh) join(); |
| renderStatus(); |
| @@ -4504,6 +4565,7 @@ |
| const t = tiles.get(key); |
| if (t && t.video) { |
| screenReattaches += 1; |
| + reattachesBy.set(id, (reattachesBy.get(id) || 0) + 1); |
| replayTile(key, t, entry, true); |
| } |
| } |
| @@ -4561,6 +4623,7 @@ |
| ask.restUntil = now + SCREEN_KEYFRAME_REST; |
| } |
| shareCounts.keyframes += 1; |
| + countShareStep(id, 'keyframes'); |
| screenLog('keyframe for ' + id.slice(0, 6) + ', whose view of the screen stalled'); |
| shareKeyframe(entry).catch(() => {}); |
| } |
| @@ -4578,22 +4641,28 @@ |
| if (action === 'rebuild') { |
| shareRebuiltAt.set(id, now); |
| shareCounts.rebuilds += 1; |
| + countShareStep(id, 'rebuilds'); |
| screenLog(id.slice(0, 6) + ' still reports a stall after keyframes: rebuilding its screen (new transceiver, same size and mode)'); |
| if (entry) rebuildShareFor(entry); |
| } else if (action === 'reshare') { |
| reshare = true; |
| + countShareStep(id, 'reshares'); |
| screenLog(id.slice(0, 6) + ' still reports a stall' + (before === 'rebuilt' ? ' after the rebuild' : ', rebuilt under a minute ago') + ': sharing again'); |
| } else if (action === 'failed') { |
| shareCounts.failures += 1; |
| + countShareStep(id, 'failures'); |
| screenLog(id.slice(0, 6) + ' still reports a stall and there is nothing left to try: giving up'); |
| } |
| } |
| if (reshare) { |
| shareResharedAt = now; |
| shareCounts.reshares += 1; |
| - // One share again answers every report in flight. |
| - for (const ask of screenAsks.values()) { |
| + // One share again answers every report in flight. For §15.26 it is |
| + // counted against each of the people it answers: it was done for |
| + // them, though it reached everyone. |
| + for (const [askId, ask] of screenAsks) { |
| if (ask.stage === 'keyframes' || ask.stage === 'rebuilt') { |
| + countShareStep(askId, 'reshares'); |
| ask.stage = 'reshared'; |
| ask.stageAt = now; |
| } |
| @@ -4622,14 +4691,23 @@ |
| // has done, every screen arriving here as its statistics have it, and |
| // the timeline. Names, states and counts only: no media, no addresses, |
| // no credentials. |
| - function callDiagnostics() { |
| + // |
| + // §15.26: "Report a problem" sends the same text with `anonymous`, in |
| + // which every person is "peer " and the first six characters of their |
| + // peer id rather than their name — the server turns those into the |
| + // call's random tags before anything is logged. "Copy call |
| + // diagnostics" keeps the names: that text goes only where its owner |
| + // pastes it. |
| + function callDiagnostics(opts) { |
| + const anonymous = !!(opts && opts.anonymous); |
| + const who = (id) => (anonymous ? 'peer ' + id.slice(0, 6) : names.get(id) || 'Someone'); |
| const lines = ['Calls web diagnostics, ' + new Date().toISOString()]; |
| lines.push('Browser: ' + (navigator.userAgent || 'unknown')); |
| lines.push(''); |
| lines.push('## Call'); |
| lines.push('admitted ' + state.admitted + ', people here ' + (pcs.size + 1) + ', stopped ' + state.stopped); |
| for (const [id, entry] of pcs) { |
| - lines.push((names.get(id) || 'Someone') + ' (' + id.slice(0, 6) + '): connection ' + entry.pc.connectionState + |
| + lines.push((anonymous ? who(id) : who(id) + ' (' + id.slice(0, 6) + ')') + ': connection ' + entry.pc.connectionState + |
| ', signalling ' + entry.pc.signalingState + ', ICE ' + entry.pc.iceConnectionState); |
| } |
| lines.push(''); |
| @@ -4638,17 +4716,16 @@ |
| lines.push('Sharing (' + shareMode() + ')'); |
| for (const [id, entry] of pcs) { |
| const r = entry.shareSend; |
| - const who = names.get(id) || 'Someone'; |
| if (!r) { |
| - lines.push('to ' + who + ': ' + (entry.shareTx ? 'negotiating' : 'no screen transceiver')); |
| + lines.push('to ' + who(id) + ': ' + (entry.shareTx ? 'negotiating' : 'no screen transceiver')); |
| continue; |
| } |
| - lines.push('to ' + who + ': ' + r.width + 'x' + r.height + ' at ' + Math.round(r.fps) + ' fps, encoder ' + |
| + lines.push('to ' + who(id) + ': ' + r.width + 'x' + r.height + ' at ' + Math.round(r.fps) + ' fps, encoder ' + |
| (r.encoder || 'unknown') + ', encoded ' + r.framesEncoded + ', sent ' + r.framesSent + ', keyframes ' + |
| r.keyFramesEncoded + ', limitation ' + (r.limitation || 'none')); |
| } |
| for (const [id, ask] of screenAsks) { |
| - if (ask.stage) lines.push('recovering ' + (names.get(id) || 'Someone') + '\u2019s view: ' + ask.stage); |
| + if (ask.stage) lines.push('recovering ' + who(id) + '\u2019s view: ' + ask.stage); |
| } |
| } else { |
| lines.push('Not sharing'); |
| @@ -4657,9 +4734,9 @@ |
| shareCounts.reshares + ' shares again, ' + shareCounts.failures + ' given up'); |
| for (const [key, reading] of screenReadings) { |
| const id = key.slice(0, -SHARE_SUFFIX.length); |
| - lines.push('from ' + (names.get(id) || 'Someone') + ': ' + readingSummary(reading)); |
| + lines.push('from ' + who(id) + ': ' + readingSummary(reading)); |
| } |
| - if (screenStalled.length) lines.push('stalled here: ' + screenStalled.map((id) => names.get(id) || 'Someone').join(', ')); |
| + if (screenStalled.length) lines.push('stalled here: ' + screenStalled.map(who).join(', ')); |
| if (screenReattaches) lines.push('pictures handed to their tiles again here: ' + screenReattaches); |
| lines.push(''); |
| lines.push('## Screen log (last ' + SCREEN_LOG_LINES + ' lines)'); |
| @@ -4668,6 +4745,547 @@ |
| return lines.join('\n'); |
| } |
| + // --------------------------------------------------------------- |
| + // Quality reports and "Report a problem" (§15.26, 2026-10-03) |
| + // --------------------------------------------------------------- |
| + // |
| + // Paul's ruling: every thirty seconds while admitted, and once more at |
| + // leave, this tab tells the server how its connections went over the |
| + // window — numbers and enums only, per remote peer, read off the stats |
| + // walk pollFrames already makes once a second rather than a walk of its |
| + // own. No name, no email, no title, no meeting, no device label, no |
| + // address and nothing anyone typed: the report names a remote peer only |
| + // by the peer id the server already knows, which it logs as the call's |
| + // random tag in its place. The person can switch the reports off in the |
| + // Devices panel; the server can switch them off for everyone (a 204). |
| + // |
| + // "Report a problem" is report.js's sheet; this file hands it the |
| + // token, the diagnostics with the people named by short peer id, and |
| + // what kind of client this is. |
| + |
| + // qualityCeiling is a number's bound in protocol/quality.go, by its JSON |
| + // name, as the server's numberCeiling has it. |
| + function qualityCeiling(name) { |
| + switch (name) { |
| + case 'seconds': case 'reconnectingSeconds': case 'cantHearSeconds': case 'freezeSeconds': |
| + return QUALITY_MAX_SECONDS; |
| + case 'rtt': case 'jitter': case 'jitterBufferMs': |
| + return QUALITY_MAX_MILLIS; |
| + case 'kbps': |
| + return QUALITY_MAX_KBPS; |
| + case 'width': case 'height': |
| + return QUALITY_MAX_PIXELS; |
| + case 'fps': |
| + return QUALITY_MAX_FPS; |
| + default: |
| + return QUALITY_MAX_COUNT; |
| + } |
| + } |
| + |
| + // qualitySet writes one number into a report part: finite and not |
| + // negative or it is left out (absent is "could not measure"), clamped |
| + // to its ceiling, and to a tenth, which is all the server keeps. |
| + function qualitySet(obj, name, v) { |
| + if (typeof v !== 'number' || !Number.isFinite(v) || v < 0) return; |
| + obj[name] = Math.round(Math.min(v, qualityCeiling(name)) * 10) / 10; |
| + } |
| + |
| + // qualityReading is one connection's getStats walk, kept as the next |
| + // window's starting point: each RTP stream by its report id (a new SSRC |
| + // is a new stream, whose counters start again from nothing) with its |
| + // running counters and what it reads now, and the selected candidate |
| + // pair's kinds, transport and round trip. `classify(r)` names an RTP |
| + // report's stream — 'audio', 'video' or 'screen', or '' to leave it out |
| + // — and a remote-inbound-rtp report (the far side's account of what we |
| + // send) takes the name of the outbound stream it is about. Fields are |
| + // copied by name from the lists below and nothing else is: a |
| + // candidate's address and port never leave this function. Pure, and |
| + // lifted by name in test/browser/room-quality.test.mjs. |
| + function qualityReading(reports, classify) { |
| + const COUNTERS = { |
| + out: ['bytesSent', 'packetsSent'], |
| + remote: ['packetsLost'], |
| + in: ['bytesReceived', 'packetsReceived', 'packetsLost', 'totalSamplesReceived', 'concealedSamples', |
| + 'insertedSamplesForDeceleration', 'removedSamplesForAcceleration', 'jitterBufferDelay', |
| + 'jitterBufferEmittedCount', 'freezeCount', 'totalFreezesDuration', 'framesDecoded', 'framesDropped'], |
| + }; |
| + const GAUGES = { |
| + out: ['frameWidth', 'frameHeight', 'framesPerSecond', 'qualityLimitationReason'], |
| + remote: ['roundTripTime', 'jitter'], |
| + in: ['jitter', 'frameWidth', 'frameHeight', 'framesPerSecond'], |
| + }; |
| + const all = []; |
| + const byId = new Map(); |
| + if (reports && typeof reports.forEach === 'function') { |
| + reports.forEach((r) => { |
| + if (!r || typeof r !== 'object') return; |
| + all.push(r); |
| + if (r.id) byId.set(r.id, r); |
| + }); |
| + } |
| + const streams = new Map(); |
| + const keep = (key, dir, kind, r) => { |
| + const counters = {}; |
| + const gauges = {}; |
| + for (const n of COUNTERS[dir]) if (typeof r[n] === 'number') counters[n] = r[n]; |
| + for (const n of GAUGES[dir]) if (typeof r[n] === 'number' || typeof r[n] === 'string') gauges[n] = r[n]; |
| + streams.set(key, { dir, kind, counters, gauges }); |
| + }; |
| + for (const r of all) { |
| + const key = r.type + ':' + (r.id || r.ssrc || ''); |
| + if (r.type === 'outbound-rtp' || r.type === 'inbound-rtp') { |
| + const kind = classify(r); |
| + if (kind) keep(key, r.type === 'outbound-rtp' ? 'out' : 'in', kind, r); |
| + } else if (r.type === 'remote-inbound-rtp') { |
| + const sent = r.localId ? byId.get(r.localId) : null; |
| + const kind = sent && sent.type === 'outbound-rtp' ? classify(sent) : ''; |
| + if (kind) keep(key, 'remote', kind, r); |
| + } |
| + } |
| + let pair = null; |
| + for (const r of all) { |
| + if (r.type === 'transport' && r.selectedCandidatePairId && byId.has(r.selectedCandidatePairId)) { |
| + pair = byId.get(r.selectedCandidatePairId); |
| + break; |
| + } |
| + } |
| + // Firefox has no transport report; it marks the pair itself. |
| + if (!pair) pair = all.find((r) => r.type === 'candidate-pair' && r.selected === true) || null; |
| + if (!pair) pair = all.find((r) => r.type === 'candidate-pair' && r.nominated && r.state === 'succeeded') || null; |
| + let path = null; |
| + if (pair) { |
| + const kinds = ['host', 'srflx', 'prflx', 'relay']; |
| + const local = byId.get(pair.localCandidateId) || {}; |
| + const remote = byId.get(pair.remoteCandidateId) || {}; |
| + // A relay candidate's own protocol is the relay's leg to the peer; |
| + // how this browser reaches the relay is relayProtocol, and that is |
| + // the one that says "TURN over TLS". |
| + const transport = local.candidateType === 'relay' && ['udp', 'tcp', 'tls'].includes(local.relayProtocol) |
| + ? local.relayProtocol |
| + : ['udp', 'tcp'].includes(local.protocol) ? local.protocol : 'unknown'; |
| + path = { |
| + local: kinds.includes(local.candidateType) ? local.candidateType : 'unknown', |
| + remote: kinds.includes(remote.candidateType) ? remote.candidateType : 'unknown', |
| + protocol: transport, |
| + rtt: typeof pair.currentRoundTripTime === 'number' ? pair.currentRoundTripTime * 1000 : undefined, |
| + }; |
| + } |
| + return { streams, path }; |
| + } |
| + |
| + // qualityPeer is one remote peer's statistics for a window: what moved |
| + // between two readings `seconds` apart, per stream kind and direction. |
| + // Counts are deltas — a stream `prev` does not have counts from zero, |
| + // which is where its counters started — and rates are over the window; |
| + // sizes, frame rates, jitter, round trips and the limitation are what |
| + // the latest reading says. Two streams of one kind (a screen rebuilt on |
| + // a new transceiver during the window) add up. Returns {path, rtt, out, |
| + // in} with whatever parts there is something for. Pure, and lifted by |
| + // name in the node test. |
| + function qualityPeer(prev, now, seconds) { |
| + const sums = new Map(); |
| + for (const [key, s] of now.streams) { |
| + const p = prev && prev.streams ? prev.streams.get(key) : null; |
| + const slot = s.dir + ':' + s.kind; |
| + let b = sums.get(slot); |
| + if (!b) { |
| + b = { d: {}, g: {} }; |
| + sums.set(slot, b); |
| + } |
| + for (const n of Object.keys(s.counters)) { |
| + const base = p && typeof p.counters[n] === 'number' ? p.counters[n] : 0; |
| + b.d[n] = (b.d[n] || 0) + Math.max(0, s.counters[n] - base); |
| + } |
| + for (const n of Object.keys(s.gauges)) { |
| + const v = s.gauges[n]; |
| + if (typeof v === 'string') { |
| + if (!b.g[n] || b.g[n] === 'none') b.g[n] = v; |
| + } else { |
| + b.g[n] = typeof b.g[n] === 'number' ? Math.max(b.g[n], v) : v; |
| + } |
| + } |
| + } |
| + const kbps = (bytes) => (typeof bytes === 'number' && seconds > 0 ? (bytes * 8) / 1000 / seconds : undefined); |
| + const ms = (s) => (typeof s === 'number' ? s * 1000 : undefined); |
| + const limits = ['none', 'cpu', 'bandwidth', 'other']; |
| + const out = {}; |
| + const inbound = {}; |
| + for (const kind of ['audio', 'video', 'screen']) { |
| + const o = sums.get('out:' + kind); |
| + const ri = sums.get('remote:' + kind); |
| + if (o || ri) { |
| + const s = {}; |
| + if (o) { |
| + qualitySet(s, 'kbps', kbps(o.d.bytesSent)); |
| + qualitySet(s, 'packets', o.d.packetsSent); |
| + if (kind !== 'audio') { |
| + qualitySet(s, 'width', o.g.frameWidth); |
| + qualitySet(s, 'height', o.g.frameHeight); |
| + qualitySet(s, 'fps', o.g.framesPerSecond); |
| + if (limits.includes(o.g.qualityLimitationReason)) s.limit = o.g.qualityLimitationReason; |
| + } |
| + } |
| + if (ri) { |
| + qualitySet(s, 'lost', ri.d.packetsLost); |
| + qualitySet(s, 'rtt', ms(ri.g.roundTripTime)); |
| + qualitySet(s, 'jitter', ms(ri.g.jitter)); |
| + } |
| + if (Object.keys(s).length) out[kind] = s; |
| + } |
| + const i = sums.get('in:' + kind); |
| + if (i) { |
| + const s = {}; |
| + qualitySet(s, 'kbps', kbps(i.d.bytesReceived)); |
| + qualitySet(s, 'packets', i.d.packetsReceived); |
| + qualitySet(s, 'lost', i.d.packetsLost); |
| + qualitySet(s, 'jitter', ms(i.g.jitter)); |
| + if (kind === 'audio') { |
| + qualitySet(s, 'samples', i.d.totalSamplesReceived); |
| + qualitySet(s, 'concealed', i.d.concealedSamples); |
| + qualitySet(s, 'inserted', i.d.insertedSamplesForDeceleration); |
| + qualitySet(s, 'removed', i.d.removedSamplesForAcceleration); |
| + if (i.d.jitterBufferEmittedCount > 0) { |
| + qualitySet(s, 'jitterBufferMs', (i.d.jitterBufferDelay / i.d.jitterBufferEmittedCount) * 1000); |
| + } |
| + } else { |
| + qualitySet(s, 'freezes', i.d.freezeCount); |
| + qualitySet(s, 'freezeSeconds', i.d.totalFreezesDuration); |
| + qualitySet(s, 'decoded', i.d.framesDecoded); |
| + qualitySet(s, 'dropped', i.d.framesDropped); |
| + qualitySet(s, 'width', i.g.frameWidth); |
| + qualitySet(s, 'height', i.g.frameHeight); |
| + qualitySet(s, 'fps', i.g.framesPerSecond); |
| + } |
| + if (Object.keys(s).length) inbound[kind] = s; |
| + } |
| + } |
| + const peer = {}; |
| + if (now.path) { |
| + peer.path = { local: now.path.local, remote: now.path.remote, protocol: now.path.protocol }; |
| + const r = {}; |
| + qualitySet(r, 'rtt', now.path.rtt); |
| + if ('rtt' in r) peer.rtt = r.rtt; |
| + } |
| + if (Object.keys(out).length) peer.out = out; |
| + if (Object.keys(inbound).length) peer.in = inbound; |
| + return peer; |
| + } |
| + |
| + // The notices a quality report counts (§15.23's "Reconnecting…", for |
| + // the whole call and per person; §15.24's "can't hear you yet"), and |
| + // the reports about sound and screens (§15.20, §15.21), watched five |
| + // times a second by watchReconnect and taken by each report. |
| + function qualityWatchRecord() { |
| + return { at: null, call: { shown: false, count: 0, ms: 0, blocked: false }, peers: new Map() }; |
| + } |
| + |
| + // qualityWatch is one look. `look` is {call, audioBlocked, peers}, peers |
| + // being peer id -> {reconnecting, cantHear, noSound, noSoundFromUs, |
| + // audioBlocked, screenStalled}, every one a boolean for what is true at |
| + // this moment. A notice counts once each time it appears, and the time |
| + // between two looks counts towards whatever was showing at the first of |
| + // them, up to QUALITY_LOOK_MAX. Pure, and lifted by name in the node |
| + // test. |
| + function qualityWatch(rec, now, look) { |
| + const dt = rec.at === null ? 0 : Math.min(Math.max(0, now - rec.at), QUALITY_LOOK_MAX); |
| + rec.at = now; |
| + const shown = (s, on) => { |
| + if (s.shown) s.ms += dt; |
| + if (on && !s.shown) s.count += 1; |
| + s.shown = !!on; |
| + }; |
| + shown(rec.call, look.call); |
| + if (look.audioBlocked) rec.call.blocked = true; |
| + for (const p of rec.peers.values()) p.present = false; |
| + for (const [id, l] of look.peers) { |
| + let p = rec.peers.get(id); |
| + if (!p) { |
| + p = { present: true, reconnecting: { shown: false, count: 0, ms: 0 }, cantHear: { shown: false, count: 0, ms: 0 }, |
| + noSound: false, noSoundFromUs: false, audioBlocked: false, screenStalled: false }; |
| + rec.peers.set(id, p); |
| + } |
| + p.present = true; |
| + shown(p.reconnecting, l.reconnecting); |
| + shown(p.cantHear, l.cantHear); |
| + for (const k of ['noSound', 'noSoundFromUs', 'audioBlocked', 'screenStalled']) if (l[k]) p[k] = true; |
| + } |
| + for (const p of rec.peers.values()) { |
| + if (p.present) continue; |
| + shown(p.reconnecting, false); |
| + shown(p.cantHear, false); |
| + } |
| + } |
| + |
| + // qualityTake is the window's events, in the report's shape — only what |
| + // happened, so a quiet window says nothing — and the record started on |
| + // the next window. A notice still showing goes on counting there, and |
| + // someone who has left is forgotten. Returns {call, peers: Map}. Pure, |
| + // and lifted by name in the node test. |
| + function qualityTake(rec) { |
| + const secs = (ms) => ms / 1000; |
| + const call = {}; |
| + if (rec.call.count) qualitySet(call, 'reconnecting', rec.call.count); |
| + if (rec.call.ms) qualitySet(call, 'reconnectingSeconds', secs(rec.call.ms)); |
| + if (rec.call.blocked) call.audioBlocked = true; |
| + rec.call.count = 0; |
| + rec.call.ms = 0; |
| + rec.call.blocked = false; |
| + const peers = new Map(); |
| + for (const [id, p] of Array.from(rec.peers)) { |
| + const e = {}; |
| + if (p.reconnecting.count) qualitySet(e, 'reconnecting', p.reconnecting.count); |
| + if (p.reconnecting.ms) qualitySet(e, 'reconnectingSeconds', secs(p.reconnecting.ms)); |
| + if (p.cantHear.ms) qualitySet(e, 'cantHearSeconds', secs(p.cantHear.ms)); |
| + for (const k of ['noSound', 'noSoundFromUs', 'audioBlocked', 'screenStalled']) if (p[k]) e[k] = true; |
| + if (Object.keys(e).length) peers.set(id, e); |
| + if (!p.present) { |
| + rec.peers.delete(id); |
| + continue; |
| + } |
| + p.reconnecting.count = 0; |
| + p.reconnecting.ms = 0; |
| + p.cantHear.count = 0; |
| + p.cantHear.ms = 0; |
| + p.noSound = p.noSoundFromUs = p.audioBlocked = p.screenStalled = false; |
| + } |
| + return { call, peers }; |
| + } |
| + |
| + // qualityReport is the body POSTed to /m/{sqid}/quality |
| + // (protocol.QualityReport). `o.peers` is [{id, prev, now, ladder, |
| + // reattaches, events}]: the two readings, the §15.22 ladder's steps |
| + // toward that person this window ({keyframes, rebuilds, reshares, |
| + // failures}), the times their screen was handed to its tile again, and |
| + // qualityTake's events for them. At most QUALITY_MAX_PEERS, each by a |
| + // well-formed peer id, and none with nothing to say. Steps and events |
| + // that did not happen are left out rather than sent as zeros: the line |
| + // the server writes has a size limit, and absent means none. Pure, and |
| + // lifted by name in the node test. |
| + function qualityReport(o) { |
| + const report = { v: 1 }; |
| + if (o.final) report.final = true; |
| + qualitySet(report, 'seconds', Math.max(0.1, o.seconds)); |
| + report.client = o.client; |
| + if (o.devices) report.devices = o.devices; |
| + if (o.events && Object.keys(o.events).length) report.events = o.events; |
| + const peers = []; |
| + for (const p of o.peers || []) { |
| + if (peers.length >= QUALITY_MAX_PEERS) break; |
| + if (typeof p.id !== 'string' || !/^[0-9a-f]{32}$/.test(p.id)) continue; |
| + const q = qualityPeer(p.prev, p.now, o.seconds); |
| + const ladder = {}; |
| + for (const k of ['keyframes', 'rebuilds', 'reshares', 'failures']) { |
| + if (p.ladder && p.ladder[k] > 0) qualitySet(ladder, k, p.ladder[k]); |
| + } |
| + if (Object.keys(ladder).length) { |
| + q.out = q.out || {}; |
| + q.out.screen = Object.assign(q.out.screen || {}, ladder); |
| + } |
| + if (p.reattaches > 0) { |
| + q.in = q.in || {}; |
| + q.in.screen = q.in.screen || {}; |
| + qualitySet(q.in.screen, 'reattaches', p.reattaches); |
| + } |
| + if (p.events && Object.keys(p.events).length) q.events = p.events; |
| + if (!Object.keys(q).length) continue; |
| + peers.push(Object.assign({ peer: p.id }, q)); |
| + } |
| + if (peers.length) report.peers = peers; |
| + return report; |
| + } |
| + |
| + // deviceKind is a microphone's or speaker's kind, by its label, which |
| + // never leaves this function: only the enum is reported. A heuristic, |
| + // and an honest one — what it cannot place is 'unknown'. Pure, and |
| + // lifted by name in the node test. |
| + function deviceKind(label) { |
| + const l = String(label || ''); |
| + if (!l) return 'unknown'; |
| + if (/airpods|bluetooth|beats|buds|hands-?free|bose|jabra|wh-1000|wf-1000|\bbt\b/i.test(l)) return 'bluetooth'; |
| + if (/airplay/i.test(l)) return 'airplay'; |
| + if (/continuity|iphone microphone/i.test(l)) return 'continuity'; |
| + if (/blackhole|loopback|soundflower|virtual|vb-audio|cable (?:in|out)put|zoomaudiodevice|teams audio/i.test(l)) return 'virtual'; |
| + if (/usb/i.test(l)) return 'usb'; |
| + if (/built-?in|macbook|imac|internal|microphone array|realtek/i.test(l)) return 'built-in'; |
| + if (/external (?:microphone|headphones)|headphones|line in|wired/i.test(l)) return 'wired'; |
| + return 'unknown'; |
| + } |
| + |
| + // qualityOn: the person's own switch, on unless they turned it off. |
| + function qualityOn() { |
| + if (qualityLocalOff) return false; |
| + try { |
| + return localStorage.getItem(QUALITY_KEY) !== 'off'; |
| + } catch { |
| + return true; |
| + } |
| + } |
| + |
| + // countShareStep is one §15.22 step toward one person, for the report. |
| + function countShareStep(id, step) { |
| + let c = shareCountsBy.get(id); |
| + if (!c) { |
| + c = { keyframes: 0, rebuilds: 0, reshares: 0, failures: 0 }; |
| + shareCountsBy.set(id, c); |
| + } |
| + c[step] += 1; |
| + } |
| + |
| + // qualityKind classifies an RTP report on one connection: audio, the |
| + // camera or the screen. Outbound by the screen transceiver's mid, |
| + // inbound by frameKey, the same split the frame counts use. |
| + function qualityKind(id, entry, r) { |
| + const kind = r.kind || r.mediaType; |
| + if (kind === 'audio') return 'audio'; |
| + if (kind !== 'video') return ''; |
| + if (r.type === 'outbound-rtp') { |
| + return entry.shareTx && entry.shareTx.mid && r.mid === entry.shareTx.mid ? 'screen' : 'video'; |
| + } |
| + const key = frameKey(id, entry, r.mid || ''); |
| + if (!key) return ''; |
| + return key === id ? 'video' : 'screen'; |
| + } |
| + |
| + // refreshOutputKind finds the speaker this tab plays through — the one |
| + // chosen, or the system's default — for the next report. Asynchronous |
| + // (enumerateDevices), so a report carries what the last one found. |
| + function refreshOutputKind() { |
| + if (!navigator.mediaDevices || !navigator.mediaDevices.enumerateDevices) return; |
| + navigator.mediaDevices.enumerateDevices().then((devices) => { |
| + const outs = devices.filter((d) => d.kind === 'audiooutput'); |
| + const chosen = (HAS_SINK_ID && prefs.speaker && outs.find((d) => d.deviceId === prefs.speaker)) || |
| + outs.find((d) => d.deviceId === 'default') || outs[0] || null; |
| + qualityOutput = chosen ? deviceKind(chosen.label) : 'unknown'; |
| + }, () => {}); |
| + } |
| + |
| + function reportClient() { |
| + const api = window.CallsReport; |
| + return api && typeof api.client === 'function' ? api.client() : { app: 'web' }; |
| + } |
| + |
| + // sendQuality closes the window and, when it may, sends its report. The |
| + // window is closed whether or not anything is sent, so the next one |
| + // starts from now whatever the switches said. `final` is the report at |
| + // leave or at the end, sent once with keepalive so it survives the page |
| + // going. Never retried, and never said to the person: a report that |
| + // did not arrive is one fewer line in a log. |
| + function sendQuality(final) { |
| + if (!qualityFrom || (final && qualityFinalSent)) return; |
| + if (final) qualityFinalSent = true; |
| + const now = Date.now(); |
| + const seconds = (now - qualityFrom) / 1000; |
| + qualityFrom = now; |
| + const events = qualityTake(qualityEvents); |
| + const peers = []; |
| + for (const [id, entry] of pcs) { |
| + if (!entry.statsReport) continue; |
| + const reading = qualityReading(entry.statsReport, (r) => qualityKind(id, entry, r)); |
| + const was = qualityPrev.get(id); |
| + qualityPrev.set(id, { pc: entry.pc, reading }); |
| + peers.push({ |
| + id, |
| + prev: was && was.pc === entry.pc ? was.reading : null, |
| + now: reading, |
| + ladder: shareCountsBy.get(id) || null, |
| + reattaches: reattachesBy.get(id) || 0, |
| + events: events.peers.get(id) || null, |
| + }); |
| + } |
| + for (const id of Array.from(qualityPrev.keys())) if (!pcs.has(id)) qualityPrev.delete(id); |
| + shareCountsBy.clear(); |
| + reattachesBy.clear(); |
| + refreshOutputKind(); |
| + if (!reportToken || qualityServerOff || !qualityOn()) return; |
| + const track = local && local.getAudioTracks()[0]; |
| + const report = qualityReport({ |
| + final, |
| + seconds, |
| + client: reportClient(), |
| + devices: { |
| + input: !track || track.readyState === 'ended' ? 'none' : deviceKind(track.label), |
| + output: qualityOutput, |
| + }, |
| + events: events.call, |
| + peers, |
| + }); |
| + let body = JSON.stringify(report); |
| + while (body.length > QUALITY_MAX_BODY && report.peers && report.peers.length) { |
| + report.peers.pop(); |
| + body = JSON.stringify(report); |
| + } |
| + quality.sent += 1; |
| + fetch('/m/' + encodeURIComponent(sqid) + '/quality', { |
| + method: 'POST', |
| + credentials: 'same-origin', |
| + headers: { 'Content-Type': 'application/json', 'X-Calls-Report': reportToken }, |
| + body, |
| + keepalive: !!final, |
| + }).then((res) => { |
| + quality.status = res.status; |
| + if (res.status === 202) quality.accepted += 1; |
| + if (res.status === 204) qualityServerOff = true; |
| + }, () => { |
| + quality.status = 0; |
| + }); |
| + } |
| + |
| + // qualityTick runs after each stats walk: the window opens when this |
| + // tab is first admitted, and a report closes it every qualityEvery. |
| + function qualityTick() { |
| + if (state.stopped || !state.admitted) return; |
| + const now = Date.now(); |
| + if (!qualityFrom) { |
| + qualityFrom = now; |
| + refreshOutputKind(); |
| + return; |
| + } |
| + if (now - qualityFrom >= qualityEvery) sendQuality(false); |
| + } |
| + |
| + // qualityLook feeds the event watch what this tab is showing now. |
| + function qualityLook(now) { |
| + const peers = new Map(); |
| + for (const id of pcs.keys()) { |
| + const m = remoteMedia.get(id) || null; |
| + const r = connecting.peers.get(id); |
| + const h = hearing.get(id); |
| + peers.set(id, { |
| + reconnecting: !reconnect.call && reconnect.peers.has(id), |
| + cantHear: !!(r && r.shown), |
| + noSound: !!(h && h.silent), |
| + noSoundFromUs: !!(m && state.peer && Array.isArray(m.noSoundFrom) && m.noSoundFrom.includes(state.peer)), |
| + audioBlocked: !!(m && m.audioBlocked), |
| + screenStalled: screenStalled.includes(id), |
| + }); |
| + } |
| + qualityWatch(qualityEvents, now, { call: reconnect.call, audioBlocked, peers }); |
| + } |
| + |
| + // problemOptions is what the sheet needs from this page: the token as |
| + // it stands when Send is pressed, and the diagnostics as they stand |
| + // then, with the people named by short peer id. |
| + function problemOptions(returnFocus) { |
| + return { |
| + sqid, |
| + token: () => reportToken, |
| + diagnostics: () => callDiagnostics({ anonymous: true }), |
| + client: reportClient, |
| + returnFocus, |
| + }; |
| + } |
| + |
| + // openProblem opens the sheet, or says why it cannot: a page without |
| + // report.js, or a server that never handed this tab a token. |
| + function openProblem(returnFocus) { |
| + const api = window.CallsReport; |
| + if (!api || !reportToken) { |
| + if (deviceNote) note('This call cannot be reported from here.'); |
| + return; |
| + } |
| + api.open(problemOptions(returnFocus)); |
| + } |
| + |
| // --------------------------------------------------------------- |
| // "Reconnecting…" (§15.23, 2026-09-30) |
| // --------------------------------------------------------------- |
| @@ -4944,6 +5562,7 @@ |
| const wasCall = reconnect.call; |
| const wasPeers = Array.from(reconnect.peers).sort().join(); |
| reconnectStep(reconnect, now, !!streamDownSince && !!state.peer, people); |
| + qualityLook(now); |
| if (reconnect.call !== wasCall) { |
| console.info('calls: reconnecting ' + (reconnect.call ? 'shown (a problem that lasted)' : 'cleared')); |
| renderStatus(); |
| @@ -5862,6 +6481,10 @@ |
| function terminal(reason) { |
| if (state.stopped) return; |
| + // §15.26: the last window's report, and the diagnostics for "Report a |
| + // problem", while the connections are still there to read. |
| + sendQuality(true); |
| + const lastDiagnostics = callDiagnostics({ anonymous: true }); |
| state.stopped = true; |
| state.terminal = reason; |
| // Admission ended with the participation. `peer` is kept: it names |
| @@ -5891,6 +6514,25 @@ |
| chatEnabled(false); |
| say(TERMINAL[reason] || TERMINAL.ended); |
| wayOut(reason); |
| + offerProblemAfter(reason, lastDiagnostics); |
| + } |
| + |
| + // offerProblemAfter puts "Report a problem" beside the way out of a |
| + // call that is over (§15.26): the token goes on working for ten |
| + // minutes, and the sheet carries the diagnostics as they were left. |
| + // Not for a sign-in that lapsed — the report needs the session — nor |
| + // for a browser that could never make a call. |
| + function offerProblemAfter(reason, diagnostics) { |
| + if (!reportToken || !window.CallsReport || reason === 'signedout' || reason === 'nowebrtc') return; |
| + const b = document.createElement('button'); |
| + b.type = 'button'; |
| + b.id = 'report-problem-after'; |
| + b.className = 'calls-report-after'; |
| + b.textContent = 'Report a problem'; |
| + b.addEventListener('click', () => { |
| + window.CallsReport.open(Object.assign(problemOptions(b), { diagnostics: () => diagnostics })); |
| + }); |
| + statusEl.append(' ', b); |
| } |
| // wayOut is the rule that no terminal screen is a dead end. Being |
| @@ -6482,6 +7124,46 @@ |
| }); |
| } |
| + // §15.26: "Report a problem" from the Devices panel and from More. The |
| + // panel that opened it closes first, so Escape in the sheet closes the |
| + // sheet and nothing behind it, and the keyboard comes back to the bar |
| + // button the panel hung from. |
| + if (reportProblem) { |
| + reportProblem.addEventListener('click', () => { |
| + if (devicesPanel && !devicesPanel.hidden && devicesToggle) { |
| + devicesPanel.hidden = true; |
| + devicesToggle.setAttribute('aria-expanded', 'false'); |
| + } |
| + openProblem(devicesToggle || reportProblem); |
| + }); |
| + } |
| + if (reportProblemMenu) { |
| + reportProblemMenu.addEventListener('click', () => { |
| + if (morePanel && !morePanel.hidden) { |
| + morePanel.hidden = true; |
| + if (moreToggle) moreToggle.setAttribute('aria-expanded', 'false'); |
| + } |
| + openProblem(moreToggle || reportProblemMenu); |
| + }); |
| + } |
| + // "Send anonymous quality reports": on unless turned off, remembered in |
| + // this browser, and read at every report, so turning it off stops the |
| + // next one. "Report a problem" is a person's own choice each time and |
| + // does not ask it. |
| + if (qualityInput) { |
| + if ($('quality-help')) qualityInput.setAttribute('aria-describedby', 'quality-help'); |
| + qualityInput.checked = qualityOn(); |
| + qualityInput.addEventListener('change', () => { |
| + try { |
| + if (qualityInput.checked) localStorage.removeItem(QUALITY_KEY); |
| + else localStorage.setItem(QUALITY_KEY, 'off'); |
| + } catch { |
| + // No storage: the switch holds for this page only. |
| + } |
| + qualityLocalOff = !qualityInput.checked; |
| + }); |
| + } |
| + |
| leaveButton.addEventListener('click', () => { |
| leave(); |
| go('/m/' + encodeURIComponent(sqid) + '/join'); |
| @@ -6500,6 +7182,14 @@ |
| function leave() { |
| if (state.leaveSent) return; |
| state.leaveSent = true; |
| + // §15.26, before the connections go: the last window's report, and |
| + // what the holding screen needs to offer "Report a problem" for the |
| + // next ten minutes — the token and the diagnostics as they are now, |
| + // because the page that has them is about to be gone. |
| + sendQuality(true); |
| + if (reportToken && window.CallsReport) { |
| + window.CallsReport.remember({ sqid, token: reportToken, diagnostics: callDiagnostics({ anonymous: true }), client: reportClient() }); |
| + } |
| state.stopped = true; |
| clearTimeout(reconnectTimer); |
| clearTimeout(fullTimer); |
| @@ -6963,6 +7653,9 @@ |
| for (const [id, entry] of pcs) { |
| try { |
| const stats = await entry.pc.getStats(); |
| + // §15.26: the quality report reads this walk rather than making |
| + // one of its own. |
| + entry.statsReport = stats; |
| const outbound = []; |
| // What the decoder is delivering, per tile: the fallback frame |
| // count where there is no requestVideoFrameCallback, and — in |
| @@ -7007,6 +7700,7 @@ |
| } |
| watchScreens(screens, screenReports); |
| answerScreenStalls(); |
| + qualityTick(); |
| } |
| function hearingState() { |
| @@ -7141,9 +7835,16 @@ |
| // §15.21, for the gate: this tab's receiver watch treats every |
| // screen as frozen for `ms`, so its stall report and the sharer's |
| // keyframe can be driven end to end. |
| - // §15.22: "Copy call diagnostics" as it would be copied. |
| - diagnostics() { |
| - return callDiagnostics(); |
| + // §15.22: "Copy call diagnostics" as it would be copied; §15.26's |
| + // {anonymous: true} is what "Report a problem" would send. |
| + diagnostics(opts) { |
| + return callDiagnostics(opts); |
| + }, |
| + // §15.26, for the gate: the quality report's cadence, shortened from |
| + // thirty seconds so a scenario sees reports in seconds. Never below |
| + // QUALITY_EVERY_MIN, and nothing else about the report changes. |
| + qualityEveryForTest(ms) { |
| + qualityEvery = Math.max(QUALITY_EVERY_MIN, Math.min(Number(ms) || QUALITY_EVERY, QUALITY_EVERY)); |
| }, |
| stallScreenForTest(ms) { |
| screenStallTestUntil = Date.now() + Math.max(0, Math.min(Number(ms) || 0, 60000)); |
| @@ -7257,6 +7958,18 @@ |
| // Speaker view: who has the floor (speakerStep), by peer id. |
| speaker: stageSpeaker.current, |
| mediaExtras, |
| + // §15.26: whether this tab holds a report token (never the token), |
| + // the reports sent and accepted, the last answer, and the two |
| + // switches — the server's (a 204) and the person's. |
| + quality: { |
| + token: !!reportToken, |
| + sent: quality.sent, |
| + accepted: quality.accepted, |
| + status: quality.status, |
| + serverOff: qualityServerOff, |
| + on: qualityOn(), |
| + every: qualityEvery, |
| + }, |
| }; |
| }, |
| }; |
diff --git a/internal/calls/templates/join.html b/internal/calls/templates/join.html| index 7de6f97..1c2bc73 100644 |
| --- a/internal/calls/templates/join.html |
| +++ b/internal/calls/templates/join.html |
| @@ -4,6 +4,7 @@ |
| sheet hides it, as the hidden attribute used to. */}} |
| <noscript><link rel="stylesheet" href="{{asset "static/noscript.css"}}"></noscript> |
| <script defer src="{{asset "static/devices.js"}}"></script> |
| +<script defer src="{{asset "static/report.js"}}"></script> |
| {{end}} |
| {{/* The holding screen. Its layout is fixed at the first paint (spec |
| §2's "reserved up front"): the controls and the status line come |
| @@ -76,8 +77,17 @@ |
| </div> |
| </div> |
| <div class="calls-holding-more"> |
| + {{/* Spec §15.26: for ten minutes after leaving a call, the call can |
| + still be reported from here. report.js reveals it only when this |
| + tab left this meeting's call that recently, and never without |
| + script, which the sheet needs. */}} |
| + <div id="report-last" class="calls-report-last" hidden><button type="button" id="report-last-open" rst-btn>Report a problem with the last call</button></div> |
| <p><a href="{{.AppHref}}" rst-btn>Join in the app</a> <span class="calls-option-help">The Calls app must be installed to open it.</span></p> |
| {{if .IsMember}}<form method="post" action="/m/{{.Sqid}}/share" target="_blank" rel="noopener" data-busy="false"><button type="submit" rst-btn aria-label="Share meeting link">{{icon "share"}}Share link</button></form> |
| <p class="calls-holding-links"><a href="/meetings/{{.Sqid}}/settings">Meeting settings</a> · <a href="/meetings/{{.Sqid}}/end">End meeting for everyone</a></p>{{end}} |
| + {{/* §15.26's disclosure, where the small print is. The switch is in |
| + the call's Devices panel. */}} |
| + <p class="calls-option-help calls-quality-note">Calls sends anonymous connection quality reports to help fix call problems. You can turn them off under Devices during a call.</p> |
| </div> |
| +{{template "report-sheet" .}} |
| {{end}} |
diff --git a/internal/calls/templates/partials/report.html b/internal/calls/templates/partials/report.html| new file mode 100644 |
| index 0000000..3de6c12 |
| --- /dev/null |
| +++ b/internal/calls/templates/partials/report.html |
| @@ -0,0 +1,48 @@ |
| +{{/* report-sheet — "Report a problem" (spec §15.26, 2026-10-03): a |
| + modal <dialog> that report.js opens, from the room (More, Devices, |
| + and the words saying the call is over) and from the holding screen |
| + for ten minutes after leaving. The browser's own modal brings the |
| + focus trap, Escape and the inert page behind it. |
| + |
| + Script only, like the call itself: it is never open without script, |
| + and a closed <dialog> renders nothing. |
| + |
| + The note is the vendored field envelope written out rather than |
| + called, as chat's is: ui's field-textarea has no Maxlength key, and |
| + the server takes a thousand characters. The hint under it is the |
| + count left, and is named in aria-describedby with the sentence |
| + saying what is sent, so both are read with the field. |
| + |
| + data-calls-version is the server's build, the version a web report |
| + carries (protocol.ReportClient), where the page has it. */}} |
| +{{define "report-sheet"}}<dialog id="report-sheet" class="calls-report" aria-labelledby="report-title"{{with .Version}} data-calls-version="{{.}}"{{end}}> |
| + <form id="report-form" class="calls-report-form" novalidate> |
| + <h2 id="report-title" class="calls-report-title">Report a problem</h2> |
| + <div id="report-ask" class="calls-report-part"> |
| + <div rst-field> |
| + <label rst-field-label for="report-note">What went wrong?</label> |
| + <textarea rst-textarea id="report-note" name="note" rows="4" maxlength="1000" aria-describedby="report-note-hint report-disclosure"></textarea> |
| + <small rst-field-hint id="report-note-hint">Optional. Up to 1,000 characters.</small> |
| + </div> |
| + <p id="report-disclosure" class="calls-report-disclosure">Sends this call’s connection diagnostics to the Calls server log. No audio or video.</p> |
| + <p id="report-error" class="calls-report-error" role="alert" hidden></p> |
| + <div class="calls-report-actions"> |
| + <button type="button" id="report-cancel" rst-btn>Cancel</button> |
| + <button type="submit" id="report-send" rst-btn="primary">Send</button> |
| + </div> |
| + </div> |
| + <div id="report-done" class="calls-report-part" hidden> |
| + {{/* Focused when it appears, so the thanks and the reference are |
| + read together. */}} |
| + <div id="report-done-title" class="calls-report-done" tabindex="-1"> |
| + <p class="calls-report-thanks">Sent — thank you</p> |
| + <p class="calls-report-reference">Reference <strong id="report-ref" class="calls-report-ref"></strong></p> |
| + </div> |
| + <p id="report-copied" class="calls-report-copied" role="status"></p> |
| + <div class="calls-report-actions"> |
| + <button type="button" id="report-copy" rst-btn>Copy reference</button> |
| + <button type="button" id="report-close" rst-btn="primary">Close</button> |
| + </div> |
| + </div> |
| + </form> |
| +</dialog>{{end}} |
diff --git a/internal/calls/templates/room.html b/internal/calls/templates/room.html| index 45cfda7..0f6b335 100644 |
| --- a/internal/calls/templates/room.html |
| +++ b/internal/calls/templates/room.html |
| @@ -2,6 +2,7 @@ |
| <noscript><link rel="stylesheet" href="{{asset "static/noscript.css"}}"></noscript> |
| <link rel="stylesheet" href="{{asset "static/chat.css"}}"> |
| <script defer src="{{asset "static/chat.js"}}"></script> |
| +<script defer src="{{asset "static/report.js"}}"></script> |
| <script defer src="{{asset "static/room.js"}}"></script> |
| <script defer src="{{asset "static/call-layout.js"}}"></script> |
| <script defer src="{{asset "static/call-window.js"}}"></script> |
| @@ -170,6 +171,13 @@ |
| states and counts only; room.js says in #device-note whether it |
| was copied. */}} |
| <button type="button" id="copy-diagnostics" rst-btn="ghost" class="calls-copy-diagnostics">Copy call diagnostics</button> |
| + {{/* Spec §15.26: the same diagnostics, with the people named by |
| + short peer id, sent to the server log with a note (report.js's |
| + sheet); and the switch for the automatic anonymous quality |
| + reports, on unless turned off in this browser. */}} |
| + <button type="button" id="report-problem" rst-btn="ghost" class="calls-copy-diagnostics calls-report-problem">Report a problem</button> |
| + <div id="quality-row" class="calls-quality-row">{{template "field-check" dict "Name" "quality-reports" "Label" "Send anonymous quality reports" "Checked" true}} |
| + <p id="quality-help" class="calls-raw-help">Connection statistics every thirty seconds, to help fix call problems: no names, no audio or video.</p></div> |
| </div> |
| {{/* More: who you are in this call, then the account menu's items in |
| its order and its words (chrome.go, roomMenu) — Meetings, |
| @@ -185,6 +193,8 @@ |
| {{if .Menu.Nav}}<hr>{{range .Menu.Nav}}<a href="{{.Href}}">{{.Label}}</a>{{end}}{{end}} |
| <hr><p class="calls-menu-heading">Help</p> |
| {{range .Menu.Help}}<a href="{{.Href}}">{{.Label}}</a>{{end}} |
| + {{/* §15.26: report.js's sheet, opened by room.js. */}} |
| + <button type="button" id="report-problem-menu">Report a problem</button> |
| {{with .Menu.SignOut}}<hr><form method="post" action="{{.Action}}"><button type="submit">{{.Label}}</button></form>{{end}} |
| {{if .IsMember}}<hr><a href="/meetings/{{.Sqid}}/end" target="_blank" rel="noopener" class="calls-menu-danger">End meeting for everyone</a>{{end}} |
| </div> |
| @@ -225,5 +235,6 @@ |
| #tile-menu for that tile. */}} |
| <template id="tile-tools"><button type="button" class="calls-tool calls-tool-pin" data-act="pin" aria-pressed="true">{{icon "pin"}}<span class="calls-tool-text">Pinned</span></button><button type="button" class="calls-tool calls-tool-more" aria-expanded="false" aria-controls="tile-menu">{{icon "ellipsis"}}</button></template> |
| </div> |
| +{{template "report-sheet" .}} |
| </main> |
| {{end}} |
diff --git a/test/browser/call.mjs b/test/browser/call.mjs| index 250627b..2362007 100644 |
| --- a/test/browser/call.mjs |
| +++ b/test/browser/call.mjs |
| @@ -5817,6 +5817,145 @@ const run = { |
| await ctx.close(); |
| }, |
| + // Spec §15.26: the anonymous quality reports and "Report a problem". |
| + // Two members in a call, each sending its report every two seconds |
| + // (the test hook; thirty in the product): Go reads its own report log |
| + // and answers whether the lines share one call tag under two peer tags, |
| + // name each other only by tag, and name nobody — no address, no display |
| + // name, no meeting, no peer id. Then "Report a problem" the way a |
| + // person reaches it, from More: the sheet, a note, Send, and the |
| + // reference it shows is the one in the log. Then the switch in Devices |
| + // stops the automatic reports. And last, B leaves for the holding |
| + // screen, which offers the sheet for the call just left, and the report |
| + // still lands: the token works for ten minutes after the participation. |
| + async quality() { |
| + const ctxA = await newContext('member A', { cookie: cookieA }); |
| + const ctxB = await newContext('member B', { cookie: cookieB }); |
| + const pageA = await openRoom(ctxA); |
| + const pageB = await openRoom(ctxB); |
| + for (const page of [pageA, pageB]) await page.evaluate(() => window.meet.qualityEveryForTest(2000)); |
| + // What a report looks like on the wire, for the record, and the proof |
| + // that the browser itself sends no name, address, label or meeting. |
| + const bodies = []; |
| + pageA.on('request', (req) => { |
| + if (req.method() === 'POST' && where({ url: () => req.url() }) === '/m' && req.url().endsWith('/quality')) { |
| + bodies.push(req.postData() || ''); |
| + } |
| + }); |
| + const pair = await assertPair(pageA, pageB, 'member A', 'member B'); |
| + if (!pair) return; |
| + const peers = [pair.a.peer, pair.b.peer]; |
| + const quality = (page) => page.evaluate(() => window.meet.state().quality); |
| + |
| + for (const [page, label] of [[pageA, 'member A'], [pageB, 'member B']]) { |
| + const q = await until(async () => { |
| + const x = await quality(page); |
| + return x.accepted >= 2 ? x : false; |
| + }, 20000); |
| + check(q, label + ' has had two quality reports accepted (' + JSON.stringify(q || (await quality(page))) + ')'); |
| + } |
| + const wire = bodies.find((x) => x.includes('"peers"')) || bodies[0] || ''; |
| + line('a quality report as sent: ' + wire); |
| + check(wire && !/paul|bea|example\.com|rslw|Board meeting/i.test(wire) && !wire.includes(sqid) && |
| + !/"(?:address|ip|port|label|name|url)"/.test(wire) && !/Mozilla|AppleWebKit/.test(wire), |
| + 'the report on the wire carries no name, address, label, meeting or user agent'); |
| + const answer = await control('quality-check ' + peers.join(' ')); |
| + check(answer === 'ok', 'the report log has both browsers’ quality lines under one call tag, and names nobody (' + answer + ')'); |
| + |
| + // What "Report a problem" sends: people by short peer id, never by name. |
| + const anon = await pageA.evaluate(() => window.meet.diagnostics({ anonymous: true })); |
| + check(anon.includes('peer ' + pair.b.peer.slice(0, 6)) && !/\bbea\b|\bpaul\b/i.test(anon), |
| + 'the anonymous diagnostics name the other person by short peer id and nobody by name'); |
| + const named = await pageA.evaluate(() => window.meet.diagnostics()); |
| + check(/\bbea\b/.test(named), '"Copy call diagnostics" still names people'); |
| + |
| + // The sheet, from More. |
| + await pageA.click('#call-more-toggle'); |
| + await pageA.click('#report-problem-menu'); |
| + const opened = await until(() => pageA.evaluate(() => document.getElementById('report-sheet').open), 5000); |
| + check(opened, 'Report a problem opens the sheet from More'); |
| + check(await pageA.evaluate(() => document.getElementById('call-more').hidden), 'the More menu closed behind it'); |
| + check(await pageA.evaluate(() => document.activeElement && document.activeElement.id === 'report-note'), |
| + 'the note field has the keyboard'); |
| + const disclosure = await pageA.locator('#report-disclosure').textContent(); |
| + check(disclosure === 'Sends this call’s connection diagnostics to the Calls server log. No audio or video.', |
| + 'the sheet says what it sends (' + disclosure + ')'); |
| + check(await pageA.getByLabel('What went wrong?').count() === 1, 'the note is labelled "What went wrong?"'); |
| + await pageA.fill('#report-note', 'The picture froze for a few seconds.'); |
| + check((await pageA.locator('#report-note-hint').textContent()).includes('964 of 1,000'), |
| + 'the hint counts the characters left (' + (await pageA.locator('#report-note-hint').textContent()) + ')'); |
| + await pageA.click('#report-send'); |
| + const ref = await until(async () => { |
| + const t = ((await pageA.locator('#report-ref').textContent()) || '').trim(); |
| + return /^[0-9A-Z]{8}$/.test(t) ? t : false; |
| + }, 10000); |
| + check(ref, 'the sheet shows a reference once sent (' + ((await pageA.locator('#report-ref').textContent()) || '') + |
| + ' / ' + ((await pageA.locator('#report-error').textContent()) || '') + ')'); |
| + check((await pageA.locator('#report-done').textContent()).includes('Sent — thank you'), 'the sheet says thank you'); |
| + check(await pageA.evaluate(() => document.activeElement && document.activeElement.id === 'report-done-title'), |
| + 'the thanks and the reference have the keyboard, so they are read'); |
| + if (ref) { |
| + const got = await control('problem-check ' + ref + ' ' + peers.join(' ')); |
| + check(got.startsWith('ok'), 'the problem report is in the log under the reference shown (' + got + ')'); |
| + } |
| + await pageA.keyboard.press('Escape'); |
| + check(await until(() => pageA.evaluate(() => !document.getElementById('report-sheet').open), 3000), 'Escape closes the sheet'); |
| + const back = await until(() => pageA.evaluate(() => { |
| + const a = document.activeElement; |
| + return a && a.id === 'call-more-toggle' ? a.id : false; |
| + }), 2000); |
| + check(back, 'the keyboard is back on More (' + (back || (await pageA.evaluate(() => { |
| + const a = document.activeElement; |
| + return a ? a.tagName + '#' + a.id + ' chrome=' + document.querySelector('.calls-window').dataset.chrome : 'none'; |
| + }))) + ')'); |
| + check(!(await state(pageA)).terminal && (await pageA.evaluate(() => window.meet.state().media.muted)) === false, |
| + 'Escape and typing in the sheet touched neither the call nor the microphone'); |
| + |
| + // The person's switch: off, and the reports stop. |
| + await pageA.click('#devices-toggle'); |
| + const toggle = pageA.locator('#quality-row input[type="checkbox"]'); |
| + check(await toggle.isChecked(), '"Send anonymous quality reports" is on by default'); |
| + await pageA.locator('#quality-row label').click(); |
| + check(!(await toggle.isChecked()) && (await pageA.evaluate(() => localStorage.getItem('calls.qualityReports'))) === 'off', |
| + 'turning it off is remembered in this browser'); |
| + const before = (await quality(pageA)).sent; |
| + await sleep(5000); |
| + const after = await quality(pageA); |
| + check(after.sent === before && after.on === false, 'no reports are sent while it is off (' + before + ' -> ' + after.sent + ')'); |
| + await pageA.locator('#quality-row label').click(); |
| + await pageA.click('#devices-toggle'); |
| + |
| + // After leaving: the holding screen offers the sheet for that call. |
| + await leaveRoom(pageB); |
| + const offered = await until(async () => await pageB.locator('#report-last-open').isVisible(), 5000); |
| + check(offered, 'the holding screen offers "Report a problem with the last call" after leaving'); |
| + if (offered) { |
| + await pageB.click('#report-last-open'); |
| + await pageB.fill('#report-note', 'Reported after leaving.'); |
| + await pageB.click('#report-send'); |
| + const after = await until(async () => { |
| + const t = ((await pageB.locator('#report-ref').textContent()) || '').trim(); |
| + return /^[0-9A-Z]{8}$/.test(t) ? t : false; |
| + }, 10000); |
| + check(after, 'a report from the holding screen is accepted (' + ((await pageB.locator('#report-error').textContent()) || '') + ')'); |
| + if (after) { |
| + const got = await control('problem-check ' + after + ' ' + peers.join(' ')); |
| + check(got.startsWith('ok') && got.includes('live false'), |
| + 'the after-leave report is in the log, marked as from a participation that has ended (' + got + ')'); |
| + } |
| + await pageB.click('#report-close'); |
| + } |
| + const final = await control('quality-final'); |
| + check(final === 'ok', 'leaving sent a final quality report (' + final + ')'); |
| + check(await pageB.locator('.calls-quality-note').isVisible(), 'the holding screen discloses the quality reports'); |
| + |
| + check(errorsFor('member A') === '', 'no console errors in A (' + errorsFor('member A') + ')'); |
| + check(errorsFor('member B') === '', 'no console errors in B (' + errorsFor('member B') + ')'); |
| + await leaveRoom(pageA); |
| + await ctxA.close(); |
| + await ctxB.close(); |
| + }, |
| + |
| async scriptless() { |
| const ctx = await newContext('scriptless', { cookie: cookieA, javaScriptEnabled: false }); |
| const sentence = 'The call needs JavaScript to reach your camera and the other participants; everything else on this page works without it.'; |
diff --git a/test/browser/room-quality.test.mjs b/test/browser/room-quality.test.mjs| new file mode 100644 |
| index 0000000..cab399c |
| --- /dev/null |
| +++ b/test/browser/room-quality.test.mjs |
| @@ -0,0 +1,381 @@ |
| +// room-quality.test.mjs — the anonymous quality report and "Report a |
| +// problem" (spec §15.26, 2026-10-03), gated without a browser. |
| +// |
| +// node --test test/browser/room-quality.test.mjs |
| +// |
| +// The report's builders are lifted out of the shipped room.js by name, |
| +// with the constants they read, as room-reconnect.test.mjs does; the |
| +// client's description and the sheet's answers out of report.js. The |
| +// fixtures are getStats walks as Chromium writes them, two windows |
| +// apart, with the fields a report must never carry — candidate |
| +// addresses and ports — present in them, so their absence from the |
| +// report is a finding and not an accident of the fixture. |
| +import { test } from 'node:test'; |
| +import assert from 'node:assert/strict'; |
| +import { readFileSync } from 'node:fs'; |
| +import { fileURLToPath } from 'node:url'; |
| + |
| +const roomSrc = readFileSync(fileURLToPath(new URL('../../internal/calls/static/room.js', import.meta.url)), 'utf8'); |
| +const reportSrc = readFileSync(fileURLToPath(new URL('../../internal/calls/static/report.js', import.meta.url)), 'utf8'); |
| + |
| +function lift(src, file, name) { |
| + const start = src.indexOf('function ' + name + '('); |
| + assert.notEqual(start, -1, name + ' is not in ' + file + ' any more — this test is stale, not passing'); |
| + let depth = 0; |
| + for (let i = src.indexOf('{', start); i < src.length; i++) { |
| + if (src[i] === '{') depth += 1; |
| + else if (src[i] === '}') { |
| + depth -= 1; |
| + if (depth === 0) return src.slice(start, i + 1); |
| + } |
| + } |
| + assert.fail('unbalanced braces reading ' + name); |
| +} |
| +function liftConst(name) { |
| + const m = roomSrc.match(new RegExp('const ' + name + ' = ([0-9.e]+);')); |
| + assert.ok(m, name + ' is not a numeric const in room.js any more'); |
| + return 'const ' + name + ' = ' + m[1] + ';'; |
| +} |
| + |
| +const CONSTS = ['QUALITY_MAX_PEERS', 'QUALITY_MAX_SECONDS', 'QUALITY_MAX_MILLIS', 'QUALITY_MAX_COUNT', |
| + 'QUALITY_MAX_KBPS', 'QUALITY_MAX_PIXELS', 'QUALITY_MAX_FPS', 'QUALITY_LOOK_MAX'].map(liftConst).join('\n'); |
| +const ROOM = ['qualityCeiling', 'qualitySet', 'qualityReading', 'qualityPeer', 'qualityWatchRecord', |
| + 'qualityWatch', 'qualityTake', 'qualityReport', 'deviceKind']; |
| +// eslint-disable-next-line no-new-func |
| +const q = new Function(CONSTS + '\n' + ROOM.map((n) => lift(roomSrc, 'room.js', n)).join('\n') + |
| + '; return { ' + ROOM.join(', ') + ' };')(); |
| +const REPORT = ['reportClient', 'problemAnswer', 'problemBody']; |
| +// eslint-disable-next-line no-new-func |
| +const r = new Function(REPORT.map((n) => lift(reportSrc, 'report.js', n)).join('\n') + |
| + '; return { ' + REPORT.join(', ') + ' };')(); |
| + |
| +// The bounds must be protocol/quality.go's, or the server refuses what |
| +// this clamps to. |
| +test('the bounds are the protocol package\'s', () => { |
| + const go = readFileSync(fileURLToPath(new URL('../../protocol/quality.go', import.meta.url)), 'utf8'); |
| + const bound = (name) => { |
| + const m = go.match(new RegExp(name + '\\s*=\\s*([0-9_.e]+)(?:\\s*<<\\s*([0-9]+))?')); |
| + assert.ok(m, name + ' is not in protocol/quality.go'); |
| + return Number(m[1].replace(/_/g, '')) * 2 ** Number(m[2] || 0); |
| + }; |
| + const room = (name) => Number(liftConst(name).match(/= ([0-9.e]+);/)[1]); |
| + assert.equal(room('QUALITY_MAX_PEERS'), bound('MaxQualityPeers')); |
| + assert.equal(room('QUALITY_MAX_SECONDS'), bound('MaxQualitySeconds')); |
| + assert.equal(room('QUALITY_MAX_MILLIS'), bound('MaxQualityMillis')); |
| + assert.equal(room('QUALITY_MAX_COUNT'), bound('MaxQualityCount')); |
| + assert.equal(room('QUALITY_MAX_KBPS'), bound('MaxQualityKbps')); |
| + assert.equal(room('QUALITY_MAX_PIXELS'), bound('MaxQualityPixels')); |
| + assert.equal(room('QUALITY_MAX_FPS'), bound('MaxQualityFPS')); |
| + assert.equal(room('QUALITY_MAX_BODY'), bound('MaxQualityBody')); |
| +}); |
| + |
| +// ── fixtures ───────────────────────────────────────────────────────── |
| + |
| +const PEER = 'a1b2c3d4e5f60718293a4b5c6d7e8f90'; |
| +const OTHER = '0f1e2d3c4b5a69788796a5b4c3d2e1f0'; |
| + |
| +// One connection's walk, Chromium's shape. `t` scales the counters, so |
| +// window two is window one plus what happened in thirty seconds. |
| +function walk(t) { |
| + const reports = [ |
| + { id: 'OA', type: 'outbound-rtp', kind: 'audio', mid: '0', ssrc: 11, bytesSent: 10000 + t * 4000, packetsSent: 100 + t * 50 }, |
| + { id: 'OV', type: 'outbound-rtp', kind: 'video', mid: '1', ssrc: 12, bytesSent: 500000 + t * 100000, packetsSent: 1000 + t * 300, |
| + frameWidth: 640, frameHeight: 480, framesPerSecond: 29.97, qualityLimitationReason: t ? 'bandwidth' : 'none' }, |
| + { id: 'RIA', type: 'remote-inbound-rtp', kind: 'audio', localId: 'OA', ssrc: 11, packetsLost: 2 + t * 3, roundTripTime: 0.0423, jitter: 0.0021 }, |
| + { id: 'RIV', type: 'remote-inbound-rtp', kind: 'video', localId: 'OV', ssrc: 12, packetsLost: 5 + t * 7, roundTripTime: 0.05, jitter: 0.004 }, |
| + { id: 'IA', type: 'inbound-rtp', kind: 'audio', mid: '0', ssrc: 21, bytesReceived: 20000 + t * 3750, packetsReceived: 200 + t * 1500, |
| + packetsLost: 1 + t * 15, jitter: 0.0031, totalSamplesReceived: 48000 + t * 1440000, concealedSamples: 100 + t * 4800, |
| + insertedSamplesForDeceleration: 10 + t * 20, removedSamplesForAcceleration: 5 + t * 30, |
| + jitterBufferDelay: 2880 + t * 86400, jitterBufferEmittedCount: 48000 + t * 1440000 }, |
| + { id: 'IV', type: 'inbound-rtp', kind: 'video', mid: '1', ssrc: 22, bytesReceived: 400000 + t * 3000000, packetsReceived: 900 + t * 3000, |
| + packetsLost: 0 + t * 30, jitter: 0.012, freezeCount: 1 + t * 2, totalFreezesDuration: 0.5 + t * 1.25, framesDecoded: 300 + t * 900, |
| + framesDropped: 2 + t * 4, frameWidth: 1280, frameHeight: 720, framesPerSecond: 30 }, |
| + { id: 'T', type: 'transport', selectedCandidatePairId: 'CP' }, |
| + { id: 'CP', type: 'candidate-pair', localCandidateId: 'L', remoteCandidateId: 'R', state: 'succeeded', nominated: true, |
| + currentRoundTripTime: 0.031 }, |
| + { id: 'L', type: 'local-candidate', candidateType: 'relay', relayProtocol: 'tls', protocol: 'udp', address: '192.0.2.44', port: 54321, |
| + ip: '192.0.2.44', url: 'turns:relay.example.net:443' }, |
| + { id: 'R', type: 'remote-candidate', candidateType: 'srflx', protocol: 'udp', address: '198.51.100.7', port: 61000, ip: '198.51.100.7' }, |
| + ]; |
| + if (t) { |
| + // A screen arriving in window two only: new streams count from zero. |
| + reports.push({ id: 'IS', type: 'inbound-rtp', kind: 'video', mid: '3', ssrc: 23, bytesReceived: 750000, packetsReceived: 800, |
| + packetsLost: 4, jitter: 0.02, freezeCount: 3, totalFreezesDuration: 6, framesDecoded: 150, framesDropped: 9, |
| + frameWidth: 1920, frameHeight: 1080, framesPerSecond: 5 }); |
| + } |
| + return new Map(reports.map((x) => [x.id, x])); |
| +} |
| +const classify = (x) => (x.kind === 'audio' ? 'audio' : x.mid === '3' ? 'screen' : 'video'); |
| +const FORBIDDEN = ['192.0.2.44', '198.51.100.7', '54321', '61000', 'relay.example.net', 'address', 'port', '"ip"', 'url']; |
| + |
| +// ── the report ─────────────────────────────────────────────────────── |
| + |
| +test('two windows: deltas, rates over the window, gauges as they stand', () => { |
| + const one = q.qualityReading(walk(0), classify); |
| + const two = q.qualityReading(walk(1), classify); |
| + const p = q.qualityPeer(one, two, 30); |
| + |
| + assert.deepEqual(p.path, { local: 'relay', remote: 'srflx', protocol: 'tls' }); |
| + assert.equal(p.rtt, 31); |
| + |
| + assert.deepEqual(p.out.audio, { kbps: 1.1, packets: 50, lost: 3, rtt: 42.3, jitter: 2.1 }); |
| + assert.deepEqual(p.out.video, { kbps: 26.7, packets: 300, width: 640, height: 480, fps: 30, limit: 'bandwidth', lost: 7, rtt: 50, jitter: 4 }); |
| + |
| + assert.deepEqual(p.in.audio, { |
| + kbps: 1, packets: 1500, lost: 15, jitter: 3.1, samples: 1440000, concealed: 4800, inserted: 20, removed: 30, |
| + // 86,400 s of buffer delay summed over 1,440,000 emitted samples: 60 ms each. |
| + jitterBufferMs: 60, |
| + }); |
| + assert.deepEqual(p.in.video, { |
| + kbps: 800, packets: 3000, lost: 30, jitter: 12, freezes: 2, freezeSeconds: 1.3, decoded: 900, dropped: 4, |
| + width: 1280, height: 720, fps: 30, |
| + }); |
| + // The screen was new in window two: everything it has counted is this |
| + // window's. |
| + assert.deepEqual(p.in.screen, { |
| + kbps: 200, packets: 800, lost: 4, jitter: 20, freezes: 3, freezeSeconds: 6, decoded: 150, dropped: 9, |
| + width: 1920, height: 1080, fps: 5, |
| + }); |
| +}); |
| + |
| +test('the first window counts from zero, and a counter that goes back counts nothing', () => { |
| + const first = q.qualityPeer(null, q.qualityReading(walk(0), classify), 10); |
| + assert.equal(first.out.audio.packets, 100); |
| + assert.equal(first.in.video.decoded, 300); |
| + const back = q.qualityPeer(q.qualityReading(walk(1), classify), q.qualityReading(walk(0), classify), 30); |
| + assert.equal(back.out.audio.packets, 0); |
| + assert.equal(back.in.audio.lost, 0); |
| +}); |
| + |
| +test('never an address, a port or a URL, whatever the walk carries', () => { |
| + const report = q.qualityReport({ |
| + seconds: 30, |
| + client: { app: 'web', browser: 'chrome' }, |
| + devices: { input: 'bluetooth', output: 'built-in' }, |
| + peers: [{ id: PEER, prev: q.qualityReading(walk(0), classify), now: q.qualityReading(walk(1), classify) }], |
| + }); |
| + const json = JSON.stringify(report); |
| + for (const bad of FORBIDDEN) assert.ok(!json.includes(bad), 'the report carries ' + bad + ': ' + json); |
| + assert.equal(report.v, 1); |
| + assert.equal(report.seconds, 30); |
| + assert.equal(report.peers[0].peer, PEER); |
| + assert.equal(report.final, undefined); |
| +}); |
| + |
| +test('clamped to the protocol\'s ceilings; NaN, infinite and negative left out', () => { |
| + const now = { |
| + streams: new Map([ |
| + ['in:x', { dir: 'in', kind: 'video', counters: { bytesReceived: 1e12, packetsReceived: 5e9, packetsLost: -3, framesDecoded: NaN }, |
| + gauges: { jitter: -0.5, frameWidth: 99999, frameHeight: Infinity, framesPerSecond: 1000 } }], |
| + ['remote:y', { dir: 'remote', kind: 'audio', counters: { packetsLost: 2 }, gauges: { roundTripTime: 1e6, jitter: NaN } }], |
| + ]), |
| + path: { local: 'host', remote: 'host', protocol: 'udp', rtt: -1 }, |
| + }; |
| + const p = q.qualityPeer(null, now, 1); |
| + assert.deepEqual(p.in.video, { kbps: 1e6, packets: 1e9, lost: 0, width: 16384, fps: 240 }); |
| + assert.deepEqual(p.out.audio, { lost: 2, rtt: 600000 }); |
| + assert.equal(p.rtt, undefined); |
| + const report = q.qualityReport({ seconds: 5000, client: { app: 'web' }, peers: [] }); |
| + assert.equal(report.seconds, 600); |
| + assert.equal(report.peers, undefined); |
| +}); |
| + |
| +test('an unknown candidate type, an unknown transport, and Firefox\'s selected pair', () => { |
| + const ff = new Map([ |
| + ['P', { id: 'P', type: 'candidate-pair', selected: true, localCandidateId: 'L', remoteCandidateId: 'R' }], |
| + ['L', { id: 'L', type: 'local-candidate', candidateType: 'weird', protocol: 'sctp', address: '10.0.0.1' }], |
| + ['R', { id: 'R', type: 'remote-candidate', candidateType: 'prflx', protocol: 'tcp' }], |
| + ]); |
| + const p = q.qualityPeer(null, q.qualityReading(ff, classify), 30); |
| + assert.deepEqual(p.path, { local: 'unknown', remote: 'prflx', protocol: 'unknown' }); |
| + const tcpRelay = new Map([ |
| + ['T', { id: 'T', type: 'transport', selectedCandidatePairId: 'P' }], |
| + ['P', { id: 'P', type: 'candidate-pair', localCandidateId: 'L', remoteCandidateId: 'R' }], |
| + ['L', { id: 'L', type: 'local-candidate', candidateType: 'host', protocol: 'tcp' }], |
| + ['R', { id: 'R', type: 'remote-candidate', candidateType: 'host' }], |
| + ]); |
| + assert.equal(q.qualityPeer(null, q.qualityReading(tcpRelay, classify), 30).path.protocol, 'tcp'); |
| +}); |
| + |
| +test('at most four peers, each a well-formed peer id, none with nothing to say', () => { |
| + const now = q.qualityReading(walk(0), classify); |
| + const ids = ['1', '2', '3', '4', '5', '6'].map((d) => d.repeat(32)); |
| + const report = q.qualityReport({ |
| + seconds: 30, |
| + client: { app: 'web' }, |
| + peers: [ |
| + { id: 'not a peer id', prev: null, now }, |
| + { id: OTHER, prev: null, now: { streams: new Map(), path: null } }, |
| + ...ids.map((id) => ({ id, prev: null, now })), |
| + ], |
| + }); |
| + assert.deepEqual(report.peers.map((p) => p.peer), ids.slice(0, 4)); |
| +}); |
| + |
| +test('the screen ladder and the mending by person, and only what happened', () => { |
| + const now = q.qualityReading(walk(1), classify); |
| + const report = q.qualityReport({ |
| + seconds: 30, |
| + final: true, |
| + client: { app: 'web' }, |
| + events: { reconnecting: 1, reconnectingSeconds: 4.2 }, |
| + peers: [{ id: PEER, prev: null, now, ladder: { keyframes: 3, rebuilds: 1, reshares: 0, failures: 0 }, reattaches: 1, |
| + events: { cantHearSeconds: 2.5, noSoundFromUs: true } }], |
| + }); |
| + assert.equal(report.final, true); |
| + assert.deepEqual(report.events, { reconnecting: 1, reconnectingSeconds: 4.2 }); |
| + const p = report.peers[0]; |
| + assert.deepEqual(p.out.screen, { keyframes: 3, rebuilds: 1 }); |
| + assert.equal(p.in.screen.reattaches, 1); |
| + assert.deepEqual(p.events, { cantHearSeconds: 2.5, noSoundFromUs: true }); |
| +}); |
| + |
| +// ── the notices ────────────────────────────────────────────────────── |
| + |
| +test('the notices: counted once each time they appear, timed while they show', () => { |
| + const rec = q.qualityWatchRecord(); |
| + const quiet = { reconnecting: false, cantHear: false }; |
| + const look = (t, call, peer) => q.qualityWatch(rec, t, { call, audioBlocked: false, peers: new Map([[PEER, peer]]) }); |
| + // Can't hear us for the first 1.4 s; then a call-wide "Reconnecting…" |
| + // from 5 s to 8 s; then one on this person from 10 s to 11 s. |
| + for (let t = 0; t <= 15000; t += 200) { |
| + look(t, t >= 5000 && t < 8000, { |
| + reconnecting: t >= 10000 && t < 11000, |
| + cantHear: t < 1400, |
| + noSound: t === 12000, |
| + }); |
| + } |
| + const got = q.qualityTake(rec); |
| + assert.deepEqual(got.call, { reconnecting: 1, reconnectingSeconds: 3 }); |
| + assert.deepEqual(got.peers.get(PEER), { reconnecting: 1, reconnectingSeconds: 1, cantHearSeconds: 1.4, noSound: true }); |
| + // A quiet window says nothing. |
| + look(15200, false, quiet); |
| + look(15400, false, quiet); |
| + const next = q.qualityTake(rec); |
| + assert.deepEqual(next.call, {}); |
| + assert.equal(next.peers.size, 0); |
| +}); |
| + |
| +test('a suspended tab\'s gap is not a notice someone saw, and someone gone is forgotten', () => { |
| + const rec = q.qualityWatchRecord(); |
| + q.qualityWatch(rec, 0, { call: true, audioBlocked: true, peers: new Map([[PEER, { reconnecting: false, cantHear: true }]]) }); |
| + q.qualityWatch(rec, 60000, { call: true, audioBlocked: false, peers: new Map() }); |
| + const got = q.qualityTake(rec); |
| + assert.deepEqual(got.call, { reconnecting: 1, reconnectingSeconds: 1, audioBlocked: true }); |
| + assert.deepEqual(got.peers.get(PEER), { cantHearSeconds: 1 }); |
| + assert.equal(rec.peers.size, 0); |
| +}); |
| + |
| +// ── the devices and the client ─────────────────────────────────────── |
| + |
| +test('a device\'s kind by its label, which never leaves', () => { |
| + const cases = [ |
| + ['Paul’s AirPods Pro', 'bluetooth'], |
| + ['Bose QC35 II', 'bluetooth'], |
| + ['Galaxy Buds2 Pro (Bluetooth)', 'bluetooth'], |
| + ['MacBook Pro Microphone (Built-in)', 'built-in'], |
| + ['Default - MacBook Air Speakers (Built-in)', 'built-in'], |
| + ['Microphone Array (Realtek(R) Audio)', 'built-in'], |
| + ['Scarlett 2i2 USB', 'usb'], |
| + ['USB Audio Device (0d8c:0014)', 'usb'], |
| + ['External Headphones', 'wired'], |
| + ['BlackHole 2ch (Virtual)', 'virtual'], |
| + ['Paul’s iPhone Microphone', 'continuity'], |
| + ['Living Room (AirPlay)', 'airplay'], |
| + ['Studio Display Microphone', 'unknown'], |
| + ['', 'unknown'], |
| + [undefined, 'unknown'], |
| + ]; |
| + for (const [label, kind] of cases) assert.equal(q.deviceKind(label), kind, String(label)); |
| +}); |
| + |
| +test('the client: enums and digits from the user agent, never the string', () => { |
| + const MAC_CHROME = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.50 Safari/537.36'; |
| + const cases = [ |
| + [MAC_CHROME, { platform: 'macOS', platformVersion: '15.5.0' }, 'dev', 0, |
| + { app: 'web', version: 'dev', browser: 'chrome', browserVersion: '138.0', os: 'macos', osVersion: '15.5.0' }], |
| + // No hints: macOS's frozen 10_15_7 is not a version. |
| + [MAC_CHROME, null, 'de04e2d', 0, { app: 'web', version: 'de04e2d', browser: 'chrome', browserVersion: '138.0', os: 'macos' }], |
| + ['Mozilla/5.0 (iPhone; CPU iPhone OS 17_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4.1 Mobile/15E148 Safari/604.1', |
| + null, '', 5, { app: 'web', browser: 'safari', browserVersion: '17.4', os: 'ios', osVersion: '17.4.1' }], |
| + // An iPad asking for the desktop site says Macintosh; its touch points say otherwise. |
| + ['Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Safari/605.1.15', |
| + null, '', 5, { app: 'web', browser: 'safari', browserVersion: '18.2', os: 'ipados' }], |
| + ['Mozilla/5.0 (iPad; CPU OS 16_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/120.0.6099.119 Mobile/15E148 Safari/604.1', |
| + null, '', 5, { app: 'web', browser: 'chrome', browserVersion: '120.0', os: 'ipados', osVersion: '16.6' }], |
| + ['Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36 Edg/138.0.3351.55', |
| + { platform: 'Windows', platformVersion: '19.0.0' }, '', 0, |
| + { app: 'web', browser: 'edge', browserVersion: '138.0', os: 'windows', osVersion: '19.0.0' }], |
| + ['Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:139.0) Gecko/20100101 Firefox/139.0', null, '', 0, |
| + { app: 'web', browser: 'firefox', browserVersion: '139.0', os: 'linux' }], |
| + ['Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Mobile Safari/537.36', null, '', 5, |
| + { app: 'web', browser: 'chrome', browserVersion: '137.0', os: 'android', osVersion: '10' }], |
| + ['Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36 OPR/120.0.0.0', |
| + null, 'not a version!', 0, { app: 'web', browser: 'other', os: 'chromeos' }], |
| + ['', null, '', 0, { app: 'web', browser: 'other', os: 'other' }], |
| + ]; |
| + for (const [ua, hints, version, touch, want] of cases) { |
| + const got = r.reportClient(ua, hints, version, touch); |
| + assert.deepEqual(got, want, ua); |
| + const json = JSON.stringify(got); |
| + assert.ok(!json.includes('Mozilla') && !json.includes('AppleWebKit'), json); |
| + } |
| +}); |
| + |
| +// ── "Report a problem" ─────────────────────────────────────────────── |
| + |
| +test('the sheet\'s answers', () => { |
| + assert.deepEqual(r.problemAnswer(200, { ref: 'K7Q2M9PD' }), { ref: 'K7Q2M9PD', text: '' }); |
| + assert.equal(r.problemAnswer(200, { ref: '<b>x</b>' }).text, 'The report could not be sent.'); |
| + assert.equal(r.problemAnswer(429, null).text, 'Too many reports from this call — try again in a minute.'); |
| + assert.equal(r.problemAnswer(404, {}).text, 'This call can no longer be reported.'); |
| + assert.equal(r.problemAnswer(403, {}).text, 'The report could not be sent.'); |
| + assert.equal(r.problemAnswer(0, null).text, 'The report could not be sent.'); |
| +}); |
| + |
| +test('the problem body fits the server\'s 64 KB, cutting the diagnostics and never the note', () => { |
| + const client = { app: 'web' }; |
| + const small = JSON.parse(r.problemBody('It froze', 'all fine', client, 65000)); |
| + assert.deepEqual(small, { note: 'It froze', diagnostics: 'all fine', client }); |
| + const big = r.problemBody('x'.repeat(1200), 'line\n'.repeat(40000), client, 65000); |
| + assert.ok(new TextEncoder().encode(big).length <= 65000); |
| + const parsed = JSON.parse(big); |
| + assert.equal(parsed.note.length, 1000); |
| + assert.ok(parsed.diagnostics.endsWith('[cut short to fit]')); |
| +}); |
| + |
| +test('the diagnostics a problem report sends name nobody', () => { |
| + const names = new Map([[PEER, 'Paul Campbell'], [OTHER, 'bea']]); |
| + const pc = { connectionState: 'connected', signalingState: 'stable', iceConnectionState: 'connected' }; |
| + const pcs = new Map([[PEER, { pc, shareSend: { width: 1280, height: 720, fps: 30, framesEncoded: 9, framesSent: 9, keyFramesEncoded: 1 } }, |
| + ], [OTHER, { pc, shareSend: null, shareTx: {} }]]); |
| + const env = { |
| + names, pcs, |
| + state: { admitted: true, stopped: false }, |
| + share: { stream: {}, track: {} }, |
| + shareMode: () => 'balanced', |
| + screenAsks: new Map([[OTHER, { stage: 'keyframes' }]]), |
| + shareCounts: { keyframes: 1, rebuilds: 0, reshares: 0, failures: 0 }, |
| + screenReadings: new Map([[PEER + '#share', { mid: '3', width: 1, height: 1 }]]), |
| + readingSummary: () => 'a reading', |
| + screenStalled: [PEER], |
| + screenReattaches: 0, |
| + screenLines: ['10:00:00.000 keyframe for ' + OTHER.slice(0, 6)], |
| + SCREEN_LOG_LINES: 200, |
| + SHARE_SUFFIX: '#share', |
| + navigator: { userAgent: 'Test/1.0' }, |
| + }; |
| + // eslint-disable-next-line no-new-func |
| + const callDiagnostics = new Function(...Object.keys(env), lift(roomSrc, 'room.js', 'callDiagnostics') + |
| + '; return callDiagnostics;')(...Object.values(env)); |
| + const anonymous = callDiagnostics({ anonymous: true }); |
| + assert.ok(!/Paul|Campbell|bea\b/.test(anonymous), anonymous); |
| + assert.ok(anonymous.includes('peer ' + PEER.slice(0, 6) + ': connection connected'), anonymous); |
| + assert.ok(anonymous.includes('to peer ' + OTHER.slice(0, 6)), anonymous); |
| + assert.ok(anonymous.includes('recovering peer ' + OTHER.slice(0, 6)), anonymous); |
| + assert.ok(anonymous.includes('stalled here: peer ' + PEER.slice(0, 6)), anonymous); |
| + // "Copy call diagnostics" keeps the names. |
| + const named = callDiagnostics(); |
| + assert.ok(named.includes('Paul Campbell (' + PEER.slice(0, 6) + ')'), named); |
| + assert.ok(named.includes('recovering bea'), named); |
| +}); |