oficina / meet Public
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 |