oficina / meet Public

Clone
git clone https://amadan.net/oficina/meet

Plain git — no account needed to clone.

Download

Download this file

File too large to show in full — download it above (showing a truncated preview below).

1// room.js: the browser half of a call.
2//
3// Spec sections this implements, and the order they appear below:
4//
5// §4.1 transport one SSE stream down, POST /signal up
6// §4.2 peer identity the server issues `peer`; one peer per session
7// per room, and nothing here ever displaces
8// another tab automatically
9// §4.3 messages the event table, including the roster as the
10// authority and the admission queue
11// §4.5 resume the probe, the replay, the four refusals and
12// the terminal reasons
13// §4.6 delivery per-target outbound queue with `seq`, pair
14// generations, inbound dedupe, candidate buffer
15// §5.1 negotiation the W3C perfect-negotiation pattern
16// §5.2 ICE servers GET /m/{sqid}/ice before the first connection
17// §5.3 renewal per connection, at the server's `renewAt`, and
18// complete only when a new ICE username fragment
19// is on the selected candidate pair
20// §5.4 screen share getDisplayMedia as one sendonly transceiver
21// per connection, a `share` message so peers can
22// label the tile, and a stop that ends the
23// receiver's track rather than muting it
24// §5.5 the floor an empty list is the floor: host candidates
25// only, and the failed tile says whether a relay
26// was there to fail
27// §6 chat the broadcast lane and the panel: chat.js owns
28// the words and the log, this file owns the wire
29// and when the panel is usable
30// §15.19 media state who is muted and whose camera is off: our own
31// reported on the broadcast lane, everyone else's
32// read from the roster and `media`
33// §15.26 reports the anonymous quality report every thirty
34// seconds and at leave, and "Report a problem"
35// (report.js's sheet) with the diagnostics
36//
37// House rules this file is written under: no library, no inline script
38// (the page's CSP is `default-src 'self'`), nothing built with
39// innerHTML, and every string a peer or the server sends is written
40// with textContent. `data-is-member` on #room is a rendering hint only
41// — the server re-derives authority on every request, and the roster
42// it sends is what this file actually believes.
43(() => {
44 'use strict';
45
46 const root = document.getElementById('room');
47 if (!root) return;
48 // Revealing the room is the first job: the server renders it hidden
49 // so a scriptless browser sees the fallback paragraph instead.
50 root.hidden = false;
51
52 const sqid = root.dataset.sqid || '';
53 const selfName = root.dataset.selfName || '';
54 const memberHint = root.dataset.isMember === '1';
55 // §5.5: whether this instance has a relay at all. It changes one
56 // sentence — the one a failed tile shows — and nothing else. Like
57 // data-is-member it is a rendering hint: the ICE list the server
58 // actually mints is what decides whether a call has a relay to use.
59 const relayConfigured = root.dataset.relay === '1';
60
61 const $ = (id) => document.getElementById(id);
62 const tilesEl = $('tiles');
63 const queueEl = $('queue');
64 const knocksEl = $('knocks');
65 const statusEl = $('status');
66 const muteButton = $('mute');
67 const cameraButton = $('camera');
68 const leaveButton = $('leave');
69 const shareButton = $('share');
70 const muteState = $('mute-state');
71 const cameraState = $('camera-state');
72 const shareState = $('share-state');
73 // The sharing notice and its own Stop sharing button (2026-09-28).
74 const shareNotice = $('share-notice');
75 const shareStop = $('share-stop');
76 const shareRecoveryEl = $('share-recovery');
77 const copyDiagnostics = $('copy-diagnostics');
78 // §15.26: "Report a problem", in the Devices panel beside Copy call
79 // diagnostics and in the More menu, and the person's own switch for
80 // the automatic reports.
81 const reportProblem = $('report-problem');
82 const reportProblemMenu = $('report-problem-menu');
83 const moreToggle = $('call-more-toggle');
84 const morePanel = $('call-more');
85 const qualityRow = $('quality-row');
86 const qualityInput = qualityRow ? qualityRow.querySelector('input[type="checkbox"]') : null;
87 // §15.19: the tile marks, rendered by the server so they are the
88 // vendored badge and the app's own icons rather than a copy of either,
89 // and the polite region that says a change out loud. #status is no
90 // use for that: renderStatus rewrites it on every render.
91 const mutedMark = $('tile-muted');
92 const cameraOffMark = $('tile-camera-off');
93 const mediaLive = $('media-live');
94 // The chat panel's root (§6). chat.js owns everything inside it; this
95 // file owns the wire under it and the moment it becomes usable.
96 const chatEl = $('chat');
97 // The device panel and its controls. Every one of these is optional
98 // as far as this file is concerned — the room renders without them in
99 // a browser that cannot enumerate devices, and switchDevice and
100 // fillDevices both check before they reach for one.
101 const devicesToggle = $('devices-toggle');
102 const devicesPanel = $('room-devices');
103 const deviceNote = $('device-note');
104 const pickCamera = $('pick-camera');
105 const pickMic = $('pick-mic');
106 const pickSpeaker = $('pick-speaker');
107 const cameraRow = $('camera-row');
108 const micRow = $('mic-row');
109 const speakerRow = $('speaker-row');
110 const rawRow = $('raw-row');
111 const rawInput = rawRow ? rawRow.querySelector('input[type="checkbox"]') : null;
112 const shareModeRow = $('share-mode-row');
113 const pickShareMode = $('pick-share-mode');
114
115 const DEVICE_KEY = 'meet.devices';
116 const BACKOFF_MIN = 500;
117 const BACKOFF_MAX = 30000;
118 // The stream's own ceiling is deliberately well under the hub's
119 // thirty-second resume window: a client that waited the full thirty
120 // would lose the participation to its own patience, and come back to
121 // a fresh peer id and a renegotiated mesh for no reason.
122 const STREAM_BACKOFF_MAX = 10000;
123 // §4.6: a target whose POSTs have failed for this long is treated as
124 // unreachable — the pair is reset and the connection rebuilt.
125 const SEND_DEADLINE = 30000;
126 const MAX_BUFFERED_CANDIDATES = 256;
127 // §4.3: `full` is not a refusal, it is "not yet" — a member waits and
128 // retries the join on this interval.
129 const FULL_RETRY = 5000;
130 // A stream refusal the probe cannot name — a full room behind a 204,
131 // a 500, a `last` the server would not parse — would otherwise retry
132 // for ever behind "Connecting to the call." After this many rounds
133 // that produced no `hello`, and this long since the last one, the
134 // client stops and says so. Both conditions, because either alone
135 // gives up on something worth waiting for: a count alone would quit on
136 // a handful of fast failures at the start of the backoff ladder, and a
137 // clock alone would quit on one slow round that was still going to
138 // succeed.
139 const MAX_STALLS = 8;
140 const STALL_WINDOW = 60000;
141 // A stream that lived at least this long was a working stream, not a
142 // refusal: only that resets the backoff. A stream dying moments after
143 // its `hello` would otherwise reconnect in a tight loop.
144 const HEALTHY_STREAM = 5000;
145 // §5.3: a fetch of /ice that fails is retried every minute, and the
146 // warning stands from the first failure until one succeeds. The gate
147 // shortens the *schedule* through X-Calls-Test-Ice-TTL's effect on
148 // `renewAt`, never this: there is no client knob for it.
149 const ICE_RETRY = 60000;
150 // §5.3 step 2: each ICE restart gets thirty seconds to prove itself,
151 // and a connection gets three of them.
152 const RENEW_ATTEMPTS = 3;
153 const RENEW_WINDOW = 30000;
154 const RENEW_POLL = 500;
155 // The floor under a renewal delay. Nothing the server can send should
156 // schedule a renewal sooner than this: a list with no margin left in
157 // it would otherwise fetch, schedule for now, and fetch again.
158 const MIN_RENEW_DELAY = 1000;
159 // And the floor under the margin itself. A list whose renew time and
160 // expiry are less than a second apart cannot be renewed on any
161 // sensible schedule: the delay floor above would turn it into a fetch
162 // roughly once a second for as long as the tab stayed open. The
163 // server clamps what it will mint, so this can only arrive through
164 // the development-only TTL header — but a client that answers a
165 // nonsense schedule with a 1 Hz loop is a client that can be made to
166 // hammer its own server, so it declines instead, and says so in
167 // state().ice rather than looking idle for no reason (W3-12).
168 const MIN_MARGIN = 1000;
169 const PEER_ID = /^[0-9a-f]{32}$/;
170 // §4.3's two untargeted messages ride one queue of their own, and this
171 // is its key: not a peer id, and it cannot collide with one, because
172 // every peer id is thirty-two hex characters.
173 const BROADCAST = '*';
174 // A 404 on the broadcast lane is diagnosed inside the message's own
175 // deadline — but a POST that spent all thirty seconds getting that 404
176 // leaves nothing to diagnose with, and a recovery with no budget
177 // decides nothing at all. This is the floor it gets instead: enough
178 // for one probe and one retry, and short enough that a tab which has
179 // really lost its participation is not left looking live.
180 const MIN_RECOVERY = 5000;
181 // §5.1: how long the polite peer waits before offering. Perfect
182 // negotiation's answer to glare is a rollback on the polite side, and
183 // in Chromium rolling back an offer that added an m-line rebuilds the
184 // whole connection's send streams: the camera's comes back on the same
185 // SSRC having encoded nothing, on an m-line that goes on reading
186 // sendrecv with a live track, and it never encodes again. Two peers
187 // sharing a screen in the same moment is how a call finds that, and
188 // the gate's mutualshare scenario is where it was measured.
189 //
190 // So the peer that would roll back yields instead. A remote offer that
191 // was already on its way arrives while this side is still stable and
192 // is answered rather than collided with, and no rollback happens at
193 // all. The cost is this much delay before the polite side offers, and
194 // only when the other side has nothing to offer. The wait itself is
195 // unconditional — the check that reads the connection happens after
196 // it, not during — so what a remote offer arriving buys is not a
197 // shorter wait but a negotiation this side no longer has to make.
198 const POLITE_YIELD = 300;
199 // How many times in a row the polite side may give way without an
200 // offer of its own going out. A boolean could not answer both "give
201 // the allowance back when the exchange that took our turn completes"
202 // and "never let giving way become a habit"; a small counter answers
203 // both (Codex S4-4, and the Task 4 re-review's item 1).
204 const POLITE_YIELDS = 2;
205 // Polls in a row that must report no new encoded frames before a
206 // camera counts as stopped, and repairs on one connection before the
207 // pair is reset instead. At one poll a second that is two seconds of
208 // a live track on a sending m-line encoding nothing, then a
209 // replaceTrack cycle, then two more seconds, then a reset (Codex
210 // S4-5, and the Task 4 re-review's item 2).
211 const CAMERA_STALL_POLLS = 2;
212 const CAMERA_REPAIRS = 1;
213 // And the same shape for the last link in the chain: how many polls a
214 // tile may hold a live, unmuted track that its connection is decoding
215 // frames for and paint none of them, before the element is handed its
216 // media again — and how many of those before the pair is rebuilt
217 // instead (Codex round 2, the branch after S4-9).
218 const RENDER_STALL_POLLS = 3;
219 const RENDER_REPLAYS = 2;
220 // How far an element's own clock must advance in a poll to count as
221 // playing rather than twitching. A second of video is a second of
222 // clock; a single frame is thirty milliseconds, and a tile painting
223 // one frame every three seconds is a stall with a tic, not a picture.
224 // Reading any advance at all as health let such a tile reset the
225 // budget below for ever and never earn its reset.
226 const RENDER_MOVED = 0.25;
227 // The ceiling studio audio asks for, in bits per second. Opus is
228 // transparent for speech well below it; the number is picked so a
229 // microphone someone turned the processing off for is not then
230 // squeezed down a pipe built for speech. It is a ceiling and not a
231 // floor — the encoder still spends only what the link will carry.
232 const RAW_AUDIO_BITRATE = 128000;
233 // §15.20: the hearing watch. How often the levels are read — five
234 // times a second, which is as fast as a bar is worth moving and slow
235 // enough that one small getStats per connection costs nothing a
236 // person could notice.
237 const LEVEL_EVERY = 200;
238 // An audioLevel below this is silence: -80 dBFS, well under any room's
239 // own noise floor, so a microphone reading it is not hearing the room
240 // at all. Anything at or above it counts as sound.
241 const FLAT_LEVEL = 0.0001;
242 // How long a stream that is arriving must stay that flat before it is
243 // called silent — long enough to cover somebody simply not talking in
244 // a quiet room, short enough to catch "can you hear me?" early.
245 const NO_SOUND_AFTER = 5000;
246 // Packets count as arriving if one came this recently. Wider than a
247 // poll, because a sender using DTX sends a packet every 400 ms or so
248 // through silence, and silence with packets is exactly the case the
249 // watch is for.
250 const ARRIVING_WINDOW = 1500;
251 // The speaking highlight, with hysteresis: on at SPEAK_ON, and off only
252 // after SPEAK_HOLD polls below SPEAK_OFF, so a tile does not flicker
253 // between syllables.
254 const SPEAK_ON = 0.04;
255 const SPEAK_OFF = 0.015;
256 const SPEAK_HOLD = 4;
257 // The speaking light on a tile's name, which replaced the ring round
258 // the tile and the meter beside the name (0.3.7), and since 0.4.3 is
259 // the warm rim, as the native apps have it (SpeakingGlow.swift,
260 // WarmRim.swift): a level by decibels between GLOW_QUIET_DB and
261 // GLOW_LOUD_DB; lit only once sound at SPEAK_ON has kept up for
262 // GLOW_ONSET ms, so a click never lights it; the level rising over
263 // GLOW_ATTACK ms and falling over GLOW_RELEASE ms, which is a CSS
264 // transition on --lvl, with --on going out once it has fallen; and one
265 // steady GLOW_STEADY for anyone who has asked for reduced motion.
266 const GLOW_QUIET_DB = -42;
267 const GLOW_LOUD_DB = -12;
268 const GLOW_ONSET = 150;
269 const GLOW_ATTACK = 50;
270 const GLOW_RELEASE = 300;
271 const GLOW_STEADY = 0.6;
272 // Speaker view's stage (0.4.0), as the native apps have it
273 // (ActiveSpeaker in SpeakerView.swift): someone takes the stage only
274 // after SPEAKER_DOMINANCE ms as the loudest speaker, and whoever is on it
275 // keeps it at least SPEAKER_HOLD ms, so an interjection, a laugh or two
276 // people talking over each other never make it flap. Never yourself.
277 const SPEAKER_DOMINANCE = 1300;
278 const SPEAKER_HOLD = 3000;
279 // §4.3's limit on the list the hub will take.
280 const MAX_NO_SOUND = 15;
281 // §5.4: the tile key a peer's screen gets, built by shareKey.
282 const SHARE_SUFFIX = '#share';
283 // §15.21, the screen watchdogs (2026-09-28). A screen that has decoded
284 // nothing new for SCREEN_STALL_AFTER while its sender says it is
285 // sharing is stalled here, and reported as `screenStalled`; every
286 // sender repeats a still screen at least once a second, so this long
287 // without a frame is not a still screen. Held for at most
288 // SCREEN_STALL_GIVE_UP, so a screen nobody can recover is not a report
289 // that never ends. A sharer named in someone's report asks its encoder
290 // for a keyframe, at most once every SCREEN_KEYFRAME_EVERY, and rests
291 // SCREEN_KEYFRAME_REST after SCREEN_KEYFRAME_TRIES of them.
292 const SCREEN_STALL_AFTER = 4000;
293 const SCREEN_STALL_GIVE_UP = 180000;
294 const SCREEN_KEYFRAME_EVERY = 4000;
295 const SCREEN_KEYFRAME_TRIES = 3;
296 const SCREEN_KEYFRAME_REST = 30000;
297 // §15.22 (2026-09-30), when keyframes do not answer. The receiver
298 // hands a stalled screen's picture to its element again once,
299 // SCREEN_REATTACH_AFTER into the stall. The sharer, still named
300 // SCREEN_REBUILD_AFTER after its first keyframe, rebuilds that
301 // connection's screen (a new transceiver: a new encoder here, a new
302 // receiver and decoder there); still named SCREEN_RESHARE_AFTER after
303 // that, shares again for everyone from the same capture; and
304 // SCREEN_GIVE_UP_AFTER after that, says it could not. A rebuild at most
305 // once every SCREEN_REBUILD_EVERY per person, a share again at most
306 // once every SCREEN_RESHARE_EVERY, and neither unless at least
307 // SCREEN_ESCALATE_SENT frames left for that person since the last step:
308 // a receiver cannot be failing to decode frames that were never sent.
309 const SCREEN_REATTACH_AFTER = 4000;
310 const SCREEN_REBUILD_AFTER = 6000;
311 const SCREEN_RESHARE_AFTER = 10000;
312 const SCREEN_GIVE_UP_AFTER = 10000;
313 const SCREEN_REBUILD_EVERY = 60000;
314 const SCREEN_RESHARE_EVERY = 300000;
315 const SCREEN_ESCALATE_SENT = 3;
316 // The screen's timeline, for "Copy call diagnostics": its last lines.
317 const SCREEN_LOG_LINES = 200;
318 // §15.23 (2026-09-30), "Reconnecting…" only for a problem that lasts:
319 // the stream down RECONNECT_STREAM_AFTER (the whole call); a connection
320 // disconnected or failed RECONNECT_CONNECTION_AFTER, or no audio packets
321 // from someone unmuted RECONNECT_AUDIO_AFTER (that person's tile, or the
322 // whole call when it is everyone). Said for RECONNECT_HOLD past the
323 // recovery, so it does not flicker. A stream renewal — ended on purpose
324 // and resumed in half a second — is never said at all.
325 const RECONNECT_STREAM_AFTER = 4000;
326 const RECONNECT_CONNECTION_AFTER = 3000;
327 const RECONNECT_AUDIO_AFTER = 2000;
328 const RECONNECT_HOLD = 1000;
329 const RECONNECT_EVERY = 200;
330 // §15.24 (2026-10-01), "<name> is connecting and can't hear you yet":
331 // said on a person's tile from the moment they appear until their own
332 // report says they hear us — but only once CONNECTING_GRACE has passed
333 // without that, so an ordinary join says nothing, and never past
334 // CONNECTING_CAP, after which §15.20's and §15.23's words take over.
335 const CONNECTING_GRACE = 1000;
336 const CONNECTING_CAP = 20000;
337 // §15.26 (2026-10-03), the automatic quality report: one every
338 // QUALITY_EVERY while admitted, and one more at leave. The gate
339 // shortens the cadence through window.meet.qualityEveryForTest, never
340 // below QUALITY_EVERY_MIN. QUALITY_KEY is the person's own switch
341 // ("Send anonymous quality reports"), stored as "off" when off.
342 // The bounds are protocol/quality.go's, so a report is clamped here to
343 // what the server would accept rather than refused there.
344 const QUALITY_EVERY = 30000;
345 const QUALITY_EVERY_MIN = 1000;
346 const QUALITY_KEY = 'calls.qualityReports';
347 const QUALITY_MAX_PEERS = 4;
348 const QUALITY_MAX_SECONDS = 600;
349 const QUALITY_MAX_MILLIS = 600000;
350 const QUALITY_MAX_COUNT = 1e9;
351 const QUALITY_MAX_KBPS = 1e6;
352 const QUALITY_MAX_PIXELS = 16384;
353 const QUALITY_MAX_FPS = 240;
354 const QUALITY_MAX_BODY = 16384;
355 // The longest stretch between two looks of the event watch that is
356 // counted as time a notice was showing: a suspended tab's timers stop,
357 // and the gap when they start again is not a notice someone saw.
358 const QUALITY_LOOK_MAX = 1000;
359 // §5.4, sending: "Optimise shared screen for". What a screen gives up
360 // first when the uplink or the encoder cannot carry all of it. The
361 // first is the default; sharePlan says what each one means.
362 const SHARE_MODES = [
363 ['balanced', 'Balanced'],
364 ['detail', 'Text and detail'],
365 ['motion', 'Motion and video'],
366 ];
367 const HAS_DISPLAY_MEDIA = typeof navigator !== 'undefined' &&
368 !!navigator.mediaDevices &&
369 typeof navigator.mediaDevices.getDisplayMedia === 'function';
370 const HAS_SINK_ID = typeof HTMLMediaElement !== 'undefined' &&
371 typeof HTMLMediaElement.prototype.setSinkId === 'function';
372
373 // ---------------------------------------------------------------
374 // State (§4.2, §4.6)
375 // ---------------------------------------------------------------
376
377 const state = {
378 peer: null, // this stream's server-issued peer id
379 boot: root.dataset.boot || '',
380 admitted: false, // a roster has arrived, so we are in the mesh
381 joined: false, // `join` has been accepted at least once
382 full: false, // the last `full` event, cleared by a roster
383 stopped: false, // terminal, or waiting on a takeover: no reconnect
384 terminal: null, // the reason this call ended, once it has
385 leaveSent: false, // §4.3: `leave` is not idempotent
386 kind: memberHint ? 'member' : 'guest',
387 };
388
389 const pcs = new Map(); // peer id -> connection record
390 const gen = new Map(); // peer id -> pair generation (§4.6)
391 const seqOut = new Map(); // peer id -> last seq we sent (never reset)
392 const lastIn = new Map(); // peer id -> highest seq received (resume cursor)
393 const queue = new Map(); // peer id -> [{seq, body}] outbound, in order
394 const inflight = new Set();// peer ids with a POST in flight
395 // Targets the hub has answered 404 for while our own participation is
396 // alive: they have left, and our roster has not caught up yet. Nothing
397 // is sent to them and no reset is asked for until a roster settles it —
398 // otherwise every message to a departed peer starts its own 404 →
399 // probe → reset → 404 cycle, each with a fresh budget (Codex S2-11).
400 const stale = new Set();
401 const resetting = new Set(); // targets with a reset already in flight
402 const names = new Map(); // peer id -> display name, from the server only
403 const frames = new Map(); // peer id -> frames rendered, for the test hook
404 const tiles = new Map(); // tile key -> {figure, video}
405 // §15.19: peer id -> {muted, cameraOff}, for the peers that have
406 // reported. A peer absent here is unknown and is drawn as neither.
407 const remoteMedia = new Map();
408 // The state this tab last put on the broadcast lane, as mediaKey
409 // writes it, so a toggle that changes nothing sends nothing.
410 let mediaSent = '';
411 // §15.20: whether the report may carry the two optional keys. A server
412 // from before them answers 400 to anything but the two booleans, and
413 // the first such answer turns them off for the life of the page.
414 let mediaExtras = true;
415 // §15.21: share key -> {decoded, movedAt}, the receiver's watch; the
416 // peers last reported stalled; peer -> {at, tries, restUntil}, the
417 // sharer's keyframe answers; and the sharer's quiet notice.
418 const screenSeen = new Map();
419 let screenStalled = [];
420 const screenAsks = new Map();
421 let shareRecovery = '';
422 let screenStallTestUntil = 0;
423 // §15.22: each arriving screen's statistics at the last poll and when
424 // it last moved (share key -> screenReading); pictures handed back;
425 // when each peer's screen was last rebuilt, and everyone's last shared
426 // again; and the timeline itself.
427 const screenReadings = new Map();
428 const screenMoving = new Map();
429 let screenReattaches = 0;
430 const shareRebuiltAt = new Map();
431 let shareResharedAt = 0;
432 const shareCounts = { keyframes: 0, rebuilds: 0, reshares: 0, failures: 0 };
433 const screenLines = [];
434 // §15.26: the same ladder and mending, per peer, taken by each quality
435 // report (peer id -> counts). shareCounts above is the whole call's,
436 // for the diagnostics.
437 const shareCountsBy = new Map();
438 const reattachesBy = new Map();
439 // §15.23: when the stream went down (0 while it is up), and the watch.
440 let streamDownSince = 0;
441 const reconnect = reconnectRecord();
442 // §15.24: who can hear us yet (connectingStep's record), and the
443 // `hearing` list as last reported, joined, so a change is seen.
444 const connecting = connectingRecord();
445 let hearingKey = '';
446 // §15.20: peer id -> what this tab is hearing from that peer, as
447 // pollLevels reads it (hearingRecord). selfHearing is the same record
448 // for our own microphone, read off the sender.
449 const hearing = new Map();
450 const selfHearing = hearingRecord();
451 // Who has the floor, for Speaker view's stage (speakerStep).
452 const stageSpeaker = speakerRecord();
453 // Tile keys whose element the browser refused to play. Non-empty is
454 // `audioBlocked`: the page banner shows, and the report says so.
455 const refusedTiles = new Set();
456 let audioBlocked = false;
457 // The autoplay policy as the browser states it in advance, where it
458 // will (Firefox). A hint that puts the banner up early; the refusal
459 // is still the authority, and a play that succeeds takes it down.
460 let autoplayDoubted = autoplayPolicyDoubtful();
461 let soundBanner = null; // {box, text, button} once built
462 let levelTimer = 0;
463 let levelsBusy = false;
464 // The noSoundFrom list as last computed, joined, so a change is seen.
465 let noSoundKey = '';
466
467 // §5.2's list and §5.3's clocks. `expiresAt`, `renewAt`, `fetchedAt`
468 // and `lastFetchAt` are all milliseconds since the epoch: the server
469 // sends unix seconds and fetchIce is the one place they are converted,
470 // so everything below — and everything the test hook exposes —
471 // compares them with Date.now() and with nothing else.
472 const ice = {
473 servers: [], // the RTCIceServer list, [] on the floor (§5.5)
474 expiresAt: 0, // the credential's hard deadline on the relay
475 renewAt: 0, // when the server asks us to come back
476 margin: 0, // expiresAt - renewAt: a duration both clocks agree on
477 fetchedAt: 0, // when the list we hold arrived
478 failedSince: 0, // the first failure of the current run, or 0
479 lastTryAt: 0, // the most recent failed attempt, for the catch-up
480 fetches: 0, // *successful* fetches, for the gate
481 lastFetchAt: 0,
482 };
483 // peer id -> §5.3's record for one connection's renewal.
484 const renewals = new Map();
485
486 let waiting = []; // the admission queue, members only (§4.3)
487 let lastRoster = null;
488 // The newest roster this browser has been sent and has not applied
489 // yet. Only §5.2's fetch can leave anything in it for longer than a
490 // microtask.
491 let pendingRoster = null;
492 let pendingRosterAt = 0; // the tick that roster arrived on
493 let tick = 0;
494 let local = null; // the local MediaStream, or null
495 // §5.4: the display capture while this tab is sharing, and nothing
496 // when it is not. One capture, whatever the mesh's size — every
497 // connection carries the same track on a transceiver of its own.
498 let share = null; // {stream, track} while sharing
499 // True from the moment getDisplayMedia is asked until it answers. The
500 // picker takes as long as a person takes, and without this a second
501 // click during it takes a second capture that nothing ever stops.
502 let sharePending = false;
503 // §6: the mounted panel, once there is one. `chatSent` counts lines
504 // the hub accepted, not lines typed, and `chatReceived` counts lines
505 // put in the log — the two facts a gate cannot read off the DOM
506 // without knowing the panel's own markup.
507 let chat = null;
508 let chatSent = 0;
509 let chatReceived = 0;
510 let prefs = loadPrefs();
511 let es = null; // the live EventSource
512 let backoff = BACKOFF_MIN;
513 let stalls = 0; // consecutive stream rounds that produced no hello
514 // When this browser last reached the room. It starts at load rather
515 // than at zero: a page that has never once got a `hello` has been
516 // trying since it opened, and that is the stretch STALL_WINDOW means.
517 let lastHelloAt = Date.now();
518 let reconnectTimer = 0;
519 let reconnectWatchTimer = 0;
520 let statsTimer = 0;
521 let backdropTimer = 0;
522 let fullTimer = 0;
523 let renewTimer = 0; // §5.3: the scheduled renewal, at `renewAt`
524 let iceRetryTimer = 0; // §5.3 step 1: the one-minute retry
525 let expiryTimer = 0; // §5.3 step 3: the must-succeed-by verdict
526 let icePrimed = false; // a first fetch has been made (success or not)
527 let firstIce = null; // that fetch, shared by concurrent callers
528 let renewing = false; // a renewal fetch is in flight
529 let renewDue = false; // the current run is a renewal, not a first fetch
530 let iceWarningEl = null;
531 // §15.26: the report token the latest `hello` carried — the authority
532 // for both report endpoints, kept after leaving because it goes on
533 // working for ten minutes after — and the quality report's own state:
534 // when the current window began (0 until admitted), each connection's
535 // reading at the last report (peer id -> {pc, reading}), the event
536 // watch, the output device's kind as last enumerated, and what has
537 // been sent. A 204 is the server's kill switch, and stops the reports
538 // for the rest of this page.
539 let reportToken = '';
540 let qualityEvery = QUALITY_EVERY;
541 let qualityFrom = 0;
542 const qualityPrev = new Map();
543 const qualityEvents = qualityWatchRecord();
544 let qualityOutput = 'unknown';
545 let qualityServerOff = false;
546 let qualityLocalOff = false; // the switch, where there is no storage to keep it in
547 let qualityFinalSent = false;
548 const quality = { sent: 0, accepted: 0, status: 0 };
549 // How many rosters this tab has applied. Nothing in the page reads
550 // it: it is the one fact a gate cannot infer from the DOM or from
551 // `peers`, because a connection can be closed locally without any
552 // roster having arrived, and "recovered" has to mean the server said
553 // so (Codex S2-16).
554 let rosters = 0;
555
556 // ---------------------------------------------------------------
557 // Preferences, stored by the holding screen (devices.js)
558 // ---------------------------------------------------------------
559
560 function loadPrefs() {
561 try {
562 return JSON.parse(localStorage.getItem(DEVICE_KEY) || '{}') || {};
563 } catch {
564 return {};
565 }
566 }
567
568 function savePrefs() {
569 try {
570 localStorage.setItem(DEVICE_KEY, JSON.stringify(prefs));
571 } catch {
572 // Private browsing or a full quota: the toggle still works for
573 // this call, it just will not be remembered for the next one.
574 }
575 }
576
577 // ---------------------------------------------------------------
578 // Local media (§3.3's stored choices, §5.1's tracks)
579 // ---------------------------------------------------------------
580
581 // audioConstraints builds the microphone half of a getUserMedia
582 // request out of the stored preferences. It is a pure function of
583 // `prefs` so the gate can lift it out of both files and check the two
584 // copies still agree (test/browser/audio.test.mjs). room.js carries
585 // it verbatim, because the room asks for a stream of its own at join
586 // — the holding screen's is stopped by then — and a choice that held
587 // on one page and not the other would be worse than no choice.
588 //
589 // The three flags are the browser's voice pipeline: echo
590 // cancellation, noise suppression and automatic gain. Every browser
591 // turns all three on for a microphone unless told otherwise, which is
592 // right for a laptop on a kitchen table and wrong for an interface in
593 // a room someone has treated — gain riding a held note, noise
594 // suppression taking a cello for a fan. `rawAudio` is the person
595 // saying which of those two rooms they are in.
596 //
597 // They are asked for as ideals and never as `exact`. A browser that
598 // cannot turn its processing off should hand over a processed
599 // microphone, not refuse getUserMedia and cost the person their
600 // voice — the same rule the device ladder is built on. It is also why
601 // the holding screen checks afterwards what it was actually given
602 // (rawHonoured) instead of trusting that it got what it asked for.
603 //
604 // voiceIsolation is the newer, heavier filter in Safari and Chrome —
605 // on a Mac it is Apple's own system Voice Isolation, reached through
606 // the browser. It is only ever asked for *off*, and never on. Asked
607 // on, it runs on top of the noise suppression above rather than
608 // instead of it, and two suppressors chewing one voice is not twice
609 // as clean: it is a growl with the keystrokes still coming through,
610 // which is what a call on 2026-09-17 came back as. The pipeline above
611 // is every browser's own default and is the one this app asks for.
612 //
613 // Off it still belongs here, because someone may have turned Voice
614 // Isolation on for the whole machine and studio audio has to be able
615 // to say no to it. An unknown constraint name in this basic,
616 // non-exact form is dropped rather than refused, so naming it costs
617 // nothing in a browser that has never heard of it.
618 function audioConstraints(prefs) {
619 const processed = !prefs.rawAudio;
620 const constraints = {
621 echoCancellation: processed,
622 noiseSuppression: processed,
623 autoGainControl: processed,
624 };
625 if (!processed) constraints.voiceIsolation = false;
626 if (prefs.mic) constraints.deviceId = { exact: prefs.mic };
627 return constraints;
628 }
629
630 // tuneAudioSender raises the audio ceiling on one connection, and is
631 // the other half of what studio audio promises: turning the filters
632 // off buys nothing if the result then goes out through Opus at its
633 // default of roughly 32 kbit/s mono, which is a codec a good
634 // microphone is heard *through* rather than heard.
635 //
636 // It is a sender-side change, so it needs no SDP of our own and no
637 // renegotiation, and §5.1's perfect negotiation above never learns it
638 // happened. Stereo is deliberately not attempted: it lives in the
639 // fmtp line, and reaching it would mean building offers by hand
640 // instead of the argumentless setLocalDescription §5.1 is written on
641 // — a large risk to the one part of this file that must not wobble,
642 // bought for a microphone that is almost always mono anyway.
643 //
644 // Every step is optional in some browser that is otherwise fine, so
645 // the whole thing is best effort: a browser that will not take the
646 // parameters carries the call at its own bitrate rather than losing
647 // the connection over it. The encodings array is read right after
648 // addTrack, which is where Chromium has already built it; a browser
649 // that fills it in later simply keeps its default here, and that is a
650 // quieter microphone and not a broken one.
651 function tuneAudioSender(pc) {
652 let sender;
653 try {
654 sender = pc.getSenders().find((s) => s.track && s.track.kind === 'audio');
655 } catch {
656 return;
657 }
658 if (!sender || typeof sender.getParameters !== 'function') return;
659 let params;
660 try {
661 params = sender.getParameters();
662 } catch {
663 return;
664 }
665 // A sender the browser has not built encodings for yet is not one to
666 // invent an encodings array for: that is a different request, and
667 // not the one this function is making.
668 if (!params.encodings || !params.encodings.length) return;
669 // Both directions. Studio audio turned off mid-call has to give the
670 // ceiling back, and `undefined` — not zero, which is a request to
671 // send nothing — is how the API spells "the encoder's own default".
672 if (prefs.rawAudio) params.encodings[0].maxBitrate = RAW_AUDIO_BITRATE;
673 else delete params.encodings[0].maxBitrate;
674 Promise.resolve().then(() => sender.setParameters(params)).catch(() => {
675 // Chromium refuses parameters that did not come from the most
676 // recent getParameters, which a renegotiation in flight can cause.
677 // The call is unharmed, and the next connection gets its own try.
678 });
679 }
680
681 // sharePlan is what a shared screen is sent as, for one "Optimise
682 // for" mode and the capture's size. Pure, and lifted by name in the
683 // node test; the native app's ScreenSendPolicy is its twin, and the two
684 // are meant to agree number for number.
685 //
686 // Why it exists (2026-09-24, "the screenshare isn't keeping up"): a
687 // screen capture left to the browser's defaults keeps every pixel and
688 // drops frames, at the encoder's default ceiling of about 2.5 Mbit/s
689 // for 1080p. On anything but a fast uplink that is a few frames a
690 // second of someone scrolling.
691 //
692 // detail full resolution, frames give way — slides, code, text
693 // balanced frame rate held, full size while the link carries it
694 // and smaller rather than a slideshow when it cannot
695 // motion frame rate held, at most 1280 on the long side
696 //
697 // Measured in the native gate (ScreenSendPolicy has the numbers): on a
698 // 1.5 Mbit/s uplink a scrolling 1080p page arrived at 2 fps as before,
699 // 13 in detail, 19 in balanced and 30 in motion.
700 //
701 // contentHint is the browser's own name for the same choice, and
702 // degradationPreference the sender's; both are set because browsers
703 // honour one or the other.
704 function sharePlan(mode, width, height) {
705 const longest = Math.max(width || 0, height || 0);
706 const fit = (side) => (longest > side ? longest / side : 1);
707 const high = { priority: 'high', networkPriority: 'high', maxFramerate: 30 };
708 if (mode === 'detail') {
709 return {
710 contentHint: 'detail', degradationPreference: 'maintain-resolution',
711 encoding: Object.assign({ maxBitrate: 3500000, scaleResolutionDownBy: fit(1920) }, high),
712 };
713 }
714 if (mode === 'motion') {
715 return {
716 contentHint: 'motion', degradationPreference: 'maintain-framerate',
717 encoding: Object.assign({ maxBitrate: 2500000, scaleResolutionDownBy: fit(1280) }, high),
718 };
719 }
720 return {
721 contentHint: 'detail', degradationPreference: 'maintain-framerate',
722 encoding: Object.assign({ maxBitrate: 3000000, scaleResolutionDownBy: fit(1920) }, high),
723 };
724 }
725
726 // cameraCap is the camera's encoding while this tab shares a screen,
727 // and its defaults again when it stops: the screen is what everyone is
728 // looking at, and it should have the uplink. `undefined` rather than a
729 // number is how the API spells "the encoder's own choice". Pure, and
730 // lifted by name in the node test.
731 function cameraCap(sharing, height) {
732 if (!sharing) return { maxBitrate: undefined, scaleResolutionDownBy: 1 };
733 return { maxBitrate: 500000, scaleResolutionDownBy: height > 360 ? height / 360 : 1 };
734 }
735
736 function shareMode() {
737 return SHARE_MODES.some(([m]) => m === prefs.shareMode) ? prefs.shareMode : SHARE_MODES[0][0];
738 }
739
740 // tuneSender sets one sender's first encoding (and, where given, its
741 // degradation preference). Best effort for the same reasons as
742 // tuneAudioSender: a sender parameter needs no SDP and no
743 // renegotiation, so §5.1 never learns it happened, and a browser that
744 // refuses carries the call at its own defaults.
745 function tuneSender(sender, encoding, degradationPreference) {
746 if (!sender || typeof sender.getParameters !== 'function') return;
747 let params;
748 try {
749 params = sender.getParameters();
750 } catch {
751 return;
752 }
753 if (!params.encodings || !params.encodings.length) return;
754 for (const [key, value] of Object.entries(encoding)) {
755 if (value === undefined) delete params.encodings[0][key];
756 else params.encodings[0][key] = value;
757 }
758 if (degradationPreference) params.degradationPreference = degradationPreference;
759 Promise.resolve().then(() => sender.setParameters(params)).catch(() => {
760 // Refused, or raced a renegotiation: the next call to this for the
761 // same sender (a mode change, the next connection) tries again.
762 });
763 }
764
765 // tuneShare puts one connection's screen and camera where the mode
766 // wants them: the screen at its plan, the camera capped beside it —
767 // or, with nothing shared, the camera back at its defaults.
768 function tuneShare(entry) {
769 if (!entry) return;
770 const sharing = !!(share && entry.shareTx);
771 if (sharing) {
772 const s = share.track.getSettings ? share.track.getSettings() : {};
773 const plan = sharePlan(shareMode(), s.width, s.height);
774 tuneSender(entry.shareTx.sender, plan.encoding, plan.degradationPreference);
775 }
776 const camera = senderFor(entry, 'video');
777 const track = camera && camera.sender && camera.sender.track;
778 if (!track) return;
779 const settings = track.getSettings ? track.getSettings() : {};
780 tuneSender(camera.sender, cameraCap(sharing, settings.height || 0));
781 }
782
783 // startLocal walks down a ladder: the remembered devices, then the
784 // browser's defaults, then audio alone. A camera that is missing or
785 // in use should not cost the call its microphone as well, and a
786 // person who reaches the bottom rung joins with nothing, which the
787 // room is built to allow.
788 async function startLocal() {
789 if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
790 applyToggles();
791 return;
792 }
793 const exact = (id) => ({ deviceId: { exact: id } });
794 const remembered = !!(prefs.mic || prefs.camera);
795 // The processing choice rides every rung. Falling back to the
796 // browser's default *device* is not a reason to put the voice
797 // pipeline back on someone who asked for it off, so only the
798 // remembered device id is dropped on the way down — by
799 // forgetDevices, which touches mic and camera and never rawAudio.
800 const raw = { rawAudio: prefs.rawAudio };
801 const attempts = [
802 { audio: audioConstraints(prefs), video: prefs.camera ? exact(prefs.camera) : true },
803 { audio: audioConstraints(raw), video: true },
804 { audio: audioConstraints(raw), video: false },
805 ];
806 for (let i = 0; i < attempts.length; i++) {
807 try {
808 local = await navigator.mediaDevices.getUserMedia(attempts[i]);
809 if (i > 0 && remembered) forgetDevices();
810 break;
811 } catch {
812 // Try the next rung. Nothing is said on the way down: the only
813 // thing worth telling a person is where they ended up.
814 }
815 }
816 applyToggles();
817 }
818
819 // forgetDevices drops remembered ids the browser would not honour, so
820 // the next visit does not repeat the same failed attempt. devices.js
821 // does the same on the holding screen; this is the room's copy for
822 // the case where the device went between the two pages.
823 function forgetDevices() {
824 delete prefs.mic;
825 delete prefs.camera;
826 savePrefs();
827 }
828
829 function applyToggles() {
830 if (local) {
831 for (const track of local.getAudioTracks()) track.enabled = !prefs.muted;
832 for (const track of local.getVideoTracks()) track.enabled = !prefs.cameraOff;
833 }
834 // The accessible name is fixed, in aria-label on the server's own
835 // markup; a toggle whose name changes under the reader is announced
836 // as a different control every time it is used. The state moves
837 // three ways and never by colour: aria-pressed, the word in the
838 // button, and calls.css's inset outline.
839 muteButton.setAttribute('aria-pressed', String(!!prefs.muted));
840 cameraButton.setAttribute('aria-pressed', String(!!prefs.cameraOff));
841 if (muteState) muteState.textContent = prefs.muted ? 'Muted' : 'On';
842 if (cameraState) cameraState.textContent = prefs.cameraOff ? 'Off' : 'On';
843 // With no stream there is no track to enable, so the two toggles
844 // are controls that would do nothing. Leave is still live.
845 const hasAudio = !!(local && local.getAudioTracks().length);
846 const hasVideo = !!(local && local.getVideoTracks().length);
847 if (!state.stopped) {
848 muteButton.disabled = !hasAudio;
849 cameraButton.disabled = !hasVideo;
850 }
851 // §15.19: every path that can change what we send ends here — the
852 // two toggles, a device switch, a stream that lost a track — so the
853 // report is made here too, and the tile marks follow.
854 reportMedia();
855 if (tiles.has('self')) markTile('self', localMedia());
856 }
857
858 function stopLocal() {
859 if (!local) return;
860 for (const track of local.getTracks()) track.stop();
861 local = null;
862 }
863
864 // ---------------------------------------------------------------
865 // Changing a device mid-call (§3.3's stored choice, made movable)
866 // ---------------------------------------------------------------
867 //
868 // The holding screen picks the devices; until now that was the only
869 // place they could be picked, and a microphone chosen wrongly — or
870 // unplugged, or wanted for a headset halfway through — cost the
871 // person the call to change. Everything below is that choice, applied
872 // to a call already running.
873 //
874 // The whole thing rests on replaceTrack, which swaps the media a
875 // sender is sending without touching the m-line it sends on. That is
876 // the part §5.1 never has to hear about: no offer, no answer, no
877 // rollback, and no chance of the glare the negotiation above spends
878 // 300 ms avoiding. The one case that does renegotiate is named at
879 // sendTrack, and it is a direction change on an m-line that already
880 // exists rather than a new one.
881
882 // Two clicks on a picker are two switches, and they must not run at
883 // once. devices.js can settle that with a generation counter alone
884 // because the only thing it is racing for is one <video>; here the
885 // loser would be racing for what every sender in the mesh is holding,
886 // and interleaved replaceTracks leave that decided by whichever
887 // getUserMedia happened to settle last.
888 //
889 // So they are serialised, and the counter only decides which queued
890 // run is still wanted: three clicks queue three runs, the first two
891 // find themselves superseded before their turn and return without
892 // opening anything, and the third does the work. One switch is in
893 // flight at a time and the last click wins, which is what clicking
894 // means.
895 // One counter per kind, not one between them: a camera and a
896 // microphone are two independent choices, and a shared counter made
897 // changing the camera silently cancel a microphone change whose
898 // getUserMedia had not settled — the select snapping back with
899 // nothing said about why.
900 const switching = { audio: 0, video: 0 };
901 let switchQueue = Promise.resolve();
902
903 // senderFor finds the transceiver carrying one kind for this call —
904 // the camera or the microphone, never the screen. entry.shareTx is
905 // video too, and replacing *its* track with a camera would put the
906 // camera on the m-line the far side has already labelled a screen
907 // (§5.4), which is the one mistake here that would look like a bug in
908 // someone else's browser.
909 function senderFor(entry, kind) {
910 let transceivers;
911 try {
912 transceivers = entry.pc.getTransceivers();
913 } catch {
914 return null;
915 }
916 for (const tx of transceivers) {
917 if (tx === entry.shareTx || tx.stopped) continue;
918 // receiver.track is present on every transceiver and carries the
919 // kind even when nothing is being sent yet, which is exactly the
920 // recvonly case this has to find.
921 const known = (tx.sender && tx.sender.track) || (tx.receiver && tx.receiver.track);
922 if (known && known.kind === kind) return tx;
923 }
924 return null;
925 }
926
927 // sendTrack puts one track on one connection.
928 //
929 // The replaceTrack is the whole job when this browser was already
930 // sending that kind. When it was not — it came down startLocal's
931 // ladder with no camera, or joined with nothing at all — createPC
932 // left a `recvonly` transceiver for the kind, and that m-line is
933 // already in the right place in the order. Turning it round is a
934 // direction change on an existing m-line, so no m-line is added and
935 // none moves: the hazard addShareTo carries (an unassociated
936 // transceiver claiming the m-line a remote offer meant for something
937 // else) is not in this path at all. It still waits for `stable`,
938 // because a direction changed mid-negotiation is a change the offer
939 // in flight does not carry, and the deferred ones are picked up by
940 // the connection's own signalingstatechange.
941 async function sendTrack(entry, kind, track) {
942 const tx = senderFor(entry, kind);
943 if (!tx || !tx.sender || typeof tx.sender.replaceTrack !== 'function') return;
944 try {
945 await tx.sender.replaceTrack(track);
946 } catch {
947 // A connection closing under us, or a browser refusing the track.
948 // Every other peer still gets it, and this one is the reset path's
949 // problem rather than this function's.
950 return;
951 }
952 // classifyTrack reads entry.sentVideo to tell an arriving camera
953 // from an arriving screen, and a browser that starts sending video
954 // for the first time here has just changed that answer.
955 if (kind === 'video') entry.sentVideo = true;
956 if (tx.direction === 'sendrecv' || tx.direction === 'sendonly') return;
957 if (entry.pc.signalingState !== 'stable') {
958 entry.upgrades.add(kind);
959 return;
960 }
961 try {
962 tx.direction = 'sendrecv';
963 } catch {
964 // Older browsers make direction read-only. The track is on the
965 // sender either way; it simply is not sent until the next full
966 // renegotiation builds the m-line sending.
967 }
968 }
969
970 // upgradeWhenQuiet runs the direction changes sendTrack could not make
971 // because the connection was mid-negotiation. Called from the same
972 // signalingstatechange that adds a deferred share transceiver.
973 function upgradeWhenQuiet(entry) {
974 if (!entry.upgrades.size || entry.pc.signalingState !== 'stable') return;
975 for (const kind of Array.from(entry.upgrades)) {
976 entry.upgrades.delete(kind);
977 const tx = senderFor(entry, kind);
978 if (!tx || !tx.sender || !tx.sender.track) continue;
979 if (tx.direction === 'sendrecv' || tx.direction === 'sendonly') continue;
980 try {
981 tx.direction = 'sendrecv';
982 } catch {
983 // As above: nothing to do about it, and nothing broken by it.
984 }
985 }
986 }
987
988 // switchDevice is the gesture: one kind, one device id, applied to
989 // every connection and then to the local stream.
990 //
991 // Order matters twice here. The new track is disabled before it is
992 // anywhere near a sender, because a track that goes live for even one
993 // frame while the person is muted has broken the only promise the
994 // mute button makes. And the old track is stopped only once the new
995 // one is on every sender: stopping it first would take the camera
996 // light off a beat early and send black frames in the gap.
997 function switchDevice(kind, deviceId) {
998 const myGen = ++switching[kind];
999 const run = switchQueue.then(() => applySwitch(kind, deviceId, myGen));
1000 // The queue must not break on a failure, or one rejected switch
1001 // would swallow every switch after it. The caller still sees the
1002 // rejection through `run`.
1003 switchQueue = run.catch(() => {});
1004 return run;
1005 }
1006
1007 // Returns true when the call is actually on the new device, so a
1008 // caller that changed a preference to ask for the switch can put it
1009 // back when the switch did not happen.
1010 async function applySwitch(kind, deviceId, myGen) {
1011 // Superseded before this run's turn came round, or the call ended
1012 // while it waited. Nothing has been opened yet, so there is nothing
1013 // to put back.
1014 if (myGen !== switching[kind] || state.stopped) return false;
1015 const wanted = kind === 'audio'
1016 ? { audio: audioConstraints(Object.assign({}, prefs, { mic: deviceId })) }
1017 : { video: deviceId ? { deviceId: { exact: deviceId } } : true };
1018 let fresh;
1019 try {
1020 fresh = await navigator.mediaDevices.getUserMedia(wanted);
1021 } catch (err) {
1022 if (myGen !== switching[kind]) return false;
1023 note(deviceError(kind, err));
1024 // The choice is not saved: the pickers are put back to the device
1025 // actually in use, so the select never claims a device the call is
1026 // not on.
1027 fillDevices().catch(() => {});
1028 return false;
1029 }
1030 const track = fresh.getTracks()[0];
1031 if (myGen !== switching[kind] || !track || state.stopped) {
1032 for (const t of fresh.getTracks()) t.stop();
1033 return false;
1034 }
1035 track.enabled = kind === 'audio' ? !prefs.muted : !prefs.cameraOff;
1036 for (const entry of pcs.values()) {
1037 // Sequential and not Promise.all: replaceTrack is cheap, the mesh
1038 // is four, and a failure on one connection should not abandon the
1039 // rest through a rejected race.
1040 await sendTrack(entry, kind, track);
1041 }
1042 // No *generation* check here, and that is deliberate. From the loop
1043 // above onwards this run owns what every sender is holding, so
1044 // stopping the track to defer to a newer choice would stop the
1045 // media the call is sending — until that newer choice arrives, which
1046 // it may never do if its own getUserMedia fails, leaving every peer
1047 // watching a track that ended. The queued run replaces the senders
1048 // and `local` together when its turn comes; until then this is the
1049 // truth and is recorded as such.
1050 //
1051 // The call *ending* is the other thing entirely, and does need
1052 // checking: terminal() leaves the page alive, so without this the
1053 // line below would build a fresh MediaStream around a camera that
1054 // was opened for a call that is already over, and nothing would
1055 // ever stop it. A light left on after the meeting ended is the one
1056 // way this file can be rude.
1057 if (state.stopped) {
1058 track.stop();
1059 return false;
1060 }
1061 if (!local) local = new MediaStream();
1062 for (const old of kind === 'audio' ? local.getAudioTracks() : local.getVideoTracks()) {
1063 local.removeTrack(old);
1064 old.stop();
1065 }
1066 local.addTrack(track);
1067 // A track that ends on its own — the device unplugged mid-call —
1068 // should leave the pickers telling the truth about what is left.
1069 track.addEventListener('ended', () => {
1070 fillDevices().catch(() => {});
1071 });
1072 // An id that names nothing is the browser's default, and the
1073 // absence of a remembered device is how the rest of this file and
1074 // devices.js both spell that.
1075 const key = kind === 'audio' ? 'mic' : 'camera';
1076 if (deviceId) prefs[key] = deviceId;
1077 else delete prefs[key];
1078 savePrefs();
1079 note('');
1080 applyToggles();
1081 renderTiles();
1082 // The audio ceiling follows the choice and not the connection: a
1083 // studio switch flipped mid-call has to reach the connections that
1084 // already exist, and flipped back has to let them go again.
1085 // tuneAudioSender reads the preference itself, so this is the same
1086 // call in both directions.
1087 if (kind === 'audio') {
1088 for (const entry of pcs.values()) tuneAudioSender(entry.pc);
1089 }
1090 await fillDevices().catch(() => {});
1091 return true;
1092 }
1093
1094 // deviceError is the sentence for a device that would not open. The
1095 // names are getUserMedia's own; the wording says what the person can
1096 // do about it, and never that the call is over, because it is not —
1097 // the old device is still on every sender.
1098 function deviceError(kind, err) {
1099 const thing = kind === 'audio' ? 'microphone' : 'camera';
1100 if (err && err.name === 'NotAllowedError') {
1101 return 'Calls needs permission to use that ' + thing + '. The call is still on the previous one.';
1102 }
1103 if (err && err.name === 'NotReadableError') {
1104 return 'That ' + thing + ' is in use by another app. The call is still on the previous one.';
1105 }
1106 return 'That ' + thing + ' could not be opened. The call is still on the previous one.';
1107 }
1108
1109 // note writes the device panel's own live region. It is not `say`:
1110 // renderStatus owns #status and rewrites it on every render, so a
1111 // sentence put there would be gone by the next roster.
1112 function note(text) {
1113 if (!deviceNote) return;
1114 deviceNote.textContent = text;
1115 deviceNote.hidden = !text;
1116 }
1117
1118 // fillDevices is devices.js's fillPickers, for the room. The two are
1119 // deliberate mirrors — same ids, same rules, same reason for each —
1120 // and test/browser/devices.test.mjs holds them to it. Labels only
1121 // exist once permission has been granted, which in the room it always
1122 // has by the time this runs.
1123 async function fillDevices() {
1124 if (!pickCamera || !navigator.mediaDevices || !navigator.mediaDevices.enumerateDevices) return;
1125 let devices;
1126 try {
1127 devices = await navigator.mediaDevices.enumerateDevices();
1128 } catch {
1129 devices = [];
1130 }
1131 const fillOne = (select, kind, chosen) => {
1132 select.replaceChildren();
1133 let count = 0;
1134 for (const device of devices) {
1135 if (device.kind !== kind) continue;
1136 count++;
1137 const option = document.createElement('option');
1138 option.value = device.deviceId;
1139 option.textContent = device.label || labelFor(kind, count);
1140 if (device.deviceId === chosen) option.selected = true;
1141 select.append(option);
1142 }
1143 return count;
1144 };
1145 const cameraCount = fillOne(pickCamera, 'videoinput', liveDeviceId('video') || prefs.camera);
1146 const micCount = fillOne(pickMic, 'audioinput', liveDeviceId('audio') || prefs.mic);
1147 const speakerCount = HAS_SINK_ID ? fillOne(pickSpeaker, 'audiooutput', prefs.speaker) : 0;
1148 // A field with nothing to choose from is clutter, not help. Unlike
1149 // the holding screen, the camera and microphone rows can be empty
1150 // here too — a call joined with neither is a call this file is
1151 // built to allow.
1152 if (cameraRow) cameraRow.hidden = cameraCount === 0;
1153 if (micRow) micRow.hidden = micCount === 0;
1154 if (speakerRow) speakerRow.hidden = !HAS_SINK_ID || speakerCount === 0;
1155 if (rawRow) rawRow.hidden = micCount === 0;
1156 }
1157
1158 function labelFor(kind, count) {
1159 return (kind === 'videoinput' ? 'Camera ' : 'Microphone ') + count;
1160 }
1161
1162 // liveDeviceId is what the call is *actually* on, which is the honest
1163 // thing for a select to be showing: prefs can name a device that
1164 // startLocal's ladder never managed to open.
1165 function liveDeviceId(kind) {
1166 const track = local && local.getTracks().find((t) => t.kind === kind && t.readyState === 'live');
1167 if (!track || typeof track.getSettings !== 'function') return '';
1168 return track.getSettings().deviceId || '';
1169 }
1170
1171 // applySinkAll re-routes every remote tile after a speaker change.
1172 // applySink does one element and is what a new tile gets; this is the
1173 // same thing for the tiles that already exist.
1174 function applySinkAll() {
1175 for (const [key, t] of tiles) {
1176 if (t.self || key === 'self') continue;
1177 applySink(t.video);
1178 }
1179 }
1180
1181 // ---------------------------------------------------------------
1182 // The stream (§4.1, §4.5)
1183 // ---------------------------------------------------------------
1184
1185 const DOWNSTREAM = ['hello', 'roster', 'peer', 'gone', 'knock', 'knock-gone',
1186 'full', 'lost', 'pair', 'gap', 'state', 'offer', 'answer', 'candidate',
1187 // §4.3's fan-out pair. They carry no `to`, no `seq` and no `gen`, so
1188 // nothing about them goes through §4.6's inbound rules: the hub
1189 // rebuilt the envelope and there is nothing to dedupe against.
1190 'chat', 'share',
1191 // §15.19: one peer's media state changed. State, not an event —
1192 // the roster carries it too — so a missed one costs nothing.
1193 'media'];
1194
1195 function streamPath(peer) {
1196 let path = '/m/' + encodeURIComponent(sqid) + '/stream';
1197 if (!peer) return path;
1198 const last = {};
1199 for (const [id, seq] of lastIn) last[id] = seq;
1200 return path + '?peer=' + encodeURIComponent(peer) +
1201 '&last=' + encodeURIComponent(JSON.stringify(last));
1202 }
1203
1204 function closeStream() {
1205 if (!es) return;
1206 // Always close by hand. EventSource reconnects on its own, and its
1207 // own retry of a fresh (peerless) URL would ask for a second
1208 // participation and be refused 409 — reconnection is this file's
1209 // job, not the browser's.
1210 es.onerror = null;
1211 es.close();
1212 es = null;
1213 }
1214
1215 // connectStream opens a fresh stream: no `peer`, so the hub issues a
1216 // new participation and this browser posts `join` on its `hello`.
1217 function connectStream() {
1218 if (state.stopped) return;
1219 closeStream();
1220 state.peer = null;
1221 state.joined = false;
1222 state.admitted = false;
1223 // `full` belongs to the participation that heard it, not to the tab.
1224 state.full = false;
1225 clearTimeout(fullTimer);
1226 renderStatus();
1227 attach(streamPath(null), diagnoseFresh);
1228 }
1229
1230 // resume reattaches to the participation we already hold. EventSource
1231 // hides the status of a refused stream, so the refusal is read first
1232 // with a plain fetch against ?probe=1 — which attaches nothing and,
1233 // per the wire contract, must NOT carry Accept: text/event-stream.
1234 async function resume() {
1235 if (state.stopped) return;
1236 closeStream();
1237 const peer = state.peer;
1238 if (!peer) {
1239 connectStream();
1240 return;
1241 }
1242 let res;
1243 try {
1244 // Bounded like every other probe: a request that never settles
1245 // would otherwise be the end of this browser's reconnecting.
1246 res = await probe('/m/' + encodeURIComponent(sqid) + '/stream?probe=1&peer=' + encodeURIComponent(peer),
1247 Date.now() + SEND_DEADLINE);
1248 } catch {
1249 later(resume);
1250 return;
1251 }
1252 if (state.stopped) return;
1253 if (refused(res)) return;
1254 if (res.status === 204) {
1255 // "A resume would attach" — not "a resume is free". If this
1256 // round produces no hello either, the stall count is what stops
1257 // the loop.
1258 if (stall()) return;
1259 attach(streamPath(peer), resume);
1260 return;
1261 }
1262 if (res.status === 410) {
1263 terminal(res.data.reason || 'ended');
1264 return;
1265 }
1266 if (res.status === 404) {
1267 // Either the process restarted (a different boot) or the window
1268 // lapsed (the same one). Both answers are the same: drop every
1269 // connection and enter afresh — but only after the local
1270 // connections are closed, so no media survives a restart.
1271 closeAllPCs();
1272 state.boot = res.data.boot || state.boot;
1273 connectStream();
1274 return;
1275 }
1276 if (res.status === 409) {
1277 terminal('replaced');
1278 return;
1279 }
1280 // A status the contract does not name (500, or anything a proxy
1281 // invented): retry, but not for ever.
1282 if (stall()) return;
1283 later(resume);
1284 }
1285
1286 // diagnoseFresh runs when a fresh stream failed to open. The peerless
1287 // probe says whether this session already holds a peer here (409) or
1288 // whether the room is gone (404); a 204 means the refusal was
1289 // something the probe cannot see — a full room, or the network.
1290 async function diagnoseFresh() {
1291 if (state.stopped) return;
1292 let res;
1293 try {
1294 res = await probe('/m/' + encodeURIComponent(sqid) + '/stream?probe=1', Date.now() + SEND_DEADLINE);
1295 } catch {
1296 later(connectStream);
1297 return;
1298 }
1299 if (state.stopped) return;
1300 if (refused(res)) return;
1301 if (res.status === 409) {
1302 terminal('replaced');
1303 return;
1304 }
1305 if (res.status === 404) {
1306 terminal('gone');
1307 return;
1308 }
1309 // 204 here means only "this session holds no peer in this room".
1310 // The stream that follows can still be refused 503 for a full
1311 // room, which the probe cannot see, so this round is counted.
1312 if (stall()) return;
1313 later(connectStream);
1314 }
1315
1316 // refused is the two answers that mean "stop, and say why", wherever
1317 // they turn up: a session that has ended, and a request the stream
1318 // endpoint will not accept in the shape this client sends it.
1319 //
1320 // There is no arm for 400 (review W-8). Both callers pass a probe
1321 // response, the stream's only 400 is a `last` it could not parse, and
1322 // neither probe form sends `last` — so the arm that used to be here
1323 // could not fire. The attaching stream's 400 is invisible to
1324 // EventSource anyway: it arrives as an error with no status, and the
1325 // stall ladder is what ends it.
1326 function refused(res) {
1327 if (res.signedOut) {
1328 terminal('signedout');
1329 return true;
1330 }
1331 if (res.status === 406) {
1332 terminal('accept');
1333 return true;
1334 }
1335 return false;
1336 }
1337
1338 // stall counts consecutive rounds that ended without a `hello`. A
1339 // network failure is not one of them: an unreachable server is worth
1340 // waiting for, and the browser says nothing new by giving up on it.
1341 function stall() {
1342 stalls += 1;
1343 if (stalls < MAX_STALLS) return false;
1344 if (Date.now() - lastHelloAt < STALL_WINDOW) return false;
1345 terminal('unreachable');
1346 return true;
1347 }
1348
1349 function attach(path, onFail) {
1350 const source = new EventSource(path);
1351 const openedAt = Date.now();
1352 es = source;
1353 for (const type of DOWNSTREAM) {
1354 source.addEventListener(type, (ev) => {
1355 if (source !== es) return;
1356 let msg;
1357 try {
1358 msg = JSON.parse(ev.data);
1359 } catch {
1360 return;
1361 }
1362 handle(type, msg);
1363 });
1364 }
1365 source.onerror = () => {
1366 if (source !== es) return;
1367 closeStream();
1368 if (state.stopped) return;
1369 if (!streamDownSince) streamDownSince = Date.now();
1370 // Only a stream that actually lived clears the backoff. One that
1371 // died moments after its hello has not proved anything, and
1372 // resetting on the hello alone is how a tight reconnect loop
1373 // starts.
1374 if (Date.now() - openedAt > HEALTHY_STREAM) backoff = BACKOFF_MIN;
1375 if (state.peer) {
1376 later(resume);
1377 return;
1378 }
1379 // No participation to come back to, so this goes to the fresh
1380 // ladder's own diagnosis — which waits before it retries. Waiting
1381 // here as well would put two backoffs in every round, and double
1382 // the ladder's own doubling.
1383 onFail();
1384 };
1385 }
1386
1387 // `deadline` is an absolute instant, and it bounds the whole exchange
1388 // — the request and the reading of its body — because both are the
1389 // same fetch and an AbortController ends both. Without it a probe that
1390 // never settles never returns, and every caller here awaits it: the
1391 // recovery's deadline was checked only between attempts, so a hanging
1392 // probe held its target's pump or reset marked in flight for as long
1393 // as the request stayed open (Codex S2-15).
1394 async function probe(path, deadline) {
1395 let stop = 0;
1396 let controller = null;
1397 if (deadline && typeof AbortController === 'function') {
1398 controller = new AbortController();
1399 stop = setTimeout(() => controller.abort(), Math.max(0, deadline - Date.now()));
1400 }
1401 try {
1402 // No Accept header: the probe forms are exempt from the stream's
1403 // text/event-stream gate and must not claim to be one.
1404 const res = await fetch(path, {
1405 credentials: 'same-origin',
1406 cache: 'no-store',
1407 signal: controller ? controller.signal : undefined,
1408 });
1409 return await readRefusal(res, 204, path);
1410 } finally {
1411 if (stop) clearTimeout(stop);
1412 }
1413 }
1414
1415 // readRefusal turns one response into the shape every caller here
1416 // branches on. `emptyOK` is the one status that legitimately carries
1417 // no body — 204 from the stream probe, 200 from the signal endpoint.
1418 //
1419 // The signed-out case needs saying: an expired session answers 303 to
1420 // /signin, fetch follows it, and what comes back is a 200 carrying a
1421 // sign-in page, which read as a status alone is indistinguishable
1422 // from success.
1423 //
1424 // A redirect on its own is not that, though, which is why `asked` is
1425 // passed in: a redirect that lands back on the path it was sent to —
1426 // a proxy normalising a URL, a trailing slash, anything a deployment
1427 // might do in front of this app — is not a sign-out, and treating it
1428 // as one would end a working call with a sentence about signing in.
1429 // Only a redirect that came out somewhere else counts.
1430 async function readRefusal(res, emptyOK, asked) {
1431 let data = null;
1432 if (res.status !== emptyOK) {
1433 try {
1434 data = await res.json();
1435 } catch {
1436 data = null;
1437 }
1438 }
1439 const signedOut = wentElsewhere(res, asked) || res.status === 401 || res.status === 403 ||
1440 (res.status === 200 && res.status !== emptyOK && data === null);
1441 return { status: res.status, data: data || {}, signedOut, retryAfter: res.headers.get('Retry-After') };
1442 }
1443
1444 // Origin as well as pathname: a redirect off this origin is somewhere
1445 // else however familiar the path looks, and a sign-in page served from
1446 // an identity host would otherwise read as success.
1447 function wentElsewhere(res, asked) {
1448 if (!res.redirected) return false;
1449 try {
1450 const got = new URL(res.url);
1451 const want = new URL(asked, location.origin);
1452 return got.origin !== want.origin || got.pathname !== want.pathname;
1453 } catch {
1454 // A URL neither of them can parse is not one this browser asked
1455 // for: treat it the way the old check did.
1456 return true;
1457 }
1458 }
1459
1460 function later(fn) {
1461 if (state.stopped) return;
1462 clearTimeout(reconnectTimer);
1463 renderStatus();
1464 reconnectTimer = setTimeout(fn, backoff);
1465 backoff = Math.min(backoff * 2, STREAM_BACKOFF_MAX);
1466 }
1467
1468 // ---------------------------------------------------------------
1469 // Events (§4.3)
1470 // ---------------------------------------------------------------
1471
1472 function handle(type, msg) {
1473 // Every downstream event moves this on. It is not a clock and not a
1474 // sequence number the server would recognise — it is only this
1475 // browser's own count of what it has been told, and it exists so
1476 // that a roster can be compared with the connections built since it
1477 // arrived (see reconcile).
1478 tick += 1;
1479 switch (type) {
1480 case 'hello': return onHello(msg);
1481 case 'roster': return onRoster(msg);
1482 case 'peer': return onPeer(msg);
1483 case 'gone': return onGone(msg);
1484 case 'knock': return onKnock(msg);
1485 case 'knock-gone': return onKnockGone(msg);
1486 case 'full': return onFull();
1487 case 'state': return onState(msg);
1488 case 'pair': return onPair(msg);
1489 case 'gap': return onGap(msg);
1490 case 'lost': return onLost(msg);
1491 case 'offer': return onOffer(msg);
1492 case 'answer': return onAnswer(msg);
1493 case 'candidate': return onCandidate(msg);
1494 case 'chat': return onChat(msg);
1495 case 'share': return onShare(msg);
1496 case 'media': return onMedia(msg);
1497 default: return undefined;
1498 }
1499 }
1500
1501 function onHello(msg) {
1502 // A hello is the proof that this round reached the room; the
1503 // backoff itself is cleared by attach, and only for a stream that
1504 // then stayed open.
1505 stalls = 0;
1506 lastHelloAt = Date.now();
1507 streamDownSince = 0;
1508 const fresh = !state.peer;
1509 state.peer = msg.peer || null;
1510 state.boot = msg.boot || state.boot;
1511 // §15.26: a resumed stream's hello carries the same token, and a new
1512 // participation's a new one; either way the latest is the one that
1513 // works. A server from before reports sends none, and nothing is sent.
1514 if (typeof msg.report === 'string' && /^[0-9a-f]{32}$/.test(msg.report)) reportToken = msg.report;
1515 root.dataset.peer = state.peer || '';
1516 if (fresh) join();
1517 renderStatus();
1518 }
1519
1520 async function join(attempt) {
1521 const round = attempt || 0;
1522 const peer = state.peer;
1523 const res = await postSignal({ peer, type: 'join' });
1524 if (state.stopped || state.peer !== peer) return;
1525 // signedOut before the status, because it can be true *of* a 200: a
1526 // session that has expired answers 303 to /signin, fetch follows
1527 // it, and the sign-in page comes back 200 — which read as a status
1528 // alone marked the join a success (Codex S2-8).
1529 if (res.signedOut) {
1530 terminal('signedout');
1531 return;
1532 }
1533 if (res.status === 200) {
1534 state.joined = true;
1535 // A guest gets nothing back from its own join: no roster, no
1536 // state. The waiting sentence is this browser's own conclusion,
1537 // not an event it waits for.
1538 renderStatus();
1539 return;
1540 }
1541 if (res.status === 404) {
1542 await recoverFromNotFound(res.data.boot);
1543 // The probe kept this participation, so the 404 raced something
1544 // rather than naming the end of it. `join` is idempotent: one
1545 // more go, and then it is the stream's ladder's problem.
1546 if (round === 0 && !state.stopped && state.peer === peer && !state.joined) {
1547 await sleep(BACKOFF_MIN);
1548 if (!state.stopped && state.peer === peer && !state.joined) join(1);
1549 }
1550 return;
1551 }
1552 // 429 and the transient failures are retried on the same peer, with
1553 // its own wait so a pending stream reconnect is never cancelled;
1554 // anything else leaves the sentence alone and the next roster or
1555 // knock speaks for itself.
1556 if (res.status === 429 || res.status >= 500 || res.status === 0) {
1557 await sleep(res.status === 429 ? retryAfter(res.retryAfter) : BACKOFF_MIN);
1558 if (!state.stopped && state.peer === peer && !state.joined) join(round);
1559 }
1560 }
1561
1562 function onPeer(msg) {
1563 const id = msg.peer;
1564 if (!id || id === state.peer) return;
1565 if (msg.name) names.set(id, msg.name);
1566 const rp = (msg.roster && msg.roster.peers && msg.roster.peers[0]) || {};
1567 const g = rp.gen || 1;
1568 if (rp.name) names.set(id, rp.name);
1569 learnMedia(id, rp.media);
1570 ensurePC(id, g);
1571 renderTiles();
1572 }
1573
1574 function onGone(msg) {
1575 if (!msg.from) return;
1576 remoteMedia.delete(msg.from);
1577 closePC(msg.from, true);
1578 dropWaiting(msg.from);
1579 renderTiles();
1580 renderQueue();
1581 }
1582
1583 function onKnock(msg) {
1584 if (!msg.from) return;
1585 names.set(msg.from, msg.name || '');
1586 if (!waiting.some((w) => w.peer === msg.from)) {
1587 waiting = waiting.concat([{ peer: msg.from, name: msg.name || '' }]);
1588 }
1589 renderQueue();
1590 }
1591
1592 function onKnockGone(msg) {
1593 if (!msg.from) return;
1594 dropWaiting(msg.from);
1595 renderQueue();
1596 }
1597
1598 function dropWaiting(id) {
1599 waiting = waiting.filter((w) => w.peer !== id);
1600 }
1601
1602 // §4.3: a member who sees `full` may wait and retry, so this does —
1603 // once every five seconds, until a roster says we are in or the
1604 // stream ends. Nothing else in the room polls.
1605 function onFull() {
1606 state.full = true;
1607 renderStatus();
1608 clearTimeout(fullTimer);
1609 fullTimer = setTimeout(() => {
1610 if (state.stopped || state.admitted || !state.peer) return;
1611 join();
1612 }, FULL_RETRY);
1613 }
1614
1615 function onState(msg) {
1616 // The only `state` the server sends is waiting:true, on a resume of
1617 // a participation that has not been admitted. `waiting` uses
1618 // omitempty, so an absent field is false.
1619 if (!msg.waiting) return;
1620 // Not joined: this only ever confirms that the participation this
1621 // stream reattached to has not been admitted. `joined` is the
1622 // join's own answer and is not re-derived from an event.
1623 state.admitted = false;
1624 renderStatus();
1625 }
1626
1627 function onPair(msg) {
1628 const id = msg.peer;
1629 if (!id || !msg.gen) return;
1630 // A generation at or below the local one is a no-op, so duplicates
1631 // and reordering are harmless (§4.6).
1632 if (msg.gen <= (gen.get(id) || 0)) return;
1633 rebuildPC(id, msg.gen);
1634 }
1635
1636 function onGap(msg) {
1637 // The replay could not fill this sender's gap, so the pair is
1638 // reset: the hub bumps the generation and tells both sides (§4.6).
1639 if (msg.from) requestReset(msg.from);
1640 }
1641
1642 function onLost(msg) {
1643 const reason = msg.reason || 'ended';
1644 if (reason === 'buffer') {
1645 // The one reason that is followed by a resume, not a screen.
1646 closeStream();
1647 resume();
1648 return;
1649 }
1650 terminal(reason);
1651 }
1652
1653 // ---------------------------------------------------------------
1654 // Reconciliation (§4.5: the roster is the authority)
1655 // ---------------------------------------------------------------
1656
1657 // onRoster is the roster's door, and it keeps `handle` synchronous:
1658 // §5.2 puts one await in front of the first connection this browser
1659 // builds, and a rejection escaping an event listener goes where
1660 // neither a person nor a gate could see it. Same shape as the
1661 // pageshow handler's.
1662 function onRoster(msg) {
1663 // The newest roster wins, and the slot is what makes that true
1664 // across §5.2's await: a roster that arrives while the first /ice
1665 // fetch is out overwrites the one waiting to be applied, rather
1666 // than queueing behind it. Reconciling a superseded roster closes
1667 // connections the hub has since named — which is exactly how the
1668 // gate's three ICE scenarios died (Task 5's report): a `peer` event
1669 // ten milliseconds after the roster built a connection the captured
1670 // roster did not carry.
1671 pendingRoster = msg.roster || { peers: [] };
1672 pendingRosterAt = tick;
1673 applyRoster().catch((err) => {
1674 console.error('meet: roster', err);
1675 });
1676 }
1677
1678 // applyRoster is §5.2's ordering: the ICE list is fetched before the
1679 // first connection is built, and only before the first — every later
1680 // roster reconciles straight away. A fetch that fails does not hold
1681 // the room up: it leaves the floor's empty list in place, shows the
1682 // warning and arms the minute retry, and the roster is applied.
1683 //
1684 // Whatever it applies is read *after* the await, never before. Two
1685 // rosters that arrive during one fetch resume in order: the first
1686 // takes the newest of them and empties the slot, and the second finds
1687 // it empty and has nothing left to do.
1688 async function applyRoster() {
1689 if (!icePrimed) await iceReady();
1690 if (state.stopped) return;
1691 const roster = pendingRoster;
1692 const at = pendingRosterAt;
1693 pendingRoster = null;
1694 if (!roster) return;
1695 reconcile(roster, at);
1696 }
1697
1698 function reconcile(roster, at) {
1699 rosters += 1;
1700 lastRoster = roster;
1701 state.admitted = true;
1702 state.joined = true;
1703 state.full = false;
1704 // The roster is the authority on who is here (§4.5), so it settles
1705 // every target a 404 marked stale: the ones it lists get their
1706 // connection back through ensurePC below, and the ones it does not
1707 // are closed a few lines further down.
1708 stale.clear();
1709 const present = new Set();
1710 for (const rp of roster.peers || []) {
1711 if (!rp || !rp.peer) continue;
1712 names.set(rp.peer, rp.name || '');
1713 if (rp.peer === state.peer) {
1714 // Our own kind, re-derived by the server on every tick: this,
1715 // not data-is-member, is what decides whether the member-only
1716 // controls are drawn.
1717 state.kind = rp.kind || state.kind;
1718 continue;
1719 }
1720 present.add(rp.peer);
1721 ensurePC(rp.peer, rp.gen || 1);
1722 }
1723 for (const [id, entry] of pcs) {
1724 if (present.has(id)) continue;
1725 // A roster can only speak for the room as it was when the hub
1726 // sent it. A connection built *since* — from a `peer` event that
1727 // arrived while §5.2's fetch was still out, which is the case
1728 // that cost the gate three scenarios (Task 5's report) — is not
1729 // something this roster ever knew about, and closing it leaves a
1730 // call with a peer it will never hear from again: no further
1731 // roster is coming, because nothing has changed as far as the hub
1732 // is concerned.
1733 if (at && entry.bornAt > at) continue;
1734 // Otherwise: a connection to an id the roster does not carry is
1735 // closed, which is how a browser that missed a `gone` still lets
1736 // go. The counters are *not* dropped with it, though — §4.6 gives
1737 // `seq` a sender's whole participation, and a roster's silence is
1738 // not the hub telling us that participation ended. `gone`, `lost`
1739 // and a departure of our own are; they forget. Dropping them here
1740 // meant a connection closed and rebuilt started numbering at 1
1741 // into a peer whose cursor was already at 3, and every message of
1742 // the new one was discarded as a replay.
1743 closePC(id, false);
1744 }
1745 waiting = (roster.waiting || []).map((w) => {
1746 names.set(w.peer, w.name || '');
1747 return { peer: w.peer, name: w.name || '' };
1748 });
1749 // §6: a roster is admission, and admission is when the chat panel
1750 // appears and becomes usable. Mounting is idempotent, so every later
1751 // roster costs one `setEnabled(true)` and nothing else.
1752 mountChat();
1753 chatEnabled(true);
1754 // §15.19: before the tiles are drawn, so they are drawn right.
1755 rosterMedia(roster);
1756 renderTiles();
1757 renderQueue();
1758 renderStatus();
1759 }
1760
1761 // ---------------------------------------------------------------
1762 // Peer connections and perfect negotiation (§5.1)
1763 // ---------------------------------------------------------------
1764
1765 // ensurePC is the one door into the mesh: a peer with no connection
1766 // gets one, and a peer whose generation has moved on is rebuilt under
1767 // the new one. Both `roster` and `peer` come through here, so the two
1768 // paths cannot drift.
1769 function ensurePC(id, g) {
1770 if (state.stopped) return null;
1771 if (pcs.has(id)) {
1772 if (g > (gen.get(id) || 0)) rebuildPC(id, g);
1773 return pcs.get(id);
1774 }
1775 gen.set(id, g);
1776 return createPC(id, g);
1777 }
1778
1779 function rebuildPC(id, g) {
1780 // A new generation: close the connection, discard the outbound
1781 // queue and the candidate buffer, keep the seq counters (§4.6).
1782 closePC(id, false);
1783 if (state.stopped) return;
1784 gen.set(id, g);
1785 createPC(id, g);
1786 renderTiles();
1787 }
1788
1789 function createPC(id, g) {
1790 // §5.2: the list `fetchIce` took from GET /m/{sqid}/ice, which the
1791 // first roster waits for. An empty one is §5.5's floor — browsers
1792 // gather host candidates only, which connects on a permissive local
1793 // network and usually not across the internet — and it is what a
1794 // connection built during the very first fetch gets, until
1795 // `adoptServers` hands it the real list.
1796 let pc;
1797 try {
1798 pc = new RTCPeerConnection({ iceServers: ice.servers });
1799 } catch {
1800 // No WebRTC at all: an old browser, or one where it is switched
1801 // off. Nothing here can recover, so the room says so once.
1802 terminal('nowebrtc');
1803 return null;
1804 }
1805 // Building a connection to a peer means the server has just named it
1806 // — a roster, a `peer`, or a `pair` — so whatever a 404 marked stale
1807 // about it is out of date. Without this, a pair the hub reset while
1808 // this tab was giving up on it would be rebuilt and then never
1809 // spoken to, because `send` drops for a stale target.
1810 stale.delete(id);
1811 const entry = {
1812 pc,
1813 // §5.1: the peer with the lower id is polite and rolls back on a
1814 // collision; the higher is impolite and ignores the offer.
1815 polite: String(state.peer) < String(id),
1816 makingOffer: false,
1817 ignoreOffer: false,
1818 // §5.1's third negotiation flag, and the one this file was
1819 // missing (Codex S2-7). It is true only while an answer is being
1820 // applied, which is a state that looks exactly like glare from
1821 // the outside — signalingState is still have-local-offer — and is
1822 // not.
1823 isSettingRemoteAnswerPending: false,
1824 // §5.1's polite yield, in three parts (Codex S4-4).
1825 //
1826 // `yields` is how many times in a row this side has given way
1827 // without an offer of its own going out, capped at POLITE_YIELDS
1828 // and handed back one at a time by an exchange this side did not
1829 // offer. `negotiation` is the generation: it moves whenever this
1830 // connection's negotiation moves on, so a yield that captured it
1831 // and then slept can tell "nothing happened" from "an exchange
1832 // came and went while I was asleep". `negotiating` is the
1833 // serializer: one local offer attempt at a time, because two
1834 // `negotiationneeded` events while one of them sleeps used to
1835 // produce two offers, the second of them skipping the yield
1836 // entirely because the first had already spent it.
1837 yields: 0,
1838 negotiation: 0,
1839 negotiating: false,
1840 // A retry scheduled for an ask the rounds ran out on, so a second
1841 // one is never stacked on top of it, and how many times that
1842 // retry has found the connection busy and put itself off.
1843 negotiateTimer: 0,
1844 negotiateDefers: 0,
1845 // A `negotiationneeded` that arrived while an attempt was in
1846 // flight, kept rather than dropped: an attempt that abandons its
1847 // offer would otherwise take that change to the grave with it.
1848 renegotiate: false,
1849 // §5.1's camera watch (Codex S4-5): the last framesEncoded read
1850 // off this connection's own camera m-line, how many polls in a
1851 // row have reported that same number, and how many times the
1852 // encoder has been rebuilt before giving up and resetting.
1853 cameraFrames: -1,
1854 cameraStalls: 0,
1855 cameraRepairs: 0,
1856 pending: [],
1857 descSeq: 0,
1858 gen: g,
1859 stream: null,
1860 failed: false,
1861 // §5.2: true while this connection is running on the empty list,
1862 // so `adoptServers` can find the ones built during the first
1863 // fetch and give them the servers when they arrive.
1864 floor: !ice.servers.length,
1865 // Which list this connection was built on. A connection built
1866 // after a fetch is already on that credential and has nothing to
1867 // renew, which is what keeps §5.3 step 3's deadline off a peer
1868 // who joined the call two minutes ago.
1869 listAt: ice.fetchedAt,
1870 // Remote descriptions applied, which is how §5.3 step 2's "the
1871 // restart's offer/answer exchange has completed" is read without
1872 // guessing from signalingState alone.
1873 desc: 0,
1874 // What this browser had been told when the connection was built.
1875 // reconcile reads it: a roster older than this cannot be talking
1876 // about this connection.
1877 bornAt: tick,
1878 // The four numbers that say where ICE stopped, and the one place
1879 // they exist: a connection whose negotiation completed and whose
1880 // transport never left `new` (the residual gate flake) is either
1881 // gathering nothing, delivering nothing, receiving nothing, or
1882 // adding nothing, and only these tell those four apart.
1883 // `delivered` is counted where the hub accepts a candidate, not
1884 // where one is handed to the queue. Counts, never candidates: an
1885 // address is not something this file publishes.
1886 gathered: 0,
1887 delivered: 0,
1888 received: 0,
1889 added: 0,
1890 // §5.4, sending: the transceiver this connection carries our
1891 // display track on, while we are sharing.
1892 shareTx: null,
1893 // §5.4, receiving: what this peer's camera came in on, and the
1894 // screen once it arrives. `cameraMid` and `cameraStream` are
1895 // recorded from the *first* video track — the camera, by
1896 // definition — and `classifyTrack` reads them to tell the second
1897 // video track apart from it. `sharing` is the `share {on:true}`
1898 // message, which usually beats the track here by a renegotiation,
1899 // and is what lets the tile carry the label before there is
1900 // anything to show in it.
1901 cameraMid: '',
1902 cameraStream: '',
1903 // The stream everything else from this peer has been arriving in,
1904 // recorded from the first track of any kind. A sender puts its
1905 // camera and its microphone in one MediaStream and its screen in
1906 // another, so this places a video track even before there is a
1907 // camera to compare it with — which is the audio-only sharer, whose
1908 // screen is the first video track its peers ever see.
1909 peerStream: '',
1910 // Whether this connection carries a camera of ours. Not the same
1911 // question as `local` having a video track: addTrack can throw,
1912 // and then this tab has a camera and this connection has a video
1913 // m-line it built for nobody. classifyTrack's direction rule turns
1914 // on which of the two is true.
1915 sentVideo: false,
1916 // Kinds whose transceiver switchDevice turned round while the
1917 // connection was mid-negotiation, waiting for it to settle.
1918 upgrades: new Set(),
1919 sharing: false,
1920 share: null,
1921 // The mid the peer's screen arrives on, so the getStats fallback
1922 // can tell two inbound video streams apart.
1923 shareMid: '',
1924 };
1925 pcs.set(id, entry);
1926
1927 // The listeners go on before any media does. `addTrack` fires
1928 // `negotiationneeded`, and a connection registered in `pcs` with
1929 // nothing listening for that is a connection that will never offer:
1930 // the event is one-shot, and by the time a listener arrives it has
1931 // been and gone. The same order also means a throw from the media
1932 // below leaves a connection that can still be reset and rebuilt,
1933 // rather than a live entry nobody is watching.
1934
1935 // The listener is not async, for the reason the pageshow handler is
1936 // not: a rejection escaping an event listener goes where neither a
1937 // person nor a gate could see it. The work is a named function, so
1938 // it is also the one §5.1 path a node test can drive.
1939 pc.addEventListener('negotiationneeded', () => {
1940 negotiate(id, entry).catch((err) => {
1941 console.error('meet: negotiate', err);
1942 });
1943 });
1944
1945 pc.addEventListener('icecandidate', ({ candidate }) => {
1946 if (pcs.get(id) !== entry || !candidate) return;
1947 entry.gathered += 1;
1948 send(id, 'candidate', { candidate: candidate.toJSON() });
1949 });
1950
1951 pc.addEventListener('track', (ev) => {
1952 if (pcs.get(id) !== entry) return;
1953 onRemoteTrack(id, entry, ev);
1954 });
1955
1956 pc.addEventListener('connectionstatechange', () => {
1957 if (pcs.get(id) !== entry) return;
1958 entry.failed = pc.connectionState === 'failed';
1959 renderTiles();
1960 });
1961
1962 // §5.2's deferred half. A connection built during the first /ice
1963 // fetch is on the floor's empty list, and `adoptServers` will not
1964 // restart it while a negotiation is in flight — doing that cost a
1965 // gate run its media. So it waits here for the connection's own
1966 // next quiet moment instead of waiting 150 minutes for the first
1967 // renewal.
1968 pc.addEventListener('signalingstatechange', () => {
1969 if (pcs.get(id) !== entry) return;
1970 // §5.4: the same quiet moment is when a deferred share transceiver
1971 // is added. addShareTo will not add one to a connection that is
1972 // mid-negotiation, because an unassociated sendonly transceiver is
1973 // exactly what a remote offer's m-line matching will claim on a
1974 // rollback — and then the screen is being sent on the m-line the
1975 // far side means for something else.
1976 addShareTo(entry);
1977 upgradeWhenQuiet(entry);
1978 adoptWhenQuiet(id, entry);
1979 // And it is when an ask the negotiation rounds ran out on gets
1980 // its turn (Codex S4-8): a connection that was mid-negotiation is
1981 // the usual reason they ran out, and this is it settling.
1982 if (entry.renegotiate && !entry.negotiating && pc.signalingState === 'stable') {
1983 negotiate(id, entry).catch((err) => {
1984 console.error('meet: negotiate', err);
1985 });
1986 }
1987 });
1988
1989 // And now the media. Our own tracks, and a transceiver for every
1990 // kind we do not have one of — the two used to be all-or-nothing,
1991 // and that was a hole: a browser that came down `startLocal`'s
1992 // ladder to audio alone (a camera in use, or gone) has a local
1993 // stream, so nothing asked to receive video, so it offered no video
1994 // m-line at all and the person on the other side sat looking at a
1995 // tile that was never going to get a frame. Per kind, and a track
1996 // that has ended counts as absent, because it is.
1997 try {
1998 if (local) {
1999 for (const track of local.getTracks()) {
2000 pc.addTrack(track, local);
2001 if (track.kind === 'video' && track.readyState !== 'ended') entry.sentVideo = true;
2002 }
2003 }
2004 } catch {
2005 // A track the browser will not add — ended between the ladder and
2006 // here, or a device that went away — is not worth the connection.
2007 // The recvonly transceivers below cover what it would have been.
2008 }
2009 tuneAudioSender(pc);
2010 for (const kind of ['video', 'audio']) {
2011 if (hasLiveTrack(kind)) continue;
2012 try {
2013 pc.addTransceiver(kind, { direction: 'recvonly' });
2014 } catch {
2015 // An old browser without addTransceiver simply waits to be
2016 // offered to, which the other side will do if it has media.
2017 }
2018 }
2019 // §5.4: a connection built while this tab is already sharing gets
2020 // the screen with everything else, rather than waiting for the next
2021 // time the button is pressed. Someone joining a call mid-share sees
2022 // the screen.
2023 //
2024 // Last, and that is load-bearing: the m-lines of the first offer
2025 // come out in the order the transceivers were created, so the camera
2026 // and the microphone take the places the far side's own createPC
2027 // built for them and the screen takes a place of its own after them.
2028 // Added before them, the screen would sit in the m-line a peer built
2029 // for a camera.
2030 addShareTo(entry);
2031
2032 return entry;
2033 }
2034
2035 // politeYield waits POLITE_YIELD and then answers whether the
2036 // negotiation it gave way for is still this side's to make.
2037 //
2038 // Three things can have happened while it slept. The connection can
2039 // have gone, or the call ended: nothing to offer to. A remote offer
2040 // can have arrived and still be being answered: signalingState says
2041 // so. Or — the case a state check alone cannot see, and Codex S4-4's
2042 // first half — a whole exchange can have arrived, been answered and
2043 // completed, leaving the connection back at `stable` looking exactly
2044 // as it did when this yield began. The generation is what tells those
2045 // two apart: it moves on every exchange, so a yield whose generation
2046 // has changed is a yield whose negotiation somebody else already
2047 // made. Offering anyway is the extra offer that produced.
2048 //
2049 // It answers with the reason rather than a bare no, because the three
2050 // are not the same afterwards (Codex S4-12). A connection that has
2051 // gone owes nothing. A negotiation somebody else made is finished
2052 // with. But an exchange still *in flight* has settled nothing: this
2053 // side's own reason for offering is as outstanding as it was, and an
2054 // attempt that walks away from it without saying so is where the ask
2055 // went missing.
2056 async function politeYield(id, entry, generation) {
2057 await sleep(POLITE_YIELD);
2058 if (pcs.get(id) !== entry || state.stopped) return 'gone';
2059 if (entry.negotiation !== generation) return 'superseded';
2060 if (entry.pc.signalingState !== 'stable') return 'busy';
2061 return '';
2062 }
2063
2064 // answeredExchange records one offer/answer exchange this side did
2065 // not offer: a return to `stable` through somebody else's turn.
2066 //
2067 // It moves the generation, which invalidates any yield sleeping
2068 // through it, and it hands back one spent yield — one, never more
2069 // than was spent, so a pair renegotiating in turn cannot talk this
2070 // side into giving way for ever. Without the hand-back, a yield lost
2071 // to an exchange that happened to satisfy this side's own change left
2072 // `yields` spent and the *next* independent negotiation unprotected,
2073 // which is the reset defect the Task 4 re-review carried (item 1).
2074 function answeredExchange(entry) {
2075 entry.negotiation += 1;
2076 if (entry.yields > 0) entry.yields -= 1;
2077 }
2078
2079 // negotiate is §5.1's offering half: one offer per negotiation, with
2080 // the polite side's yield in front of it.
2081 //
2082 // The yield is fix round 2's finding, and it is the whole reason this
2083 // function is not two lines. Perfect negotiation's answer to glare is
2084 // a rollback on the polite side, and in Chromium rolling back an offer
2085 // that added an m-line rebuilds every send stream on the connection:
2086 // the camera's comes back having encoded nothing and never encodes
2087 // again, on an m-line that goes on reading sendrecv with a live track
2088 // the whole time. So the side that would roll back gives way *before*
2089 // it offers instead, and the offer that was already on its way is
2090 // answered from a stable state with no rollback anywhere.
2091 //
2092 // Bounded, which is what `yields` is for: a side that gave way and
2093 // came back to find another offer waiting would give way again, and
2094 // two peers renegotiating in turn would keep one of them permanently a
2095 // beat behind. Past POLITE_YIELDS in a row it offers and takes the
2096 // ordinary collision path, which is what the pattern is for. The count
2097 // goes back to zero on this side's own offer, and back by one on an
2098 // exchange this side answered instead.
2099 //
2100 // One attempt at a time, too (Codex S4-4). `negotiationneeded` can
2101 // fire again while the first attempt is asleep in its yield — a
2102 // remote exchange completing is exactly the thing that fires it — and
2103 // the second attempt used to skip the yield, because the first had
2104 // already spent it, and then both of them offered.
2105 // An attempt abandoned by its yield is not the end of the story, and
2106 // this is where the first draft of the serializer was wrong — the
2107 // browser gate's mutualshare caught it. `negotiationneeded` fires for
2108 // the polite side's own screen while its attempt is asleep; the
2109 // attempt then wakes, finds the generation moved by the exchange it
2110 // slept through, and abandons its offer. If that event was simply
2111 // dropped because an attempt was in flight, the screen it was about
2112 // is never offered at all — the transceiver sits unassociated for the
2113 // rest of the call and the far side sees a caption with nothing in
2114 // it. So an event that arrives mid-attempt is remembered, and an
2115 // attempt that abandons its offer runs again for it.
2116 //
2117 // Bounded, because "go round again" and "give way" are the same two
2118 // steps: each round can spend a yield, and the allowance runs out.
2119 const NEGOTIATE_ROUNDS = 4;
2120
2121 // How many times a kept ask's retry may find the connection busy and
2122 // put itself off again. After that it stops asking and waits: the
2123 // connection's own `signalingstatechange` is what picks the ask up,
2124 // and a timer that keeps waking every 300 ms adds nothing to it.
2125 const NEGOTIATE_DEFERS = 10;
2126
2127 async function negotiate(id, entry) {
2128 if (pcs.get(id) !== entry) return;
2129 if (entry.negotiating) {
2130 entry.renegotiate = true;
2131 return;
2132 }
2133 entry.negotiating = true;
2134 try {
2135 for (let round = 0; round < NEGOTIATE_ROUNDS; round++) {
2136 entry.renegotiate = false;
2137 const generation = entry.negotiation;
2138 if (entry.polite && entry.yields < POLITE_YIELDS) {
2139 entry.yields += 1;
2140 const why = await politeYield(id, entry, generation);
2141 if (why === 'gone') return;
2142 if (why === 'busy') {
2143 // An exchange is in flight and has settled nothing of ours.
2144 // The ask goes back on the connection for the return to
2145 // `stable` to collect (Codex S4-12): walking away from it
2146 // here is how a preserved request went missing, and this
2147 // attempt may be the only thing that was holding it.
2148 entry.renegotiate = true;
2149 return;
2150 }
2151 if (why === 'superseded') {
2152 // Somebody else made this negotiation. Only an ask that
2153 // arrived *while* we slept is still outstanding.
2154 if (!entry.renegotiate || pcs.get(id) !== entry || state.stopped) return;
2155 continue;
2156 }
2157 }
2158 let offered = false;
2159 try {
2160 entry.makingOffer = true;
2161 await entry.pc.setLocalDescription();
2162 // This side's own offer: the negotiation is ours, the
2163 // generation moves with it, and the next one starts with the
2164 // whole allowance again.
2165 entry.negotiation += 1;
2166 entry.yields = 0;
2167 send(id, 'offer', { description: describe(entry.pc.localDescription) });
2168 offered = true;
2169 } catch {
2170 // A failed local description is not fatal: the pair recovers
2171 // through a reset if it never connects.
2172 } finally {
2173 entry.makingOffer = false;
2174 }
2175 // An offer that went out was made from the connection as it
2176 // stands, so it carries every local change asked for up to this
2177 // moment. One that did not — an impolite side finding the
2178 // connection mid-exchange, say — has settled nothing, and the
2179 // ask goes back for the quiet moment to collect.
2180 if (!offered) entry.renegotiate = true;
2181 return;
2182 }
2183 } finally {
2184 entry.negotiating = false;
2185 // The bound limits how long one attempt may go round; it does not
2186 // decide that outstanding work stops mattering (Codex S4-8). If
2187 // something asked for a negotiation that this attempt never got
2188 // to, the ask stays on the connection and is picked up at its
2189 // next quiet moment.
2190 keepNegotiation(id, entry);
2191 }
2192 }
2193
2194 // keepNegotiation is what happens to an ask the rounds ran out on.
2195 // The request stays true and a retry is scheduled: on the next return
2196 // to `stable` if the connection is mid-negotiation (which is the
2197 // usual reason the rounds ran out), and on a timer otherwise, because
2198 // a connection that is already stable and quiet will not announce
2199 // anything for the retry to hang off.
2200 //
2201 // A retry is scheduled once at a time: `negotiate` clears the flag at
2202 // the top of every round, so a scheduled one that finds nothing
2203 // outstanding does nothing at all.
2204 function keepNegotiation(id, entry) {
2205 if (!entry.renegotiate) return;
2206 if (pcs.get(id) !== entry || state.stopped) {
2207 entry.renegotiate = false;
2208 return;
2209 }
2210 if (entry.negotiateTimer) return;
2211 entry.negotiateTimer = setTimeout(() => {
2212 entry.negotiateTimer = 0;
2213 if (!entry.renegotiate || pcs.get(id) !== entry || state.stopped) return;
2214 // A retry that lands mid-exchange must not spend the ask on an
2215 // attempt that cannot offer (Codex S4-12): it defers instead, and
2216 // the connection's own return to `stable` is what collects it.
2217 // The timer comes back a bounded number of times for the case
2218 // where nothing announces that return; after that the ask simply
2219 // waits, because the stable handler is not going anywhere.
2220 if (entry.negotiating || entry.pc.signalingState !== 'stable') {
2221 if (entry.negotiateDefers >= NEGOTIATE_DEFERS) return;
2222 entry.negotiateDefers += 1;
2223 keepNegotiation(id, entry);
2224 return;
2225 }
2226 entry.negotiateDefers = 0;
2227 negotiate(id, entry).catch((err) => {
2228 console.error('meet: negotiate', err);
2229 });
2230 }, POLITE_YIELD);
2231 }
2232
2233 // hasLiveTrack answers whether the local stream has a track of this
2234 // kind that could actually carry media. An ended track is not one:
2235 // the browser keeps it in the stream, and `addTrack` will take it,
2236 // but nothing ever comes out of it.
2237 function hasLiveTrack(kind) {
2238 if (!local) return false;
2239 const tracks = kind === 'video' ? local.getVideoTracks() : local.getAudioTracks();
2240 return tracks.some((track) => track && track.readyState !== 'ended');
2241 }
2242
2243 function describe(desc) {
2244 return desc ? { type: desc.type, sdp: desc.sdp } : null;
2245 }
2246
2247 function closePC(id, forget) {
2248 const entry = pcs.get(id);
2249 if (entry) {
2250 try {
2251 entry.pc.close();
2252 } catch {
2253 // Already closed.
2254 }
2255 // A retry scheduled for a connection that is going away has
2256 // nothing left to negotiate; the timer's own guard would refuse
2257 // it anyway, and clearing it here keeps nothing pending.
2258 if (entry.negotiateTimer) {
2259 clearTimeout(entry.negotiateTimer);
2260 entry.negotiateTimer = 0;
2261 }
2262 entry.renegotiate = false;
2263 pcs.delete(id);
2264 }
2265 queue.delete(id);
2266 // §5.3's record belongs to a connection, not to a peer id: a pair
2267 // rebuilt under a new generation has renewed nothing yet, and a
2268 // record left behind would say it had.
2269 renewals.delete(id);
2270 // §15.20: what was heard belongs to a connection too. A rebuilt one
2271 // starts its counters again, and a silence timed on the old one is
2272 // not a silence on the new.
2273 hearing.delete(id);
2274 if (forget) {
2275 stale.delete(id);
2276 gen.delete(id);
2277 seqOut.delete(id);
2278 lastIn.delete(id);
2279 names.delete(id);
2280 frames.delete(id);
2281 frames.delete(shareKey(id));
2282 }
2283 }
2284
2285 function closeAllPCs() {
2286 for (const id of Array.from(pcs.keys())) closePC(id, true);
2287 waiting = [];
2288 lastRoster = null;
2289 renderTiles();
2290 renderQueue();
2291 }
2292
2293 // accept applies §4.6's inbound rules to a targeted message: drop
2294 // anything at or below the seq already seen from this sender, then
2295 // drop anything below the current pair generation. A generation
2296 // *above* the local one means a `pair` was missed, so the connection
2297 // is rebuilt before the message is used.
2298 function accept(msg) {
2299 const from = msg.from;
2300 if (!from) return null;
2301 const seq = msg.seq || 0;
2302 const g = msg.gen || 0;
2303 const mine = gen.get(from) || 0;
2304 // The generation is read before the cursor, and that order is
2305 // load-bearing. A message from a generation *older* than ours
2306 // belongs to a pair that has been reset out from under it and is
2307 // gone whatever its seq. One from a *newer* generation is the other
2308 // side of a reset we have not seen yet — and the cursor it would be
2309 // measured against was built by the connection that reset, so it
2310 // cannot honestly call that message a replay. Reading the cursor
2311 // first meant the first message of a rebuilt pair could be
2312 // discarded for ever, which is the line Task 5's report found under
2313 // the stale-roster defect.
2314 if (g && g < mine) return null;
2315 if (g && g > mine) {
2316 rebuildPC(from, g);
2317 if (seq) lastIn.set(from, seq);
2318 return pcs.get(from) || null;
2319 }
2320 if (seq && seq <= (lastIn.get(from) || 0)) return null;
2321 if (seq) lastIn.set(from, seq);
2322 return pcs.get(from) || null;
2323 }
2324
2325 // offerCollision is the W3C perfect-negotiation decision, written out
2326 // as its own function because it is the one piece of §5.1 that can be
2327 // reasoned about — and gated — without a browser (see
2328 // test/browser/negotiation.test.mjs, which reads it out of this file).
2329 //
2330 // readyForOffer = !makingOffer &&
2331 // (signalingState === "stable" || isSettingRemoteAnswerPending)
2332 // offerCollision = !readyForOffer
2333 //
2334 // The second half of that disjunction is the whole point: while an
2335 // answer is being applied, signalingState is still have-local-offer,
2336 // and without this an offer arriving in that window read as glare and
2337 // was discarded by the impolite side (Codex S2-7).
2338 function offerCollision(entry, signalingState) {
2339 const readyForOffer = !entry.makingOffer &&
2340 (signalingState === 'stable' || entry.isSettingRemoteAnswerPending);
2341 return !readyForOffer;
2342 }
2343
2344 async function onOffer(msg) {
2345 const entry = accept(msg);
2346 if (!entry) return;
2347 const desc = msg.payload && msg.payload.description;
2348 if (!desc || desc.type !== 'offer') return;
2349 if (offerCollision(entry, entry.pc.signalingState)) {
2350 entry.ignoreOffer = !entry.polite;
2351 if (entry.ignoreOffer) return;
2352 } else {
2353 entry.ignoreOffer = false;
2354 }
2355 try {
2356 // The polite peer's implicit rollback lives inside this call.
2357 entry.isSettingRemoteAnswerPending = false; // this one is an offer
2358 await entry.pc.setRemoteDescription(desc);
2359 entry.ignoreOffer = false;
2360 entry.descSeq = msg.seq || entry.descSeq;
2361 await drainCandidates(entry);
2362 await entry.pc.setLocalDescription();
2363 // One exchange completed, on the answering side (§5.3 step 2) —
2364 // and an exchange this side did not offer, which is what moves
2365 // the negotiation generation and hands a spent yield back.
2366 entry.desc += 1;
2367 answeredExchange(entry);
2368 send(msg.from, 'answer', { description: describe(entry.pc.localDescription) });
2369 } catch {
2370 // A description that will not apply leaves the pair to recover
2371 // through a reset rather than throwing into the event handler.
2372 }
2373 }
2374
2375 async function onAnswer(msg) {
2376 const entry = accept(msg);
2377 if (!entry) return;
2378 const desc = msg.payload && msg.payload.description;
2379 if (!desc || desc.type !== 'answer') return;
2380 if (entry.pc.signalingState !== 'have-local-offer') return;
2381 try {
2382 // The W3C flag, set exactly where the reference sets it: around
2383 // setRemoteDescription, true because this description is an
2384 // answer. An offer that arrives while this is in flight is a
2385 // legitimate offer, not glare, and offerCollision reads this to
2386 // tell the two apart.
2387 entry.isSettingRemoteAnswerPending = true;
2388 try {
2389 await entry.pc.setRemoteDescription(desc);
2390 } finally {
2391 entry.isSettingRemoteAnswerPending = false;
2392 }
2393 // The reference sets ignoreOffer on every description it applies,
2394 // and an answer that lands means the collision it was set for is
2395 // over: candidate failures after this point are real again.
2396 entry.ignoreOffer = false;
2397 entry.descSeq = msg.seq || entry.descSeq;
2398 // One exchange completed, on the offering side (§5.3 step 2).
2399 entry.desc += 1;
2400 await drainCandidates(entry);
2401 } catch {
2402 // Same as above: a stale answer is not worth an exception.
2403 }
2404 }
2405
2406 async function onCandidate(msg) {
2407 const entry = accept(msg);
2408 if (!entry) return;
2409 const candidate = msg.payload && msg.payload.candidate;
2410 if (!candidate) return;
2411 entry.received += 1;
2412 // §4.6: a candidate whose seq precedes the current description's
2413 // belongs to a description that has been replaced.
2414 if (msg.seq && entry.descSeq && msg.seq < entry.descSeq) return;
2415 if (!entry.pc.remoteDescription) {
2416 entry.pending.push(candidate);
2417 if (entry.pending.length > MAX_BUFFERED_CANDIDATES) entry.pending.shift();
2418 return;
2419 }
2420 await addCandidate(entry, candidate);
2421 }
2422
2423 async function drainCandidates(entry) {
2424 const held = entry.pending;
2425 entry.pending = [];
2426 for (const candidate of held) await addCandidate(entry, candidate);
2427 }
2428
2429 async function addCandidate(entry, candidate) {
2430 try {
2431 await entry.pc.addIceCandidate(candidate);
2432 entry.added += 1;
2433 } catch (err) {
2434 // The W3C reference swallows exactly this: an ignored offer's
2435 // candidates cannot apply, and that is expected, not an error.
2436 if (!entry.ignoreOffer) console.warn('meet: candidate', err && err.name);
2437 }
2438 }
2439
2440 // ---------------------------------------------------------------
2441 // ICE servers and renewal (§5.2, §5.3, §5.5)
2442 // ---------------------------------------------------------------
2443 //
2444 // The shape of it: the first roster waits for `fetchIce`, every
2445 // connection is built with the list it took, and each connection is
2446 // renewed on its own at the time the server named — with new servers,
2447 // an ICE restart, and proof that the restart landed. Nothing about the
2448 // servers is logged: the list carries a credential, and the three
2449 // diagnostics below print an error object and never a response.
2450
2451 const ICE_WARNING = 'Calls could not reach the relay. Calls may not connect until it can.';
2452 const ICE_EXPIRED = 'The relay credential expired for this connection.';
2453 const ICE_STUCK = 'Could not renew this connection through the relay.';
2454 const FAILED_RELAY = 'Could not connect, even through the relay.';
2455 const FAILED_PLAIN = 'Could not connect.';
2456
2457 // iceReady is the one await §5.2 puts in front of the first connection
2458 // this browser builds. One shared promise, so two rosters in the same
2459 // breath cannot start two fetches, and a failure primes exactly as a
2460 // success does: the room carries on with the floor's empty list while
2461 // the minute retry runs behind it.
2462 function iceReady() {
2463 if (icePrimed) return Promise.resolve(false);
2464 if (!firstIce) {
2465 firstIce = fetchIce().finally(() => {
2466 firstIce = null;
2467 });
2468 }
2469 return firstIce;
2470 }
2471
2472 // fetchIce is §5.2's client half: GET /m/{sqid}/ice for this
2473 // participation, answering {iceServers, expiresAt, renewAt}. It never
2474 // throws and never rejects — every caller carries on either way — and
2475 // answers whether a new list is in hand.
2476 //
2477 // The stream's `probe` is borrowed whole: same-origin credentials, no
2478 // store, no Accept the endpoint did not ask for, and an absolute
2479 // deadline, so a request that never settles cannot be the end of this
2480 // browser's renewing.
2481 async function fetchIce() {
2482 const peer = state.peer;
2483 if (state.stopped || !peer) return false;
2484 const path = '/m/' + encodeURIComponent(sqid) + '/ice?peer=' + encodeURIComponent(peer);
2485 // One budget for the whole exchange, however many requests it takes
2486 // — the shape §4.6 already gives one outbound message, and the same
2487 // reason applies: /ice spends /signal's own per-session, per-room
2488 // bucket, so a renewal that lands inside a burst of signalling gets
2489 // a 429 and a Retry-After rather than an answer.
2490 const deadline = Date.now() + SEND_DEADLINE;
2491 for (;;) {
2492 let res;
2493 try {
2494 res = await probe(path, deadline);
2495 } catch {
2496 icePrimed = true;
2497 iceFailed();
2498 return false;
2499 }
2500 if (state.stopped || state.peer !== peer) return false;
2501 if (res.signedOut) {
2502 terminal('signedout');
2503 return false;
2504 }
2505 if (res.status === 429) {
2506 // Not a failure: the server said "not now, come back in n". Honour
2507 // it inside this budget and say nothing — a warning about the
2508 // relay would be untrue, and the person's own call is what filled
2509 // that bucket. Only a wait that runs out of budget is a failure.
2510 if (!(await waitWithin(retryAfter(res.retryAfter), deadline))) {
2511 icePrimed = true;
2512 iceFailed();
2513 return false;
2514 }
2515 continue;
2516 }
2517 if (res.status === 404) {
2518 // Not a relay failure: a 404 here says this participation is
2519 // gone — a resume window that has lapsed included, now that
2520 // /ice gates on the same liveness /signal does — and /signal's
2521 // own 404 says no more than that either. So it goes through
2522 // the same probe-first recovery — this browser's own
2523 // participation is the authority on what the 404 meant — and the
2524 // next roster fetches again, because whatever comes back from that
2525 // ladder is a call that has not got a list yet.
2526 icePrimed = false;
2527 firstIce = null;
2528 await recoverFromNotFound(res.data.boot);
2529 // The ladder is allowed to decide nothing at all: a probe that
2530 // cannot answer inside its budget leaves this participation alone
2531 // (§4.5), and a call left with no list and no retry pending would
2532 // simply sit there. So unless it ended the call or entered afresh
2533 // — both of which change what is checked here — the minute retry
2534 // covers it.
2535 if (!state.stopped && state.peer === peer) iceFailed();
2536 return false;
2537 }
2538 if (res.status !== 200 || !Array.isArray(res.data.iceServers)) {
2539 icePrimed = true;
2540 iceFailed();
2541 return false;
2542 }
2543 icePrimed = true;
2544 ice.servers = res.data.iceServers;
2545 ice.expiresAt = millis(res.data.expiresAt);
2546 ice.renewAt = millis(res.data.renewAt);
2547 // The margin the server left itself: expiry minus renew time, on
2548 // its clock, where the two subtract to a duration both machines
2549 // agree on. Everything below is scheduled from it and from
2550 // `fetchedAt`, which is this browser's own clock — see renewDelay.
2551 ice.margin = Math.max(0, ice.expiresAt - ice.renewAt);
2552 ice.fetchedAt = Date.now();
2553 ice.lastFetchAt = ice.fetchedAt;
2554 ice.fetches += 1;
2555 ice.failedSince = 0;
2556 ice.lastTryAt = 0;
2557 clearTimeout(iceRetryTimer);
2558 iceWarning(false);
2559 scheduleRenewal();
2560 adoptServers();
2561 return true;
2562 }
2563 }
2564
2565 // The wire carries unix seconds; everything in this file works in
2566 // milliseconds since the epoch, and this is the one place the two
2567 // meet. A field that is missing, zero or not a number means "no
2568 // clock", which is exactly what the server sends when there is no
2569 // relay to renew.
2570 function millis(v) {
2571 const n = Number(v);
2572 return Number.isFinite(n) && n > 0 ? n * 1000 : 0;
2573 }
2574
2575 // iceFailed is §5.3 step 1: keep the list we have — or the floor's
2576 // empty one, on the very first fetch — say so from the first failure,
2577 // and come back in a minute. `ice.expiresAt` is deliberately left
2578 // alone: the old credential's deadline is the must-succeed-by for
2579 // every connection that has not renewed (§5.3 step 3), and a failed
2580 // fetch is the case that makes it matter.
2581 function iceFailed() {
2582 if (state.stopped) return;
2583 // Every failure, not just the first: the catch-up on a tab coming
2584 // back has to know when this browser last actually tried, and
2585 // `failedSince` is the start of the run and answers a different
2586 // question (whether the sentence is up).
2587 ice.lastTryAt = Date.now();
2588 if (!ice.failedSince) ice.failedSince = Date.now();
2589 iceWarning(true);
2590 clearTimeout(iceRetryTimer);
2591 iceRetryTimer = setTimeout(() => {
2592 if (state.stopped) return;
2593 const run = renewDue ? renewAll() : fetchIce();
2594 run.catch((err) => {
2595 console.error('meet: ice', err);
2596 });
2597 }, ICE_RETRY);
2598 }
2599
2600 // iceWarning is §5.3 step 1's sentence, in a live region of its own
2601 // created once beside #status. Its own, because the status paragraph
2602 // is busy with the call's state and a warning coming and going from
2603 // there would take whatever a person was reading with it. Never colour
2604 // alone: the sentence is the whole signal.
2605 function iceWarning(show) {
2606 const el = warningEl();
2607 if (!el) return;
2608 // On the floor the sentence would be untrue: there is no relay this
2609 // instance could reach, /ice answers an empty list, and a fetch that
2610 // fails there costs the call nothing a person could act on (§5.5).
2611 if (show && !relayConfigured) return;
2612 if (show) {
2613 // Unhidden first, then written: a live region announces the change
2614 // it can see, and text set into a hidden element is not one.
2615 el.hidden = false;
2616 el.textContent = ICE_WARNING;
2617 return;
2618 }
2619 el.textContent = '';
2620 el.hidden = true;
2621 }
2622
2623 function warningEl() {
2624 if (iceWarningEl) return iceWarningEl;
2625 if (!statusEl) return null;
2626 const el = document.createElement('p');
2627 el.id = 'ice-warning';
2628 el.className = 'calls-ice-warning';
2629 el.setAttribute('role', 'status');
2630 el.hidden = true;
2631 statusEl.after(el);
2632 iceWarningEl = el;
2633 return el;
2634 }
2635
2636 // adoptServers closes the one window §5.2's ordering leaves open: a
2637 // `peer` event can arrive while the very first fetch is still out, and
2638 // that connection was built on the floor's empty list. Nothing about
2639 // it is broken — it is gathering host candidates — so it is handed the
2640 // servers and restarted rather than torn down and rebuilt.
2641 function adoptServers() {
2642 if (!ice.servers.length) return;
2643 for (const [id, entry] of pcs) {
2644 if (!entry.floor) continue;
2645 try {
2646 entry.pc.setConfiguration({ iceServers: ice.servers });
2647 } catch {
2648 // An older browser without setConfiguration: nothing to adopt
2649 // into, and the connection keeps what it was built with.
2650 continue;
2651 }
2652 // And the restart, if this connection is quiet enough to take
2653 // one now. If it is not — which is the common case for a peer
2654 // built during the fetch, because it is mid-offer — it is
2655 // deferred, not dropped: `createPC`'s signalingstatechange
2656 // listener runs the same decision at every quiet moment, so the
2657 // connection takes the list as soon as its negotiation settles
2658 // rather than waiting 150 minutes for the first renewal.
2659 adoptWhenQuiet(id, entry);
2660 }
2661 }
2662
2663 // adoptWhenQuiet is that decision and the act. It is safe to call as
2664 // often as anything likes: `floor` is what makes it happen at most
2665 // once per list, and clearing it first means the restart's own
2666 // signalingstatechange cannot come back round here.
2667 function adoptWhenQuiet(id, entry) {
2668 if (!readyToAdopt(entry, ice.servers.length)) return;
2669 entry.floor = false;
2670 entry.listAt = ice.fetchedAt;
2671 try {
2672 entry.pc.restartIce();
2673 } catch {
2674 // No restartIce on this browser: a fresh generation is the only
2675 // way to put the servers under this pair, and the `pair` that
2676 // answers rebuilds the connection with them from nothing.
2677 requestReset(id);
2678 }
2679 }
2680
2681 // readyToAdopt is §5.2's deferral rule, pure so it can be gated
2682 // without a browser: still on the floor, a list to move to, and a
2683 // connection quiet enough that a restart will not cut across a
2684 // negotiation already in flight.
2685 function readyToAdopt(entry, servers) {
2686 return !!(entry.floor && servers && !entry.makingOffer &&
2687 entry.pc.signalingState === 'stable');
2688 }
2689
2690 // scheduleRenewal is §5.3 step 4: the next renewal is the `renewAt`
2691 // the server named, counted from the fetch that succeeded. Nothing is
2692 // scheduled on the floor — there is no credential to renew — and a
2693 // `renewAt` already behind us runs at once.
2694 function scheduleRenewal() {
2695 clearTimeout(renewTimer);
2696 if (state.stopped || !schedulable()) return;
2697 renewTimer = setTimeout(renew, Math.max(0, renewDelay() - (Date.now() - ice.fetchedAt)));
2698 }
2699
2700 // renewDelay and mustSucceedBy are §5.3's two instants, measured as
2701 // durations from the moment this browser took the list rather than as
2702 // points on the server's clock. A browser whose clock is minutes off
2703 // the server's would otherwise renew at the wrong time, and one far
2704 // enough ahead would decide the credential had expired before it ever
2705 // used it.
2706 //
2707 // There are two ways to read the duration and neither is right on its
2708 // own, so both are taken and the earlier wins.
2709 //
2710 // The *ratio* reading uses the margin the server left itself —
2711 // expiresAt minus renewAt, a subtraction of two instants on one clock,
2712 // so it survives any skew at all. The contract's shape (renew at five
2713 // sixths of the life) makes the rest arithmetic: the margin is a sixth
2714 // of the life, the delay five of them, the life six. But the server
2715 // sends whole seconds, and truncation eats the margin at short lives:
2716 // a twelve-second life gives 10 and 12 — margin 2, and 2 x 5 = 10, the
2717 // truth — while a ten-second life gives 8 and 10, where the same
2718 // arithmetic says 10 and would renew at the very moment the credential
2719 // died.
2720 //
2721 // The *clock* reading subtracts this browser's fetch stamp from the
2722 // server's instants. It is exact when the clocks agree and wrong by
2723 // the skew when they do not — but wrong in a direction that only
2724 // matters one way: a browser running ahead of the server gets a
2725 // shorter answer and renews early, which costs nothing, and one
2726 // running behind gets a longer one, which the ratio reading then caps.
2727 //
2728 // So: the earlier of the two, and a floor under the delay so that a
2729 // server answering with no margin at all cannot turn the schedule into
2730 // a spin.
2731 function renewDelay() {
2732 return Math.max(MIN_RENEW_DELAY,
2733 Math.min(ice.margin * 5, ice.renewAt - ice.fetchedAt));
2734 }
2735
2736 function mustSucceedBy() {
2737 return ice.fetchedAt + Math.min(ice.margin * 6, ice.expiresAt - ice.fetchedAt);
2738 }
2739
2740 // §5.3's last paragraph: a suspended tab stops its timers, so a tab
2741 // coming back to visible compares the clock with `renewAt` rather than
2742 // trusting one, and runs the sequence at once if it is overdue.
2743 function catchUpRenewal() {
2744 if (state.stopped || !schedulable()) return;
2745 // A fetch that is already failing has a retry of its own running on
2746 // its own minute, and a tab coming back to visible is not a reason
2747 // to jump that queue. The comparison is with the clock rather than
2748 // with the timer because that timer is exactly what a suspended tab
2749 // stops: a failure older than the retry interval means the minute
2750 // has passed with nothing to run it, and this is what runs it.
2751 if (ice.failedSince && Date.now() - ice.lastTryAt < ICE_RETRY) return;
2752 if (Date.now() - ice.fetchedAt >= renewDelay()) renew();
2753 }
2754
2755 function renew() {
2756 renewAll().catch((err) => {
2757 console.error('meet: renew', err);
2758 });
2759 }
2760
2761 // renewAll is §5.3's sequence for the whole mesh: fetch, then hand
2762 // what came back to every connection still on the old list. Both the
2763 // deadline it arms the watchdog with — step 3's must-succeed-by, the
2764 // one honest signal, because the browser cannot see which credential
2765 // an allocation is using — and the stamp naming the credential that
2766 // deadline belongs to are read *before* the fetch. A failed fetch
2767 // leaves neither where the watchdog could confuse them: `fetchedAt`
2768 // only ever advances on a success, which is exactly why reading it
2769 // afterwards made the watchdog unreachable (round-1 review, item 1).
2770 async function renewAll() {
2771 if (state.stopped || renewing) return;
2772 renewing = true;
2773 renewDue = true;
2774 const expiring = ice.fetchedAt;
2775 armMustSucceedBy(mustSucceedBy(), expiring);
2776 try {
2777 // A failed fetch has already armed the warning and the minute
2778 // retry, and that retry comes back here.
2779 if (!(await fetchIce())) return;
2780 renewDue = false;
2781 renewOutdated(expiring);
2782 } finally {
2783 renewing = false;
2784 }
2785 }
2786
2787 // renewOutdated hands a list that has just arrived to every
2788 // connection still on an older one. Every path that produces a list
2789 // comes through here — the scheduled renewal and a tile's Retry
2790 // alike — because a fetch is a fetch: one credential is minted for
2791 // the call, and renewing only the connection whose Retry happened to
2792 // make the request left every other connection on a credential about
2793 // to die, with the shared timers already reset out from under them
2794 // (Codex S3-2).
2795 function renewOutdated(expiring) {
2796 for (const id of Array.from(pcs.keys())) {
2797 const entry = pcs.get(id);
2798 if (!entry || entry.listAt > expiring) continue;
2799 renewOne(id).catch((err) => {
2800 console.error('meet: renew', err);
2801 });
2802 }
2803 }
2804
2805 // armMustSucceedBy sets §5.3 step 3's verdict for the connections that
2806 // have nothing running: with the fetch still failing a minute at a
2807 // time, the old credential's deadline passes and their allocation dies
2808 // with it, and the person is owed the sentence. A connection whose own
2809 // restart is still in flight is left alone — it is the thing that
2810 // would clear this, and interrupting it would show the sentence to
2811 // someone whose call is about to come back.
2812 function armMustSucceedBy(at, expiring) {
2813 clearTimeout(expiryTimer);
2814 if (!at) return;
2815 expiryTimer = setTimeout(() => expireUnrenewed(expiring), Math.max(0, at - Date.now()));
2816 }
2817
2818 // expireUnrenewed is the verdict itself. `expiring` is the stamp of
2819 // the credential that has just run out, so "newer than that" means
2820 // "built on a list this deadline was never about" — a peer who joined
2821 // after the fetch, whose connection has the current credential and
2822 // nothing to renew. It has to be the *expiring* stamp and not
2823 // `ice.fetchedAt`: on the path this exists for, the fetch is still
2824 // failing and `ice.fetchedAt` has not moved, so comparing against it
2825 // exempted every connection in the call and nothing was ever marked.
2826 function expireUnrenewed(expiring) {
2827 if (state.stopped) return;
2828 for (const [id, entry] of pcs) {
2829 // The one exemption that means anything: this connection is on a
2830 // list *newer* than the one that just expired, because it was
2831 // built on it or was proven onto it. `listAt` is that list's
2832 // stamp and it moves only on proof, so it answers "which
2833 // credential is this connection actually using" — which is the
2834 // question. A `renewed` flag answered "did this connection ever
2835 // renew, once, at any time", and stayed true through every later
2836 // failure: one successful renewal exempted a connection from
2837 // every deadline for the rest of the call, and nobody was ever
2838 // told the second credential had died (Codex S3-1).
2839 if (entry.listAt > expiring) continue;
2840 const record = renewals.get(id);
2841 // A restart still in flight is the thing that would clear this,
2842 // and interrupting it would show the sentence to someone whose
2843 // call is about to come back.
2844 if (record && record.running) continue;
2845 markRelayExpired(id, expiring);
2846 }
2847 }
2848
2849 // The two verdicts a renewal can end on, and they are not
2850 // interchangeable. `expired` is the must-succeed-by: the credential
2851 // this connection is using ran out before a new one landed, which is
2852 // §5.3 step 3's honest signal. `stuck` is the other ending — three
2853 // ICE restarts with a good new credential in hand, none of them
2854 // proven — where the credential is fine and the path is not. Saying
2855 // "expired" there would be a sentence about the wrong thing.
2856 function markRelayExpired(id, expiring) {
2857 markTrouble(id, 'expired', expiring);
2858 }
2859
2860 // markTrouble is where a verdict becomes a sentence, and `upTo` is
2861 // the list the verdict is about: the credential that expired, or the
2862 // one a restart could not install. A connection proven onto something
2863 // newer has nothing to answer for and is left alone; anything else is
2864 // told.
2865 //
2866 // It used to read `record.renewed` instead — "did this connection
2867 // ever renew, once, at any time" — which was true for the rest of the
2868 // call after a single success and swallowed every later verdict
2869 // silently, watchdog included (Codex S3-8). The boolean is gone from
2870 // the decision entirely; `listAt` is the only thing that answers
2871 // which credential a connection is actually on.
2872 function markTrouble(id, kind, upTo) {
2873 const entry = pcs.get(id);
2874 if (!entry || (upTo && entry.listAt > upTo)) return;
2875 renewalRecord(id).trouble = kind;
2876 renderTiles();
2877 }
2878
2879 function renewalRecord(id) {
2880 let record = renewals.get(id);
2881 if (!record) {
2882 record = {
2883 attempts: 0, renewed: false, ufrag: '', at: 0, running: false, trouble: '',
2884 // The last run's own budget and how it ended, for a reader who
2885 // needs to know a verdict followed three restarts and not one —
2886 // which `attempts`, counting a whole life, cannot answer.
2887 lastRun: null,
2888 };
2889 renewals.set(id, record);
2890 }
2891 return record;
2892 }
2893
2894 // renewOne is §5.3 step 2 for one connection: the new servers under
2895 // the existing transport, an ICE restart, perfect negotiation carrying
2896 // the offer, and then proof. Three attempts, thirty seconds each, and
2897 // the must-succeed-by is read *between* attempts rather than during
2898 // one — cutting a restart that is about to land would show a person
2899 // the expired sentence for a call that was coming back.
2900 // renewOne is reached only with a fresh credential in hand — every
2901 // caller has just fetched one — so the deadline plays no part in it.
2902 // Its whole budget is three restarts, and its verdict on failure is
2903 // always `stuck`: the credential is fine and the path is not. Cutting
2904 // the budget short at the old deadline and calling the result
2905 // "expired" was a sentence about the wrong thing, told after one
2906 // attempt of the three (Codex S3-4). `expired` belongs to the
2907 // watchdog, which is the only place that knows the other case: no
2908 // fresh credential arrived at all.
2909 async function renewOne(id) {
2910 const entry = pcs.get(id);
2911 if (!entry || state.stopped) return false;
2912 const record = renewalRecord(id);
2913 if (record.running) return false;
2914 record.running = true;
2915 record.renewed = false;
2916 // The stamp of the last list this run actually installed, for the
2917 // verdict at the bottom: it is the list the restarts failed on, and
2918 // not necessarily the newest one the call holds.
2919 let lastInstalled = ice.fetchedAt;
2920 // What *this* run spends, as against `record.attempts`, which is
2921 // the connection's whole life. A verdict has to be readable
2922 // alongside the budget it followed, and a lifetime counter cannot
2923 // say that: a later run raises it while the first verdict is still
2924 // on the screen (Codex S3-14).
2925 let spent = 0;
2926 try {
2927 for (let attempt = 1; attempt <= RENEW_ATTEMPTS; attempt++) {
2928 if (state.stopped || pcs.get(id) !== entry) return false;
2929 // Restarts spent on this connection, and never reset: a
2930 // connection that is handed one list after another and cannot
2931 // land on any of them has spent all of them, and a count that
2932 // started again with each run read as 1 the instant a
2933 // follow-up began — which is a number that says nothing and
2934 // that a gate cannot wait on.
2935 record.attempts += 1;
2936 spent += 1;
2937 // The generation this connection is on now, and the count of
2938 // exchanges it has completed: the two things the proof is
2939 // measured against.
2940 const oldUfrag = await selectedUfrag(entry.pc);
2941 const from = entry.desc;
2942 // The list this attempt installs, and the stamp that names it.
2943 // Read here and used on proof, because `ice.fetchedAt` is the
2944 // *newest* list the call has fetched and another tile's Retry
2945 // can replace it while this attempt is still running: stamping
2946 // the global one claimed the connection was on a credential it
2947 // had never been given, and handed it that credential's
2948 // schedule (Codex S3-9).
2949 const installed = ice.servers;
2950 const installedAt = ice.fetchedAt;
2951 try {
2952 entry.pc.setConfiguration({ iceServers: installed });
2953 entry.floor = !installed.length;
2954 } catch {
2955 // The connection is still on the list it had, and a restart
2956 // from here would re-gather on *those* servers — and could
2957 // prove, on a new fragment, and be stamped onto a list it
2958 // never took (Codex S3-13). Nothing is stamped, nothing is
2959 // restarted, and a fresh generation is asked for instead: a
2960 // rebuilt connection takes `ice.servers` at construction,
2961 // which is the only way left to put these servers under this
2962 // pair, and it keeps §4.6's counters.
2963 requestReset(id);
2964 await sleep(RENEW_WINDOW);
2965 if (state.stopped || pcs.get(id) !== entry) return false;
2966 verdict(id, record, spent, 'stuck', lastInstalled);
2967 return false;
2968 }
2969 // Installed, and only now is this the list the run is working
2970 // on. The stamp moves after the call returns, never before it.
2971 lastInstalled = installedAt;
2972 try {
2973 entry.pc.restartIce();
2974 } catch {
2975 // No restartIce at all: a fresh generation is the only way to
2976 // put a new credential under this pair, and the `pair` that
2977 // answers rebuilds the connection from nothing — a rebuild
2978 // this record does not survive, which is what "the connection
2979 // is gone from under us" means below. If nothing has rebuilt
2980 // it by the end of the window, nothing is going to, and the
2981 // person is owed a sentence rather than a tile that looks
2982 // fine and carries no media.
2983 requestReset(id);
2984 await sleep(RENEW_WINDOW);
2985 if (state.stopped || pcs.get(id) !== entry) return false;
2986 verdict(id, record, spent, 'stuck', installedAt);
2987 return false;
2988 }
2989 const proven = await proveRenewed(id, entry, oldUfrag, from, Date.now() + RENEW_WINDOW);
2990 if (state.stopped || pcs.get(id) !== entry) return false;
2991 if (proven !== null) {
2992 record.renewed = true;
2993 record.ufrag = proven;
2994 record.at = Date.now();
2995 entry.listAt = installedAt;
2996 record.trouble = '';
2997 record.lastRun = { attempts: spent, verdict: 'renewed' };
2998 renderTiles();
2999 return true;
3000 }
3001 }
3002 verdict(id, record, spent, 'stuck', lastInstalled);
3003 return false;
3004 } finally {
3005 record.running = false;
3006 // And if a newer list arrived while this attempt was running,
3007 // this connection owes a renewal onto it: `renewOutdated` skipped
3008 // it because a restart was in flight, and nothing else is coming
3009 // for it until the next scheduled renewal (Codex S3-9).
3010 followUp(id, lastInstalled);
3011 }
3012 }
3013
3014 // verdict records how a run ended and what it spent getting there,
3015 // and then says it on the tile. `spent` is this run's own budget;
3016 // `record.attempts` keeps counting across runs for whoever wants the
3017 // whole story.
3018 function verdict(id, record, spent, kind, upTo) {
3019 record.lastRun = { attempts: spent, verdict: kind };
3020 markTrouble(id, kind, upTo);
3021 }
3022
3023 // followUp is that debt, paid once the attempt that incurred it has
3024 // let go of the record. `attempted` is the newest list that run had
3025 // to work with: only a list newer than *that* is a debt, which is
3026 // what keeps a connection that simply cannot be renewed from
3027 // restarting itself for ever. A timeout of zero rather than a direct
3028 // call, because `renewOne` refuses to start while `running` is set
3029 // and this runs from the `finally` that clears it.
3030 function followUp(id, attempted) {
3031 if (state.stopped || ice.fetchedAt <= attempted) return;
3032 const entry = pcs.get(id);
3033 if (!entry || entry.listAt >= ice.fetchedAt) return;
3034 setTimeout(() => {
3035 const now = pcs.get(id);
3036 if (state.stopped || !now || now.listAt >= ice.fetchedAt) return;
3037 renewOne(id).catch((err) => {
3038 console.error('meet: renew', err);
3039 });
3040 }, 0);
3041 }
3042
3043 // retryRenewal is the control §5.3 step 3 puts on a tile in trouble:
3044 // a fresh fetch, then step 2 again — for this connection and for
3045 // every other one the fetch just re-credentialed.
3046 //
3047 // A fetch that fails is the case worth writing out. `restartIce` has
3048 // already cleared the sentence off the tile, so returning here left a
3049 // person looking at a tile that said nothing was wrong while nothing
3050 // had been done about it. The sentence goes back up — it is still
3051 // true — and the connection still gets the plain restart the ordinary
3052 // Retry would have given it, which is the one thing left that might
3053 // help.
3054 async function retryRenewal(id, wasTrouble) {
3055 const expiring = ice.fetchedAt;
3056 if (await fetchIce()) {
3057 // Not just this one. The fetch this Retry made replaced the
3058 // credential every connection was using, and reset the shared
3059 // schedule with it, so every connection still on the old list is
3060 // renewed here (Codex S3-2).
3061 renewOutdated(expiring);
3062 return;
3063 }
3064 if (wasTrouble) {
3065 renewalRecord(id).trouble = wasTrouble;
3066 renderTiles();
3067 }
3068 const entry = pcs.get(id);
3069 if (entry) bareRestart(id, entry);
3070 }
3071
3072 // proveRenewed polls getStats until the decision below says the
3073 // restart landed, or until the window runs out. Half a second between
3074 // polls: an ICE restart is a round trip and a handful of candidate
3075 // exchanges, and asking faster only costs the call CPU.
3076 async function proveRenewed(id, entry, oldUfrag, from, deadline) {
3077 // The generation this connection was on before the restart, when it
3078 // could be read. When it could not, the first fragment readable
3079 // afterwards stands in for it, and the proof is then the ordinary
3080 // one: a *different* fragment after that. It is a weaker starting
3081 // point and it can honestly fail to prove a restart that did land —
3082 // but failing to prove costs a sentence and a Retry, where wrongly
3083 // proving is a call running on a credential nobody ever checked.
3084 let baseline = oldUfrag || '';
3085 for (;;) {
3086 // A connection replaced under us — a `pair`, a roster, an
3087 // unreachable target — is not the connection this proof is about,
3088 // and polling its stats for another thirty seconds proves nothing
3089 // about either of them.
3090 if (state.stopped || pcs.get(id) !== entry) return null;
3091 const reports = await statsList(entry.pc);
3092 const proven = renewedUfrag(reports, baseline,
3093 entry.pc.signalingState, entry.desc > from);
3094 if (proven !== null) return proven;
3095 if (!baseline) baseline = selectedPairUfrag(reports) || '';
3096 const left = deadline - Date.now();
3097 if (left <= 0) return null;
3098 await sleep(Math.min(RENEW_POLL, left));
3099 }
3100 }
3101
3102 // renewedUfrag is §5.3 step 2's decision, written as a pure function
3103 // of one getStats report so it can be reasoned about — and gated —
3104 // without a browser (test/browser/negotiation.test.mjs lifts it out of
3105 // this file by name).
3106 //
3107 // A connection is renewed when the restart's offer/answer exchange has
3108 // completed, `signalingState` is stable with that answer applied, and
3109 // the selected succeeded pair's local candidate carries a *different*
3110 // ICE username fragment from the one the connection was on. An
3111 // `iceConnectionState` change proves nothing on its own: a `completed`
3112 // transport moves to `connected` merely by starting a restart.
3113 //
3114 // It answers the new fragment, '' when the restart is proven but the
3115 // browser does not name fragments, and null when it is not proven —
3116 // so callers test `!== null`, never truthiness.
3117 function renewedUfrag(reports, oldUfrag, signalingState, negotiated) {
3118 if (!negotiated || signalingState !== 'stable') return null;
3119 // No baseline, no proof. Without knowing which generation this
3120 // connection was on, a selected pair that has not moved at all
3121 // looks exactly like one that has — and the old reading took that
3122 // as proof, cleared the warning, and stamped the connection as
3123 // renewed onto a credential it had never gathered on (Codex S3-3).
3124 // A getStats that rejected before the restart arrived here the same
3125 // way. The caller establishes a baseline from the first reading it
3126 // can take *after* the restart and comes back; if none can be read,
3127 // the restart stays unproven, which is the honest answer.
3128 if (!oldUfrag) return null;
3129 const ufrag = selectedPairUfrag(reports);
3130 // And a pair the browser will not name is not evidence either: ''
3131 // says "there is a pair", not "there is a new one".
3132 if (!ufrag) return null;
3133 return ufrag !== oldUfrag ? ufrag : null;
3134 }
3135
3136 // selectedPairUfrag reads one getStats report and answers the ICE
3137 // username fragment on the local candidate of the selected, succeeded
3138 // candidate pair: the fragment when the browser names it, '' when the
3139 // pair is there and the fragment is not, and null when there is no
3140 // such pair at all. Pure, like the decision above.
3141 //
3142 // Where a browser names the selected pair itself — Chromium's
3143 // transport.selectedCandidatePairId — that answer wins outright, and a
3144 // pair it does not name is not consulted however succeeded and
3145 // nominated it looks. Chromium keeps the *previous* generation's pair
3146 // in the report for a while after a restart, still succeeded and still
3147 // nominated, and scanning for one of those was how a restart could
3148 // read as proven by the connection it had just replaced (round-1
3149 // review, item 2). `nominated`/`selected` are the fallback for a
3150 // report with no transport in it, which is what Firefox sends.
3151 function selectedPairUfrag(reports) {
3152 const locals = new Map();
3153 const pairs = [];
3154 const selected = new Set();
3155 let transports = 0;
3156 for (const r of reports || []) {
3157 if (!r || !r.type) continue;
3158 if (r.type === 'local-candidate') locals.set(r.id, r);
3159 else if (r.type === 'candidate-pair') pairs.push(r);
3160 else if (r.type === 'transport') {
3161 transports += 1;
3162 if (r.selectedCandidatePairId) selected.add(r.selectedCandidatePairId);
3163 }
3164 }
3165 // A transport that has not named a pair yet has not selected one:
3166 // `selected` is empty, nothing matches, and the answer is null.
3167 const chosen = transports
3168 ? pairs.filter((p) => selected.has(p.id))
3169 : pairs.filter((p) => p.nominated || p.selected);
3170 let found = null;
3171 for (const p of chosen) {
3172 if (p.state !== 'succeeded') continue;
3173 const local = locals.get(p.localCandidateId);
3174 const ufrag = (local && local.usernameFragment) || '';
3175 // A pair that names the fragment is worth more than one that does
3176 // not, so keep looking rather than settling for the first.
3177 if (ufrag) return ufrag;
3178 found = '';
3179 }
3180 return found;
3181 }
3182
3183 // selectedUfrag is the generation a connection is on right now. null
3184 // means the browser will not say — no selected pair yet, or no
3185 // fragment reported — which is the case renewedUfrag falls back for.
3186 async function selectedUfrag(pc) {
3187 return selectedPairUfrag(await statsList(pc)) || null;
3188 }
3189
3190 // statsList flattens one RTCStatsReport into an array, and answers an
3191 // empty one for a connection that is closing — getStats can reject,
3192 // and on some browsers throw outright, and neither is worth an
3193 // exception in the middle of a renewal.
3194 function statsList(pc) {
3195 try {
3196 return pc.getStats().then((stats) => {
3197 const list = [];
3198 stats.forEach((report) => list.push(report));
3199 return list;
3200 }, () => []);
3201 } catch {
3202 return Promise.resolve([]);
3203 }
3204 }
3205
3206 function stopIceTimers() {
3207 clearTimeout(renewTimer);
3208 clearTimeout(iceRetryTimer);
3209 clearTimeout(expiryTimer);
3210 }
3211
3212 // ---------------------------------------------------------------
3213 // Outbound queue (§4.6)
3214 // ---------------------------------------------------------------
3215
3216 function send(to, type, payload) {
3217 if (!state.peer) return;
3218 // A target the hub has already said it does not have. Queueing for
3219 // it would buy one more 404 and one more recovery round each time,
3220 // and ICE alone produces a dozen candidates a renegotiation; the
3221 // roster is what settles this, and it clears the mark.
3222 if (stale.has(to)) return;
3223 const seq = (seqOut.get(to) || 0) + 1;
3224 seqOut.set(to, seq);
3225 const q = queue.get(to) || [];
3226 q.push({ seq, body: { peer: state.peer, type, to, seq, gen: gen.get(to) || 1, payload } });
3227 queue.set(to, q);
3228 pump(to);
3229 }
3230
3231 // pump owns one POST at a time per target, in order, and does not
3232 // start the next message until this one is accepted. Every status the
3233 // wire contract names has its own arm here.
3234 //
3235 // §4.6's thirty seconds is an absolute deadline per message, stamped
3236 // when the message reaches the head of the queue and covering
3237 // everything that follows: the requests, the backoff between them,
3238 // and the 429s. Each of those used to be able to slip it (Codex
3239 // S2-6) — a 429 skipped the check entirely, a sleep could overshoot
3240 // it, and a fetch that never settled meant the check was never
3241 // reached at all — so a pair could sit "unreachable" for ever with
3242 // nothing rebuilding it. The fetch is aborted at the deadline for
3243 // that last one.
3244 async function pump(to) {
3245 if (inflight.has(to)) return;
3246 inflight.add(to);
3247 let wait = BACKOFF_MIN;
3248 try {
3249 for (;;) {
3250 const q = queue.get(to);
3251 if (!q || !q.length || state.stopped) return;
3252 const head = q[0];
3253 if (!head.deadline) head.deadline = Date.now() + SEND_DEADLINE;
3254 if (Date.now() >= head.deadline) {
3255 unreachable(to);
3256 return;
3257 }
3258 const res = await postSignal(head.body, { deadline: head.deadline });
3259 if (queue.get(to) !== q) return; // the queue was dropped underneath us
3260 // signedOut is read before the status, because it can be true
3261 // *of* a 200: an expired session answers 303 to /signin, fetch
3262 // follows it, and the sign-in page comes back 200 (Codex S2-8).
3263 if (res.signedOut) {
3264 terminal('signedout');
3265 return;
3266 }
3267 if (res.status === 200) {
3268 // Accepted by the hub, which is the only count worth having:
3269 // one taken at `send` counted candidates that a stale target
3270 // dropped on the spot and ones still sitting in a queue
3271 // nobody was pumping (Codex S3-12).
3272 if (head.body.type === 'candidate') {
3273 const entry = pcs.get(to);
3274 if (entry) entry.delivered += 1;
3275 }
3276 q.shift();
3277 wait = BACKOFF_MIN;
3278 continue;
3279 }
3280 if (res.status === 429) {
3281 // Retry-After is whole seconds, and the same message is
3282 // retried with the same seq — but never past this message's
3283 // own deadline.
3284 if (!(await waitWithin(retryAfter(res.retryAfter), head.deadline))) {
3285 unreachable(to);
3286 return;
3287 }
3288 continue;
3289 }
3290 if (res.status === 409) {
3291 if (res.data.error === 'stale') {
3292 // The pair moved on. Drop everything queued for this
3293 // target; a `pair` or the next roster rebuilds it, and if
3294 // the roster already knows a higher generation, use it now.
3295 queue.set(to, []);
3296 const known = rosterGen(to);
3297 if (known > (gen.get(to) || 0)) rebuildPC(to, known);
3298 else requestReset(to);
3299 return;
3300 }
3301 return; // `conflict` belongs to admit, which is not queued here
3302 }
3303 if (res.status === 404) {
3304 // The message's own deadline, not a fresh one: the diagnosis
3305 // and any reset it leads to are part of getting this message
3306 // delivered, and §4.6 gives that thirty seconds in total
3307 // (Codex S2-15).
3308 await recoverFromNotFound(res.data.boot, to, { deadline: head.deadline });
3309 return;
3310 }
3311 if (res.status === 400 || res.status === 413) {
3312 // Malformed, or over 64 KB: never retried. Drop that one
3313 // message and carry on with the rest of the queue — the next
3314 // one gets a deadline of its own, because this one was
3315 // answered.
3316 q.shift();
3317 wait = BACKOFF_MIN;
3318 continue;
3319 }
3320 if (!(await waitWithin(wait, head.deadline))) {
3321 unreachable(to);
3322 return;
3323 }
3324 wait = Math.min(wait * 2, BACKOFF_MAX);
3325 }
3326 } finally {
3327 inflight.delete(to);
3328 // A send() that arrived while this POST was in flight found the
3329 // pump busy and returned; and if the queue was replaced under
3330 // this loop, the loop returned rather than touching the new one.
3331 // Either way what is here now has no pump, so it gets this one.
3332 const rest = queue.get(to);
3333 if (!state.stopped && rest && rest.length) pump(to);
3334 }
3335 }
3336
3337 // unreachable is §4.6's verdict on one target: the message at the head
3338 // of its queue never got through inside its deadline. Close the
3339 // connection, drop what was queued for it, and ask the hub for a fresh
3340 // generation — `pair` or the next roster builds it again.
3341 function unreachable(to) {
3342 queue.set(to, []);
3343 closePC(to, false);
3344 renderTiles();
3345 requestReset(to);
3346 }
3347
3348 // ---------------------------------------------------------------
3349 // The broadcast lane (§4.3's chat and share)
3350 // ---------------------------------------------------------------
3351 //
3352 // `send` above is §4.6: per target, with a seq, a generation and a
3353 // pair to reset when it stops working. Chat and screen share are
3354 // neither targeted nor paired — the hub fans them out to every other
3355 // admitted peer and rebuilds the envelope itself — so they ride a
3356 // queue of their own under the sentinel key '*', with the same pump
3357 // discipline and none of §4.6's bookkeeping: no `to`, no `seq`, no
3358 // `gen` on the wire, and `stale`/`gen` mean nothing here.
3359 //
3360 // The one thing this lane has that the other does not is an answer:
3361 // the chat panel echoes a line into the log only when the hub took
3362 // it, so every message resolves exactly once, and only a 200 resolves
3363 // true.
3364
3365 // broadcastBody is the whole wire shape of an untargeted message. It
3366 // is its own function because what is *not* in it is the contract:
3367 // any `to`, `seq` or `gen` here is a 400 from /signal.
3368 function broadcastBody(peer, type, payload) {
3369 return { peer, type, payload };
3370 }
3371
3372 // broadcastVerdict reads one POST answer, in the wire contract's own
3373 // terms. Pure, so the node test can lift it by name.
3374 function broadcastVerdict(res) {
3375 // signedOut before the status, for the reason pump gives: an
3376 // expired session answers 303 to /signin and fetch follows it, so
3377 // the sign-in page comes back 200.
3378 if (res.signedOut) return 'signedout';
3379 if (res.status === 200) return 'sent';
3380 if (res.status === 429) return 'wait';
3381 if (res.status === 404) return 'recover';
3382 // 400 is a bug in this file, 413 is a message the server will never
3383 // take however often it is offered, and 409 belongs to the targeted
3384 // lane and cannot mean anything here. None of the three is retried.
3385 if (res.status === 400 || res.status === 413 || res.status === 409) return 'drop';
3386 // A 5xx, a network failure, or a status the contract does not name:
3387 // worth trying again inside the deadline.
3388 return 'retry';
3389 }
3390
3391 // accepted is the answer the sender gets, and the only true in it is
3392 // a 200. chat.js echoes a line into the log on exactly `true` and on
3393 // nothing else, so anything looser here would put a message on the
3394 // screen that nobody received.
3395 function accepted(verdict) {
3396 return verdict === 'sent';
3397 }
3398
3399 // recoveryDeadline is the budget a 404's diagnosis actually gets. The
3400 // message's own deadline, unless the POST that produced the 404 spent
3401 // it — a recovery with nothing left decides nothing, leaves the
3402 // participation unexamined, and the tab goes on looking live. Pure, so
3403 // the node test can lift it by name.
3404 function recoveryDeadline(deadline, now) {
3405 return Math.max(deadline, now + MIN_RECOVERY);
3406 }
3407
3408 // broadcast queues one untargeted message and resolves when the hub
3409 // has answered for it: true if it was accepted, false for every other
3410 // ending, including a deadline that ran out with nothing decided.
3411 // `onStatus`, if given, hears the status of the answer that settled it
3412 // — the one thing the boolean cannot say, and what §15.20's report
3413 // needs to tell an old server's 400 from a network that failed.
3414 function broadcast(type, payload, onStatus) {
3415 if (!state.peer || state.stopped) return Promise.resolve(false);
3416 return new Promise((resolve) => {
3417 const q = queue.get(BROADCAST) || [];
3418 q.push({ body: broadcastBody(state.peer, type, payload), resolve, onStatus });
3419 queue.set(BROADCAST, q);
3420 runBroadcast();
3421 });
3422 }
3423
3424 // runBroadcast is the fire-and-forget face of pumpBroadcast: nothing
3425 // awaits the pump, and a rejection escaping an async function nobody
3426 // awaits goes where neither a person nor a gate could see it. Same
3427 // shape as requestReset's, and as the share button's.
3428 function runBroadcast() {
3429 pumpBroadcast().catch((err) => {
3430 console.error('meet: chat', err);
3431 });
3432 }
3433
3434 // pumpBroadcast is pump's discipline for the lane with no target: one
3435 // POST in flight, in order, each message with its own absolute thirty
3436 // seconds covering the requests, the backoff and the 429s.
3437 async function pumpBroadcast() {
3438 if (inflight.has(BROADCAST)) return;
3439 inflight.add(BROADCAST);
3440 let wait = BACKOFF_MIN;
3441 try {
3442 for (;;) {
3443 const q = queue.get(BROADCAST);
3444 if (!q || !q.length) return;
3445 if (state.stopped) {
3446 // Nothing more will be sent, and a sender waiting on a
3447 // promise that never settles is a chat box that never comes
3448 // back. Answer every one of them.
3449 drainBroadcast(q);
3450 return;
3451 }
3452 const head = q[0];
3453 if (!head.deadline) head.deadline = Date.now() + SEND_DEADLINE;
3454 if (Date.now() >= head.deadline) {
3455 // Out of time with nothing decided: the sender is told so, and
3456 // the next message in the lane starts on a budget of its own.
3457 settleBroadcast(q, false);
3458 wait = BACKOFF_MIN;
3459 continue;
3460 }
3461 const res = await postSignal(head.body, { deadline: head.deadline });
3462 if (queue.get(BROADCAST) !== q) return; // the queue was dropped underneath us
3463 const verdict = broadcastVerdict(res);
3464 if (verdict === 'signedout') {
3465 drainBroadcast(q);
3466 terminal('signedout');
3467 return;
3468 }
3469 if (verdict === 'sent' || verdict === 'drop') {
3470 if (typeof head.onStatus === 'function') head.onStatus(res.status);
3471 settleBroadcast(q, accepted(verdict));
3472 wait = BACKOFF_MIN;
3473 continue;
3474 }
3475 if (verdict === 'recover') {
3476 // The participation is gone, or was never admitted. Nothing
3477 // queued here can be delivered by anyone, so every sender is
3478 // answered before the probe-first diagnosis decides what this
3479 // tab does next — with no target to pass it, because a
3480 // fan-out message never had one.
3481 queue.set(BROADCAST, []);
3482 drainBroadcast(q);
3483 await recoverFromNotFound(res.data.boot, null, {
3484 deadline: recoveryDeadline(head.deadline, Date.now()),
3485 });
3486 return;
3487 }
3488 const ms = verdict === 'wait' ? retryAfter(res.retryAfter) : wait;
3489 if (!(await waitWithin(ms, head.deadline))) {
3490 settleBroadcast(q, false);
3491 wait = BACKOFF_MIN;
3492 continue;
3493 }
3494 if (verdict !== 'wait') wait = Math.min(wait * 2, BACKOFF_MAX);
3495 }
3496 } finally {
3497 inflight.delete(BROADCAST);
3498 const rest = queue.get(BROADCAST);
3499 if (rest && rest.length) {
3500 // Something was queued while this pump was busy — during the
3501 // 404's diagnosis, most of the time, which is the one arm that
3502 // returns while the tab may still be alive. If that diagnosis
3503 // ended the session there is nobody left to send it, and a
3504 // sender left holding a promise that never settles is a composer
3505 // that never comes back; otherwise it gets this pump.
3506 if (state.stopped) drainBroadcast(rest);
3507 else runBroadcast();
3508 }
3509 }
3510 }
3511
3512 // settleBroadcast answers the message at the head of the lane and
3513 // drops it.
3514 function settleBroadcast(q, ok) {
3515 const head = q.shift();
3516 if (head && head.resolve) head.resolve(ok);
3517 }
3518
3519 // drainBroadcast answers everything still queued with false: the lane
3520 // is finished, and nothing in it was delivered.
3521 function drainBroadcast(q) {
3522 while (q.length) settleBroadcast(q, false);
3523 }
3524
3525 function rosterGen(id) {
3526 if (!lastRoster) return 0;
3527 for (const rp of lastRoster.peers || []) if (rp.peer === id) return rp.gen || 0;
3528 return 0;
3529 }
3530
3531 function retryAfter(header) {
3532 const n = parseInt(header || '', 10);
3533 return Number.isFinite(n) && n > 0 ? Math.min(n, 30) * 1000 : 1000;
3534 }
3535
3536 function sleep(ms) {
3537 return new Promise((resolve) => setTimeout(resolve, ms));
3538 }
3539
3540 // waitWithin sleeps for ms, or until the deadline, whichever comes
3541 // first, and answers whether there is any budget left afterwards. A
3542 // backoff is not allowed to be the reason a deadline is missed.
3543 async function waitWithin(ms, deadline) {
3544 const left = deadline - Date.now();
3545 if (left <= 0) return false;
3546 await sleep(Math.min(ms, left));
3547 return Date.now() < deadline;
3548 }
3549
3550 // postSignal is the one place this file talks upstream. csrf.Protect
3551 // is origin-based, so a same-origin fetch needs no token.
3552 //
3553 // `deadline` is an absolute instant, not a duration: a request that
3554 // has not settled by then is aborted, so a hanging fetch cannot hold
3555 // a queue open past the moment its caller had promised to give up
3556 // (Codex S2-6). An aborted fetch throws, and lands on the same
3557 // status 0 as any other network failure — which the caller then reads
3558 // as "out of budget" on its next pass.
3559 async function postSignal(body, opts) {
3560 const o = opts || {};
3561 const path = '/m/' + encodeURIComponent(sqid) + '/signal';
3562 let stop = 0;
3563 let controller = null;
3564 if (o.deadline && typeof AbortController === 'function') {
3565 controller = new AbortController();
3566 stop = setTimeout(() => controller.abort(), Math.max(0, o.deadline - Date.now()));
3567 }
3568 try {
3569 const res = await fetch(path, {
3570 method: 'POST',
3571 credentials: 'same-origin',
3572 headers: { 'Content-Type': 'application/json' },
3573 body: JSON.stringify(body),
3574 keepalive: !!o.keepalive,
3575 signal: controller ? controller.signal : undefined,
3576 });
3577 return await readRefusal(res, 200, path);
3578 } catch {
3579 return { status: 0, data: {}, signedOut: false, retryAfter: null };
3580 } finally {
3581 if (stop) clearTimeout(stop);
3582 }
3583 }
3584
3585 // requestReset is the fire-and-forget face of resetPair: every caller
3586 // asks for a reset and carries on, and a rejection escaping an async
3587 // function nobody awaits would go where neither a person nor a gate
3588 // could see it. Same shape as the pageshow handler's.
3589 function requestReset(to, budget) {
3590 resetPair(to, budget).catch((err) => {
3591 console.error('meet: reset', err);
3592 });
3593 }
3594
3595 // resetPair asks the hub for a fresh generation with this target.
3596 // The answer used to be discarded (Codex S2-5), so a 429 or a 5xx
3597 // lost the reset silently — and the reset that follows an outbound
3598 // deadline is the only thing that rebuilds a connection this browser
3599 // has just closed, which left the pair down with nothing coming to
3600 // fix it. Bounded by the same thirty seconds one queued message gets,
3601 // and by the same rules: Retry-After on a 429, doubling backoff on
3602 // the transient failures, terminal on a session that has been signed
3603 // out, and the 404 ladder.
3604 async function resetPair(to, budget) {
3605 const peer = state.peer;
3606 if (!peer) return;
3607 // One reset per target at a time. Two in flight can only ask the hub
3608 // for two generations, and the second is stale before it is sent.
3609 if (resetting.has(to)) return;
3610 resetting.add(to);
3611 try {
3612 await resetLoop(to, peer, budget || Date.now() + SEND_DEADLINE);
3613 } finally {
3614 resetting.delete(to);
3615 }
3616 }
3617
3618 async function resetLoop(to, peer, deadline) {
3619 let wait = BACKOFF_MIN;
3620 for (;;) {
3621 if (state.stopped || state.peer !== peer) return;
3622 if (Date.now() >= deadline) {
3623 // The budget this reset inherited is spent. One more POST would
3624 // be a request made outside the deadline that was supposed to
3625 // cover it, and the target has to be released either way
3626 // (Codex S2-17): the roster is what settles it now.
3627 staleTarget(to, true, deadline);
3628 return;
3629 }
3630 const res = await postSignal({ peer, type: 'reset', to }, { deadline });
3631 if (res.signedOut) {
3632 terminal('signedout');
3633 return;
3634 }
3635 if (res.status === 200) return;
3636 if (res.status === 404) {
3637 // Whatever this 404 turns out to be, it is not worth another
3638 // reset: `fromReset` is what stops the cycle, and the budget is
3639 // the same one, not a fresh thirty seconds (Codex S2-11).
3640 await recoverFromNotFound(res.data.boot, to, { deadline, fromReset: true });
3641 return;
3642 }
3643 if (res.status !== 429 && res.status !== 0 && res.status < 500) {
3644 // 400, 409 and anything else the contract names: a reset that
3645 // is refused on its merits is not retried. The roster is the
3646 // other way this pair converges (§4.6).
3647 return;
3648 }
3649 const ms = res.status === 429 ? retryAfter(res.retryAfter) : wait;
3650 wait = Math.min(wait * 2, BACKOFF_MAX);
3651 // As in the diagnosis: round the loop, and let the check at the
3652 // top be the one place a spent budget is answered.
3653 await waitWithin(ms, deadline);
3654 }
3655 }
3656
3657 // recoverFromNotFound is a 404 from either endpoint. It used to read
3658 // as "this participation no longer exists" and close everything, but
3659 // a 404 from `/signal` says no such thing (Codex S2-3): the *target*
3660 // may simply have left, which is the ordinary case while this
3661 // browser's own stream is down and the `gone` event cannot reach it.
3662 // Acting on that as if it were our own ending closed a live call and
3663 // then met a 409 from our own still-live participation, and showed a
3664 // takeover screen for a tab that does not exist.
3665 //
3666 // So the sender's own participation is probed, and that is the
3667 // authority: 204 keeps the call and lets the next roster reconcile
3668 // the target; 410 is terminal with the reason the tombstone carries;
3669 // 404 — and only 404 — is the fresh-entry ladder, which is what this
3670 // function always was.
3671 //
3672 // A 429 or a 5xx from the probe proves nothing either way, and used
3673 // to fall through to the fresh-entry ladder and re-create exactly the
3674 // failure above (Codex S2-12). It is retried instead, on the stream's
3675 // own backoff, inside one budget shared with whatever asked for the
3676 // diagnosis — a recovery that cannot get an answer inside thirty
3677 // seconds leaves the participation alone and lets the stream ladder,
3678 // which is running anyway, be the thing that decides.
3679 async function recoverFromNotFound(boot, to, opts) {
3680 const o = opts || {};
3681 const deadline = o.deadline || Date.now() + SEND_DEADLINE;
3682 if (state.stopped) return;
3683 if (boot) state.boot = boot;
3684 const peer = state.peer;
3685 if (!peer) {
3686 enterAfresh();
3687 return;
3688 }
3689 const path = '/m/' + encodeURIComponent(sqid) + '/stream?probe=1&peer=' + encodeURIComponent(peer);
3690 let wait = BACKOFF_MIN;
3691 for (;;) {
3692 if (Date.now() >= deadline) {
3693 // Out of budget with nothing decided. The participation is left
3694 // alone — nothing here proved anything about it — but the target
3695 // this recovery was for is treated as stale, so the queue is
3696 // dropped and the in-flight mark released rather than held by a
3697 // diagnosis that is never going to answer (Codex S2-15). The
3698 // next roster settles it.
3699 staleTarget(to, true, deadline);
3700 return;
3701 }
3702 let res;
3703 try {
3704 res = await probe(path, deadline);
3705 } catch {
3706 // The probe could not be made at all: the same "nothing is
3707 // proved" as a 5xx, and treated the same way.
3708 res = { status: 0, data: {}, signedOut: false, retryAfter: null };
3709 }
3710 if (state.stopped || state.peer !== peer) return;
3711 if (refused(res)) return;
3712 if (res.status === 410) {
3713 terminal(res.data.reason || 'ended');
3714 return;
3715 }
3716 if (res.status === 204) {
3717 staleTarget(to, !!o.fromReset, deadline);
3718 return;
3719 }
3720 if (res.status === 404) {
3721 enterAfresh();
3722 return;
3723 }
3724 // 429, 5xx, a network failure, or a status the contract does not
3725 // name: try the diagnosis again rather than acting on it.
3726 const ms = res.status === 429 ? retryAfter(res.retryAfter) : wait;
3727 wait = Math.min(wait * 2, STREAM_BACKOFF_MAX);
3728 // Deliberately not a `return` when the budget runs out here: the
3729 // loop goes round and the check at the top is the one exit for an
3730 // exhausted diagnosis, so it cannot be reached without releasing
3731 // the target (Codex S2-17). Returning straight from this line left
3732 // the queue intact, the pump's `finally` restarted it, its expired
3733 // head called `unreachable`, and that opened a reset with a fresh
3734 // thirty seconds — the bounded recovery this whole path exists to
3735 // be. `waitWithin` does not sleep when there is nothing left, so
3736 // this is not a spin.
3737 await waitWithin(ms, deadline);
3738 }
3739 }
3740
3741 // staleTarget is what a 404 means once the probe has said this
3742 // browser's own participation is fine: the peer we were writing to has
3743 // gone, and our roster has not caught up. Nothing queued for it can be
3744 // delivered, and nothing new may be queued until a roster settles it.
3745 //
3746 // The single reset is worth asking for once, while the roster still
3747 // lists the target, because a `pair` is how a live peer that merely
3748 // lost its own participation comes back. Asking again after that reset
3749 // has itself been answered 404 is the cycle S2-11 found — twelve
3750 // rounds and counting — so `fromReset` skips straight to closing the
3751 // connection and waiting for the roster, which recreates it through
3752 // `reconcile` if the peer is still there.
3753 function staleTarget(to, fromReset, deadline) {
3754 if (!to) return;
3755 queue.set(to, []);
3756 const first = !stale.has(to);
3757 stale.add(to);
3758 if (first && !fromReset && rosterGen(to)) {
3759 // The same budget again: one message, one thirty seconds, however
3760 // many requests it takes to settle (Codex S2-15).
3761 requestReset(to, deadline);
3762 return;
3763 }
3764 closePC(to, false);
3765 renderTiles();
3766 }
3767
3768 // enterAfresh is the fresh-entry ladder: drop every connection, forget
3769 // the peer, and open a new stream. `boot` has already been recorded by
3770 // the caller, so the fresh stream starts from the right one.
3771 function enterAfresh() {
3772 if (state.stopped) return;
3773 closeStream();
3774 closeAllPCs();
3775 state.peer = null;
3776 connectStream();
3777 }
3778
3779 // ---------------------------------------------------------------
3780 // Chat (§6) and screen share (§5.4)
3781 // ---------------------------------------------------------------
3782 //
3783 // The panel is chat.js's: it owns the log, the composer, the two
3784 // sentences it can say and every word on a line. This file owns four
3785 // things about it — the wire underneath it, the name on the local
3786 // echo, when it appears, and when it is usable — and nothing else.
3787 // Nothing below reads inside `#chat`.
3788 //
3789 // Screen share is §5.4 in full: one display capture, carried to every
3790 // connection on a `sendonly` transceiver of its own so the camera
3791 // m-line is untouched, announced with a `share` message so the far
3792 // side can label the tile before the track lands, and stopped by
3793 // stopping the transceiver — which ends the receiver's track rather
3794 // than leaving a frozen last frame on the screen.
3795
3796 const SHARE_REFUSED = 'Screen sharing was not allowed.';
3797
3798 // mountChat hands chat.js the panel and the one thing it cannot know:
3799 // how to put a line on the wire. It is called on admission and on the
3800 // first arriving message, whichever comes first — a `chat` can be
3801 // fanned out to us while §5.2's fetch still has our own roster
3802 // waiting behind it, and a message that reached this tab is a message
3803 // a person should see.
3804 //
3805 // Un-hiding is this file's job and not the panel's: `mount` never
3806 // touches `hidden`, because when a panel is usable is the host's
3807 // fact. And it is only un-hidden if chat.js is actually here — a
3808 // visible composer with no script behind it would submit its form and
3809 // navigate away from the call.
3810 function mountChat() {
3811 if (chat) return chat;
3812 if (!chatEl || !window.Chat || typeof window.Chat.mount !== 'function') return null;
3813 // chat.js answers a root it cannot use with a working no-op rather
3814 // than a throw, which is right for it and wrong to trust here: an
3815 // un-hidden panel backed by a no-op is a composer that swallows
3816 // everything typed into it in silence. These are the three elements
3817 // its own mount requires, so this is the same question asked before
3818 // the answer stops being distinguishable.
3819 if (!chatEl.querySelector('[data-chat-log]') ||
3820 !chatEl.querySelector('[data-chat-form]') ||
3821 !chatEl.querySelector('[data-chat-text]')) {
3822 return null;
3823 }
3824 const panel = window.Chat.mount(chatEl, { name: selfName, send: sendChat });
3825 if (!panel) return null;
3826 chat = panel;
3827 chatEl.hidden = false;
3828 chat.setEnabled(!!state.admitted && !state.stopped);
3829 return chat;
3830 }
3831
3832 // chatEnabled is the panel's gate, and it moves with admission: the
3833 // roster turns it on, terminal and leave turn it off. It is not the
3834 // same thing as `hidden` — a panel that has been usable stays
3835 // readable after the call ends.
3836 function chatEnabled(on) {
3837 if (!chat) return;
3838 chat.setEnabled(!!on);
3839 }
3840
3841 // sendChat is the panel's `send`: chat.js hands it text it has
3842 // already trimmed and measured, and reads back a strict boolean. True
3843 // is the only answer that echoes the line into the log, so anything
3844 // this file is unsure about has to be false.
3845 async function sendChat(text) {
3846 const ok = await broadcast('chat', { text });
3847 if (ok === true) chatSent += 1;
3848 return ok === true;
3849 }
3850
3851 // onChat renders one arrived message (§6). The name comes from the
3852 // envelope and from nowhere else: the hub put the sender's own name
3853 // on it, and a peer that is not in this tab's roster — one admitted
3854 // while our own roster was in flight — is still someone the hub says
3855 // is in the room. The text is re-validated as a string here, because
3856 // what reaches `append` reaches the DOM.
3857 function onChat(msg) {
3858 // Validated before anything is built: a malformed message is not a
3859 // reason to put a panel on the screen.
3860 const text = msg.payload && msg.payload.text;
3861 if (typeof text !== 'string' || !text) return;
3862 const panel = mountChat();
3863 if (!panel) return;
3864 chatReceived += 1;
3865 panel.append({
3866 from: msg.from || '',
3867 name: msg.name || '',
3868 text,
3869 at: Date.now(),
3870 self: false,
3871 });
3872 }
3873
3874 // onShare is the label arriving ahead of the media, or the removal
3875 // arriving ahead of the track's own `ended` — §5.4 says whichever
3876 // comes first wins, and both endings meet in dropShare.
3877 function onShare(msg) {
3878 const from = msg.from;
3879 const entry = from ? pcs.get(from) : null;
3880 if (!entry) return;
3881 const on = !!(msg.payload && msg.payload.on === true);
3882 if (!on) {
3883 dropShare(entry);
3884 return;
3885 }
3886 entry.sharing = true;
3887 renderTiles();
3888 }
3889
3890 // shareKey is the tile key a peer's screen gets: the peer's own key
3891 // with a suffix that no peer id can carry, so one peer holds two
3892 // tiles and every map keyed by tile key — `tiles`, `frames` — keeps
3893 // them apart without a second structure.
3894 function shareKey(id) {
3895 return id + SHARE_SUFFIX;
3896 }
3897
3898 // classifyTrack tells a peer's screen from its camera, which is the
3899 // one judgement §5.4's receiving half turns on. Pure, and lifted by
3900 // name in the node test.
3901 //
3902 // known what this connection has recorded: cameraMid, cameraStream,
3903 // peerStream, sharing
3904 // info the arriving track: {kind, streamId, mid, direction},
3905 // direction being the transceiver's currentDirection
3906 // self this tab: {hasVideo}, a live local video track
3907 //
3908 // "The first video track is the camera" is the rule this started as,
3909 // and it is wrong for one sender that really exists: the audio-only
3910 // rung of startLocal. A peer whose camera was in use or absent has no
3911 // camera m-line at all, so its *screen* is the first video track its
3912 // peers ever see, and the rule would caption it as a face.
3913 //
3914 // So four signals, strongest first.
3915 //
3916 // 1. The transceiver's direction. This connection builds a video
3917 // m-line of its own — sendrecv when it is sending a camera,
3918 // recvonly when it has none to send — so a video track arriving
3919 // `recvonly` on a connection that *is* sending a camera is a track
3920 // on an m-line the far side added, and the only m-line it adds is
3921 // the screen.
3922 // 2. The camera's own mid, once one is known. Assigned once, and it
3923 // survives every renegotiation and ICE restart.
3924 // 3. The camera's stream id, for a browser that reports no mid.
3925 // 4. The stream everything else from this peer has arrived in. A
3926 // sender puts its camera and its microphone in one MediaStream and
3927 // its screen in another, so a video track in a *different* stream
3928 // from the peer's audio is the screen even when no camera has ever
3929 // arrived — which is the audio-only sharer above.
3930 //
3931 // And, when none of the four can speak — a peer that has sent nothing
3932 // else at all — `sharing`, the `share {on:true}` message, breaks the
3933 // tie. It is last on purpose: it says this peer is showing a screen
3934 // somewhere, not that *this* track is it.
3935 //
3936 // One hole is left, and it is left knowingly. A peer that joined with
3937 // no camera *and* no microphone — startLocal's bottom rung, where
3938 // getUserMedia gave nothing at all — has sent this connection nothing
3939 // before its screen, so there is no camera mid, no camera stream and
3940 // no peer stream, and if this tab has no camera either the direction
3941 // says nothing. Everything then rests on the `share` message, which is
3942 // sent before the track and is what the tiebreaker reads. If that
3943 // message were ever lost, that peer's screen would be captioned as its
3944 // face. Nothing in §4.3 loses it — a fan-out message is delivered to
3945 // an admitted peer or the sender is told it failed — so the hole is a
3946 // consequence of the wire, not of this judgement.
3947 function classifyTrack(known, info, self) {
3948 if (info.kind !== 'video') return 'audio';
3949 if (info.direction === 'recvonly' && self.hasVideo) return 'share';
3950 if (known.cameraMid && info.mid) return info.mid === known.cameraMid ? 'camera' : 'share';
3951 if (known.cameraStream && info.streamId) {
3952 return info.streamId === known.cameraStream ? 'camera' : 'share';
3953 }
3954 if (known.peerStream && info.streamId) {
3955 return info.streamId === known.peerStream ? 'camera' : 'share';
3956 }
3957 if (!known.cameraMid && !known.cameraStream) return known.sharing ? 'share' : 'camera';
3958 // A camera is known but there is nothing on this track to compare
3959 // with it: a second video track is the screen.
3960 return 'share';
3961 }
3962
3963 // onRemoteTrack is the `track` handler's whole body. A camera track
3964 // goes on the peer's own tile, exactly as it always did; a screen
3965 // goes on a tile of its own and leaves the camera alone.
3966 function onRemoteTrack(id, entry, ev) {
3967 const track = ev.track;
3968 const stream = (ev.streams && ev.streams.length && ev.streams[0]) || null;
3969 const mid = (ev.transceiver && ev.transceiver.mid) || '';
3970 const tx = ev.transceiver;
3971 const what = classifyTrack(entry, {
3972 kind: track.kind,
3973 streamId: (stream && stream.id) || '',
3974 mid,
3975 // currentDirection is the negotiated one and the one to believe —
3976 // but it is null until an *answer* has been applied, so on the
3977 // side that answers an offer it says nothing at all, which is
3978 // exactly the side a screen arrives at. `direction` is what the
3979 // browser set when it built the transceiver, and a transceiver
3980 // built by setRemoteDescription for an m-line the far side added
3981 // is recvonly. Falling back to it is what makes the rule work in
3982 // both roles.
3983 direction: (tx && (tx.currentDirection || tx.direction)) || '',
3984 }, { hasVideo: !!entry.sentVideo });
3985 if (what === 'share') {
3986 entry.sharing = true;
3987 entry.shareMid = mid || entry.shareMid;
3988 // The stream a tile shows is this file's own, kept for the life
3989 // of the entry, and what goes in it is decided here (Codex S4-9).
3990 // A remote stream is a thing the far side composes: it can carry
3991 // the old track and the new one at once, in whatever order, and
3992 // binding a tile to "its first video track" then shows whichever
3993 // happens to be first. So the role owns a stream, the role's
3994 // authoritative track goes in it, and nothing else of that kind
3995 // stays behind.
3996 const held = (entry.share && entry.share.stream) || new MediaStream();
3997 entry.share = { stream: held, track };
3998 entry.shareTrack = track;
3999 adoptTrack(held, track);
4000 // §5.4's first ending: the sender stopped the transceiver, so
4001 // this track ends rather than freezing. The listener is dropped
4002 // with the connection, and dropShare is idempotent, so the `share
4003 // {on:false}` that usually arrives alongside it costs nothing.
4004 //
4005 // Only for the screen that is still showing, though (Codex
4006 // S4-15). A screen can be *replaced* on its own mid — the far
4007 // side rebuilding it, or a rollback doing it for them — and the
4008 // predecessor ends afterwards, as a superseded track does. Its
4009 // listener is still on the connection, and without this it took
4010 // the live replacement's tile away with it.
4011 track.addEventListener('ended', () => {
4012 if (pcs.get(id) !== entry) return;
4013 if (!entry.share || entry.share.track !== track) return;
4014 dropShare(entry);
4015 });
4016 renderTiles();
4017 return;
4018 }
4019 if (what === 'camera') {
4020 // Recorded from the first video track and never revised: this is
4021 // what every later video track is judged against.
4022 if (mid) entry.cameraMid = mid;
4023 if (stream) entry.cameraStream = stream.id;
4024 // The authoritative camera track, which is what the tile binds
4025 // to. A replacement arriving on the same mid supersedes its
4026 // predecessor whether or not the predecessor has ended.
4027 entry.cameraTrack = track;
4028 }
4029 // Recorded from the first track of any kind, camera or microphone:
4030 // it is what places a video track for a peer that has no camera.
4031 if (stream && !entry.peerStream) entry.peerStream = stream.id;
4032 // This tile's own stream, again: the remote one is only ever read
4033 // for its id. Audio and video are kept apart by `adoptTrack`, so a
4034 // camera replacing a camera leaves the microphone alone.
4035 entry.stream = entry.stream || new MediaStream();
4036 adoptTrack(entry.stream, track);
4037 renderTiles();
4038 }
4039
4040 // adoptTrack makes one arriving track *the* track of its kind in the
4041 // stream a tile shows: every other track of that kind comes out,
4042 // whatever its readyState, and this one goes in. Pure enough to lift
4043 // by name, and gated in the node test.
4044 //
4045 // This is the receiving half of the rollback damage, and its first
4046 // draft got the rule wrong (Codex S4-9). Chromium rebuilds every
4047 // stream on a connection when it rolls back an offer that added an
4048 // m-line — receive streams included — so the far side's camera
4049 // arrives again as a *new* track, sometimes with no stream of its own
4050 // and sometimes inside the very stream that still holds the old one.
4051 // The old one is not necessarily `ended`: a receiver can be
4052 // superseded while its track is still live and simply never carries
4053 // another frame. Pruning only ended tracks therefore left the stream
4054 // holding [old, new], and a media element renders the first track of
4055 // its kind — a face that never moves again on a connection that is
4056 // otherwise perfect: connected, stable, sendrecv, and decoding
4057 // hundreds of frames nobody can see.
4058 //
4059 // Which track is authoritative is `onRemoteTrack`'s judgement, not
4060 // this function's: classifyTrack has already said whether this is the
4061 // camera or the screen, and each role owns a stream of its own. So
4062 // there is no ordering question left here, and none of "is the old
4063 // one finished with" either. Audio is a kind of its own and is
4064 // untouched by a video track arriving.
4065 function adoptTrack(stream, track) {
4066 if (!stream || !track) return stream;
4067 for (const old of stream.getTracks()) {
4068 if (old !== track && old.kind === track.kind) stream.removeTrack(old);
4069 }
4070 if (!stream.getTracks().includes(track)) stream.addTrack(track);
4071 return stream;
4072 }
4073
4074 // shownTrack is the video track a tile is actually rendering: the
4075 // first one in the stream. The tile follows it, not just the stream
4076 // object, because the two can change independently.
4077 function shownTrack(stream) {
4078 if (!stream || !stream.getVideoTracks) return null;
4079 return stream.getVideoTracks()[0] || null;
4080 }
4081
4082 // dropShare removes one peer's screen, from either ending. The frame
4083 // count is deliberately left behind: it says the screen was really
4084 // arriving before it went away, which is exactly what a gate watching
4085 // a stop needs to be able to see afterwards.
4086 function dropShare(entry) {
4087 if (!entry.sharing && !entry.share) return;
4088 entry.sharing = false;
4089 entry.share = null;
4090 entry.shareMid = '';
4091 renderTiles();
4092 }
4093
4094 // hasShare: this peer holds a share tile. The message alone is enough
4095 // — it beats the media by a renegotiation — so the tile can carry the
4096 // label before there is anything to show in it.
4097 function hasShare(entry) {
4098 return !!(entry.sharing || entry.share);
4099 }
4100
4101 function shareCaption(name) {
4102 return name + '’s screen';
4103 }
4104
4105 // startSharing is the button's on half. The capture is asked for
4106 // first: a person who changes their mind at the browser's own picker
4107 // has shared nothing, and nothing should have been announced.
4108 async function startSharing() {
4109 if (share || sharePending || state.stopped || !HAS_DISPLAY_MEDIA) return;
4110 // The picker is open for as long as a person takes to answer it, and
4111 // the button is live the whole time. Without this a second click
4112 // starts a second capture: the peers would be watching the first,
4113 // `share` would point at the second, and Off would stop only the one
4114 // nobody could see — leaving a screen being sent that this tab no
4115 // longer believes in.
4116 sharePending = true;
4117 let stream;
4118 try {
4119 stream = await navigator.mediaDevices.getDisplayMedia({ video: true, audio: false });
4120 } catch {
4121 // Refused at the picker, or not permitted by policy. One
4122 // sentence, and no diagnosis of which.
4123 say(SHARE_REFUSED);
4124 return;
4125 } finally {
4126 sharePending = false;
4127 }
4128 const track = stream.getVideoTracks()[0];
4129 // Read again on this side of the await: a capture that arrives after
4130 // the call ended, or after some other path started one, is a capture
4131 // nothing will ever stop unless it is stopped here.
4132 if (!track || share || state.stopped) {
4133 stopStream(stream);
4134 return;
4135 }
4136 share = { stream, track };
4137 try {
4138 track.contentHint = sharePlan(shareMode()).contentHint;
4139 } catch {
4140 // A browser without contentHint: the sender's degradation
4141 // preference below says the same thing.
4142 }
4143 // The browser's own "Stop sharing" bar ends the track without
4144 // telling this file anything else. §5.4 gives it the same ending as
4145 // the button.
4146 track.addEventListener('ended', () => stopSharing());
4147 for (const entry of pcs.values()) addShareTo(entry);
4148 renderShare();
4149 // The label, not the media: peers use it to draw the tile before
4150 // the renegotiation lands, and the track carries no name of its own.
4151 broadcast('share', { on: true });
4152 }
4153
4154 // addShareTo gives one connection the display track on a transceiver
4155 // of its own. `sendonly` and a stream of its own is what keeps it
4156 // clear of the camera at the far end, and adding it fires
4157 // `negotiationneeded`, so §5.1's perfect negotiation carries the
4158 // offer with no help from here.
4159 function addShareTo(entry) {
4160 if (!share || !entry || entry.shareTx) return;
4161 // Only on a stable connection. A transceiver added mid-negotiation
4162 // is unassociated when the far side's offer arrives, and m-line
4163 // matching will hand it that offer's m-line — so two peers sharing
4164 // at the same moment end up with each one's screen in the place the
4165 // other built for its own. The connection's own signalingstatechange
4166 // brings this back the moment it settles, which is where every
4167 // deferred one is added.
4168 if (entry.pc.signalingState !== 'stable') return;
4169 try {
4170 entry.shareTx = entry.pc.addTransceiver(share.track, {
4171 direction: 'sendonly',
4172 streams: [share.stream],
4173 });
4174 tuneShare(entry);
4175 } catch {
4176 // A browser without addTransceiver, or a connection already
4177 // closing: that peer simply never sees this screen, and the call
4178 // carries on.
4179 }
4180 }
4181
4182 // stopSharing is the off half, and every way out uses it: the button,
4183 // the browser's own stop bar, and the end of the call.
4184 function stopSharing() {
4185 if (!share) return;
4186 const held = share;
4187 share = null;
4188 for (const entry of pcs.values()) removeShareFrom(entry);
4189 renderShare();
4190 broadcast('share', { on: false });
4191 // Last, so the transceivers are stopped and the message is queued
4192 // before the capture goes: stopping the track first would fire its
4193 // own `ended` into a path that has already run.
4194 stopStream(held.stream);
4195 }
4196
4197 // stopStream ends a capture, whether it was ever used or not.
4198 function stopStream(stream) {
4199 if (!stream) return;
4200 for (const t of stream.getTracks()) {
4201 try {
4202 t.stop();
4203 } catch {
4204 // Already stopped.
4205 }
4206 }
4207 }
4208
4209 // removeShareFrom ends one connection's share m-line. `stop()` is
4210 // §5.4's own instruction and the only one that makes the receiver's
4211 // track *end*; the fallback is for a browser whose transceivers
4212 // cannot be stopped, where nulling the sender and removing it at
4213 // least stops the media and renegotiates.
4214 //
4215 // Only ever this connection's own share transceiver, which fix round 2
4216 // pins with a test: a camera that stopped encoding was that round's
4217 // whole hunt, and `removeTrack` on the wrong sender is one of the two
4218 // ways this file could have done that to itself.
4219 function removeShareFrom(entry) {
4220 const tx = entry.shareTx;
4221 if (!tx) return;
4222 entry.shareTx = null;
4223 tuneShare(entry);
4224 try {
4225 if (typeof tx.stop === 'function') {
4226 tx.stop();
4227 return;
4228 }
4229 } catch {
4230 // Fall through to the older way.
4231 }
4232 try {
4233 const sender = tx.sender;
4234 if (!sender) return;
4235 if (typeof sender.replaceTrack === 'function') {
4236 const replaced = sender.replaceTrack(null);
4237 if (replaced && typeof replaced.catch === 'function') replaced.catch(() => {});
4238 }
4239 entry.pc.removeTrack(sender);
4240 } catch {
4241 // A connection that is closing: there is nothing left to remove.
4242 }
4243 }
4244
4245 // renderShare moves the button's state three ways and never by
4246 // colour, exactly as the other two toggles do: aria-pressed, the word
4247 // in the button, and calls.css's inset outline.
4248 function renderShare() {
4249 if (!shareButton) return;
4250 shareButton.setAttribute('aria-pressed', String(!!share));
4251 if (shareState) shareState.textContent = share ? 'On' : 'Off';
4252 if (shareNotice) shareNotice.hidden = !share;
4253 if (shareRecoveryEl) {
4254 const text = share ? shareRecovery : '';
4255 shareRecoveryEl.textContent = text;
4256 shareRecoveryEl.hidden = !text;
4257 }
4258 }
4259
4260 // sharingPeers: the peers holding a share tile, for the gate.
4261 function sharingPeers() {
4262 const out = [];
4263 for (const [id, entry] of pcs) if (hasShare(entry)) out.push(id);
4264 return out;
4265 }
4266
4267 // ---------------------------------------------------------------
4268 // Media state (§15.19): who is muted, whose camera is off
4269 // ---------------------------------------------------------------
4270 //
4271 // State, not an event. The hub keeps each peer's last report and puts
4272 // it in every roster, so what a tile shows is always the roster's word
4273 // or a `media` since; nothing here is replayed or reconstructed. A
4274 // peer that never reports — a client from before this — is unknown,
4275 // and unknown is drawn as neither muted nor camera-off.
4276
4277 // mediaFrom accepts the two booleans and nothing looser, for a roster
4278 // entry or a `media` payload alike: either is missing or not a
4279 // boolean, and the whole report is unknown. §15.20's two optional keys
4280 // are read more forgivingly, because they are news about hearing and
4281 // not about who is muted: an `audioBlocked` that is not exactly true,
4282 // or a `noSoundFrom` entry that is not a peer id, is dropped on its
4283 // own and the rest of the report stands. Pure, and lifted by name in
4284 // the node test.
4285 function mediaFrom(value) {
4286 if (!value || typeof value !== 'object') return null;
4287 if (typeof value.muted !== 'boolean' || typeof value.cameraOff !== 'boolean') return null;
4288 const out = { muted: value.muted, cameraOff: value.cameraOff };
4289 if (value.audioBlocked === true) out.audioBlocked = true;
4290 if (Array.isArray(value.noSoundFrom)) {
4291 const ids = [];
4292 for (const id of value.noSoundFrom) {
4293 if (ids.length >= 15) break;
4294 if (typeof id === 'string' && /^[0-9a-f]{32}$/.test(id) && !ids.includes(id)) ids.push(id);
4295 }
4296 if (ids.length) out.noSoundFrom = ids;
4297 }
4298 if (Array.isArray(value.screenStalled)) {
4299 const ids = [];
4300 for (const id of value.screenStalled) {
4301 if (ids.length >= 15) break;
4302 if (typeof id === 'string' && /^[0-9a-f]{32}$/.test(id) && !ids.includes(id)) ids.push(id);
4303 }
4304 if (ids.length) out.screenStalled = ids;
4305 }
4306 // §15.24: kept even when empty — [] is "I hear nobody yet", where an
4307 // absent list is a client that does not say.
4308 if (Array.isArray(value.hearing)) {
4309 const ids = [];
4310 for (const id of value.hearing) {
4311 if (ids.length >= 15) break;
4312 if (typeof id === 'string' && /^[0-9a-f]{32}$/.test(id) && !ids.includes(id)) ids.push(id);
4313 }
4314 out.hearing = ids;
4315 }
4316 return out;
4317 }
4318
4319 function sameMedia(a, b) {
4320 return !!a && !!b && mediaKey(a) === mediaKey(b);
4321 }
4322
4323 // mediaKey is one string per distinct report, the optional keys
4324 // included, so a change in what this tab can hear is a change that is
4325 // sent. The list is sorted here: the same peers in another order are
4326 // the same report.
4327 function mediaKey(m) {
4328 let key = (m.muted ? 'm' : '-') + (m.cameraOff ? 'c' : '-');
4329 if (m.audioBlocked) key += 'b';
4330 if (m.noSoundFrom && m.noSoundFrom.length) key += ':' + m.noSoundFrom.slice().sort().join(',');
4331 if (m.screenStalled && m.screenStalled.length) key += '/' + m.screenStalled.slice().sort().join(',');
4332 if (Array.isArray(m.hearing)) key += '|' + m.hearing.slice().sort().join(',');
4333 return key;
4334 }
4335
4336 // localMedia is what this tab is actually sending. No microphone at
4337 // all is muted as far as anyone listening can tell, and no camera is
4338 // off; the preference alone would claim a microphone that is not there.
4339 function localMedia() {
4340 const hasAudio = !!(local && local.getAudioTracks().length);
4341 const hasVideo = !!(local && local.getVideoTracks().length);
4342 return { muted: !!prefs.muted || !hasAudio, cameraOff: !!prefs.cameraOff || !hasVideo };
4343 }
4344
4345 // mediaPayload is the report itself: §15.19's two booleans, and
4346 // §15.20's two keys only when they carry bad news. §15.24's `hearing`
4347 // is the exception, and travels whenever it is given, [] included: it
4348 // is good news, and its presence tells the others this tab says who it
4349 // hears. `plain` is a server that has refused the extras once. Pure,
4350 // and lifted by name in the node test.
4351 function mediaPayload(mine, blocked, silentFrom, plain, stalled, heard) {
4352 const out = { muted: !!mine.muted, cameraOff: !!mine.cameraOff };
4353 if (plain) return out;
4354 if (blocked) out.audioBlocked = true;
4355 if (silentFrom && silentFrom.length) out.noSoundFrom = silentFrom.slice(0, 15);
4356 // §15.21: whose screens have stalled here, bad news only, like the rest.
4357 if (stalled && stalled.length) out.screenStalled = stalled.slice(0, 15);
4358 if (Array.isArray(heard)) out.hearing = heard.slice(0, 15);
4359 return out;
4360 }
4361
4362 // reportMedia puts our state on the broadcast lane, once per change,
4363 // and only once admitted: before that the hub refuses it, and the
4364 // roster that admits us is where it is sent from. A refusal forgets
4365 // what was sent, so the next toggle or roster tries again — which
4366 // against a server from before §15.19 is a quiet 400 each time.
4367 //
4368 // A 400 for a report that carried §15.20's keys is a server from
4369 // between the two, and the answer is to stop sending them and say the
4370 // two booleans at once, for the rest of this page's life: being heard
4371 // as muted or not matters more than being heard as unable to hear.
4372 function reportMedia() {
4373 if (!state.admitted || state.stopped || !state.peer) return;
4374 const now = mediaPayload(localMedia(), audioBlocked, noSoundList(), !mediaExtras, screenStalled, hearingList());
4375 const key = mediaKey(now);
4376 if (key === mediaSent) return;
4377 mediaSent = key;
4378 const extras = key !== mediaKey(mediaPayload(now, false, null, true));
4379 let fallBack = false;
4380 broadcast('media', now, (status) => {
4381 if (status === 400 && extras && mediaExtras) {
4382 mediaExtras = false;
4383 fallBack = true;
4384 }
4385 }).then((ok) => {
4386 if (!ok && mediaSent === key) mediaSent = '';
4387 if (fallBack) reportMedia();
4388 });
4389 }
4390
4391 // rosterMedia takes a roster's word on everybody's media. On ours as
4392 // well: missing is a participation the hub has never heard from — a
4393 // fresh join, or a restart — and different is a report lost while the
4394 // stream was down, and either way it is told again.
4395 function rosterMedia(roster) {
4396 for (const rp of roster.peers || []) {
4397 if (!rp || !rp.peer) continue;
4398 if (rp.peer !== state.peer) {
4399 learnMedia(rp.peer, rp.media);
4400 continue;
4401 }
4402 const now = mediaPayload(localMedia(), audioBlocked, noSoundList(), !mediaExtras, screenStalled, hearingList());
4403 mediaSent = sameMedia(mediaFrom(rp.media), now) ? mediaKey(now) : '';
4404 }
4405 reportMedia();
4406 }
4407
4408 // learnMedia records a roster's or a peer event's word on one peer.
4409 // Absent is unknown, and forgets anything older: the roster is the
4410 // authority, and a participation that has not reported has not.
4411 function learnMedia(id, value) {
4412 const m = mediaFrom(value);
4413 if (m) remoteMedia.set(id, m);
4414 else remoteMedia.delete(id);
4415 }
4416
4417 // onMedia is one peer's change. Only a peer we hold a connection to
4418 // is drawn; the roster will carry the rest.
4419 function onMedia(msg) {
4420 const id = msg.from;
4421 const next = mediaFrom(msg.payload);
4422 if (!id || id === state.peer || !next) return;
4423 const before = remoteMedia.get(id) || null;
4424 remoteMedia.set(id, next);
4425 announceMedia(mediaChange(msg.name || names.get(id) || 'Someone', before, next));
4426 markTile(id, next);
4427 answerScreenStalls();
4428 }
4429
4430 // ---------------------------------------------------------------
4431 // The screen watchdogs (§15.21, 2026-09-28)
4432 // ---------------------------------------------------------------
4433 //
4434 // Paul, on the Mac app: "after a while, my screen froze on her end and
4435 // she was no longer able to see my screen. I had to stop and re-share
4436 // again." The receiver can see that happening and the sharer cannot, so
4437 // the receiver says so and the sharer answers with a keyframe — the
4438 // part of a stop and a share again that a frozen picture is missing.
4439
4440 // judgeScreen is the receiver's verdict on one screen, from its decoded
4441 // count this poll: stalled, or not. `seen` is its record, updated here.
4442 // Pure over its arguments, and lifted by name in the node test.
4443 //
4444 // Any change in the count is movement, a smaller one included: a
4445 // screen the sharer has rebuilt arrives on a new receiver whose count
4446 // starts again, and up to 2026-09-30 only a larger count moved, so a
4447 // rebuilt screen that was playing again stayed "stalled" here — and the
4448 // sharer's notice with it — until the new count passed the old.
4449 function judgeScreen(seen, decoded, now, frozen) {
4450 if (!seen.movedAt || (decoded !== seen.decoded && !frozen)) {
4451 seen.decoded = decoded;
4452 seen.movedAt = now;
4453 seen.reattached = false;
4454 return false;
4455 }
4456 const quiet = now - seen.movedAt;
4457 return quiet >= SCREEN_STALL_AFTER && quiet < SCREEN_STALL_GIVE_UP;
4458 }
4459
4460 // reattachDue says, once per stall, when a stalled screen's picture
4461 // should be handed to its element again (§15.22): SCREEN_REATTACH_AFTER
4462 // into the stall. A picture stopped on this side — an element or a
4463 // track Chromium has left muted — is this side's to mend. Pure over
4464 // `seen`, and lifted by name in the node test.
4465 function reattachDue(seen, now) {
4466 const quiet = now - seen.movedAt;
4467 if (seen.reattached || quiet < SCREEN_STALL_AFTER + SCREEN_REATTACH_AFTER || quiet >= SCREEN_STALL_GIVE_UP) {
4468 return false;
4469 }
4470 seen.reattached = true;
4471 return true;
4472 }
4473
4474 // screenLog is one line of the screen's timeline: the console, and the
4475 // last SCREEN_LOG_LINES kept for "Copy call diagnostics".
4476 function screenLog(line) {
4477 const d = new Date();
4478 const pad = (n, w) => String(n).padStart(w || 2, '0');
4479 const stamp = pad(d.getHours()) + ':' + pad(d.getMinutes()) + ':' + pad(d.getSeconds()) + '.' + pad(d.getMilliseconds(), 3);
4480 screenLines.push(stamp + ' ' + line);
4481 if (screenLines.length > SCREEN_LOG_LINES) screenLines.splice(0, screenLines.length - SCREEN_LOG_LINES);
4482 console.info('calls: ' + line);
4483 }
4484
4485 // screenReading is what one inbound screen report says, and
4486 // readingSummary and readingMoved put it in words. Pure, and lifted by
4487 // name in the node test: packets arriving and frames assembled but none
4488 // decoded is a decoder; nothing arriving is the sender or the path.
4489 function screenReading(r, trackMuted) {
4490 return {
4491 mid: r.mid || '',
4492 packets: r.packetsReceived || 0,
4493 lost: r.packetsLost || 0,
4494 received: r.framesReceived || 0,
4495 decoded: r.framesDecoded || 0,
4496 dropped: r.framesDropped || 0,
4497 keyframes: r.keyFramesDecoded || 0,
4498 pli: r.pliCount || 0,
4499 fir: r.firCount || 0,
4500 nack: r.nackCount || 0,
4501 freezes: r.freezeCount || 0,
4502 width: r.frameWidth || 0,
4503 height: r.frameHeight || 0,
4504 decoder: r.decoderImplementation || '',
4505 muted: !!trackMuted,
4506 };
4507 }
4508
4509 function readingSummary(r) {
4510 return 'mid ' + (r.mid || '-') + ', ' + r.width + 'x' + r.height + ', decoder ' + (r.decoder || 'unknown') +
4511 (r.muted ? ', track muted' : '') + '; packets ' + r.packets + ' (lost ' + r.lost + '), frames received ' +
4512 r.received + ', decoded ' + r.decoded + ', dropped ' + r.dropped + ', keyframes ' + r.keyframes +
4513 ', PLI ' + r.pli + ', FIR ' + r.fir + ', NACK ' + r.nack + ', freezes ' + r.freezes;
4514 }
4515
4516 function readingMoved(r, b) {
4517 if (!b || b.mid !== r.mid) return readingSummary(r) + ' — no earlier reading';
4518 const d = (a, z) => (a >= z ? '+' + (a - z) : 'new ' + a);
4519 let verdict = 'decoding';
4520 if (r.packets === b.packets) verdict = 'no packets arriving (the sender or the path)';
4521 else if (r.received === b.received) verdict = 'packets arriving, no frames assembled (loss, or waiting on a keyframe)';
4522 else if (r.decoded === b.decoded) verdict = 'frames arriving, none decoded (the decoder)';
4523 return 'packets ' + d(r.packets, b.packets) + ', frames received ' + d(r.received, b.received) + ', decoded ' +
4524 d(r.decoded, b.decoded) + ', dropped ' + d(r.dropped, b.dropped) + ', keyframes ' + d(r.keyframes, b.keyframes) +
4525 ', PLI ' + d(r.pli, b.pli) + ', NACK ' + d(r.nack, b.nack) + (r.muted ? ', track muted' : '') + ' — ' + verdict;
4526 }
4527
4528 // watchScreens takes one poll's decoded counts, by share key, and
4529 // reports the list when it changes.
4530 //
4531 // `readings` is the same poll's screenReading per share key: kept for
4532 // the diagnostics, and set against the reading from when the screen last
4533 // moved, so a stall is logged with what is and is not still arriving.
4534 function watchScreens(decodedByKey, readings) {
4535 const now = Date.now();
4536 const frozen = now < screenStallTestUntil;
4537 const stalled = [];
4538 for (const key of Array.from(screenSeen.keys())) {
4539 if (!decodedByKey.has(key)) screenSeen.delete(key);
4540 }
4541 for (const key of Array.from(screenReadings.keys())) {
4542 if (!decodedByKey.has(key)) {
4543 screenReadings.delete(key);
4544 screenMoving.delete(key);
4545 }
4546 }
4547 for (const [key, decoded] of decodedByKey) {
4548 const id = key.slice(0, -SHARE_SUFFIX.length);
4549 const entry = pcs.get(id);
4550 if (!entry || !hasShare(entry)) {
4551 screenSeen.delete(key);
4552 continue;
4553 }
4554 let seen = screenSeen.get(key);
4555 if (!seen) {
4556 seen = { decoded: 0, movedAt: 0, reattached: false };
4557 screenSeen.set(key, seen);
4558 }
4559 const reading = readings && readings.get(key);
4560 if (reading) screenReadings.set(key, reading);
4561 if (judgeScreen(seen, decoded, now, frozen)) {
4562 stalled.push(id);
4563 if (reattachDue(seen, now)) {
4564 screenLog('screen from ' + id.slice(0, 6) + ' still stalled: handing its picture to the tile again; ' +
4565 (reading ? readingMoved(reading, screenMoving.get(key)) : 'no reading'));
4566 const t = tiles.get(key);
4567 if (t && t.video) {
4568 screenReattaches += 1;
4569 reattachesBy.set(id, (reattachesBy.get(id) || 0) + 1);
4570 replayTile(key, t, entry, true);
4571 }
4572 }
4573 } else if (reading) {
4574 screenMoving.set(key, reading);
4575 }
4576 }
4577 stalled.sort();
4578 if (stalled.join() === screenStalled.join()) return;
4579 for (const id of stalled) {
4580 if (screenStalled.includes(id)) continue;
4581 const key = shareKey(id);
4582 const reading = screenReadings.get(key);
4583 screenLog('screen from ' + id.slice(0, 6) + ' stalled here: reporting it; since it last moved ' +
4584 (reading ? readingMoved(reading, screenMoving.get(key)) : 'no reading'));
4585 }
4586 for (const id of screenStalled) {
4587 if (stalled.includes(id)) continue;
4588 const reading = screenReadings.get(shareKey(id));
4589 screenLog('screen from ' + id.slice(0, 6) + ' moving here again' + (reading ? ': ' + readingSummary(reading) : ''));
4590 }
4591 screenStalled = stalled;
4592 reportMedia();
4593 }
4594
4595 // answerScreenStalls is the sharer's half: every peer whose report
4596 // names us gets a keyframe from our screen's encoder, and — when
4597 // keyframes do not answer (§15.22) — that connection's screen rebuilt,
4598 // then everyone's shared again from the same capture, and then words
4599 // saying it could not be restored. The quiet notice says which.
4600 function answerScreenStalls() {
4601 const me = state.peer;
4602 const now = Date.now();
4603 let reshare = false;
4604 for (const [id, m] of remoteMedia) {
4605 const asking = !!(share && me && m && m.screenStalled && m.screenStalled.includes(me));
4606 if (!asking) {
4607 const gone = screenAsks.get(id);
4608 if (gone && gone.stage && share) screenLog(id.slice(0, 6) + '\u2019s view restored after ' + stageWords(gone.stage));
4609 screenAsks.delete(id);
4610 continue;
4611 }
4612 const entry = pcs.get(id);
4613 let ask = screenAsks.get(id);
4614 if (!ask) {
4615 ask = { at: 0, tries: 0, restUntil: 0, stage: '', stageAt: 0 };
4616 screenAsks.set(id, ask);
4617 if (entry) entry.shareSentSince = 0;
4618 }
4619 if (entry && now >= ask.restUntil && now - ask.at >= SCREEN_KEYFRAME_EVERY) {
4620 ask.at = now;
4621 ask.tries += 1;
4622 if (ask.tries >= SCREEN_KEYFRAME_TRIES) {
4623 ask.tries = 0;
4624 ask.restUntil = now + SCREEN_KEYFRAME_REST;
4625 }
4626 shareCounts.keyframes += 1;
4627 countShareStep(id, 'keyframes');
4628 screenLog('keyframe for ' + id.slice(0, 6) + ', whose view of the screen stalled');
4629 shareKeyframe(entry).catch(() => {});
4630 }
4631 // Frames left for this person since the last step, or no screen
4632 // negotiated with them at all (a rebuild that never came back).
4633 const negotiated = !!(entry && entry.shareTx && entry.shareSend);
4634 const owed = !negotiated || (entry.shareSentSince || 0) >= SCREEN_ESCALATE_SENT;
4635 const rec = { stage: ask.stage, at: ask.stageAt, lastRebuild: shareRebuiltAt.get(id) || 0 };
4636 const before = rec.stage;
4637 const reshareFree = reshare || !shareResharedAt || now - shareResharedAt >= SCREEN_RESHARE_EVERY;
4638 const action = escalateShare(rec, now, owed, reshareFree);
4639 ask.stage = rec.stage;
4640 ask.stageAt = rec.at;
4641 if (rec.stage !== before && entry) entry.shareSentSince = 0;
4642 if (action === 'rebuild') {
4643 shareRebuiltAt.set(id, now);
4644 shareCounts.rebuilds += 1;
4645 countShareStep(id, 'rebuilds');
4646 screenLog(id.slice(0, 6) + ' still reports a stall after keyframes: rebuilding its screen (new transceiver, same size and mode)');
4647 if (entry) rebuildShareFor(entry);
4648 } else if (action === 'reshare') {
4649 reshare = true;
4650 countShareStep(id, 'reshares');
4651 screenLog(id.slice(0, 6) + ' still reports a stall' + (before === 'rebuilt' ? ' after the rebuild' : ', rebuilt under a minute ago') + ': sharing again');
4652 } else if (action === 'failed') {
4653 shareCounts.failures += 1;
4654 countShareStep(id, 'failures');
4655 screenLog(id.slice(0, 6) + ' still reports a stall and there is nothing left to try: giving up');
4656 }
4657 }
4658 if (reshare) {
4659 shareResharedAt = now;
4660 shareCounts.reshares += 1;
4661 // One share again answers every report in flight. For §15.26 it is
4662 // counted against each of the people it answers: it was done for
4663 // them, though it reached everyone.
4664 for (const [askId, ask] of screenAsks) {
4665 if (ask.stage === 'keyframes' || ask.stage === 'rebuilt') {
4666 countShareStep(askId, 'reshares');
4667 ask.stage = 'reshared';
4668 ask.stageAt = now;
4669 }
4670 }
4671 for (const entry of pcs.values()) entry.shareSentSince = 0;
4672 reshareAll();
4673 }
4674 const recovering = [];
4675 const restarting = [];
4676 const failed = [];
4677 for (const [id, ask] of screenAsks) {
4678 const who = names.get(id) || 'Someone';
4679 if (ask.stage === 'failed') failed.push(who);
4680 else if (ask.stage === 'rebuilt' || ask.stage === 'reshared') restarting.push(who);
4681 else recovering.push(who);
4682 }
4683 const text = noticeWords(recovering.sort(), restarting.sort(), failed.sort());
4684 if (text !== shareRecovery) {
4685 shareRecovery = text;
4686 renderShare();
4687 }
4688 }
4689
4690 // callDiagnostics is "Copy call diagnostics" for the browser: the
4691 // connections, and the screen — how ours is leaving and what the ladder
4692 // has done, every screen arriving here as its statistics have it, and
4693 // the timeline. Names, states and counts only: no media, no addresses,
4694 // no credentials.
4695 //
4696 // §15.26: "Report a problem" sends the same text with `anonymous`, in
4697 // which every person is "peer " and the first six characters of their
4698 // peer id rather than their name — the server turns those into the
4699 // call's random tags before anything is logged. "Copy call
4700 // diagnostics" keeps the names: that text goes only where its owner
4701 // pastes it.
4702 function callDiagnostics(opts) {
4703 const anonymous = !!(opts && opts.anonymous);
4704 const who = (id) => (anonymous ? 'peer ' + id.slice(0, 6) : names.get(id) || 'Someone');
4705 const lines = ['Calls web diagnostics, ' + new Date().toISOString()];
4706 lines.push('Browser: ' + (navigator.userAgent || 'unknown'));
4707 lines.push('');
4708 lines.push('## Call');
4709 lines.push('admitted ' + state.admitted + ', people here ' + (pcs.size + 1) + ', stopped ' + state.stopped);
4710 for (const [id, entry] of pcs) {
4711 lines.push((anonymous ? who(id) : who(id) + ' (' + id.slice(0, 6) + ')') + ': connection ' + entry.pc.connectionState +
4712 ', signalling ' + entry.pc.signalingState + ', ICE ' + entry.pc.iceConnectionState);
4713 }
4714 lines.push('');
4715 lines.push('## Screen');
4716 if (share) {
4717 lines.push('Sharing (' + shareMode() + ')');
4718 for (const [id, entry] of pcs) {
4719 const r = entry.shareSend;
4720 if (!r) {
4721 lines.push('to ' + who(id) + ': ' + (entry.shareTx ? 'negotiating' : 'no screen transceiver'));
4722 continue;
4723 }
4724 lines.push('to ' + who(id) + ': ' + r.width + 'x' + r.height + ' at ' + Math.round(r.fps) + ' fps, encoder ' +
4725 (r.encoder || 'unknown') + ', encoded ' + r.framesEncoded + ', sent ' + r.framesSent + ', keyframes ' +
4726 r.keyFramesEncoded + ', limitation ' + (r.limitation || 'none'));
4727 }
4728 for (const [id, ask] of screenAsks) {
4729 if (ask.stage) lines.push('recovering ' + who(id) + '\u2019s view: ' + ask.stage);
4730 }
4731 } else {
4732 lines.push('Not sharing');
4733 }
4734 lines.push('recoveries: ' + shareCounts.keyframes + ' keyframes, ' + shareCounts.rebuilds + ' rebuilds, ' +
4735 shareCounts.reshares + ' shares again, ' + shareCounts.failures + ' given up');
4736 for (const [key, reading] of screenReadings) {
4737 const id = key.slice(0, -SHARE_SUFFIX.length);
4738 lines.push('from ' + who(id) + ': ' + readingSummary(reading));
4739 }
4740 if (screenStalled.length) lines.push('stalled here: ' + screenStalled.map(who).join(', '));
4741 if (screenReattaches) lines.push('pictures handed to their tiles again here: ' + screenReattaches);
4742 lines.push('');
4743 lines.push('## Screen log (last ' + SCREEN_LOG_LINES + ' lines)');
4744 if (screenLines.length) lines.push(...screenLines);
4745 else lines.push('(nothing logged)');
4746 return lines.join('\n');
4747 }
4748
4749 // ---------------------------------------------------------------
4750 // Quality reports and "Report a problem" (§15.26, 2026-10-03)
4751 // ---------------------------------------------------------------
4752 //
4753 // Paul's ruling: every thirty seconds while admitted, and once more at
4754 // leave, this tab tells the server how its connections went over the
4755 // window — numbers and enums only, per remote peer, read off the stats
4756 // walk pollFrames already makes once a second rather than a walk of its
4757 // own. No name, no email, no title, no meeting, no device label, no
4758 // address and nothing anyone typed: the report names a remote peer only
4759 // by the peer id the server already knows, which it logs as the call's
4760 // random tag in its place. The person can switch the reports off in the
4761 // Devices panel; the server can switch them off for everyone (a 204).
4762 //
4763 // "Report a problem" is report.js's sheet; this file hands it the
4764 // token, the diagnostics with the people named by short peer id, and
4765 // what kind of client this is.
4766
4767 // qualityCeiling is a number's bound in protocol/quality.go, by its JSON
4768 // name, as the server's numberCeiling has it.
4769 function qualityCeiling(name) {
4770 switch (name) {
4771 case 'seconds': case 'reconnectingSeconds': case 'cantHearSeconds': case 'freezeSeconds':
4772 return QUALITY_MAX_SECONDS;
4773 case 'rtt': case 'jitter': case 'jitterBufferMs':
4774 return QUALITY_MAX_MILLIS;
4775 case 'kbps':
4776 return QUALITY_MAX_KBPS;
4777 case 'width': case 'height':
4778 return QUALITY_MAX_PIXELS;
4779 case 'fps':
4780 return QUALITY_MAX_FPS;
4781 default:
4782 return QUALITY_MAX_COUNT;
4783 }
4784 }
4785
4786 // qualitySet writes one number into a report part: finite and not
4787 // negative or it is left out (absent is "could not measure"), clamped
4788 // to its ceiling, and to a tenth, which is all the server keeps.
4789 function qualitySet(obj, name, v) {
4790 if (typeof v !== 'number' || !Number.isFinite(v) || v < 0) return;
4791 obj[name] = Math.round(Math.min(v, qualityCeiling(name)) * 10) / 10;
4792 }
4793
4794 // qualityReading is one connection's getStats walk, kept as the next
4795 // window's starting point: each RTP stream by its report id (a new SSRC
4796 // is a new stream, whose counters start again from nothing) with its
4797 // running counters and what it reads now, and the selected candidate
4798 // pair's kinds, transport and round trip. `classify(r)` names an RTP
4799 // report's stream — 'audio', 'video' or 'screen', or '' to leave it out
4800 // — and a remote-inbound-rtp report (the far side's account of what we
4801 // send) takes the name of the outbound stream it is about. Fields are
4802 // copied by name from the lists below and nothing else is: a
4803 // candidate's address and port never leave this function. Pure, and
4804 // lifted by name in test/browser/room-quality.test.mjs.
4805 function qualityReading(reports, classify) {
4806 const COUNTERS = {
4807 out: ['bytesSent', 'packetsSent'],
4808 remote: ['packetsLost'],
4809 in: ['bytesReceived', 'packetsReceived', 'packetsLost', 'totalSamplesReceived', 'concealedSamples',
4810 'insertedSamplesForDeceleration', 'removedSamplesForAcceleration', 'jitterBufferDelay',
4811 'jitterBufferEmittedCount', 'freezeCount', 'totalFreezesDuration', 'framesDecoded', 'framesDropped'],
4812 };
4813 const GAUGES = {
4814 out: ['frameWidth', 'frameHeight', 'framesPerSecond', 'qualityLimitationReason'],
4815 remote: ['roundTripTime', 'jitter'],
4816 in: ['jitter', 'frameWidth', 'frameHeight', 'framesPerSecond'],
4817 };
4818 const all = [];
4819 const byId = new Map();
4820 if (reports && typeof reports.forEach === 'function') {
4821 reports.forEach((r) => {
4822 if (!r || typeof r !== 'object') return;
4823 all.push(r);
4824 if (r.id) byId.set(r.id, r);
4825 });
4826 }
4827 const streams = new Map();
4828 const keep = (key, dir, kind, r) => {
4829 const counters = {};
4830 const gauges = {};
4831 for (const n of COUNTERS[dir]) if (typeof r[n] === 'number') counters[n] = r[n];
4832 for (const n of GAUGES[dir]) if (typeof r[n] === 'number' || typeof r[n] === 'string') gauges[n] = r[n];
4833 streams.set(key, { dir, kind, counters, gauges });
4834 };
4835 for (const r of all) {
4836 const key = r.type + ':' + (r.id || r.ssrc || '');
4837 if (r.type === 'outbound-rtp' || r.type === 'inbound-rtp') {
4838 const kind = classify(r);
4839 if (kind) keep(key, r.type === 'outbound-rtp' ? 'out' : 'in', kind, r);
4840 } else if (r.type === 'remote-inbound-rtp') {
4841 const sent = r.localId ? byId.get(r.localId) : null;
4842 const kind = sent && sent.type === 'outbound-rtp' ? classify(sent) : '';
4843 if (kind) keep(key, 'remote', kind, r);
4844 }
4845 }
4846 let pair = null;
4847 for (const r of all) {
4848 if (r.type === 'transport' && r.selectedCandidatePairId && byId.has(r.selectedCandidatePairId)) {
4849 pair = byId.get(r.selectedCandidatePairId);
4850 break;
4851 }
4852 }
4853 // Firefox has no transport report; it marks the pair itself.
4854 if (!pair) pair = all.find((r) => r.type === 'candidate-pair' && r.selected === true) || null;
4855 if (!pair) pair = all.find((r) => r.type === 'candidate-pair' && r.nominated && r.state === 'succeeded') || null;
4856 let path = null;
4857 if (pair) {
4858 const kinds = ['host', 'srflx', 'prflx', 'relay'];
4859 const local = byId.get(pair.localCandidateId) || {};
4860 const remote = byId.get(pair.remoteCandidateId) || {};
4861 // A relay candidate's own protocol is the relay's leg to the peer;
4862 // how this browser reaches the relay is relayProtocol, and that is
4863 // the one that says "TURN over TLS".
4864 const transport = local.candidateType === 'relay' && ['udp', 'tcp', 'tls'].includes(local.relayProtocol)
4865 ? local.relayProtocol
4866 : ['udp', 'tcp'].includes(local.protocol) ? local.protocol : 'unknown';
4867 path = {
4868 local: kinds.includes(local.candidateType) ? local.candidateType : 'unknown',
4869 remote: kinds.includes(remote.candidateType) ? remote.candidateType : 'unknown',
4870 protocol: transport,
4871 rtt: typeof pair.currentRoundTripTime === 'number' ? pair.currentRoundTripTime * 1000 : undefined,
4872 };
4873 }
4874 return { streams, path };
4875 }
4876
4877 // qualityPeer is one remote peer's statistics for a window: what moved
4878 // between two readings `seconds` apart, per stream kind and direction.
4879 // Counts are deltas — a stream `prev` does not have counts from zero,
4880 // which is where its counters started — and rates are over the window;
4881 // sizes, frame rates, jitter, round trips and the limitation are what
4882 // the latest reading says. Two streams of one kind (a screen rebuilt on
4883 // a new transceiver during the window) add up. Returns {path, rtt, out,
4884 // in} with whatever parts there is something for. Pure, and lifted by
4885 // name in the node test.
4886 function qualityPeer(prev, now, seconds) {
4887 const sums = new Map();
4888 for (const [key, s] of now.streams) {
4889 const p = prev && prev.streams ? prev.streams.get(key) : null;
4890 const slot = s.dir + ':' + s.kind;
4891 let b = sums.get(slot);
4892 if (!b) {
4893 b = { d: {}, g: {} };
4894 sums.set(slot, b);
4895 }
4896 for (const n of Object.keys(s.counters)) {
4897 const base = p && typeof p.counters[n] === 'number' ? p.counters[n] : 0;
4898 b.d[n] = (b.d[n] || 0) + Math.max(0, s.counters[n] - base);
4899 }
4900 for (const n of Object.keys(s.gauges)) {
4901 const v = s.gauges[n];
4902 if (typeof v === 'string') {
4903 if (!b.g[n] || b.g[n] === 'none') b.g[n] = v;
4904 } else {
4905 b.g[n] = typeof b.g[n] === 'number' ? Math.max(b.g[n], v) : v;
4906 }
4907 }
4908 }
4909 const kbps = (bytes) => (typeof bytes === 'number' && seconds > 0 ? (bytes * 8) / 1000 / seconds : undefined);
4910 const ms = (s) => (typeof s === 'number' ? s * 1000 : undefined);
4911 const limits = ['none', 'cpu', 'bandwidth', 'other'];
4912 const out = {};
4913 const inbound = {};
4914 for (const kind of ['audio', 'video', 'screen']) {
4915 const o = sums.get('out:' + kind);
4916 const ri = sums.get('remote:' + kind);
4917 if (o || ri) {
4918 const s = {};
4919 if (o) {
4920 qualitySet(s, 'kbps', kbps(o.d.bytesSent));
4921 qualitySet(s, 'packets', o.d.packetsSent);
4922 if (kind !== 'audio') {
4923 qualitySet(s, 'width', o.g.frameWidth);
4924 qualitySet(s, 'height', o.g.frameHeight);
4925 qualitySet(s, 'fps', o.g.framesPerSecond);
4926 if (limits.includes(o.g.qualityLimitationReason)) s.limit = o.g.qualityLimitationReason;
4927 }
4928 }
4929 if (ri) {
4930 qualitySet(s, 'lost', ri.d.packetsLost);
4931 qualitySet(s, 'rtt', ms(ri.g.roundTripTime));
4932 qualitySet(s, 'jitter', ms(ri.g.jitter));
4933 }
4934 if (Object.keys(s).length) out[kind] = s;
4935 }
4936 const i = sums.get('in:' + kind);
4937 if (i) {
4938 const s = {};
4939 qualitySet(s, 'kbps', kbps(i.d.bytesReceived));
4940 qualitySet(s, 'packets', i.d.packetsReceived);
4941 qualitySet(s, 'lost', i.d.packetsLost);
4942 qualitySet(s, 'jitter', ms(i.g.jitter));
4943 if (kind === 'audio') {
4944 qualitySet(s, 'samples', i.d.totalSamplesReceived);
4945 qualitySet(s, 'concealed', i.d.concealedSamples);
4946 qualitySet(s, 'inserted', i.d.insertedSamplesForDeceleration);
4947 qualitySet(s, 'removed', i.d.removedSamplesForAcceleration);
4948 if (i.d.jitterBufferEmittedCount > 0) {
4949 qualitySet(s, 'jitterBufferMs', (i.d.jitterBufferDelay / i.d.jitterBufferEmittedCount) * 1000);
4950 }
4951 } else {
4952 qualitySet(s, 'freezes', i.d.freezeCount);
4953 qualitySet(s, 'freezeSeconds', i.d.totalFreezesDuration);
4954 qualitySet(s, 'decoded', i.d.framesDecoded);
4955 qualitySet(s, 'dropped', i.d.framesDropped);
4956 qualitySet(s, 'width', i.g.frameWidth);
4957 qualitySet(s, 'height', i.g.frameHeight);
4958 qualitySet(s, 'fps', i.g.framesPerSecond);
4959 }
4960 if (Object.keys(s).length) inbound[kind] = s;
4961 }
4962 }
4963 const peer = {};
4964 if (now.path) {
4965 peer.path = { local: now.path.local, remote: now.path.remote, protocol: now.path.protocol };
4966 const r = {};
4967 qualitySet(r, 'rtt', now.path.rtt);
4968 if ('rtt' in r) peer.rtt = r.rtt;
4969 }
4970 if (Object.keys(out).length) peer.out = out;
4971 if (Object.keys(inbound).length) peer.in = inbound;
4972 return peer;
4973 }
4974
4975 // The notices a quality report counts (§15.23's "Reconnecting…", for
4976 // the whole call and per person; §15.24's "can't hear you yet"), and
4977 // the reports about sound and screens (§15.20, §15.21), watched five
4978 // times a second by watchReconnect and taken by each report.
4979 function qualityWatchRecord() {
4980 return { at: null, call: { shown: false, count: 0, ms: 0, blocked: false }, peers: new Map() };
4981 }
4982
4983 // qualityWatch is one look. `look` is {call, audioBlocked, peers}, peers
4984 // being peer id -> {reconnecting, cantHear, noSound, noSoundFromUs,
4985 // audioBlocked, screenStalled}, every one a boolean for what is true at
4986 // this moment. A notice counts once each time it appears, and the time
4987 // between two looks counts towards whatever was showing at the first of
4988 // them, up to QUALITY_LOOK_MAX. Pure, and lifted by name in the node
4989 // test.
4990 function qualityWatch(rec, now, look) {
4991 const dt = rec.at === null ? 0 : Math.min(Math.max(0, now - rec.at), QUALITY_LOOK_MAX);
4992 rec.at = now;
4993 const shown = (s, on) => {
4994 if (s.shown) s.ms += dt;
4995 if (on && !s.shown) s.count += 1;
4996 s.shown = !!on;
4997 };
4998 shown(rec.call, look.call);
4999 if (look.audioBlocked) rec.call.blocked = true;
5000 for (const p of rec.peers.values()) p.present = false;
5001 for (const [id, l] of look.peers) {
5002 let p = rec.peers.get(id);
5003 if (!p) {
5004 p = { present: true, reconnecting: { shown: false, count: 0, ms: 0 }, cantHear: { shown: false, count: 0, ms: 0 },
5005 noSound: false, noSoundFromUs: false, audioBlocked: false, screenStalled: false };
5006 rec.peers.set(id, p);
5007 }
5008 p.present = true;
5009 shown(p.reconnecting, l.reconnecting);
5010 shown(p.cantHear, l.cantHear);
5011 for (const k of ['noSound', 'noSoundFromUs', 'audioBlocked', 'screenStalled']) if (l[k]) p[k] = true;
5012 }
5013 for (const p of rec.peers.values()) {
5014 if (p.present) continue;
5015 shown(p.reconnecting, false);
5016 shown(p.cantHear, false);
5017 }
5018 }
5019
5020 // qualityTake is the window's events, in the report's shape — only what
5021 // happened, so a quiet window says nothing — and the record started on
5022 // the next window. A notice still showing goes on counting there, and
5023 // someone who has left is forgotten. Returns {call, peers: Map}. Pure,
5024 // and lifted by name in the node test.
5025 function qualityTake(rec) {
5026 const secs = (ms) => ms / 1000;
5027 const call = {};
5028 if (rec.call.count) qualitySet(call, 'reconnecting', rec.call.count);
5029 if (rec.call.ms) qualitySet(call, 'reconnectingSeconds', secs(rec.call.ms));
5030 if (rec.call.blocked) call.audioBlocked = true;
5031 rec.call.count = 0;
5032 rec.call.ms = 0;
5033 rec.call.blocked = false;
5034 const peers = new Map();
5035 for (const [id, p] of Array.from(rec.peers)) {
5036 const e = {};
5037 if (p.reconnecting.count) qualitySet(e, 'reconnecting', p.reconnecting.count);
5038 if (p.reconnecting.ms) qualitySet(e, 'reconnectingSeconds', secs(p.reconnecting.ms));
5039 if (p.cantHear.ms) qualitySet(e, 'cantHearSeconds', secs(p.cantHear.ms));
5040 for (const k of ['noSound', 'noSoundFromUs', 'audioBlocked', 'screenStalled']) if (p[k]) e[k] = true;
5041 if (Object.keys(e).length) peers.set(id, e);
5042 if (!p.present) {
5043 rec.peers.delete(id);
5044 continue;
5045 }
5046 p.reconnecting.count = 0;
5047 p.reconnecting.ms = 0;
5048 p.cantHear.count = 0;
5049 p.cantHear.ms = 0;
5050 p.noSound = p.noSoundFromUs = p.audioBlocked = p.screenStalled = false;
5051 }
5052 return { call, peers };
5053 }
5054
5055 // qualityReport is the body POSTed to /m/{sqid}/quality
5056 // (protocol.QualityReport). `o.peers` is [{id, prev, now, ladder,
5057 // reattaches, events}]: the two readings, the §15.22 ladder's steps
5058 // toward that person this window ({keyframes, rebuilds, reshares,
5059 // failures}), the times their screen was handed to its tile again, and
5060 // qualityTake's events for them. At most QUALITY_MAX_PEERS, each by a
5061 // well-formed peer id, and none with nothing to say. Steps and events
5062 // that did not happen are left out rather than sent as zeros: the line
5063 // the server writes has a size limit, and absent means none. Pure, and
5064 // lifted by name in the node test.
5065 function qualityReport(o) {
5066 const report = { v: 1 };
5067 if (o.final) report.final = true;
5068 qualitySet(report, 'seconds', Math.max(0.1, o.seconds));
5069 report.client = o.client;
5070 if (o.devices) report.devices = o.devices;
5071 if (o.events && Object.keys(o.events).length) report.events = o.events;
5072 const peers = [];
5073 for (const p of o.peers || []) {
5074 if (peers.length >= QUALITY_MAX_PEERS) break;
5075 if (typeof p.id !== 'string' || !/^[0-9a-f]{32}$/.test(p.id)) continue;
5076 const q = qualityPeer(p.prev, p.now, o.seconds);
5077 const ladder = {};
5078 for (const k of ['keyframes', 'rebuilds', 'reshares', 'failures']) {
5079 if (p.ladder && p.ladder[k] > 0) qualitySet(ladder, k, p.ladder[k]);
5080 }
5081 if (Object.keys(ladder).length) {
5082 q.out = q.out || {};
5083 q.out.screen = Object.assign(q.out.screen || {}, ladder);
5084 }
5085 if (p.reattaches > 0) {
5086 q.in = q.in || {};
5087 q.in.screen = q.in.screen || {};
5088 qualitySet(q.in.screen, 'reattaches', p.reattaches);
5089 }
5090 if (p.events && Object.keys(p.events).length) q.events = p.events;
5091 if (!Object.keys(q).length) continue;
5092 peers.push(Object.assign({ peer: p.id }, q));
5093 }
5094 if (peers.length) report.peers = peers;
5095 return report;
5096 }
5097
5098 // deviceKind is a microphone's or speaker's kind, by its label, which
5099 // never leaves this function: only the enum is reported. A heuristic,
5100 // and an honest one — what it cannot place is 'unknown'. Pure, and
5101 // lifted by name in the node test.
5102 function deviceKind(label) {
5103 const l = String(label || '');
5104 if (!l) return 'unknown';
5105 if (/airpods|bluetooth|beats|buds|hands-?free|bose|jabra|wh-1000|wf-1000|\bbt\b/i.test(l)) return 'bluetooth';
5106 if (/airplay/i.test(l)) return 'airplay';
5107 if (/continuity|iphone microphone/i.test(l)) return 'continuity';
5108 if (/blackhole|loopback|soundflower|virtual|vb-audio|cable (?:in|out)put|zoomaudiodevice|teams audio/i.test(l)) return 'virtual';
5109 if (/usb/i.test(l)) return 'usb';
5110 if (/built-?in|macbook|imac|internal|microphone array|realtek/i.test(l)) return 'built-in';
5111 if (/external (?:microphone|headphones)|headphones|line in|wired/i.test(l)) return 'wired';
5112 return 'unknown';
5113 }
5114
5115 // qualityOn: the person's own switch, on unless they turned it off.
5116 function qualityOn() {
5117 if (qualityLocalOff) return false;
5118 try {
5119 return localStorage.getItem(QUALITY_KEY) !== 'off';
5120 } catch {
5121 return true;
5122 }
5123 }
5124
5125 // countShareStep is one §15.22 step toward one person, for the report.
5126 function countShareStep(id, step) {
5127 let c = shareCountsBy.get(id);
5128 if (!c) {
5129 c = { keyframes: 0, rebuilds: 0, reshares: 0, failures: 0 };
5130 shareCountsBy.set(id, c);
5131 }
5132 c[step] += 1;
5133 }
5134
5135 // qualityKind classifies an RTP report on one connection: audio, the
5136 // camera or the screen. Outbound by the screen transceiver's mid,
5137 // inbound by frameKey, the same split the frame counts use.
5138 function qualityKind(id, entry, r) {
5139 const kind = r.kind || r.mediaType;
5140 if (kind === 'audio') return 'audio';
5141 if (kind !== 'video') return '';
5142 if (r.type === 'outbound-rtp') {
5143 return entry.shareTx && entry.shareTx.mid && r.mid === entry.shareTx.mid ? 'screen' : 'video';
5144 }
5145 const key = frameKey(id, entry, r.mid || '');
5146 if (!key) return '';
5147 return key === id ? 'video' : 'screen';
5148 }
5149
5150 // refreshOutputKind finds the speaker this tab plays through — the one
5151 // chosen, or the system's default — for the next report. Asynchronous
5152 // (enumerateDevices), so a report carries what the last one found.
5153 function refreshOutputKind() {
5154 if (!navigator.mediaDevices || !navigator.mediaDevices.enumerateDevices) return;
5155 navigator.mediaDevices.enumerateDevices().then((devices) => {
5156 const outs = devices.filter((d) => d.kind === 'audiooutput');
5157 const chosen = (HAS_SINK_ID && prefs.speaker && outs.find((d) => d.deviceId === prefs.speaker)) ||
5158 outs.find((d) => d.deviceId === 'default') || outs[0] || null;
5159 qualityOutput = chosen ? deviceKind(chosen.label) : 'unknown';
5160 }, () => {});
5161 }
5162
5163 function reportClient() {
5164 const api = window.CallsReport;
5165 return api && typeof api.client === 'function' ? api.client() : { app: 'web' };
5166 }
5167
5168 // sendQuality closes the window and, when it may, sends its report. The
5169 // window is closed whether or not anything is sent, so the next one
5170 // starts from now whatever the switches said. `final` is the report at
5171 // leave or at the end, sent once with keepalive so it survives the page
5172 // going. Never retried, and never said to the person: a report that
5173 // did not arrive is one fewer line in a log.
5174 function sendQuality(final) {
5175 if (!qualityFrom || (final && qualityFinalSent)) return;
5176 if (final) qualityFinalSent = true;
5177 const now = Date.now();
5178 const seconds = (now - qualityFrom) / 1000;
5179 qualityFrom = now;
5180 const events = qualityTake(qualityEvents);
5181 const peers = [];
5182 for (const [id, entry] of pcs) {
5183 if (!entry.statsReport) continue;
5184 const reading = qualityReading(entry.statsReport, (r) => qualityKind(id, entry, r));
5185 const was = qualityPrev.get(id);
5186 qualityPrev.set(id, { pc: entry.pc, reading });
5187 peers.push({
5188 id,
5189 prev: was && was.pc === entry.pc ? was.reading : null,
5190 now: reading,
5191 ladder: shareCountsBy.get(id) || null,
5192 reattaches: reattachesBy.get(id) || 0,
5193 events: events.peers.get(id) || null,
5194 });
5195 }
5196 for (const id of Array.from(qualityPrev.keys())) if (!pcs.has(id)) qualityPrev.delete(id);
5197 shareCountsBy.clear();
5198 reattachesBy.clear();
5199 refreshOutputKind();
5200 if (!reportToken || qualityServerOff || !qualityOn()) return;
5201 const track = local && local.getAudioTracks()[0];
5202 const report = qualityReport({
5203 final,
5204 seconds,
5205 client: reportClient(),
5206 devices: {
5207 input: !track || track.readyState === 'ended' ? 'none' : deviceKind(track.label),
5208 output: qualityOutput,
5209 },
5210 events: events.call,
5211 peers,
5212 });
5213 let body = JSON.stringify(report);
5214 while (body.length > QUALITY_MAX_BODY && report.peers && report.peers.length) {
5215 report.peers.pop();
5216 body = JSON.stringify(report);
5217 }
5218 quality.sent += 1;
5219 fetch('/m/' + encodeURIComponent(sqid) + '/quality', {
5220 method: 'POST',
5221 credentials: 'same-origin',
5222 headers: { 'Content-Type': 'application/json', 'X-Calls-Report': reportToken },
5223 body,
5224 keepalive: !!final,
5225 }).then((res) => {
5226 quality.status = res.status;
5227 if (res.status === 202) quality.accepted += 1;
5228 if (res.status === 204) qualityServerOff = true;
5229 }, () => {
5230 quality.status = 0;
5231 });
5232 }
5233
5234 // qualityTick runs after each stats walk: the window opens when this
5235 // tab is first admitted, and a report closes it every qualityEvery.
5236 function qualityTick() {
5237 if (state.stopped || !state.admitted) return;
5238 const now = Date.now();
5239 if (!qualityFrom) {
5240 qualityFrom = now;
5241 refreshOutputKind();
5242 return;
5243 }
5244 if (now - qualityFrom >= qualityEvery) sendQuality(false);
5245 }
5246
5247 // qualityLook feeds the event watch what this tab is showing now.
5248 function qualityLook(now) {
5249 const peers = new Map();
5250 for (const id of pcs.keys()) {
5251 const m = remoteMedia.get(id) || null;
5252 const r = connecting.peers.get(id);
5253 const h = hearing.get(id);
5254 peers.set(id, {
5255 reconnecting: !reconnect.call && reconnect.peers.has(id),
5256 cantHear: !!(r && r.shown),
5257 noSound: !!(h && h.silent),
5258 noSoundFromUs: !!(m && state.peer && Array.isArray(m.noSoundFrom) && m.noSoundFrom.includes(state.peer)),
5259 audioBlocked: !!(m && m.audioBlocked),
5260 screenStalled: screenStalled.includes(id),
5261 });
5262 }
5263 qualityWatch(qualityEvents, now, { call: reconnect.call, audioBlocked, peers });
5264 }
5265
5266 // problemOptions is what the sheet needs from this page: the token as
5267 // it stands when Send is pressed, and the diagnostics as they stand
5268 // then, with the people named by short peer id.
5269 function problemOptions(returnFocus) {
5270 return {
5271 sqid,
5272 token: () => reportToken,
5273 diagnostics: () => callDiagnostics({ anonymous: true }),
5274 client: reportClient,
5275 returnFocus,
5276 };
5277 }
5278
5279 // openProblem opens the sheet, or says why it cannot: a page without
5280 // report.js, or a server that never handed this tab a token.
5281 function openProblem(returnFocus) {
5282 const api = window.CallsReport;
5283 if (!api || !reportToken) {
5284 if (deviceNote) note('This call cannot be reported from here.');
5285 return;
5286 }
5287 api.open(problemOptions(returnFocus));
5288 }
5289
5290 // ---------------------------------------------------------------
5291 // "Reconnecting…" (§15.23, 2026-09-30)
5292 // ---------------------------------------------------------------
5293 //
5294 // Paul: "I saw 'Reconnecting' appear occasionally over the video, but
5295 // only for a second … I didn't notice any interruption in the call."
5296 // The Mac app said so the moment its stream ended, and a stream ends on
5297 // purpose every few minutes and is resumed half a second later. Here,
5298 // as there, the words wait for a problem that lasts.
5299
5300 function reconnectRecord() {
5301 return { streamSince: 0, connection: new Map(), audio: new Map(), call: false, callClear: 0,
5302 peers: new Set(), peerClear: new Map() };
5303 }
5304
5305 // reconnectStep is one look: `streamDown`, and `people` (peer id ->
5306 // {connectionBad, audioMissing}) for everyone else. It moves `rec` on:
5307 // rec.call is the whole call's words, rec.peers the tiles that say
5308 // "Reconnecting to …". The native app's ReconnectWatch is its twin.
5309 // Pure, and lifted by name in the node test.
5310 function reconnectStep(rec, now, streamDown, people) {
5311 rec.streamSince = streamDown ? rec.streamSince || now : 0;
5312 for (const id of Array.from(rec.connection.keys())) if (!people.has(id)) rec.connection.delete(id);
5313 for (const id of Array.from(rec.audio.keys())) if (!people.has(id)) rec.audio.delete(id);
5314 const troubled = new Set();
5315 for (const [id, p] of people) {
5316 if (p.connectionBad) {
5317 if (!rec.connection.has(id)) rec.connection.set(id, now);
5318 } else {
5319 rec.connection.delete(id);
5320 }
5321 if (p.audioMissing) {
5322 if (!rec.audio.has(id)) rec.audio.set(id, now);
5323 } else {
5324 rec.audio.delete(id);
5325 }
5326 const connection = rec.connection.has(id) && now - rec.connection.get(id) >= RECONNECT_CONNECTION_AFTER;
5327 const audio = rec.audio.has(id) && now - rec.audio.get(id) >= RECONNECT_AUDIO_AFTER;
5328 if (connection || audio) troubled.add(id);
5329 }
5330 const stream = !!rec.streamSince && now - rec.streamSince >= RECONNECT_STREAM_AFTER;
5331 const everyone = troubled.size >= 2 && troubled.size === people.size;
5332 const wantCall = stream || everyone;
5333 if (wantCall) {
5334 rec.call = true;
5335 rec.callClear = 0;
5336 } else if (rec.call) {
5337 if (!rec.callClear) rec.callClear = now;
5338 if (now - rec.callClear >= RECONNECT_HOLD) {
5339 rec.call = false;
5340 rec.callClear = 0;
5341 }
5342 }
5343 const shown = new Set(Array.from(rec.peers).filter((id) => people.has(id)));
5344 if (!wantCall) {
5345 for (const id of troubled) {
5346 shown.add(id);
5347 rec.peerClear.delete(id);
5348 }
5349 }
5350 for (const id of Array.from(shown)) {
5351 if (!wantCall && troubled.has(id)) continue;
5352 if (wantCall) {
5353 shown.delete(id);
5354 rec.peerClear.delete(id);
5355 continue;
5356 }
5357 if (!rec.peerClear.has(id)) rec.peerClear.set(id, now);
5358 if (now - rec.peerClear.get(id) >= RECONNECT_HOLD) {
5359 shown.delete(id);
5360 rec.peerClear.delete(id);
5361 }
5362 }
5363 for (const id of Array.from(rec.peerClear.keys())) if (!shown.has(id)) rec.peerClear.delete(id);
5364 rec.peers = shown;
5365 }
5366
5367 // ---------------------------------------------------------------
5368 // "… is connecting and can't hear you yet" (§15.24, 2026-10-01)
5369 // ---------------------------------------------------------------
5370 //
5371 // Paul asked for Zoom's "… is connecting to audio and cannot hear you
5372 // yet": the gap after someone joins, before their side is receiving and
5373 // playing our audio, said rather than talked into. Their own report's
5374 // `hearing` is the authority; a client that does not send one is judged
5375 // on what this tab can see of its own audio reaching them.
5376
5377 function connectingRecord() {
5378 return { peers: new Map() };
5379 }
5380
5381 // hearsYou is a peer's report on us: true when its `hearing` lists us,
5382 // false when it has a list and we are not on it, and null when it has
5383 // no list — no report yet, or a client from before §15.24. Pure, and
5384 // lifted by name in the node test.
5385 function hearsYou(media, me) {
5386 if (!media || !Array.isArray(media.hearing)) return null;
5387 return !!me && media.hearing.includes(me);
5388 }
5389
5390 // connectingStep is one look at everyone else: `people` is peer id ->
5391 // {epoch, hearsMe, reported, connected, acked}, where epoch is the pair
5392 // generation (a new one is a new connection, and they cannot hear us on
5393 // it yet), hearsMe is hearsYou's answer, reported whether they have
5394 // made any media report, connected whether the connection to them is
5395 // up, and acked whether their receiver reports say our audio reaches
5396 // them. rec.peers holds {epoch, since, done, shown} per person. Returns
5397 // the people whose words have just gone because they can hear us, for
5398 // "<name> can hear you now". The native app's ConnectingWatch is its
5399 // twin. Pure, and lifted by name in the node test.
5400 //
5401 // Clears when they say they hear us; or, for a client that does not
5402 // say (hearsMe null), once the connection is up and either our audio is
5403 // acknowledged or they have reported without a list. Said only past
5404 // CONNECTING_GRACE, and given up at CONNECTING_CAP whatever happens.
5405 function connectingStep(rec, now, people) {
5406 const cleared = [];
5407 for (const id of Array.from(rec.peers.keys())) if (!people.has(id)) rec.peers.delete(id);
5408 for (const [id, p] of people) {
5409 let r = rec.peers.get(id);
5410 if (!r || r.epoch !== p.epoch) {
5411 r = { epoch: p.epoch, since: now, done: false, shown: false };
5412 rec.peers.set(id, r);
5413 }
5414 if (r.done) continue;
5415 const fallback = p.hearsMe === null && p.connected && (p.acked || p.reported);
5416 if (p.hearsMe === true || fallback) {
5417 if (r.shown) cleared.push(id);
5418 r.done = true;
5419 r.shown = false;
5420 } else if (now - r.since >= CONNECTING_CAP) {
5421 r.done = true;
5422 r.shown = false;
5423 } else {
5424 r.shown = now - r.since >= CONNECTING_GRACE;
5425 }
5426 }
5427 return cleared;
5428 }
5429
5430 // connectingNow: whether a person is still in their first moments, as
5431 // far as we can tell not hearing us — the words may not show yet.
5432 function connectingNow(id) {
5433 const r = connecting.peers.get(id);
5434 return !!r && !r.done;
5435 }
5436
5437 // The words. The ellipsis is drawn apart from them (calls-ellipsis), so
5438 // a screen reader hears the sentence and never the dots.
5439 function connectingWords(name) {
5440 return name + ' is connecting and can\u2019t hear you yet';
5441 }
5442 function canHearWords(name) {
5443 return name + ' can hear you now';
5444 }
5445
5446 // peerNotice is what a person's tile says about them and us, in order:
5447 // in their first moments (`joining`), a more specific report about us
5448 // (their browser blocking sound, or no sound from us) and otherwise
5449 // "connecting" once the grace has passed (`shown`) — "Reconnecting"
5450 // would be wrong of a connection that has never been up; after that,
5451 // §15.23's "Reconnecting to …" first and then the report. Returns
5452 // {kind, text}: kind '' for nothing, 'connecting' for the words with
5453 // the ellipsis. Pure, and lifted by name in the node test.
5454 function peerNotice(name, media, me, muted, joining, shown, reconnecting) {
5455 const report = remoteNotice(name, media, me, muted);
5456 if (joining) {
5457 if (report) return { kind: 'report', text: report };
5458 if (shown) return { kind: 'connecting', text: connectingWords(name) };
5459 return { kind: '', text: '' };
5460 }
5461 if (reconnecting) return { kind: 'reconnecting', text: 'Reconnecting to ' + name + '\u2026' };
5462 if (report) return { kind: 'report', text: report };
5463 return { kind: '', text: '' };
5464 }
5465
5466 // judgeHeard is this tab's half of §15.24 for one connection: whether
5467 // the audio path from that person is established. Once it is, it stays
5468 // so until the connection fails or closes. Established is: the
5469 // connection up, and either their audio arriving — packets received and
5470 // decoded samples coming out — on a live receiver, or, for someone who
5471 // says they are muted, nothing more than that (a muted sender may send
5472 // only silence, or no audio at all, and must not look unheard: the
5473 // moment they unmute, it plays). `f` is {connected, bad, hasReceiver,
5474 // receiverLive, packets, samples, senderMuted}. Pure, and lifted by
5475 // name in the node test.
5476 function judgeHeard(was, f) {
5477 if (f.bad) return false;
5478 if (was) return true;
5479 if (!f.connected) return false;
5480 if (f.senderMuted && (!f.hasReceiver || f.receiverLive)) return true;
5481 return !!(f.receiverLive && f.packets > 0 && f.samples > 0);
5482 }
5483
5484 // hearingList is the `hearing` this tab would report now: everyone
5485 // whose audio path is established and whose tile is playing — not
5486 // refused by an autoplay policy — sorted, at most fifteen.
5487 function hearingList() {
5488 const out = [];
5489 for (const [id, entry] of pcs) {
5490 if (!entry.heard) continue;
5491 const t = tiles.get(id);
5492 if (!t || refusedTiles.has(id)) continue;
5493 if (t.video && t.video.paused && entry.audioLive) continue;
5494 out.push(id);
5495 }
5496 return out.sort().slice(0, 15);
5497 }
5498
5499 // hearingMoved is whether `hearing` has changed in a way worth a report
5500 // (both lists joined with commas): someone heard who was not, or someone
5501 // still here no longer heard. Someone who has left is not news — every
5502 // departure would otherwise cost a report from everyone left — and their
5503 // id rides along, ignored, until the next report for another reason.
5504 // Pure, and lifted by name in the node test.
5505 function hearingMoved(before, now, present) {
5506 const was = new Set(before ? before.split(',') : []);
5507 const is = new Set(now ? now.split(',') : []);
5508 for (const id of is) if (!was.has(id)) return true;
5509 for (const id of was) if (!is.has(id) && present.has(id)) return true;
5510 return false;
5511 }
5512
5513 // watchConnecting feeds connectingStep what this tab knows, and says
5514 // "<name> can hear you now" for anyone whose words have just gone.
5515 function watchConnecting(now) {
5516 const people = new Map();
5517 for (const [id, entry] of pcs) {
5518 const media = remoteMedia.get(id) || null;
5519 const ice = entry.pc.iceConnectionState;
5520 people.set(id, {
5521 epoch: gen.get(id) || 0,
5522 hearsMe: hearsYou(media, state.peer),
5523 reported: !!media,
5524 connected: entry.pc.connectionState === 'connected' || ice === 'connected' || ice === 'completed',
5525 acked: !!entry.acked,
5526 });
5527 }
5528 const was = Array.from(connecting.peers).filter(([, r]) => !r.done).map(([id]) => id).sort().join();
5529 const wasShown = Array.from(connecting.peers).filter(([, r]) => r.shown).map(([id]) => id).sort().join();
5530 const cleared = connectingStep(connecting, now, people);
5531 for (const id of cleared) {
5532 console.info('calls: ' + id.slice(0, 6) + ' can hear us');
5533 announceMedia(canHearWords(names.get(id) || 'Someone'));
5534 }
5535 const is = Array.from(connecting.peers).filter(([, r]) => !r.done).map(([id]) => id).sort().join();
5536 const isShown = Array.from(connecting.peers).filter(([, r]) => r.shown).map(([id]) => id).sort().join();
5537 return is !== was || isShown !== wasShown;
5538 }
5539
5540 // watchReconnect feeds the watch what this tab can see, five times a
5541 // second, and redraws what changed.
5542 function watchReconnect() {
5543 if (state.stopped) return;
5544 const now = Date.now();
5545 const joiningMoved = watchConnecting(now);
5546 const people = new Map();
5547 for (const [id, entry] of pcs) {
5548 // Someone in their first moments has not lost anything yet: §15.24
5549 // speaks for them until it gives up.
5550 if (connectingNow(id)) {
5551 people.set(id, { connectionBad: false, audioMissing: false });
5552 continue;
5553 }
5554 const ice = entry.pc.iceConnectionState;
5555 const rec = hearing.get(id);
5556 const media = remoteMedia.get(id);
5557 people.set(id, {
5558 connectionBad: ice === 'disconnected' || ice === 'failed',
5559 // Two level polls without a packet: stopped, as far as a poll can tell.
5560 audioMissing: !!(rec && rec.packetAt && media && !media.muted && now - rec.packetAt > 500),
5561 });
5562 }
5563 const wasCall = reconnect.call;
5564 const wasPeers = Array.from(reconnect.peers).sort().join();
5565 reconnectStep(reconnect, now, !!streamDownSince && !!state.peer, people);
5566 qualityLook(now);
5567 if (reconnect.call !== wasCall) {
5568 console.info('calls: reconnecting ' + (reconnect.call ? 'shown (a problem that lasted)' : 'cleared'));
5569 renderStatus();
5570 }
5571 if (Array.from(reconnect.peers).sort().join() !== wasPeers || joiningMoved) {
5572 for (const id of pcs.keys()) drawHearing(id);
5573 }
5574 }
5575
5576 // noteShareSent counts the frames that left on this connection's
5577 // screen since its escalation last moved on, across a new transceiver's
5578 // counters starting again from nothing.
5579 function noteShareSent(entry) {
5580 if (!entry.shareSend) return;
5581 const sent = entry.shareSend.framesSent || 0;
5582 const last = entry.shareSentLast || 0;
5583 entry.shareSentSince = (entry.shareSentSince || 0) + (sent >= last ? sent - last : sent);
5584 entry.shareSentLast = sent;
5585 }
5586
5587 // escalateShare is §15.22's ladder for one reporting peer: '' (nothing
5588 // now), 'rebuild', 'reshare' or 'failed', moving `rec` ({stage, at,
5589 // lastRebuild}) on. `owed` is whether frames have left for that person
5590 // since the last step; `reshareFree` whether a share again may happen
5591 // now. The native app's ScreenWatchdog is its twin. Pure, and lifted by
5592 // name in the node test.
5593 function escalateShare(rec, now, owed, reshareFree) {
5594 const move = (stage) => {
5595 rec.stage = stage;
5596 rec.at = now;
5597 };
5598 if (!rec.stage) {
5599 move('keyframes');
5600 return '';
5601 }
5602 if (rec.stage === 'keyframes') {
5603 if (now - rec.at < SCREEN_REBUILD_AFTER || !owed) return '';
5604 if (!rec.lastRebuild || now - rec.lastRebuild >= SCREEN_REBUILD_EVERY) {
5605 move('rebuilt');
5606 rec.lastRebuild = now;
5607 return 'rebuild';
5608 }
5609 } else if (rec.stage === 'rebuilt') {
5610 if (now - rec.at < SCREEN_RESHARE_AFTER || !owed) return '';
5611 } else if (rec.stage === 'reshared') {
5612 if (now - rec.at < SCREEN_GIVE_UP_AFTER || !owed) return '';
5613 move('failed');
5614 return 'failed';
5615 } else {
5616 return '';
5617 }
5618 if (reshareFree) {
5619 move('reshared');
5620 return 'reshare';
5621 }
5622 move('failed');
5623 return 'failed';
5624 }
5625
5626 function stageWords(stage) {
5627 return { keyframes: 'keyframes', rebuilt: 'the rebuild', reshared: 'sharing again', failed: 'giving up' }[stage] || stage;
5628 }
5629
5630 // rebuildShareFor is a stop and share again for one person (§15.22):
5631 // this connection's screen transceiver stopped, and a new one added by
5632 // the connection's own signalingstatechange once that has been
5633 // negotiated — a new encoder here, a new receiver and decoder there, at
5634 // the same size and mode.
5635 function rebuildShareFor(entry) {
5636 if (!share || !entry.shareTx) return;
5637 removeShareFrom(entry);
5638 sayShareAgain();
5639 }
5640
5641 // reshareAll is Stop and Share for everyone without the picker: every
5642 // connection's screen transceiver stopped, and a clone of the captured
5643 // track — the same capture, a new track — added to each as it settles.
5644 function reshareAll() {
5645 if (!share) return;
5646 const held = share;
5647 let track;
5648 try {
5649 track = held.track.clone();
5650 } catch {
5651 return;
5652 }
5653 try {
5654 track.contentHint = held.track.contentHint;
5655 } catch {
5656 // A browser without contentHint.
5657 }
5658 // The browser's own Stop sharing bar ends every clone.
5659 track.addEventListener('ended', () => {
5660 if (share && share.track === track) stopSharing();
5661 });
5662 for (const entry of pcs.values()) removeShareFrom(entry);
5663 share = { stream: new MediaStream([track]), track };
5664 // The old one after: stopping it alone does not fire its own
5665 // `ended`, and the clone keeps the capture running.
5666 try {
5667 held.track.stop();
5668 } catch {
5669 // Already stopped.
5670 }
5671 sayShareAgain();
5672 }
5673
5674 // sayShareAgain repeats `share {on:true}` once a rebuilt screen has had
5675 // time to arrive: its new track comes without a label of its own.
5676 function sayShareAgain() {
5677 setTimeout(() => {
5678 if (share) broadcast('share', { on: true });
5679 }, 2000);
5680 }
5681
5682 // recoveringWords is the sharer's notice. Pure, and lifted by name in
5683 // the node test.
5684 function recoveringWords(people) {
5685 if (!people.length) return '';
5686 if (people.length === 1) return people[0] + '\u2019s view of your screen stalled \u2014 recovering';
5687 if (people.length === 2) {
5688 return people[0] + '\u2019s and ' + people[1] + '\u2019s views of your screen stalled \u2014 recovering';
5689 }
5690 return people.length + ' people\u2019s views of your screen stalled \u2014 recovering';
5691 }
5692
5693 // noticeWords is the notice as §15.22's ladder moves on: recovering,
5694 // then "Restarting your share for Samuel…", then either nothing or
5695 // "Couldn’t restore Samuel’s view — try Stop and Share again". Worst
5696 // news first. The native app's ScreenWords.notice, word for word. Pure,
5697 // and lifted by name in the node test (with recoveringWords).
5698 function noticeWords(recovering, restarting, failed) {
5699 const parts = [];
5700 if (failed.length) {
5701 let whose = failed.length + ' people\u2019s views';
5702 if (failed.length === 1) whose = failed[0] + '\u2019s view';
5703 else if (failed.length === 2) whose = failed[0] + '\u2019s and ' + failed[1] + '\u2019s views';
5704 parts.push('Couldn\u2019t restore ' + whose + ' \u2014 try Stop and Share again');
5705 }
5706 if (restarting.length) {
5707 let who = restarting.length + ' people';
5708 if (restarting.length === 1) who = restarting[0];
5709 else if (restarting.length === 2) who = restarting[0] + ' and ' + restarting[1];
5710 parts.push('Restarting your share for ' + who + '\u2026');
5711 }
5712 const stalled = recoveringWords(recovering);
5713 if (stalled) parts.push(stalled);
5714 return parts.join('. ');
5715 }
5716
5717 // shareKeyframe switches this connection's screen encoding off and on
5718 // again, which WebRTC answers by starting it on a keyframe: there is
5719 // no API that asks for one directly.
5720 async function shareKeyframe(entry) {
5721 const sender = entry.shareTx && entry.shareTx.sender;
5722 if (!sender || !sender.getParameters) return;
5723 const off = sender.getParameters();
5724 if (!off.encodings || !off.encodings.length) return;
5725 off.encodings[0].active = false;
5726 await sender.setParameters(off);
5727 await sleep(150);
5728 const on = sender.getParameters();
5729 if (!on.encodings || !on.encodings.length) return;
5730 on.encodings[0].active = true;
5731 await sender.setParameters(on);
5732 }
5733
5734 // mediaChange is the sentence a change earns, or '' for none. A first
5735 // report is not news — it is somebody arriving the way they arrived —
5736 // so only a known state that moved is said out loud. Pure, and lifted
5737 // by name in the node test.
5738 function mediaChange(name, before, after) {
5739 if (!before || !after) return '';
5740 const parts = [];
5741 if (before.muted !== after.muted) {
5742 parts.push(name + (after.muted ? ' muted their microphone.' : ' unmuted their microphone.'));
5743 }
5744 if (before.cameraOff !== after.cameraOff) {
5745 parts.push(name + (after.cameraOff ? ' turned their camera off.' : ' turned their camera on.'));
5746 }
5747 return parts.join(' ');
5748 }
5749
5750 // spaceKeyAction is what a keydown means for Space-to-mute: 'toggle',
5751 // 'swallow' (the same key repeating while held), or '' when it is not
5752 // ours — another key, a modified Space (those are shortcuts of their
5753 // own), text being composed by an input method, or focus on something
5754 // for which Space is typing or activation. Pure, and lifted by name in
5755 // the node test with spaceBelongsTo.
5756 function spaceKeyAction(ev, target) {
5757 if (!ev || (ev.key !== ' ' && ev.code !== 'Space')) return '';
5758 if (ev.ctrlKey || ev.metaKey || ev.altKey || ev.shiftKey || ev.isComposing) return '';
5759 if (spaceBelongsTo(target)) return '';
5760 return ev.repeat ? 'swallow' : 'toggle';
5761 }
5762
5763 // spaceBelongsTo says whether Space already means something to the
5764 // focused element: text to type into, a control it activates (buttons,
5765 // checkboxes, summaries, links, anything given such a role), a select
5766 // it opens, or a media element's own controls. Unknown is not
5767 // claimed: a tile, the page body, a heading.
5768 function spaceBelongsTo(el) {
5769 if (!el || !el.tagName) return false;
5770 if (el.isContentEditable) return true;
5771 const tag = String(el.tagName).toUpperCase();
5772 if (['INPUT', 'TEXTAREA', 'SELECT', 'BUTTON', 'SUMMARY', 'OPTION', 'IFRAME'].includes(tag)) return true;
5773 const has = (name) => !!(el.hasAttribute && el.hasAttribute(name));
5774 if (tag === 'A' && has('href')) return true;
5775 if ((tag === 'VIDEO' || tag === 'AUDIO') && has('controls')) return true;
5776 const role = String((el.getAttribute && el.getAttribute('role')) || '').trim().toLowerCase();
5777 return [
5778 'button', 'link', 'checkbox', 'switch', 'radio', 'tab', 'menuitem', 'menuitemcheckbox',
5779 'menuitemradio', 'option', 'slider', 'spinbutton', 'combobox', 'textbox', 'searchbox',
5780 'listbox', 'treeitem', 'gridcell', 'scrollbar',
5781 ].includes(role);
5782 }
5783
5784 function announceMedia(text) {
5785 if (!text || !mediaLive) return;
5786 mediaLive.textContent = text;
5787 }
5788
5789 // screenWake keeps the screen on through the call (0.4.10): a call is
5790 // often watched rather than typed into, and a screen dimming and
5791 // locking under it reads as the call having ended. The Screen Wake Lock
5792 // is held from the start of the call until it is left or ends. The
5793 // browser lets it go whenever the tab is hidden, so it is asked for
5794 // again when the tab is visible again, while still wanted; released on
5795 // leaving. Where there is no navigator.wakeLock, or the request is
5796 // refused (a battery saver, a policy), nothing is said: the call works
5797 // as before. Self-contained, so room-wake.test.mjs can lift it.
5798 function screenWake(nav, doc) {
5799 let sentinel = null;
5800 let wanted = false;
5801 let asking = null;
5802 const ask = () => {
5803 if (asking) return asking;
5804 if (!wanted || sentinel || !nav || !nav.wakeLock || doc.visibilityState !== 'visible') return Promise.resolve();
5805 asking = Promise.resolve()
5806 .then(() => nav.wakeLock.request('screen'))
5807 .then((lock) => {
5808 asking = null;
5809 if (!wanted) return lock.release().catch(() => {});
5810 sentinel = lock;
5811 if (lock.addEventListener) {
5812 lock.addEventListener('release', () => { if (sentinel === lock) sentinel = null; });
5813 }
5814 }, () => { asking = null; });
5815 return asking;
5816 };
5817 doc.addEventListener('visibilitychange', () => {
5818 if (doc.visibilityState === 'visible') ask();
5819 });
5820 return {
5821 hold() { wanted = true; return ask(); },
5822 release() {
5823 wanted = false;
5824 const lock = sentinel;
5825 sentinel = null;
5826 return lock ? Promise.resolve(lock.release()).catch(() => {}) : Promise.resolve();
5827 },
5828 get held() { return !!sentinel; },
5829 };
5830 }
5831 const wake = screenWake(navigator, document);
5832
5833 // paintBackdrops draws the shared scr