initial setup

AuthorKonata <konata@posteo.jp>
Date
Commit9bdf484eb969ffba62c45f56c9ad8907a417575c
7 files changed, 5436 insertions(+)
A.gitignore
@@ -0,0 +1,4 @@
1+static
2+server/*
3+!server/*.c
4+!server/*.h
AContainerfile
@@ -0,0 +1,20 @@
1+# Multi-stage build for the WebRTC signaling server.
2+# Stage 1: compile signal.c statically against musl so the runtime image
3+# doesn't need libc at all.
4+FROM docker.io/library/alpine:3.20 AS build
5+RUN apk add --no-cache gcc musl-dev
6+WORKDIR /src
7+COPY server/signal.c ./
8+RUN cc -O3 -Wall -Wextra -Werror -static signal.c -o signal
9+
10+# Stage 2: minimal runtime. Keep alpine (instead of scratch) so the
11+# entrypoint can use /bin/sh + cp to publish index.html into the bind-
12+# mounted /static directory on startup.
13+FROM docker.io/library/alpine:3.20
14+RUN apk add --no-cache curl
15+COPY --from=build /src/signal /usr/local/bin/signal
16+COPY index.html /opt/static/index.html
17+COPY entrypoint.sh /usr/local/bin/entrypoint.sh
18+RUN chmod +x /usr/local/bin/entrypoint.sh
19+EXPOSE 8080
20+ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
AREADME.md
@@ -0,0 +1,204 @@
1+# WebRTC Tool
2+
3+A single-page tool for direct peer-to-peer audio, video, chat, and file
4+transfer between two browsers, using WebRTC for the media/data path. The
5+client is one self-contained `index.html` with no build step. An optional
6+signaling server (~500 lines of C, POSIX `poll`, no dependencies) lets
7+peers connect by sharing a short room code instead of pasting SDP blobs.
8+
9+## What it does
10+
11+- **Two-peer call.** Microphone, camera, screen share. Standard WebRTC.
12+- **Chat** over a text data channel.
13+- **File transfer** over a separate data channel with backpressure,
14+ cancellable mid-upload.
15+- **Manual signaling.** Generate an offer, copy/paste it to the other
16+ peer, paste back their answer. No backend required.
17+- **Auto signaling.** Optional: both peers enter the same room code and
18+ the signaling server relays the offer/answer for them. Media itself
19+ still flows peer-to-peer.
20+- **Loopback mode.** Run both peers in the same tab for testing.
21+- **Stats** (RTP, codec, bandwidth) and an in-page console.
22+
23+## How it works
24+
25+WebRTC requires the two peers to exchange Session Description Protocol
26+(SDP) blobs (offer/answer) before media can flow. After that exchange,
27+the connection is peer-to-peer; the signaling channel is no longer used.
28+
29+This tool offers two ways to do that exchange:
30+
31+1. **Manual.** The initiator generates an offer (a few KB of text),
32+ sends it to the joiner by any means (email, chat, etc.). The joiner
33+ pastes it, generates an answer, sends it back, and the initiator
34+ pastes it. No server involved.
35+2. **Auto.** Both peers enter the same room code into the signaling
36+ server's UI. The server stores the offer briefly, hands it to the
37+ other peer when they ask, and is forgotten as soon as the answer
38+ has been delivered. Rooms expire after 5 minutes of inactivity.
39+
40+The signaling server understands four endpoints:
41+
42+```
43+POST /room/<code>/offer POST /room/<code>/answer
44+GET /room/<code>/offer GET /room/<code>/answer
45+GET /health
46+```
47+
48+Long-polling: a `GET` that arrives before the matching blob exists is
49+parked for up to 10 s and answered with `204 No Content` on timeout; the
50+client retries. As soon as the matching `POST` arrives, the parked
51+request is woken with `200` and the SDP body. After the answer has been
52+delivered to the initiator, the room is dropped.
53+
54+## Usage
55+
56+### Standalone (no backend)
57+
58+The full feature set works without the backend, using manual signaling.
59+Each peer just needs to load `index.html`; they don't need to load it
60+from the same place. Options:
61+
62+- Send the file to the other peer and have them open it from disk
63+ (`file://…`).
64+- Host it on any static web server (a personal site, GitHub Pages,
65+ Netlify, S3, etc.) and share the URL.
66+- For local testing on one machine, serve it on loopback:
67+ `python3 -m http.server 8000` and open `http://localhost:8000/`.
68+
69+Then both peers pick **Manual** mode in the configure screen, the
70+initiator generates the offer and sends the blob to the joiner (email,
71+chat, etc.), and the joiner sends the answer back the same way.
72+
73+For mic/camera/screen-share to work, the page must be loaded from a
74+secure context: either `https://…`, `http://localhost`, or `file://`
75+(some browsers, with limits). On plain `http://` to a remote host,
76+browsers will refuse to grant media access.
77+
78+### With the signaling backend
79+
80+The backend is needed only if you want auto signaling (peers exchange
81+SDPs via a room code rather than copy-paste). It does **not** see or
82+proxy media — that's still peer-to-peer.
83+
84+```
85+podman compose up -d # builds the image and starts the server on :8080
86+# or: docker compose up -d
87+```
88+
89+After startup, `./static/index.html` appears on the host (copied out of
90+the image). Point your own web server at `./static` to serve the page.
91+
92+Or just build and run the binary directly:
93+
94+```
95+cc -O2 -Wall -Wextra -Werror server/signal.c -o signal
96+./signal 8080
97+```
98+
99+### Loopback (testing)
100+
101+In the configure screen, pick **Loopback** and click Continue. Both
102+peers run in the same tab over a local RTCPeerConnection pair. Useful
103+for trying chat/file transfer/codec settings without a second device.
104+
105+## Self-hosting at a single URL
106+
107+For a production setup with both the HTML and the signaling server on
108+the same origin (no CORS, no `Server URL` field to fill in), reverse-
109+proxy `/room/*` and `/health` to the backend and serve everything else
110+as static files.
111+
112+The backend binds to `127.0.0.1:8080` (or whatever you mapped in
113+`compose.yml`). Configs below assume the static files live at
114+`/var/www/webrtc` and the backend listens on `127.0.0.1:8080`.
115+
116+### Caddy
117+
118+```caddy
119+example.com {
120+ encode zstd gzip
121+ root * /var/www/webrtc
122+
123+ @signaling path /room/* /health
124+ reverse_proxy @signaling 127.0.0.1:8080
125+
126+ file_server
127+}
128+```
129+
130+### nginx
131+
132+```nginx
133+server {
134+ listen 443 ssl http2;
135+ server_name example.com;
136+
137+ ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
138+ ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
139+
140+ root /var/www/webrtc;
141+ index index.html;
142+
143+ # Signaling backend: long-polled, must disable response buffering
144+ # so the 204/200 reaches the browser as soon as the server writes it.
145+ location ~ ^/(room/|health$) {
146+ proxy_pass http://127.0.0.1:8080;
147+ proxy_http_version 1.1;
148+ proxy_set_header Host $host;
149+ proxy_buffering off;
150+ proxy_read_timeout 30s;
151+ }
152+
153+ location / {
154+ try_files $uri $uri/ =404;
155+ }
156+}
157+```
158+
159+### lighttpd
160+
161+```lighttpd
162+server.modules += ( "mod_proxy" )
163+server.document-root = "/var/www/webrtc"
164+index-file.names = ( "index.html" )
165+
166+$HTTP["url"] =~ "^/(room/|health$)" {
167+ proxy.server = ( "" =>
168+ (( "host" => "127.0.0.1", "port" => 8080 ))
169+ )
170+ proxy.header = ( "upgrade" => "disable" )
171+}
172+```
173+
174+## Configuration notes
175+
176+- **HTTPS is required for production.** Browsers gate microphone,
177+ camera, and screen-share behind a secure context. The page works in
178+ receive-only mode without media access, but most users will want at
179+ least one side to publish.
180+- **Same-origin removes the CORS path entirely.** When the HTML and
181+ signaling are served from the same scheme+host+port, the browser
182+ doesn't issue preflights and the `Server URL` field in the configure
183+ screen can be left at its default (`location.origin`).
184+- **`http://localhost` is treated as secure** by Chromium and Firefox,
185+ so local development with mic/cam works without certificates.
186+- **Server capacity is hard-coded in `server/signal.c`:** 1024 rooms.
187+ Rooms expire after 5 minutes of inactivity and are also deleted immediately
188+ once the answer reaches the initiator; a GC sweep runs every 60 s.
189+ Each connection pre-allocates a ~68 KB request buffer, so the BSS reaches
190+ ~140 MB — Linux only touches the pages on demand, so an idle server
191+ uses around 10 MB resident.
192+
193+## Repository layout
194+
195+```
196+.
197+├── index.html # client (one self-contained page, no build)
198+├── server/
199+│ └── signal.c # signaling server (~500 lines, POSIX poll, no deps)
200+├── Containerfile # multi-stage build, static-musl binary, alpine runtime
201+├── compose.yml # podman/docker compose: builds image, exposes :8080
202+├── entrypoint.sh # copies index.html into the bind-mounted /static
203+└── README.md
204+```
Acompose.yml
@@ -0,0 +1,18 @@
1+services:
2+ webrtc-tool:
3+ build:
4+ context: .
5+ ports:
6+ - "8080:8080"
7+ volumes:
8+ # Bind-mount a host directory the entrypoint will drop index.html into.
9+ # Point your own web server (nginx/caddy/python -m http.server / etc.)
10+ # at this directory to serve the page. Anything else placed here is
11+ # preserved; only index.html is overwritten on container start.
12+ - ./static:/static
13+ healthcheck:
14+ test: ["CMD-SHELL", "curl --show-error --silent http://localhost:8080/health -o /dev/null"]
15+ interval: 30s
16+ timeout: 5s
17+ retries: 3
18+ start_period: 5s
Aentrypoint.sh
@@ -0,0 +1,9 @@
1+#!/bin/sh
2+# Publish the bundled index.html into the (typically bind-mounted) /static
3+# directory so the host's web server can pick it up, then start the
4+# signaling server. Always overwrites so a rebuilt image updates the file.
5+set -e
6+if [ -d /static ] && [ -w /static ]; then
7+ cp -f /opt/static/index.html /static/index.html
8+fi
9+exec /usr/local/bin/signal "${PORT:-8080}"
Aindex.html
@@ -0,0 +1,4555 @@
1+<!doctype html>
2+<html lang="en" data-theme="dark">
3+<head>
4+<meta charset="utf-8">
5+<meta name="viewport" content="width=500">
6+<title>WebRTC Tool</title>
7+<style>
8+ :root {
9+ --bg: #0d1117;
10+ --bg-elev: #161b22;
11+ --bg-elev-2: #1f2630;
12+ --border: #30363d;
13+ --border-strong: #484f58;
14+ --text: #e6edf3;
15+ --text-dim: #8b949e;
16+ --text-faint: #6e7681;
17+ --accent: #2f81f7;
18+ --accent-fg: #ffffff;
19+ --ok: #3fb950;
20+ --warn: #d29922;
21+ --err: #f85149;
22+ --shadow: 0 8px 24px rgba(0,0,0,0.5);
23+ --radius: 10px;
24+ --radius-sm: 6px;
25+ --mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
26+ --sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
27+ }
28+ html[data-theme="light"] {
29+ --bg: #ffffff;
30+ --bg-elev: #f6f8fa;
31+ --bg-elev-2: #eaeef2;
32+ --border: #d0d7de;
33+ --border-strong: #afb8c1;
34+ --text: #1f2328;
35+ --text-dim: #59636e;
36+ --text-faint: #818b98;
37+ --accent: #0969da;
38+ --accent-fg: #ffffff;
39+ --ok: #1a7f37;
40+ --warn: #9a6700;
41+ --err: #cf222e;
42+ --shadow: 0 8px 24px rgba(140,149,159,0.2);
43+ }
44+ * { box-sizing: border-box; }
45+ html, body { height: 100%; margin: 0; overflow: hidden; }
46+ body {
47+ background: var(--bg);
48+ color: var(--text);
49+ font-family: var(--sans);
50+ font-size: 14px;
51+ line-height: 1.5;
52+ -webkit-font-smoothing: antialiased;
53+ }
54+ a { color: var(--accent); }
55+ code, kbd, pre { font-family: var(--mono); font-size: 12.5px; }
56+ button {
57+ font-family: inherit;
58+ font-size: inherit;
59+ background: var(--bg-elev-2);
60+ color: var(--text);
61+ border: 1px solid var(--border);
62+ border-radius: var(--radius-sm);
63+ padding: 7px 14px;
64+ cursor: pointer;
65+ transition: background 120ms, border-color 120ms, transform 80ms;
66+ }
67+ button:hover { background: var(--bg-elev); border-color: var(--border-strong); }
68+ button:active { transform: translateY(1px); }
69+ button:disabled { opacity: 0.5; cursor: not-allowed; }
70+ /* Firefox adds an invisible inner border/padding to buttons via the
71+ ::-moz-focus-inner pseudo-element that shrinks the usable content box
72+ and makes our buttons render visibly smaller than in Chromium. */
73+ button::-moz-focus-inner { border: 0; padding: 0; }
74+ button.primary { background: var(--accent); color: var(--accent-fg); border-color: transparent; }
75+ button.primary:hover { filter: brightness(1.1); }
76+ button.danger { background: var(--err); color: white; border-color: transparent; }
77+ button.danger:hover { filter: brightness(1.1); }
78+ button.ghost { background: transparent; }
79+ button.icon { padding: 7px 10px; }
80+ input[type="text"], input[type="number"], input[type="url"], select, textarea {
81+ font-family: inherit;
82+ font-size: inherit;
83+ background: var(--bg);
84+ color: var(--text);
85+ border: 1px solid var(--border);
86+ border-radius: var(--radius-sm);
87+ padding: 7px 10px;
88+ outline: none;
89+ transition: border-color 120ms;
90+ }
91+ input:focus, select:focus, textarea:focus { border-color: var(--accent); }
92+ textarea { display: block; width: 100%; font-family: var(--mono); font-size: 12.5px; resize: vertical; min-height: 110px; }
93+ label.row { display: flex; align-items: center; gap: 8px; padding: 4px 0; }
94+ label.row input[type="checkbox"] { accent-color: var(--accent); }
95+ label.field { display: block; margin-bottom: 12px; }
96+ label.field > span { display: block; font-size: 12px; color: var(--text-dim); margin-bottom: 4px; }
97+ .hidden { display: none !important; }
98+
99+ /* Inline icon: inherits the surrounding text color, doesn't shrink in flex
100+ rows, never hijacks pointer events from its parent button. */
101+ .ic { flex: none; display: inline-block; vertical-align: middle; pointer-events: none; }
102+
103+ /* Layout — body never scrolls; each view either scrolls internally
104+ (welcome/configure/exchange) or is overflow:hidden (call). */
105+ #app { height: 100%; display: flex; flex-direction: column; overflow: hidden; }
106+ .view { flex: 1; min-height: 0; display: flex; flex-direction: column; }
107+ .view.hidden { display: none; }
108+ #view-welcome, #view-configure, #view-exchange, #view-sdp-inspect { overflow-y: auto; }
109+
110+ /* SDP inspector */
111+ .sdp-section { background: var(--bg-elev); border: 1px solid var(--border); border-radius: var(--radius); padding: 14px 18px; margin-bottom: 14px; }
112+ .sdp-section > .sdp-head { display: flex; gap: 10px; align-items: baseline; flex-wrap: wrap; margin-bottom: 8px; }
113+ .sdp-section > .sdp-head h3 { margin: 0; text-transform: none; letter-spacing: 0; font-size: 14px; color: var(--text); }
114+ .sdp-section > .sdp-head .sdp-sub { color: var(--text-dim); font-size: 12px; font-family: var(--mono); }
115+ .sdp-kv { display: grid; grid-template-columns: 170px 1fr; gap: 4px 12px; font-size: 13px; }
116+ .sdp-kv .k { color: var(--text-dim); }
117+ .sdp-kv .v { font-family: var(--mono); word-break: break-all; }
118+ .sdp-kv .v.code { font-family: var(--mono); }
119+ .sdp-help { color: var(--text-dim); font-size: 12px; margin: 6px 0 0; font-style: italic; }
120+ .sdp-list { list-style: none; padding: 0; margin: 6px 0 0; display: grid; gap: 4px; font-size: 12.5px; font-family: var(--mono); }
121+ .sdp-list li { padding: 4px 8px; border-radius: var(--radius-sm); background: var(--bg-elev-2); }
122+ .sdp-list li .tag { display: inline-block; min-width: 36px; padding: 0 6px; border-radius: 8px; background: var(--bg-elev); color: var(--text-dim); margin-right: 8px; text-align: center; }
123+ .sdp-list li .badge { display: inline-block; padding: 0 6px; border-radius: 8px; font-size: 11px; margin-left: 6px; background: rgba(99,158,255,0.12); color: var(--accent); }
124+ .sdp-list li .dim { color: var(--text-dim); }
125+ details.sdp-rawblock { margin-top: 12px; }
126+ details.sdp-rawblock > summary { font-size: 12px; color: var(--text-dim); cursor: pointer; user-select: none; }
127+ details.sdp-rawblock pre { margin: 8px 0 0; padding: 10px; background: var(--bg-elev-2); border-radius: var(--radius-sm); font-size: 12px; white-space: pre-wrap; word-break: break-word; }
128+ .topbar {
129+ min-height: 48px; flex: none;
130+ display: flex; align-items: center; justify-content: space-between;
131+ gap: 8px;
132+ padding: 6px 16px;
133+ border-bottom: 1px solid var(--border);
134+ background: var(--bg-elev);
135+ }
136+ .topbar .brand { font-weight: 600; letter-spacing: 0.2px; min-width: 0; }
137+ .topbar .brand small { color: var(--text-faint); font-weight: 400; margin-left: 8px; }
138+ .topbar .right { display: flex; gap: 6px; align-items: center; flex-shrink: 0; }
139+ /* Theme toggle shows the current theme: moon in dark mode, sun in light. */
140+ html[data-theme="light"] #theme-toggle .theme-icon-moon { display: none; }
141+ html:not([data-theme="light"]) #theme-toggle .theme-icon-sun { display: none; }
142+ @media (max-width: 720px) {
143+ .topbar { padding: 6px 10px; }
144+ }
145+ .role-badge {
146+ display: inline-block; padding: 2px 8px; border-radius: 12px;
147+ background: var(--bg-elev-2); color: var(--text-dim);
148+ font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px;
149+ }
150+ .role-badge.initiator { background: rgba(47,129,247,0.15); color: var(--accent); }
151+ .role-badge.joiner { background: rgba(63,185,80,0.15); color: var(--ok); }
152+ .role-badge.loopback { background: rgba(210,153,34,0.15); color: var(--warn); }
153+
154+ .container { max-width: 880px; width: 100%; margin: 0 auto; padding: 32px 24px; }
155+ .container.wide { max-width: 1280px; }
156+ h1 { font-size: 24px; margin: 0 0 8px; }
157+ h2 { font-size: 16px; margin: 24px 0 12px; padding-bottom: 6px; border-bottom: 1px solid var(--border); }
158+ h3 { font-size: 13px; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-dim); margin: 16px 0 8px; }
159+ .lede { color: var(--text-dim); margin-bottom: 24px; }
160+ .card {
161+ background: var(--bg-elev);
162+ border: 1px solid var(--border);
163+ border-radius: var(--radius);
164+ padding: 18px;
165+ margin-bottom: 16px;
166+ }
167+ .card h2:first-child { margin-top: 0; }
168+ details { margin-bottom: 16px; }
169+ details > summary { cursor: pointer; padding: 10px 14px; background: var(--bg-elev); border: 1px solid var(--border); border-radius: var(--radius-sm); user-select: none; }
170+ details[open] > summary { border-bottom-left-radius: 0; border-bottom-right-radius: 0; }
171+ details > .details-body { border: 1px solid var(--border); border-top: none; border-radius: 0 0 var(--radius-sm) var(--radius-sm); padding: 14px; background: var(--bg-elev); }
172+
173+ /* Collapsible card: <details class="card card-collapsible"> */
174+ details.card-collapsible { padding: 0; margin-bottom: 16px; background: var(--bg-elev); }
175+ details.card-collapsible > summary {
176+ list-style: none;
177+ cursor: pointer;
178+ padding: 14px 18px;
179+ user-select: none;
180+ display: flex;
181+ align-items: center;
182+ gap: 10px;
183+ background: transparent;
184+ border: none;
185+ border-radius: var(--radius);
186+ }
187+ details.card-collapsible > summary::-webkit-details-marker { display: none; }
188+ details.card-collapsible > summary::before {
189+ content: '';
190+ width: 12px; height: 12px;
191+ background: var(--text-dim);
192+ -webkit-mask: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'><polyline points='9 6 15 12 9 18'/></svg>") center/contain no-repeat;
193+ mask: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'><polyline points='9 6 15 12 9 18'/></svg>") center/contain no-repeat;
194+ flex: none;
195+ transition: transform 120ms ease;
196+ }
197+ details.card-collapsible[open] > summary::before { transform: rotate(90deg); }
198+ details.card-collapsible > summary h2 {
199+ margin: 0;
200+ padding: 0;
201+ border-bottom: 0;
202+ display: inline-block;
203+ }
204+ details.card-collapsible[open] > summary {
205+ border-bottom: 1px solid var(--border);
206+ border-radius: var(--radius) var(--radius) 0 0;
207+ }
208+ details.card-collapsible > .card-body { padding: 14px 18px 18px; }
209+
210+ /* Prominent room code "hero" callout. */
211+ .room-code-hero {
212+ background: rgba(47,129,247,0.08);
213+ border: 1px solid rgba(47,129,247,0.35);
214+ border-radius: var(--radius);
215+ padding: 18px 20px;
216+ margin: 8px 0 4px;
217+ }
218+ .room-code-hero .hero-label {
219+ display: block;
220+ font-size: 12px;
221+ font-weight: 600;
222+ color: var(--accent);
223+ text-transform: uppercase;
224+ letter-spacing: 0.6px;
225+ margin-bottom: 10px;
226+ }
227+ .room-code-input-row { display: flex; gap: 8px; align-items: stretch; }
228+ .room-code-input-row input {
229+ flex: 1;
230+ font-family: var(--mono);
231+ font-size: 15px;
232+ font-weight: 600;
233+ padding: 9px 12px;
234+ letter-spacing: 0.5px;
235+ text-transform: lowercase;
236+ background: var(--bg);
237+ }
238+ .room-code-input-row button {
239+ padding: 0 14px;
240+ font-size: 16px;
241+ background: var(--bg);
242+ }
243+ .room-code-hero .hero-help { margin: 10px 0 0; font-size: 12px; color: var(--text-dim); }
244+ .room-code-hero .hero-help code { background: rgba(47,129,247,0.12); padding: 1px 4px; border-radius: 3px; }
245+
246+ .grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
247+ .opus-list { display: flex; flex-direction: column; gap: 14px; }
248+ .opus-item { display: flex; flex-direction: column; gap: 2px; }
249+ .opus-item > .small { margin: 0 0 0 22px; color: var(--text-dim); }
250+ .grid-3 { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 12px; }
251+ @media (max-width: 720px) {
252+ .grid-2, .grid-3 { grid-template-columns: 1fr; }
253+ }
254+ .actions { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; margin-top: 16px; }
255+ .role-picker { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; margin-top: 16px; }
256+ .role-picker button {
257+ padding: 22px;
258+ text-align: left;
259+ border-radius: var(--radius);
260+ border: 1px solid var(--border);
261+ background: var(--bg-elev);
262+ display: flex; flex-direction: column; gap: 6px;
263+ transition: border-color 120ms, transform 120ms;
264+ }
265+ .role-picker button:hover { border-color: var(--accent); transform: translateY(-1px); }
266+ .role-picker .role-title { font-size: 16px; font-weight: 600; }
267+ .role-picker .role-desc { color: var(--text-dim); font-size: 13px; }
268+ .role-picker .role-loopback { grid-column: 1 / -1; }
269+
270+ /* Two-option segmented toggle (signaling mode). */
271+ .seg-toggle { display: inline-flex; border: 1px solid var(--border); border-radius: var(--radius-sm); overflow: hidden; margin: 4px 0 10px; }
272+ .seg-toggle button {
273+ border: none; border-radius: 0; background: transparent; color: var(--text-dim);
274+ padding: 8px 14px; font-weight: 500;
275+ }
276+ .seg-toggle button + button { border-left: 1px solid var(--border); }
277+ .seg-toggle button.active { background: rgba(47,129,247,0.15); color: var(--accent); }
278+ .seg-toggle button:hover:not(.active) { background: var(--bg-elev-2); }
279+
280+ .ice-row { display: grid; grid-template-columns: 2fr 1fr 1fr auto; gap: 8px; margin-bottom: 6px; }
281+ .ice-row input { width: 100%; }
282+ @media (max-width: 720px) { .ice-row { grid-template-columns: 1fr; } }
283+
284+ .blob-area { display: flex; flex-direction: column; gap: 8px; }
285+ .blob-area textarea { min-height: 180px; }
286+ .blob-controls { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; margin-top: 10px; }
287+ .pill { padding: 2px 8px; border-radius: 12px; font-size: 11px; background: var(--bg-elev-2); color: var(--text-dim); }
288+ .pill:empty { display: none; }
289+ .pill.ok { background: rgba(63,185,80,0.15); color: var(--ok); }
290+ .pill.warn { background: rgba(210,153,34,0.15); color: var(--warn); }
291+ .pill.err { background: rgba(248,81,73,0.15); color: var(--err); }
292+
293+ .progress { height: 6px; background: var(--bg-elev-2); border-radius: 3px; overflow: hidden; margin: 8px 0; }
294+ .progress > div { height: 100%; background: var(--accent); width: 0%; transition: width 200ms ease; }
295+
296+ /* Call view — flex:1 inside #app fills viewport minus the topbar; nothing
297+ scrolls except internal panes (chat log, files list, settings, stats). */
298+ #view-call { display: flex; flex-direction: column; overflow: hidden; min-height: 0; }
299+ .call-body {
300+ flex: 1; min-height: 0; min-width: 0;
301+ display: grid;
302+ grid-template-columns: 1fr 360px;
303+ grid-template-rows: minmax(0, 1fr);
304+ overflow: hidden;
305+ }
306+ @media (max-width: 900px) {
307+ .call-body { grid-template-columns: 1fr; grid-template-rows: minmax(0, 1fr) minmax(0, 1fr); }
308+ }
309+ .video-area {
310+ background: #000;
311+ position: relative;
312+ display: grid;
313+ grid-template-columns: 1fr 1fr;
314+ grid-template-rows: minmax(0, 1fr);
315+ gap: 8px;
316+ padding: 12px;
317+ min-height: 0;
318+ min-width: 0;
319+ overflow: hidden;
320+ }
321+ .video-tile { position: relative; background: #050608; border-radius: var(--radius); overflow: hidden; min-height: 0; display: flex; align-items: center; justify-content: center; }
322+ .video-tile > video { width: 100%; height: 100%; object-fit: contain; background: #050608; display: block; }
323+ .video-tile.screen > video { cursor: zoom-in; }
324+ .video-tile .tile-label {
325+ position: absolute; left: 8px; bottom: 8px;
326+ background: rgba(0,0,0,0.55); color: white;
327+ padding: 3px 8px; border-radius: 12px; font-size: 11px;
328+ letter-spacing: 0.4px;
329+ pointer-events: none;
330+ }
331+ .video-tile .empty-state {
332+ position: absolute; inset: 0;
333+ display: none; align-items: center; justify-content: center;
334+ color: var(--text-faint);
335+ pointer-events: none;
336+ }
337+ .video-tile.empty .empty-state { display: flex; }
338+ .video-tile.empty > video { display: none; }
339+ .video-tile .mic-muted {
340+ position: absolute;
341+ bottom: 8px; right: 8px;
342+ background: rgba(0,0,0,0.55);
343+ color: white;
344+ border-radius: 50%;
345+ width: 28px; height: 28px;
346+ display: flex; align-items: center; justify-content: center;
347+ pointer-events: none;
348+ z-index: 2;
349+ }
350+ /* When a PIP is visible in the bottom-right corner, move the mic-muted
351+ badge to the top-right so they don't overlap. */
352+ .video-tile:has(.pip:not(.hidden)) .mic-muted { bottom: auto; top: 8px; }
353+ .video-tile .mic-muted.hidden { display: none; }
354+ .video-tile .pip {
355+ position: absolute;
356+ right: 10px; bottom: 10px;
357+ width: 22%; max-width: 200px; min-width: 120px;
358+ aspect-ratio: 16 / 9;
359+ border-radius: 6px;
360+ overflow: hidden;
361+ background: #050608;
362+ border: 1px solid rgba(255,255,255,0.15);
363+ box-shadow: 0 4px 12px rgba(0,0,0,0.5);
364+ z-index: 1;
365+ }
366+ .video-tile .pip.hidden { display: none; }
367+ .video-tile .pip video { width: 100%; height: 100%; object-fit: cover; background: #050608; display: block; }
368+ @media (max-width: 900px) {
369+ .video-area { grid-template-columns: 1fr; grid-template-rows: repeat(2, minmax(0, 1fr)); }
370+ }
371+
372+ .sidebar { background: var(--bg-elev); border-left: 1px solid var(--border); display: flex; flex-direction: column; min-height: 0; min-width: 0; overflow: hidden; }
373+ .tabs { display: flex; border-bottom: 1px solid var(--border); }
374+ .tabs button {
375+ flex: 1;
376+ border: none;
377+ background: transparent;
378+ border-radius: 0;
379+ border-bottom: 2px solid transparent;
380+ padding: 12px 10px;
381+ color: var(--text-dim);
382+ }
383+ .tabs button.active { color: var(--text); border-bottom-color: var(--accent); }
384+ .tab-pane { flex: 1; overflow-y: auto; padding: 14px; min-height: 0; display: none; }
385+ .tab-pane.active { display: flex; flex-direction: column; }
386+
387+ .toolbar {
388+ flex: none;
389+ display: flex; gap: 8px; justify-content: center; align-items: center;
390+ flex-wrap: wrap;
391+ padding: 10px 16px;
392+ background: var(--bg-elev);
393+ border-top: 1px solid var(--border);
394+ }
395+ .toolbar button { padding: 0 16px; height: 40px; min-width: 44px; display: flex; align-items: center; justify-content: center; gap: 6px; line-height: 1; }
396+ .toolbar button > * { line-height: 1; }
397+ .toolbar #tb-mic, .toolbar #tb-cam, .toolbar #tb-screen { min-width: 140px; }
398+ .toolbar .spacer { flex: 1; }
399+ .toolbar button.on { background: rgba(47,129,247,0.15); border-color: var(--accent); color: var(--accent); }
400+ .toolbar button.off { background: rgba(248,81,73,0.12); border-color: var(--err); color: var(--err); }
401+
402+ /* Device picker: chevron sits flush next to its primary toolbar button. */
403+ .toolbar .device-picker { position: relative; display: flex; gap: 2px; }
404+ .toolbar .device-chevron { min-width: 28px; padding: 0 6px; }
405+ .toolbar .device-chevron::before {
406+ content: '';
407+ width: 12px; height: 12px;
408+ background: currentColor;
409+ -webkit-mask: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'><polyline points='18 15 12 9 6 15'/></svg>") center/contain no-repeat;
410+ mask: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'><polyline points='18 15 12 9 6 15'/></svg>") center/contain no-repeat;
411+ transition: transform 120ms ease;
412+ }
413+ .toolbar .device-chevron[aria-expanded="true"]::before { transform: rotate(180deg); }
414+ .toolbar .device-chevron[aria-expanded="true"] { background: rgba(47,129,247,0.15); border-color: var(--accent); color: var(--accent); }
415+ .device-menu {
416+ position: absolute; bottom: calc(100% + 6px); left: 0;
417+ min-width: 240px; max-width: min(360px, calc(100vw - 24px));
418+ max-height: 50vh; overflow: auto;
419+ background: var(--bg-elev); border: 1px solid var(--border); border-radius: 6px;
420+ padding: 6px; z-index: 50;
421+ box-shadow: 0 8px 24px rgba(0,0,0,0.4);
422+ display: flex; flex-direction: column; gap: 2px;
423+ }
424+ .device-menu.hidden { display: none; }
425+ .device-menu .device-row {
426+ display: flex; align-items: center; gap: 8px;
427+ padding: 6px 8px; border-radius: 4px; cursor: pointer;
428+ font-size: 13px; line-height: 1.3;
429+ }
430+ .device-menu .device-row:hover { background: rgba(255,255,255,0.05); }
431+ .device-menu .device-row input { margin: 0; flex: none; }
432+ .device-menu .device-row .device-label { flex: 1; word-break: break-word; }
433+ .device-menu .device-row .device-active {
434+ color: var(--accent); font-size: 11px; flex: none;
435+ display: inline-flex; align-items: center; gap: 4px;
436+ }
437+ .device-menu .device-empty { padding: 6px 8px; font-size: 12px; font-style: italic; color: var(--muted, #888); }
438+ @media (max-width: 720px) {
439+ /* Drop the spacer so the row collapses to its content, shrink button
440+ padding/widths so all five buttons fit on one line at 500 px wide. */
441+ .toolbar { padding: 8px 10px; gap: 6px; }
442+ .toolbar .spacer { display: none; }
443+ .toolbar button { padding: 0 10px; }
444+ .toolbar #tb-mic, .toolbar #tb-cam, .toolbar #tb-screen { min-width: 0; }
445+ .toolbar .device-chevron { min-width: 24px; padding: 0 4px; }
446+ }
447+
448+ /* Chat */
449+ .chat-log { flex: 1; overflow: auto; padding-right: 4px; display: flex; flex-direction: column; gap: 8px; min-height: 0; }
450+ .chat-msg { padding: 8px 10px; border-radius: var(--radius-sm); background: var(--bg-elev-2); max-width: 80%; word-wrap: break-word; }
451+ .chat-msg.me { align-self: flex-end; background: rgba(47,129,247,0.15); }
452+ .chat-msg .meta { font-size: 10px; color: var(--text-faint); margin-top: 3px; }
453+ .chat-input { display: flex; gap: 6px; margin-top: 8px; }
454+ .chat-input input { flex: 1; }
455+ .chat-counter { color: var(--text-faint); margin-top: 4px; text-align: right; font-variant-numeric: tabular-nums; }
456+ .chat-counter.over { color: var(--err); }
457+
458+ /* Files */
459+ .files-drop {
460+ border: 2px dashed var(--border-strong);
461+ border-radius: var(--radius);
462+ padding: 18px;
463+ text-align: center;
464+ color: var(--text-dim);
465+ margin-bottom: 12px;
466+ transition: border-color 120ms, background 120ms;
467+ }
468+ .files-drop.over { border-color: var(--accent); background: rgba(47,129,247,0.06); }
469+ .files-drop.disabled { opacity: 0.5; pointer-events: none; }
470+ .files-section-head { display: flex; align-items: center; justify-content: space-between; margin-top: 16px; }
471+ .files-section-head h3 { margin: 0; }
472+ .file-item { position: relative; padding: 8px 28px 8px 10px; background: var(--bg-elev-2); border-radius: var(--radius-sm); margin-bottom: 6px; font-size: 12px; }
473+ .file-item .name { font-weight: 500; }
474+ .file-item .meta { color: var(--text-dim); font-size: 11px; }
475+ .file-item .row-close {
476+ position: absolute; top: 4px; right: 4px;
477+ background: transparent; border: none; color: var(--text-faint);
478+ width: 22px; height: 22px; padding: 0; border-radius: 4px;
479+ cursor: pointer; font-size: 14px; line-height: 1;
480+ }
481+ .file-item .row-close:hover { background: var(--bg-elev); color: var(--text); }
482+
483+ /* Stats */
484+ .stats-table { width: 100%; border-collapse: collapse; font-family: var(--mono); font-size: 12px; }
485+ .stats-table td { padding: 4px 6px; border-bottom: 1px solid var(--border); }
486+ .stats-table td:first-child { color: var(--text-dim); width: 45%; }
487+
488+ /* Modal dialog (native <dialog>) — used for peer-left notification. */
489+ dialog {
490+ background: var(--bg-elev);
491+ color: var(--text);
492+ border: 1px solid var(--border-strong);
493+ border-radius: var(--radius);
494+ padding: 22px 24px;
495+ max-width: 420px;
496+ box-shadow: 0 12px 40px rgba(0,0,0,0.5);
497+ }
498+ dialog::backdrop { background: rgba(0,0,0,0.55); }
499+ dialog h2 { margin: 0 0 8px; padding: 0; border: none; font-size: 17px; }
500+ dialog p { margin: 0 0 16px; color: var(--text-dim); }
501+ .dialog-actions { display: flex; justify-content: flex-end; gap: 8px; }
502+ .step-dialog-head { display: flex; align-items: center; gap: 10px; margin-bottom: 6px; }
503+ .step-dialog-head h2 { margin: 0; }
504+ .step-dialog-room {
505+ display: flex; align-items: center; gap: 10px;
506+ margin: 0 0 16px;
507+ padding: 10px 12px;
508+ background: var(--bg-elev-2);
509+ border: 1px solid var(--border);
510+ border-radius: var(--radius-sm);
511+ }
512+ .step-dialog-room.hidden { display: none; }
513+ .step-dialog-room-label { color: var(--text-dim); font-size: 12px; }
514+ .step-dialog-room code {
515+ font-family: var(--mono); font-size: 14px;
516+ color: var(--text); user-select: all;
517+ }
518+
519+ /* Console drawer — sits below the toolbar inside #view-call so opening it
520+ shrinks the call area instead of overlapping it. */
521+ #console-drawer {
522+ flex: none;
523+ height: 35vh; min-height: 200px; max-height: 50vh;
524+ background: var(--bg-elev); border-top: 1px solid var(--border-strong);
525+ display: flex; flex-direction: column;
526+ min-width: 0;
527+ }
528+ #console-drawer.hidden { display: none; }
529+ .console-head { display: flex; align-items: center; gap: 8px; padding: 8px 12px; border-bottom: 1px solid var(--border); flex-wrap: wrap; }
530+ .console-head .spacer { flex: 1; }
531+ .console-head select { padding: 4px 8px; font-size: 12px; }
532+ .console-head label.small { display: inline-flex; align-items: center; gap: 4px; }
533+ .console-head #console-filter { width: 120px; min-width: 80px; }
534+ @media (max-width: 720px) {
535+ .console-head { padding: 6px 10px; gap: 6px; }
536+ /* Hide the "Level" / "Filter" inline label text — the dropdown and the
537+ placeholder make the controls self-evident. */
538+ .console-head label.small { font-size: 0; }
539+ .console-head label.small > * { font-size: 12px; }
540+ .console-head #console-filter { width: 100px; }
541+ }
542+ .console-body { flex: 1; overflow: auto; padding: 6px 12px; font-family: var(--mono); font-size: 11px; }
543+ .log-line { padding: 2px 0; white-space: pre-wrap; word-break: break-word; }
544+ .log-line .ts { color: var(--text-faint); margin-right: 6px; }
545+ .log-line .lvl { display: inline-block; min-width: 34px; padding: 0 5px; border-radius: 3px; margin-right: 6px; font-size: 9px; text-align: center; }
546+ .log-line.debug .lvl { background: var(--bg-elev-2); color: var(--text-dim); }
547+ .log-line.info .lvl { background: rgba(47,129,247,0.15); color: var(--accent); }
548+ .log-line.warn .lvl { background: rgba(210,153,34,0.15); color: var(--warn); }
549+ .log-line.error .lvl { background: rgba(248,81,73,0.15); color: var(--err); }
550+ .log-line .label { color: var(--text-dim); margin-right: 6px; }
551+
552+ .kbd { font-family: var(--mono); padding: 1px 5px; border-radius: 4px; background: var(--bg-elev-2); border: 1px solid var(--border); font-size: 11px; }
553+ .small { font-size: 12px; color: var(--text-dim); }
554+ .nowrap { white-space: nowrap; }
555+ .mono { font-family: var(--mono); }
556+
557+ /* Inline progress (Continue to signaling, Apply offer) */
558+ .progress-row {
559+ display: flex; align-items: center; gap: 10px;
560+ margin-top: 12px;
561+ padding: 10px 12px;
562+ background: var(--bg-elev);
563+ border: 1px solid var(--border);
564+ border-radius: var(--radius-sm);
565+ color: var(--text);
566+ font-size: 13px;
567+ }
568+ .progress-row.hidden { display: none; }
569+ .spinner {
570+ width: 14px; height: 14px; flex: none;
571+ border: 2px solid var(--border-strong);
572+ border-top-color: var(--accent);
573+ border-radius: 50%;
574+ animation: spin 0.8s linear infinite;
575+ }
576+ @keyframes spin { to { transform: rotate(360deg); } }
577+ .progress-row .sub { color: var(--text-dim); font-size: 12px; }
578+
579+ /* Insecure-context warning */
580+ .warn-banner {
581+ margin-bottom: 16px;
582+ padding: 12px 14px;
583+ background: rgba(210,153,34,0.1);
584+ border: 1px solid rgba(210,153,34,0.4);
585+ border-radius: var(--radius-sm);
586+ color: var(--warn);
587+ font-size: 13px;
588+ }
589+ .warn-banner.hidden { display: none; }
590+ .warn-banner strong { color: var(--warn); }
591+
592+ ::-webkit-scrollbar { width: 10px; height: 10px; }
593+ ::-webkit-scrollbar-track { background: transparent; }
594+ ::-webkit-scrollbar-thumb { background: var(--border); border-radius: 5px; }
595+ ::-webkit-scrollbar-thumb:hover { background: var(--border-strong); }
596+</style>
597+</head>
598+<body>
599+
600+<main id="app">
601+ <header class="topbar">
602+ <div class="brand">WebRTC Tool <small>direct peer-to-peer audio, video, chat, and files</small></div>
603+ <div class="right">
604+ <span id="role-badge" class="role-badge hidden"></span>
605+ <span id="conn-pill" class="pill">disconnected</span>
606+ <button class="icon" id="theme-toggle" title="Toggle theme" aria-label="Toggle theme">
607+ <svg class="theme-icon-moon" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
608+ <svg class="theme-icon-sun" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"/></svg>
609+ </button>
610+ </div>
611+ </header>
612+
613+ <!-- ============== WELCOME ============== -->
614+ <section id="view-welcome" class="view">
615+ <div class="container">
616+ <h1>WebRTC Tool</h1>
617+ <p class="lede">
618+ A direct peer-to-peer call tool — a fallback for video, screen share, chat, and file transfer
619+ when your usual conferencing software isn't cooperating. Two peers can connect either via a
620+ shared room code (using a small relay server that only shuttles the offer/answer) or by
621+ pasting two short JSON blobs to each other. Browser-only on each end: no install, no account.
622+ </p>
623+
624+ <div class="card">
625+ <h2>Pick your role</h2>
626+ <p class="small">One side starts the call (creates the offer); the other side joins it.</p>
627+ <div class="role-picker">
628+ <button data-role="initiator">
629+ <span class="role-title">Start a call</span>
630+ <span class="role-desc">You'll generate the offer. Connect via a shared room code, or send your peer the JSON blob and apply the answer they send back.</span>
631+ </button>
632+ <button data-role="joiner">
633+ <span class="role-title">Join a call</span>
634+ <span class="role-desc">You'll apply your peer's offer. Connect via the room code they share, or paste the JSON blob they send and reply with the generated answer.</span>
635+ </button>
636+ <button data-role="loopback" class="role-loopback">
637+ <span class="role-title">Loopback test (same tab)</span>
638+ <span class="role-desc">Run both peers in this tab. Useful for verifying that media and the call UI work locally.</span>
639+ </button>
640+ </div>
641+ </div>
642+
643+ <div class="card">
644+ <h2>Tools</h2>
645+ <div class="actions">
646+ <button id="welcome-sdp-inspect" class="ghost">SDP inspector →</button>
647+ </div>
648+ <p class="small">Paste an offer/answer (raw SDP, JSON, or <code>b64:</code>-wrapped) and see it broken down into sections, codecs, ICE candidates, and DTLS info — with short explanations.</p>
649+ </div>
650+ </div>
651+ </section>
652+
653+ <!-- ============== CONFIGURE ============== -->
654+ <section id="view-configure" class="view hidden">
655+ <div class="container">
656+ <h1>Configure <span id="role-title-cfg" class="role-badge"></span></h1>
657+ <p class="lede" id="cfg-lede">Set up media and ICE servers, then continue to the signaling step.</p>
658+
659+ <div id="insecure-warn" class="warn-banner hidden">
660+ <strong>Insecure context:</strong>
661+ <span id="insecure-warn-text">this page isn't served over HTTPS (or localhost), so the browser will not expose microphone, camera, or screen-share APIs. You can still join the call in listen-only mode and receive the other peer's audio/video, chat, and files.</span>
662+ </div>
663+
664+ <div class="card" id="signaling-card">
665+ <h2>Signaling</h2>
666+ <div class="seg-toggle" role="tablist" aria-label="Signaling mode">
667+ <button type="button" id="sig-mode-auto" role="tab" class="active">Auto via server (room code)</button>
668+ <button type="button" id="sig-mode-manual" role="tab">Manual SDP exchange</button>
669+ </div>
670+ <p class="small hidden" id="sig-help-manual">
671+ You and your peer copy two JSON blobs (offer and answer) between yourselves through any
672+ channel — email, chat, paper. Nothing leaves the browser except the call itself. Use this when
673+ you don't want to (or can't) run a server, or when you want to inspect the SDP.
674+ </p>
675+ <p class="small" id="sig-help-auto">
676+ Both peers enter the same short <em>room code</em> on a small relay server that just shuttles
677+ the offer/answer pair. The server never sees media or chat — those still flow peer-to-peer.
678+ Whoever opens the room first becomes the initiator; the second peer joins. The relay holds
679+ each blob for 10 minutes and forgets it after.
680+ </p>
681+
682+ <div id="sig-auto-fields" class="hidden">
683+ <div class="room-code-hero">
684+ <label class="hero-label" for="sig-room-code">Enter a room code to connect</label>
685+ <div class="room-code-input-row">
686+ <input type="text" id="sig-room-code" placeholder="e.g. blue-fish-42" spellcheck="false" autocomplete="off" maxlength="15">
687+ <button type="button" id="sig-room-gen" class="ghost" title="Generate a random code"><svg class="ic" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="16 3 21 3 21 8"/><line x1="4" y1="20" x2="21" y2="3"/><polyline points="21 16 21 21 16 21"/><line x1="15" y1="15" x2="21" y2="21"/><line x1="4" y1="4" x2="9" y2="9"/></svg></button>
688+ </div>
689+ <p class="hero-help">Any short alphanumeric string (max 15 chars, letters/digits/<code>-_</code>). Share it with your peer through any channel.</p>
690+ </div>
691+
692+ <label class="field" style="max-width:420px; margin-top:14px">
693+ <span>Server URL</span>
694+ <div class="row" style="gap:6px; align-items:stretch">
695+ <input type="url" id="sig-server-url" placeholder="https://example.com:8080" spellcheck="false" autocomplete="off" style="flex:1">
696+ <button type="button" id="sig-check" class="ghost" title="Probe the server's /health endpoint">Check</button>
697+ </div>
698+ <span id="sig-check-status" class="pill hidden" style="margin-top:6px; align-self:flex-start"></span>
699+ </label>
700+ <p class="small">Base URL of the signaling server. The page will POST/GET to <code>&lt;url&gt;/room/&lt;code&gt;/offer</code> and <code>/answer</code>.</p>
701+ </div>
702+
703+ <details style="margin-top:14px">
704+ <summary>Advanced</summary>
705+ <div class="details-body">
706+ <label class="field" style="max-width:260px">
707+ <span>ICE gathering timeout (seconds)</span>
708+ <input type="number" id="sig-ice-timeout" min="0" step="1" placeholder="8">
709+ </label>
710+ <p class="small">
711+ Hard cap on how long the page waits for ICE candidate gathering before exporting the SDP.
712+ Use <code>0</code> to wait indefinitely — useful when you want every candidate (e.g. slow TURN
713+ relays) but expect to abort manually if gathering stalls. Default is 8 seconds.
714+ </p>
715+ </div>
716+ </details>
717+ </div>
718+
719+ <details class="card card-collapsible">
720+ <summary><h2>ICE / TURN servers</h2></summary>
721+ <div class="card-body">
722+ <p class="small">
723+ Leave the STUN default for typical LAN/internet use. Add TURN servers for cross-NAT peers.
724+ Empty list means fully-local (host candidates only) — works on the same LAN or same machine.
725+ </p>
726+ <div id="ice-rows"></div>
727+ <div class="actions">
728+ <button id="ice-add" class="ghost">+ Add server</button>
729+ <button id="ice-clear" class="ghost">Clear all</button>
730+ <button id="ice-reset" class="ghost">Reset to default</button>
731+ <button id="ice-toggle-json" class="ghost">Edit as JSON…</button>
732+ </div>
733+ <div id="ice-json-wrap" class="hidden" style="margin-top:10px">
734+ <label class="field">
735+ <span>RTCIceServer[] JSON</span>
736+ <textarea id="ice-json" spellcheck="false"></textarea>
737+ </label>
738+ <div class="actions">
739+ <button id="ice-json-apply" class="primary">Apply JSON</button>
740+ </div>
741+ </div>
742+
743+ <h3 style="margin-top:18px">LAN connectivity</h3>
744+ <p class="small">
745+ For privacy, browsers (especially Firefox) restrict WebRTC ICE candidates to the default network
746+ interface while no microphone or camera stream is active on the page. If both peers are on the
747+ same network and you don't have a TURN server, enabling this opens a muted microphone stream
748+ (no audio is captured or sent) so the ICE agent can see all your local interfaces and direct
749+ LAN candidates can be exchanged. The OS will indicate the microphone is in use; stop it from
750+ here, the toolbar, or by hanging up.
751+ </p>
752+ <div class="actions">
753+ <button id="ice-warmup" class="ghost">Enable LAN connectivity</button>
754+ <span id="ice-warmup-status" class="pill">off</span>
755+ </div>
756+ </div>
757+ </details>
758+
759+ <details class="card card-collapsible">
760+ <summary><h2>Media</h2></summary>
761+ <div class="card-body">
762+ <p class="small">Munged into the offer/answer SDP at signaling time. These can <strong>only</strong> be set here — they are locked once the call is up and cannot be changed mid-call.</p>
763+
764+ <label class="field" style="max-width:260px">
765+ <span>Receive codec preference</span>
766+ <select id="preferred-codec">
767+ <option value="auto">auto (browser default)</option>
768+ </select>
769+ </label>
770+ <p class="small">Reorders this side's SDP to ask the peer to encode with this codec when sending to you. Influences what the peer sends, not what you send.</p>
771+
772+ <label class="field" style="max-width:260px">
773+ <span>Send codec</span>
774+ <select id="send-codec">
775+ <option value="auto">auto (browser default)</option>
776+ </select>
777+ </label>
778+ <p class="small">Pins the encoder used when sending video (both camera and screen share). Picks from the codecs negotiated with the peer; falls back to the browser default if the codec isn't available. Can also be changed mid-call from the runtime panel.</p>
779+ <p class="small">If video freezes after a few seconds, try a different send codec — some browser/device combinations have buggy encoders (notably VP8 on Firefox Android).</p>
780+
781+ <details>
782+ <summary>Advanced Opus settings (SDP)</summary>
783+ <div class="details-body">
784+ <div class="opus-list">
785+ <div class="opus-item">
786+ <label class="row"><input type="checkbox" id="o-stereo"> stereo / sprop-stereo</label>
787+ <p class="small">Negotiate a 2-channel Opus stream. Only useful if the capture device is actually stereo — otherwise the second channel just duplicates the first and wastes bitrate.</p>
788+ </div>
789+ <div class="opus-item">
790+ <label class="row"><input type="checkbox" id="o-fec" checked> useinbandfec</label>
791+ <p class="small">Forward error correction. The encoder embeds a low-bitrate copy of each frame inside the next one so the decoder can reconstruct single-packet losses without a retransmit. Costs a few percent bitrate.</p>
792+ </div>
793+ <div class="opus-item">
794+ <label class="row"><input type="checkbox" id="o-dtx" checked> usedtx</label>
795+ <p class="small">Discontinuous transmission: the encoder stops sending packets during silence and the decoder fills in comfort noise. Saves bandwidth on quiet channels; can cut off very soft speech.</p>
796+ </div>
797+ <div class="opus-item">
798+ <label class="row"><input type="checkbox" id="o-cbr"> cbr (constant bitrate)</label>
799+ <p class="small">Force a constant bitrate instead of letting Opus vary it with content complexity. Mostly useful when something downstream expects a steady rate; usually leave off.</p>
800+ </div>
801+ <div class="opus-item">
802+ <label class="field">
803+ <span>maxaveragebitrate (bits/s; 0 = unset)</span>
804+ <input type="number" id="o-maxbr" placeholder="0" min="0" max="510000" step="1000">
805+ </label>
806+ <p class="small">Upper bound the encoder targets on average. Set lower to save bandwidth on weak links; leave at 0 to let Opus pick (typically 32–64 kbps for speech, higher for music).</p>
807+ </div>
808+ </div>
809+ </div>
810+ </details>
811+
812+ </div>
813+ </details>
814+
815+ <div class="actions">
816+ <button id="cfg-back" class="ghost">← Back</button>
817+ <button id="cfg-continue" class="primary">Continue to signaling →</button>
818+ </div>
819+
820+ <div id="cfg-progress" class="progress-row hidden" role="status" aria-live="polite">
821+ <span class="spinner"></span>
822+ <span>
823+ <span id="cfg-progress-label">Working…</span>
824+ <span id="cfg-progress-sub" class="sub"></span>
825+ </span>
826+ </div>
827+ </div>
828+ </section>
829+
830+ <!-- ============== EXCHANGE ============== -->
831+ <section id="view-exchange" class="view hidden">
832+ <div class="container">
833+ <h1>Signaling exchange <span id="role-title-exch" class="role-badge"></span></h1>
834+ <p class="lede" id="exch-lede"></p>
835+
836+ <div class="card">
837+ <h2 id="step-1-h">Step 1</h2>
838+ <div id="step-1-body" class="blob-area">
839+ <!-- filled by JS -->
840+ </div>
841+ </div>
842+
843+ <div class="card" id="step-2-card">
844+ <h2 id="step-2-h">Step 2</h2>
845+ <div id="step-2-body" class="blob-area">
846+ <!-- filled by JS -->
847+ </div>
848+ </div>
849+
850+ <div class="actions">
851+ <button id="exch-cancel" class="ghost">Cancel</button>
852+ </div>
853+
854+ <div id="exch-progress" class="progress-row hidden" role="status" aria-live="polite">
855+ <span class="spinner"></span>
856+ <span>
857+ <span id="exch-progress-label">Working…</span>
858+ <span id="exch-progress-sub" class="sub"></span>
859+ </span>
860+ </div>
861+ </div>
862+ </section>
863+
864+ <!-- ============== SDP INSPECTOR ============== -->
865+ <section id="view-sdp-inspect" class="view hidden">
866+ <div class="container">
867+ <h1>SDP inspector</h1>
868+ <p class="lede">Paste an offer or answer below — raw SDP, the JSON blob this tool emits, or its base64-wrapped form. Nothing leaves the page; parsing happens locally.</p>
869+
870+ <div class="card">
871+ <h2>Input</h2>
872+ <textarea id="sdp-in" spellcheck="false" placeholder="Paste SDP / JSON / b64:… here"></textarea>
873+ <div class="blob-controls">
874+ <button id="sdp-inspect-go" class="primary">Inspect</button>
875+ <button id="sdp-inspect-clear" class="ghost">Clear</button>
876+ <button id="sdp-inspect-upload" class="ghost">Upload…</button>
877+ <input id="sdp-inspect-file" type="file" accept=".json,.txt,application/json,text/plain" hidden>
878+ <span class="pill" id="sdp-inspect-status"></span>
879+ </div>
880+ </div>
881+
882+ <div id="sdp-inspect-out"></div>
883+
884+ <div class="actions">
885+ <button id="sdp-inspect-back" class="ghost">← Back</button>
886+ </div>
887+ </div>
888+ </section>
889+
890+ <!-- ============== CALL ============== -->
891+ <section id="view-call" class="view hidden">
892+ <div class="call-body">
893+ <div class="video-area">
894+ <div class="video-tile empty" id="tile-local">
895+ <video id="vid-local-main" autoplay muted playsinline></video>
896+ <div class="pip hidden" id="pip-local">
897+ <video id="vid-local-pip" autoplay muted playsinline></video>
898+ </div>
899+ <div class="empty-state"><svg viewBox="0 0 24 24" width="64" height="64" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-label="no video"><path d="M16 16v1a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h2m5.66 0H14a2 2 0 0 1 2 2v3.34l1 1L23 7v10"/><line x1="1" y1="1" x2="23" y2="23"/></svg></div>
900+ <div class="mic-muted hidden" id="mic-muted-local" title="microphone muted"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-label="muted"><line x1="1" y1="1" x2="23" y2="23"/><path d="M9 9v3a3 3 0 0 0 5.12 2.12M15 9.34V4a3 3 0 0 0-5.94-.6"/><path d="M17 16.95A7 7 0 0 1 5 12v-2m14 0v2a7 7 0 0 1-.11 1.23"/><line x1="12" y1="19" x2="12" y2="23"/></svg></div>
901+ <span class="tile-label">you</span>
902+ </div>
903+ <div class="video-tile empty" id="tile-remote">
904+ <video id="vid-remote-main" autoplay muted playsinline></video>
905+ <div class="pip hidden" id="pip-remote">
906+ <video id="vid-remote-pip" autoplay muted playsinline></video>
907+ </div>
908+ <div class="empty-state"><svg viewBox="0 0 24 24" width="64" height="64" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-label="no video"><path d="M16 16v1a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h2m5.66 0H14a2 2 0 0 1 2 2v3.34l1 1L23 7v10"/><line x1="1" y1="1" x2="23" y2="23"/></svg></div>
909+ <div class="mic-muted hidden" id="mic-muted-remote" title="microphone muted"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-label="muted"><line x1="1" y1="1" x2="23" y2="23"/><path d="M9 9v3a3 3 0 0 0 5.12 2.12M15 9.34V4a3 3 0 0 0-5.94-.6"/><path d="M17 16.95A7 7 0 0 1 5 12v-2m14 0v2a7 7 0 0 1-.11 1.23"/><line x1="12" y1="19" x2="12" y2="23"/></svg></div>
910+ <span class="tile-label">peer</span>
911+ </div>
912+ </div>
913+ <!-- Plays the peer's audio independently of any video element so it
914+ keeps working even when the remote tile is showing the empty state
915+ (i.e. mic-only call) — display:none on a <video> suppresses audio
916+ output in some browsers (notably Firefox). -->
917+ <audio id="audio-remote" autoplay></audio>
918+
919+ <aside class="sidebar">
920+ <div class="tabs">
921+ <button data-tab="chat" class="active">Chat</button>
922+ <button data-tab="files">Files</button>
923+ <button data-tab="settings">Settings</button>
924+ <button data-tab="stats">Stats</button>
925+ </div>
926+
927+ <div class="tab-pane active" data-pane="chat">
928+ <div id="chat-log" class="chat-log"></div>
929+ <div class="chat-input">
930+ <input type="text" id="chat-text" placeholder="Type a message and press Enter" autocomplete="off">
931+ <button id="chat-send" class="primary">Send</button>
932+ </div>
933+ <div id="chat-counter" class="chat-counter small">0 / 8192 B</div>
934+ </div>
935+
936+ <div class="tab-pane" data-pane="files">
937+ <div id="files-drop" class="files-drop">
938+ <p><strong>Drop a file here</strong> or <a href="#" id="files-pick">pick one</a>.</p>
939+ <p class="small">Single file at a time. Chunked over a dedicated data channel. Max <span id="files-max">5 GB</span> per file.</p>
940+ <input type="file" id="files-input" class="hidden">
941+ </div>
942+ <div class="files-section-head"><h3>Outgoing</h3><button class="ghost small" id="files-out-clear">Clear all</button></div>
943+ <div id="files-out"></div>
944+ <div class="files-section-head"><h3>Incoming</h3><button class="ghost small" id="files-in-clear">Clear all</button></div>
945+ <div id="files-in"></div>
946+ </div>
947+
948+ <div class="tab-pane" data-pane="settings">
949+ <h3>Video</h3>
950+ <p class="small">Resolution and framerate are applied by restarting the camera. Bitrate and degradation preference apply instantly without touching the camera.</p>
951+ <div class="grid-2">
952+ <label class="field">
953+ <span>Width (px, 0 = auto)</span>
954+ <input type="number" id="rt-v-w" min="0" max="3840">
955+ </label>
956+ <label class="field">
957+ <span>Height (px, 0 = auto)</span>
958+ <input type="number" id="rt-v-h" min="0" max="2160">
959+ </label>
960+ <label class="field">
961+ <span>Frame rate (fps, 0 = auto)</span>
962+ <input type="number" id="rt-v-fps" min="0" max="120">
963+ </label>
964+ <label class="field">
965+ <span>Max send bitrate (kbps, 0 = unset)</span>
966+ <input type="number" id="rt-v-maxbr" min="0" max="20000" step="50">
967+ </label>
968+ <label class="field">
969+ <span>Degradation preference</span>
970+ <select id="rt-v-degrade">
971+ <option value="balanced">balanced</option>
972+ <option value="maintain-framerate">maintain-framerate</option>
973+ <option value="maintain-resolution">maintain-resolution</option>
974+ </select>
975+ </label>
976+ </div>
977+ <button id="rt-v-apply" class="ghost">Apply</button>
978+
979+ <h3>Send codec</h3>
980+ <p class="small">Applies to both camera and screen-share encoders. Picks from the codecs negotiated at signaling time.</p>
981+ <div class="grid-2">
982+ <label class="field">
983+ <span>Send codec</span>
984+ <select id="rt-codec">
985+ <option value="auto">auto</option>
986+ </select>
987+ </label>
988+ </div>
989+ <button id="rt-codec-apply" class="ghost">Apply</button>
990+
991+ <h3>Screen share</h3>
992+ <p class="small">Resolution and framerate are applied by restarting the share — you'll be re-prompted for the source. Bitrate and degradation preference apply instantly.</p>
993+ <div class="grid-2">
994+ <label class="field">
995+ <span>Width (px, 0 = auto)</span>
996+ <input type="number" id="rt-s-w" min="0" max="7680">
997+ </label>
998+ <label class="field">
999+ <span>Height (px, 0 = auto)</span>
1000+ <input type="number" id="rt-s-h" min="0" max="4320">
1001+ </label>
1002+ <label class="field">
1003+ <span>Frame rate (fps, 0 = auto)</span>
1004+ <input type="number" id="rt-s-fps" min="0" max="120">
1005+ </label>
1006+ <label class="field">
1007+ <span>Max send bitrate (kbps, 0 = unset)</span>
1008+ <input type="number" id="rt-s-maxbr" min="0" max="50000" step="100">
1009+ </label>
1010+ <label class="field">
1011+ <span>Degradation preference</span>
1012+ <select id="rt-s-degrade">
1013+ <option value="balanced">balanced</option>
1014+ <option value="maintain-framerate">maintain-framerate</option>
1015+ <option value="maintain-resolution">maintain-resolution</option>
1016+ </select>
1017+ </label>
1018+ </div>
1019+ <button id="rt-s-apply" class="ghost">Apply</button>
1020+
1021+ <h3>Audio</h3>
1022+ <p class="small">Echo/noise/AGC apply live. Channels and sample rate take effect by restarting the microphone.</p>
1023+ <label class="row"><input type="checkbox" id="rt-a-aec"> Echo cancellation</label>
1024+ <label class="row"><input type="checkbox" id="rt-a-ns" > Noise suppression</label>
1025+ <label class="row"><input type="checkbox" id="rt-a-agc"> Auto gain control</label>
1026+ <div class="grid-2">
1027+ <label class="field">
1028+ <span>Channels</span>
1029+ <select id="rt-a-channels">
1030+ <option value="1">1 (mono)</option>
1031+ <option value="2">2 (stereo)</option>
1032+ </select>
1033+ </label>
1034+ <label class="field">
1035+ <span>Sample rate (Hz, 0 = auto)</span>
1036+ <input type="number" id="rt-a-rate" min="0" max="96000" step="1000">
1037+ </label>
1038+ </div>
1039+ <button id="rt-a-apply" class="ghost">Apply</button>
1040+ </div>
1041+
1042+ <div class="tab-pane" data-pane="stats">
1043+ <h3>Peer connection <button id="stats-export" class="ghost small" style="float:right">Export</button></h3>
1044+ <table class="stats-table" id="stats-table"><tbody></tbody></table>
1045+ </div>
1046+ </aside>
1047+ </div>
1048+
1049+ <div class="toolbar">
1050+ <div class="device-picker">
1051+ <button id="tb-mic" class="off" title="Enable microphone"><svg class="ic" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="2" width="6" height="12" rx="3"/><path d="M5 11v1a7 7 0 0 0 14 0v-1"/><line x1="12" y1="19" x2="12" y2="22"/><line x1="8" y1="22" x2="16" y2="22"/></svg><span class="nowrap">Mic off</span></button>
1052+ <button id="tb-mic-pick" class="device-chevron" title="Choose microphone" aria-haspopup="true" aria-expanded="false"></button>
1053+ <div id="tb-mic-menu" class="device-menu hidden" role="menu" aria-label="Microphone"></div>
1054+ </div>
1055+ <div class="device-picker">
1056+ <button id="tb-cam" class="off" title="Enable camera"><svg class="ic" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M23 7l-7 5 7 5z"/><rect x="1" y="5" width="15" height="14" rx="2"/></svg><span class="nowrap">Cam off</span></button>
1057+ <button id="tb-cam-pick" class="device-chevron" title="Choose camera" aria-haspopup="true" aria-expanded="false"></button>
1058+ <div id="tb-cam-menu" class="device-menu hidden" role="menu" aria-label="Camera"></div>
1059+ </div>
1060+ <button id="tb-screen" class="off" title="Share screen"><svg class="ic" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="3" width="20" height="13" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="16" x2="12" y2="21"/><polyline points="9 10 12 7 15 10"/><line x1="12" y1="7" x2="12" y2="13"/></svg><span class="nowrap">Screen off</span></button>
1061+ <span class="spacer"></span>
1062+ <button id="console-toggle" title="Debug console"><svg class="ic" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/></svg><span class="nowrap">Console</span></button>
1063+ <button id="tb-hangup" class="danger" title="Hang up"><svg class="ic" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18.36 6.64a9 9 0 1 1-12.73 0"/><line x1="12" y1="2" x2="12" y2="12"/></svg><span class="nowrap">Hang up</span></button>
1064+ </div>
1065+ <aside id="console-drawer" class="hidden">
1066+ <div class="console-head">
1067+ <strong>Debug console</strong>
1068+ <span class="small" id="console-count">0 entries</span>
1069+ <span class="spacer"></span>
1070+ <label class="small">Level
1071+ <select id="console-level">
1072+ <option value="debug">debug</option>
1073+ <option value="info" selected>info</option>
1074+ <option value="warn">warn</option>
1075+ <option value="error">error</option>
1076+ </select>
1077+ </label>
1078+ <label class="small">Filter <input type="text" id="console-filter" placeholder="text"></label>
1079+ <button class="ghost small" id="console-clear">Clear</button>
1080+ <button class="ghost small" id="console-export">Export</button>
1081+ <button class="ghost small" id="console-stats-toggle">Stats poll</button>
1082+ <button class="ghost small" id="console-close">×</button>
1083+ </div>
1084+ <div class="console-body" id="console-body"></div>
1085+ </aside>
1086+ </section>
1087+
1088+ <dialog id="peer-left-dialog">
1089+ <h2>Peer left the call</h2>
1090+ <p>The other person hung up. The call has ended.</p>
1091+ <div class="dialog-actions">
1092+ <button id="peer-left-ok" class="primary">OK</button>
1093+ </div>
1094+ </dialog>
1095+
1096+ <dialog id="step-dialog">
1097+ <div class="step-dialog-head">
1098+ <span class="spinner" aria-hidden="true"></span>
1099+ <h2 id="step-dialog-label">Working…</h2>
1100+ </div>
1101+ <p id="step-dialog-sub"></p>
1102+ <div id="step-dialog-room" class="step-dialog-room hidden">
1103+ <span class="step-dialog-room-label">Room code</span>
1104+ <code id="step-dialog-room-code"></code>
1105+ </div>
1106+ <div class="dialog-actions">
1107+ <button id="step-dialog-cancel" class="ghost">Cancel</button>
1108+ </div>
1109+ </dialog>
1110+
1111+</main>
1112+
1113+<script>
1114+'use strict';
1115+
1116+/* =========================================================================
1117+ WebRTC Playground — single-file, backend-less.
1118+
1119+ Module layout (all under one `App` namespace):
1120+ App.log — ring-buffer log + console drawer rendering
1121+ App.theme — light/dark
1122+ App.state — current view / role / pc / dc / settings
1123+ App.signal — blob format + wait for ICE complete
1124+ App.codec — Opus SDP munging
1125+ App.media — gUM/gDM, pre-allocated transceivers, replaceTrack
1126+ App.chat — text over the chat data channel
1127+ App.files — chunked file transfer over the files data channel
1128+ App.stats — getStats() polling
1129+ App.ui — view rendering, event wiring
1130+========================================================================= */
1131+
1132+const App = {};
1133+window.App = App; /* useful for poking from devtools */
1134+
1135+/* -------------------------------------------------------------------------
1136+ Log
1137+------------------------------------------------------------------------- */
1138+App.log = (() => {
1139+ const MAX = 1000;
1140+ const buf = [];
1141+ const subs = new Set();
1142+ function push(level, label, args) {
1143+ const entry = { level, label, ts: Date.now(), args };
1144+ buf.push(entry);
1145+ if (buf.length > MAX) buf.shift();
1146+ subs.forEach(fn => { try { fn(entry); } catch (e) { /* swallow */ } });
1147+ const m = level === 'debug' ? 'log' : level;
1148+ try { console[m](`[${label}]`, ...args); } catch (_) {}
1149+ }
1150+ return {
1151+ debug: (l, ...a) => push('debug', l, a),
1152+ info: (l, ...a) => push('info', l, a),
1153+ warn: (l, ...a) => push('warn', l, a),
1154+ error: (l, ...a) => push('error', l, a),
1155+ subscribe: fn => { subs.add(fn); return () => subs.delete(fn); },
1156+ snapshot: () => buf.slice(),
1157+ clear: () => { buf.length = 0; subs.forEach(fn => fn(null)); },
1158+ };
1159+})();
1160+
1161+window.addEventListener('error', e => App.log.error('window', e.message, e.filename + ':' + e.lineno));
1162+window.addEventListener('unhandledrejection', e => App.log.error('promise', String(e.reason)));
1163+
1164+/* -------------------------------------------------------------------------
1165+ Theme
1166+------------------------------------------------------------------------- */
1167+App.theme = (() => {
1168+ const stored = localStorage.getItem('webrtc-tool.theme');
1169+ if (stored) document.documentElement.setAttribute('data-theme', stored);
1170+ return {
1171+ toggle() {
1172+ const cur = document.documentElement.getAttribute('data-theme') || 'dark';
1173+ const next = cur === 'dark' ? 'light' : 'dark';
1174+ document.documentElement.setAttribute('data-theme', next);
1175+ localStorage.setItem('webrtc-tool.theme', next);
1176+ }
1177+ };
1178+})();
1179+
1180+/* -------------------------------------------------------------------------
1181+ Progress: inline labeled spinner shown during slow setup steps (gUM
1182+ prompt, ICE gathering, etc.). Two hosts: configure view + exchange view.
1183+------------------------------------------------------------------------- */
1184+App.progress = (() => {
1185+ let modalCancelHandler = null;
1186+ function active() {
1187+ /* Pick the progress widget inside the currently visible view. */
1188+ const candidates = ['cfg-progress', 'exch-progress'];
1189+ for (const id of candidates) {
1190+ const el = document.getElementById(id);
1191+ if (!el) continue;
1192+ const view = el.closest('.view');
1193+ if (view && !view.classList.contains('hidden')) return el;
1194+ }
1195+ return null;
1196+ }
1197+ function setBusy(busy) {
1198+ /* Disable the primary action button(s) while a step is running so the
1199+ user can't double-fire ICE gathering. */
1200+ const ids = ['cfg-continue', 'cfg-back', 'blob-apply'];
1201+ ids.forEach(id => {
1202+ const el = document.getElementById(id);
1203+ if (el) el.disabled = busy;
1204+ });
1205+ }
1206+ function show(label, sub) {
1207+ /* If the modal is up from a previous step, close it before falling back
1208+ to the inline widget. */
1209+ hideModal();
1210+ const host = active();
1211+ if (host) {
1212+ host.classList.remove('hidden');
1213+ host.querySelector('#' + host.id + '-label').textContent = label || 'Working…';
1214+ host.querySelector('#' + host.id + '-sub').textContent = sub || '';
1215+ }
1216+ setBusy(true);
1217+ }
1218+ /* Modal version: used for long blocking steps (ICE gathering, waiting for
1219+ peer) where the user needs an explicit Cancel affordance and where the
1220+ inline widget would otherwise sit under a Continue button they can't
1221+ reach. opts.roomCode, when set, is rendered in a highlighted block. */
1222+ function showModal(label, sub, opts) {
1223+ opts = opts || {};
1224+ const dlg = document.getElementById('step-dialog');
1225+ if (!dlg) return;
1226+ /* Hide the inline widget if it happened to be up. */
1227+ document.getElementById('cfg-progress')?.classList.add('hidden');
1228+ document.getElementById('exch-progress')?.classList.add('hidden');
1229+ document.getElementById('step-dialog-label').textContent = label || 'Working…';
1230+ document.getElementById('step-dialog-sub').textContent = sub || '';
1231+ const roomWrap = document.getElementById('step-dialog-room');
1232+ if (opts.roomCode) {
1233+ document.getElementById('step-dialog-room-code').textContent = opts.roomCode;
1234+ roomWrap.classList.remove('hidden');
1235+ } else {
1236+ roomWrap.classList.add('hidden');
1237+ }
1238+ modalCancelHandler = opts.onCancel || null;
1239+ setBusy(true);
1240+ if (!dlg.open && typeof dlg.showModal === 'function') {
1241+ try { dlg.showModal(); } catch (_) { /* already open */ }
1242+ }
1243+ }
1244+ function hideModal() {
1245+ const dlg = document.getElementById('step-dialog');
1246+ if (dlg && dlg.open) { try { dlg.close(); } catch (_) {} }
1247+ modalCancelHandler = null;
1248+ }
1249+ function hide() {
1250+ document.getElementById('cfg-progress')?.classList.add('hidden');
1251+ document.getElementById('exch-progress')?.classList.add('hidden');
1252+ hideModal();
1253+ setBusy(false);
1254+ }
1255+ function triggerCancel() {
1256+ const fn = modalCancelHandler;
1257+ modalCancelHandler = null;
1258+ if (fn) fn();
1259+ }
1260+ return { show, showModal, hide, hideModal, triggerCancel };
1261+})();
1262+
1263+/* -------------------------------------------------------------------------
1264+ State
1265+------------------------------------------------------------------------- */
1266+App.state = {
1267+ role: null, /* 'initiator' | 'joiner' | 'loopback' */
1268+ pc: null, /* primary RTCPeerConnection */
1269+ pcB: null, /* loopback only: secondary RTCPeerConnection */
1270+ dcChat: null,
1271+ dcFiles: null,
1272+ micTransceiver: null,
1273+ camTransceiver: null,
1274+ screenTransceiver: null,
1275+ localStream: null, /* gUM result */
1276+ screenStream: null,
1277+ iceWarmupStream: null, /* kept alive (muted) to unlock LAN ICE candidates in Firefox */
1278+ remoteStream: null, /* assembled from incoming audio + cam tracks */
1279+ remoteScreenStream: null,/* assembled from incoming screen track */
1280+ peerMediaState: { mic: false, cam: false, screen: false }, /* sent by peer over the chat dc */
1281+ settings: defaultSettings(),
1282+};
1283+
1284+function defaultSettings() {
1285+ const stored = localStorage.getItem('webrtc-tool.iceServers');
1286+ let ice;
1287+ try { ice = stored ? JSON.parse(stored) : [{ urls: 'stun:stun.l.google.com:19302' }]; }
1288+ catch (_) { ice = [{ urls: 'stun:stun.l.google.com:19302' }]; }
1289+ return {
1290+ iceServers: ice,
1291+ audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true, channelCount: 1, sampleRate: 0, deviceId: '' },
1292+ opus: { stereo: false, fec: true, dtx: true, cbr: false, maxAverageBitrate: 0 },
1293+ video: { width: 0, height: 0, frameRate: 0, maxBitrateKbps: 0, degradationPreference: 'balanced', deviceId: '' },
1294+ screen: { width: 0, height: 0, frameRate: 0, maxBitrateKbps: 0, degradationPreference: 'maintain-resolution' },
1295+ preferredVideoCodec: 'auto',
1296+ sendVideoCodec: 'auto',
1297+ base64: false,
1298+ signaling: loadSignaling(),
1299+ };
1300+}
1301+
1302+function loadSignaling() {
1303+ /* Default server URL is the page's own origin — the natural assumption is
1304+ that the relay is colocated with the static page. For file:// loads
1305+ location.origin is "null"; fall back to blank so the user fills it in. */
1306+ const defaultUrl = (location.protocol === 'http:' || location.protocol === 'https:')
1307+ ? location.origin : '';
1308+ const defaults = { mode: 'auto', serverUrl: defaultUrl, iceGatherTimeoutMs: 8000 };
1309+ try {
1310+ const stored = localStorage.getItem('webrtc-tool.signaling');
1311+ if (stored) {
1312+ const s = JSON.parse(stored);
1313+ const t = Number(s.iceGatherTimeoutMs);
1314+ return {
1315+ mode: s.mode === 'manual' ? 'manual' : 'auto',
1316+ serverUrl: typeof s.serverUrl === 'string' ? s.serverUrl : defaultUrl,
1317+ /* 0 is a valid value (= no timeout); only fall back when missing/NaN/negative. */
1318+ iceGatherTimeoutMs: Number.isFinite(t) && t >= 0 ? t : defaults.iceGatherTimeoutMs,
1319+ };
1320+ }
1321+ } catch (_) {}
1322+ return defaults;
1323+}
1324+
1325+function saveSignaling() {
1326+ try {
1327+ localStorage.setItem('webrtc-tool.signaling', JSON.stringify({
1328+ mode: App.state.settings.signaling.mode,
1329+ serverUrl: App.state.settings.signaling.serverUrl,
1330+ iceGatherTimeoutMs: App.state.settings.signaling.iceGatherTimeoutMs,
1331+ }));
1332+ } catch (_) {}
1333+}
1334+
1335+function saveIce() {
1336+ try { localStorage.setItem('webrtc-tool.iceServers', JSON.stringify(App.state.settings.iceServers)); } catch (_) {}
1337+}
1338+
1339+/* -------------------------------------------------------------------------
1340+ Signal: blob format + ICE-complete wait
1341+------------------------------------------------------------------------- */
1342+App.signal = (() => {
1343+ function waitForIceComplete(pc, signal) {
1344+ return new Promise(resolve => {
1345+ if (pc.iceGatheringState === 'complete') return resolve();
1346+ if (signal && signal.aborted) return resolve();
1347+ let timeoutId = null;
1348+ function done() {
1349+ if (timeoutId !== null) { clearTimeout(timeoutId); timeoutId = null; }
1350+ pc.removeEventListener('icegatheringstatechange', check);
1351+ if (signal) signal.removeEventListener('abort', done);
1352+ resolve();
1353+ }
1354+ function check() { if (pc.iceGatheringState === 'complete') done(); }
1355+ pc.addEventListener('icegatheringstatechange', check);
1356+ if (signal) signal.addEventListener('abort', done, { once: true });
1357+ /* Hard cap so a never-completing gathering doesn't stall the UI forever.
1358+ User-configurable in the Signaling → Advanced panel; 0 disables the
1359+ cap entirely (the abort signal / setRemoteDescription is then the
1360+ only way out). */
1361+ const cap = App.state.settings.signaling.iceGatherTimeoutMs;
1362+ if (cap > 0) {
1363+ timeoutId = setTimeout(() => {
1364+ if (pc.iceGatheringState !== 'complete')
1365+ App.log.warn('signal', 'ICE gathering timed out at ' + (cap / 1000) + 's; exporting partial SDP');
1366+ timeoutId = null;
1367+ done();
1368+ }, cap);
1369+ }
1370+ });
1371+ }
1372+ function utf8ToBase64(s) {
1373+ const bytes = new TextEncoder().encode(s);
1374+ /* btoa works on binary strings (one char = one byte). Convert through
1375+ String.fromCharCode in 8 KB chunks to avoid blowing the argument limit. */
1376+ let bin = '';
1377+ for (let i = 0; i < bytes.length; i += 0x2000)
1378+ bin += String.fromCharCode.apply(null, bytes.subarray(i, i + 0x2000));
1379+ return btoa(bin);
1380+ }
1381+ function base64ToUtf8(b64) {
1382+ const bin = atob(b64);
1383+ const bytes = new Uint8Array(bin.length);
1384+ for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
1385+ return new TextDecoder().decode(bytes);
1386+ }
1387+ function encode(desc) {
1388+ const obj = { v: 1, type: desc.type, sdp: desc.sdp, ts: Date.now() };
1389+ let text = JSON.stringify(obj);
1390+ if (App.state.settings.base64) text = 'b64:' + utf8ToBase64(text);
1391+ return text;
1392+ }
1393+ function decode(text) {
1394+ text = (text || '').trim();
1395+ if (!text) throw new Error('empty');
1396+ if (text.startsWith('b64:')) {
1397+ try { text = base64ToUtf8(text.slice(4)); }
1398+ catch (e) { throw new Error('bad base64'); }
1399+ }
1400+ let obj;
1401+ try { obj = JSON.parse(text); } catch (e) { throw new Error('not JSON: ' + e.message); }
1402+ if (!obj || (obj.type !== 'offer' && obj.type !== 'answer'))
1403+ throw new Error('expected {type:"offer"|"answer", sdp:...}');
1404+ if (typeof obj.sdp !== 'string' || !obj.sdp.includes('v=')) throw new Error('missing SDP');
1405+ return obj;
1406+ }
1407+ return { waitForIceComplete, encode, decode };
1408+})();
1409+
1410+function iceGatheringSubtitle() {
1411+ const cap = App.state.settings.signaling.iceGatherTimeoutMs;
1412+ if (cap > 0) return 'Probing the network for routable addresses. Up to ~' + Math.round(cap / 1000) + ' seconds.';
1413+ return 'Probing the network for routable addresses. No timeout — cancel manually if it stalls.';
1414+}
1415+
1416+/* -------------------------------------------------------------------------
1417+ Codec: Opus SDP munging
1418+------------------------------------------------------------------------- */
1419+App.codec = (() => {
1420+ /* Find Opus payload type in the m=audio section, then ensure an fmtp line
1421+ exists for it with the params we want. */
1422+ function mungeOpus(sdp, opus) {
1423+ if (!opus) return sdp;
1424+ const lines = sdp.split(/\r?\n/);
1425+ /* Locate audio m-section bounds */
1426+ let audioStart = -1, audioEnd = lines.length;
1427+ for (let i = 0; i < lines.length; i++) {
1428+ if (lines[i].startsWith('m=audio')) { audioStart = i; }
1429+ else if (audioStart >= 0 && lines[i].startsWith('m=') && i > audioStart) { audioEnd = i; break; }
1430+ }
1431+ if (audioStart < 0) return sdp;
1432+
1433+ /* Find Opus payload types */
1434+ const opusPts = [];
1435+ for (let i = audioStart; i < audioEnd; i++) {
1436+ const m = lines[i].match(/^a=rtpmap:(\d+)\s+opus\/(\d+)(?:\/(\d+))?/i);
1437+ if (m) opusPts.push(m[1]);
1438+ }
1439+ if (!opusPts.length) return sdp;
1440+
1441+ const params = [];
1442+ if (opus.stereo) { params.push('stereo=1'); params.push('sprop-stereo=1'); }
1443+ params.push('useinbandfec=' + (opus.fec ? 1 : 0));
1444+ if (opus.dtx) params.push('usedtx=1');
1445+ if (opus.cbr) params.push('cbr=1');
1446+ if (opus.maxAverageBitrate && opus.maxAverageBitrate > 0)
1447+ params.push('maxaveragebitrate=' + opus.maxAverageBitrate);
1448+ const want = params.join(';');
1449+
1450+ for (const pt of opusPts) {
1451+ let found = false;
1452+ for (let i = audioStart; i < audioEnd; i++) {
1453+ if (lines[i].startsWith('a=fmtp:' + pt + ' ')) {
1454+ /* Merge: drop any of the keys we're setting, keep the rest, append ours */
1455+ const existing = lines[i].slice(('a=fmtp:' + pt + ' ').length);
1456+ const kept = existing.split(';')
1457+ .map(s => s.trim()).filter(Boolean)
1458+ .filter(kv => {
1459+ const k = kv.split('=')[0].toLowerCase();
1460+ return !['stereo','sprop-stereo','useinbandfec','usedtx','cbr','maxaveragebitrate'].includes(k);
1461+ });
1462+ const merged = [...kept, ...params.filter(Boolean)].join(';');
1463+ lines[i] = 'a=fmtp:' + pt + ' ' + merged;
1464+ found = true; break;
1465+ }
1466+ }
1467+ if (!found) {
1468+ /* Insert after the matching rtpmap */
1469+ for (let i = audioStart; i < audioEnd; i++) {
1470+ if (lines[i].match(new RegExp('^a=rtpmap:' + pt + '\\b'))) {
1471+ lines.splice(i + 1, 0, 'a=fmtp:' + pt + ' ' + want);
1472+ audioEnd++; break;
1473+ }
1474+ }
1475+ }
1476+ }
1477+ return lines.join('\r\n');
1478+ }
1479+ return { mungeOpus };
1480+})();
1481+
1482+/* -------------------------------------------------------------------------
1483+ Media: gUM/gDM, pre-allocated transceivers, replaceTrack
1484+------------------------------------------------------------------------- */
1485+App.media = (() => {
1486+ /* Reorder the codec list so the user's preferred video codec is first.
1487+ Must be called before createOffer (initiator/loopback A) or createAnswer
1488+ (joiner / loopback B). With 'auto' we don't touch the list, letting the
1489+ browser's default order win. */
1490+ function applyVideoCodecPreference(pc) {
1491+ const pref = App.state.settings.preferredVideoCodec;
1492+ if (!pref || pref === 'auto') return;
1493+ if (typeof RTCRtpSender === 'undefined' || !RTCRtpSender.getCapabilities) return;
1494+ const caps = RTCRtpSender.getCapabilities('video');
1495+ if (!caps || !caps.codecs) return;
1496+ const wanted = pref.toLowerCase();
1497+ const head = [], tail = [];
1498+ for (const c of caps.codecs) {
1499+ const sub = (c.mimeType || '').toLowerCase().split('/')[1] || '';
1500+ (sub === wanted ? head : tail).push(c);
1501+ }
1502+ if (!head.length) { App.log.warn('media', 'preferred codec not available', pref); return; }
1503+ const ordered = [...head, ...tail];
1504+ for (const t of pc.getTransceivers()) {
1505+ const kind = (t.receiver && t.receiver.track && t.receiver.track.kind)
1506+ || (t.sender && t.sender.track && t.sender.track.kind);
1507+ const isVideo = kind === 'video'
1508+ || t === App.state.camTransceiver
1509+ || t === App.state.screenTransceiver;
1510+ if (!isVideo || !t.setCodecPreferences) continue;
1511+ try { t.setCodecPreferences(ordered); }
1512+ catch (e) { App.log.warn('media', 'setCodecPreferences failed', e.message); }
1513+ }
1514+ App.log.info('media', 'preferred video codec', pref);
1515+ }
1516+
1517+ /* Pre-allocate three transceivers on the initiator so all toolbar actions
1518+ are renegotiation-free (see plan). The joiner gets matching m-sections
1519+ from setRemoteDescription and we index transceivers by position. */
1520+ function preallocate(pc) {
1521+ App.state.micTransceiver = pc.addTransceiver('audio', { direction: 'sendrecv' });
1522+ App.state.camTransceiver = pc.addTransceiver('video', { direction: 'sendrecv' });
1523+ App.state.screenTransceiver = pc.addTransceiver('video', { direction: 'sendrecv' });
1524+ App.log.debug('media', 'pre-allocated 1 audio + 2 video transceivers');
1525+ }
1526+ function adoptTransceiversFromRemote(pc) {
1527+ /* For the joiner: after setRemoteDescription, transceivers exist in
1528+ the same order the initiator added them (mid 0, 1, 2). They were
1529+ auto-created by SRD and default to recvonly because the joiner has
1530+ no local tracks yet — but later enabling mic/cam on a recvonly
1531+ transceiver would never send. Force sendrecv so the answer SDP
1532+ advertises bidirectional intent. */
1533+ const ts = pc.getTransceivers();
1534+ for (const t of ts) {
1535+ try { t.direction = 'sendrecv'; }
1536+ catch (e) { App.log.warn('media', 'could not upgrade transceiver to sendrecv', e.message); }
1537+ }
1538+ App.state.micTransceiver = ts[0] || null;
1539+ App.state.camTransceiver = ts[1] || null;
1540+ App.state.screenTransceiver = ts[2] || null;
1541+ App.log.debug('media', 'adopted', ts.length, 'transceivers from remote SDP');
1542+ }
1543+
1544+ /* The local preview MediaStream is a synthetic view: we add/remove tracks
1545+ to it as the user enables/disables mic and camera from the toolbar. */
1546+ function localStream() {
1547+ if (!App.state.localStream) App.state.localStream = new MediaStream();
1548+ return App.state.localStream;
1549+ }
1550+ function setLocalTrack(kind, track) {
1551+ const s = localStream();
1552+ s.getTracks().filter(t => t.kind === kind).forEach(t => s.removeTrack(t));
1553+ if (track) s.addTrack(track);
1554+ refreshLocalDisplay();
1555+ }
1556+ /* Decide what plays in the main tile vs the corner PIP for the local side.
1557+ Screen share takes the main tile; the cam moves to the PIP. */
1558+ function refreshLocalDisplay() {
1559+ const main = document.getElementById('vid-local-main');
1560+ const pip = document.getElementById('vid-local-pip');
1561+ const pipWrap = document.getElementById('pip-local');
1562+ const tile = document.getElementById('tile-local');
1563+ const camStream = App.state.localStream;
1564+ const hasCam = camStream && camStream.getVideoTracks().length > 0;
1565+ const screenStream = App.state.screenStream;
1566+ const hasScreen = !!screenStream;
1567+ if (hasScreen) {
1568+ main.srcObject = screenStream;
1569+ tile.classList.add('screen');
1570+ if (hasCam) { pip.srcObject = camStream; pipWrap.classList.remove('hidden'); }
1571+ else { pip.srcObject = null; pipWrap.classList.add('hidden'); }
1572+ } else if (hasCam) {
1573+ main.srcObject = camStream;
1574+ tile.classList.remove('screen');
1575+ pip.srcObject = null; pipWrap.classList.add('hidden');
1576+ } else {
1577+ main.srcObject = null;
1578+ tile.classList.remove('screen');
1579+ pip.srcObject = null; pipWrap.classList.add('hidden');
1580+ }
1581+ tile.classList.toggle('empty', !hasCam && !hasScreen);
1582+ const micOn = !!(App.state.micTransceiver && App.state.micTransceiver.sender.track);
1583+ document.getElementById('mic-muted-local').classList.toggle('hidden', micOn);
1584+ }
1585+ function refreshRemoteDisplay() {
1586+ const main = document.getElementById('vid-remote-main');
1587+ const pip = document.getElementById('vid-remote-pip');
1588+ const pipWrap = document.getElementById('pip-remote');
1589+ const tile = document.getElementById('tile-remote');
1590+ const audioEl = document.getElementById('audio-remote');
1591+ const camStream = App.state.remoteStream;
1592+ const screenStream = App.state.remoteScreenStream;
1593+ /* Always route the peer's audio through the dedicated audio element so it
1594+ plays regardless of whether any video is currently visible. */
1595+ if (audioEl && audioEl.srcObject !== camStream) audioEl.srcObject = camStream || null;
1596+ /* Track presence isn't enough — replaceTrack(null) on the sender leaves
1597+ the receiver's track in place (frozen on last frame). Trust the peer's
1598+ broadcast media state when deciding whether to show video. */
1599+ const peer = App.state.peerMediaState || { mic: false, cam: false, screen: false };
1600+ const hasCam = peer.cam && camStream && camStream.getVideoTracks().length > 0;
1601+ const hasScreen = peer.screen && screenStream && screenStream.getVideoTracks().length > 0;
1602+ if (hasScreen) {
1603+ main.srcObject = screenStream;
1604+ tile.classList.add('screen');
1605+ if (hasCam) { pip.srcObject = camStream; pipWrap.classList.remove('hidden'); }
1606+ else { pip.srcObject = null; pipWrap.classList.add('hidden'); }
1607+ } else if (hasCam) {
1608+ main.srcObject = camStream;
1609+ tile.classList.remove('screen');
1610+ pip.srcObject = null; pipWrap.classList.add('hidden');
1611+ } else {
1612+ /* Keep audio attached even when not showing video, so the peer's mic
1613+ still plays through. */
1614+ main.srcObject = camStream || null;
1615+ tile.classList.remove('screen');
1616+ pip.srcObject = null; pipWrap.classList.add('hidden');
1617+ }
1618+ tile.classList.toggle('empty', !hasCam && !hasScreen);
1619+ document.getElementById('mic-muted-remote').classList.toggle('hidden', peer.mic);
1620+ }
1621+ function mirrorToLoopback() {
1622+ /* In loopback mode, pcB needs to "see" the same tracks pcA is sending so
1623+ the remote tile gets video back. Called whenever a sender track changes. */
1624+ if (!App.state.pcB) return;
1625+ const ts = App.state.pcB.getTransceivers();
1626+ const a = App.state.micTransceiver && App.state.micTransceiver.sender.track;
1627+ const v = App.state.camTransceiver && App.state.camTransceiver.sender.track;
1628+ const s = App.state.screenTransceiver && App.state.screenTransceiver.sender.track;
1629+ const fail = label => err => App.log.warn('loopback', 'mirror ' + label + ' failed', err.message);
1630+ if (ts[0]) ts[0].sender.replaceTrack(a || null).catch(fail('mic'));
1631+ if (ts[1]) ts[1].sender.replaceTrack(v || null).catch(fail('cam'));
1632+ if (ts[2]) ts[2].sender.replaceTrack(s || null).catch(fail('screen'));
1633+ }
1634+ function gumAvailable() {
1635+ return !!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia);
1636+ }
1637+
1638+ /* Detect "device unplugged while active": the track fires 'ended'. Flip the
1639+ toolbar button off so the UI reflects reality and the user can pick a
1640+ different device. We guard with sender.track === track to ignore the
1641+ ended event that fires when *we* swap the track via replaceTrack(). */
1642+ function attachTrackEndedHandler(track, kind) {
1643+ const sender = kind === 'mic'
1644+ ? App.state.micTransceiver && App.state.micTransceiver.sender
1645+ : App.state.camTransceiver && App.state.camTransceiver.sender;
1646+ track.addEventListener('ended', () => {
1647+ if (!sender || sender.track !== track) return;
1648+ const label = kind === 'mic' ? 'Microphone' : 'Camera';
1649+ App.log.warn('media', kind + ' track ended unexpectedly');
1650+ App.chat.appendSystem?.(label + ' disconnected.');
1651+ if (kind === 'mic') setMic(false); else setCam(false);
1652+ });
1653+ }
1654+
1655+ async function setMic(on) {
1656+ const sender = App.state.micTransceiver && App.state.micTransceiver.sender;
1657+ if (!sender) return;
1658+ const btn = document.getElementById('tb-mic');
1659+ if (on) {
1660+ if (!gumAvailable()) {
1661+ App.log.warn('media', 'mic unavailable (insecure context?)');
1662+ App.chat.appendSystem?.('Microphone unavailable — this page must be served over HTTPS or localhost.');
1663+ return;
1664+ }
1665+ const a = App.state.settings.audio;
1666+ let stream = null;
1667+ try {
1668+ stream = await navigator.mediaDevices.getUserMedia({
1669+ audio: {
1670+ echoCancellation: a.echoCancellation,
1671+ noiseSuppression: a.noiseSuppression,
1672+ autoGainControl: a.autoGainControl,
1673+ channelCount: a.channelCount || undefined,
1674+ sampleRate: a.sampleRate || undefined,
1675+ deviceId: a.deviceId ? { exact: a.deviceId } : undefined,
1676+ },
1677+ });
1678+ const track = stream.getAudioTracks()[0];
1679+ /* If the call was torn down while gUM was waiting on the user's
1680+ permission prompt, the sender's pc is now closed and replaceTrack
1681+ would reject — stop the track instead so the OS-level mic
1682+ indicator turns off. */
1683+ if (!App.state.pc || App.state.pc.connectionState === 'closed') {
1684+ throw new Error('call ended before mic prompt resolved');
1685+ }
1686+ await sender.replaceTrack(track);
1687+ setLocalTrack('audio', track);
1688+ attachTrackEndedHandler(track, 'mic');
1689+ mirrorToLoopback();
1690+ btn.classList.add('on'); btn.classList.remove('off');
1691+ btn.querySelector('.nowrap').textContent = 'Mic on';
1692+ App.log.info('media', 'mic on');
1693+ applyMediaButtonAvailability?.();
1694+ } catch (e) {
1695+ /* If the saved device disappeared between sessions, fall back to the
1696+ OS default rather than trapping the user with a broken preference. */
1697+ if (e && e.name === 'OverconstrainedError' && a.deviceId) {
1698+ App.log.warn('media', 'saved mic deviceId no longer available, clearing');
1699+ a.deviceId = '';
1700+ }
1701+ App.log.error('media', 'mic getUserMedia failed', e.message);
1702+ App.chat.appendSystem?.('Microphone failed: ' + e.message);
1703+ if (stream) stream.getTracks().forEach(t => t.stop());
1704+ }
1705+ } else {
1706+ if (sender.track) sender.track.stop();
1707+ await sender.replaceTrack(null);
1708+ setLocalTrack('audio', null);
1709+ mirrorToLoopback();
1710+ btn.classList.remove('on'); btn.classList.add('off');
1711+ btn.querySelector('.nowrap').textContent = 'Mic off';
1712+ App.log.info('media', 'mic off');
1713+ }
1714+ broadcastMediaState();
1715+ }
1716+
1717+ async function setCam(on) {
1718+ const sender = App.state.camTransceiver && App.state.camTransceiver.sender;
1719+ if (!sender) return;
1720+ const btn = document.getElementById('tb-cam');
1721+ if (on) {
1722+ if (!gumAvailable()) {
1723+ App.log.warn('media', 'camera unavailable (insecure context?)');
1724+ App.chat.appendSystem?.('Camera unavailable — this page must be served over HTTPS or localhost.');
1725+ return;
1726+ }
1727+ const vs = App.state.settings.video;
1728+ let stream = null;
1729+ try {
1730+ stream = await navigator.mediaDevices.getUserMedia({
1731+ video: {
1732+ width: vs.width || undefined,
1733+ height: vs.height || undefined,
1734+ frameRate: vs.frameRate || undefined,
1735+ deviceId: vs.deviceId ? { exact: vs.deviceId } : undefined,
1736+ },
1737+ });
1738+ const track = stream.getVideoTracks()[0];
1739+ if (!App.state.pc || App.state.pc.connectionState === 'closed') {
1740+ throw new Error('call ended before camera prompt resolved');
1741+ }
1742+ await sender.replaceTrack(track);
1743+ setLocalTrack('video', track);
1744+ attachTrackEndedHandler(track, 'cam');
1745+ applyCamSendParams(); /* sets both bitrate and degradationPreference in one roundtrip */
1746+ mirrorToLoopback();
1747+ btn.classList.add('on'); btn.classList.remove('off');
1748+ btn.querySelector('.nowrap').textContent = 'Cam on';
1749+ App.log.info('media', 'cam on');
1750+ applyMediaButtonAvailability?.();
1751+ } catch (e) {
1752+ if (e && e.name === 'OverconstrainedError' && vs.deviceId) {
1753+ App.log.warn('media', 'saved cam deviceId no longer available, clearing');
1754+ vs.deviceId = '';
1755+ }
1756+ App.log.error('media', 'camera getUserMedia failed', e.message);
1757+ App.chat.appendSystem?.('Camera failed: ' + e.message);
1758+ if (stream) stream.getTracks().forEach(t => t.stop());
1759+ }
1760+ } else {
1761+ if (sender.track) sender.track.stop();
1762+ await sender.replaceTrack(null);
1763+ setLocalTrack('video', null);
1764+ mirrorToLoopback();
1765+ btn.classList.remove('on'); btn.classList.add('off');
1766+ btn.querySelector('.nowrap').textContent = 'Cam off';
1767+ App.log.info('media', 'cam off');
1768+ }
1769+ broadcastMediaState();
1770+ }
1771+
1772+ async function startScreenshare() {
1773+ if (!navigator.mediaDevices || !navigator.mediaDevices.getDisplayMedia) {
1774+ throw new Error('Screen share requires HTTPS (or localhost). Open this page over a secure context.');
1775+ }
1776+ const ss = App.state.settings.screen;
1777+ const ms = await navigator.mediaDevices.getDisplayMedia({
1778+ video: {
1779+ width: ss.width || undefined,
1780+ height: ss.height || undefined,
1781+ frameRate: ss.frameRate || undefined,
1782+ },
1783+ audio: false,
1784+ });
1785+ /* If the call ended while the source picker was open, the user has
1786+ already selected a source — release it instead of leaving it active. */
1787+ if (!App.state.pc || App.state.pc.connectionState === 'closed') {
1788+ ms.getTracks().forEach(t => t.stop());
1789+ throw new Error('call ended before screen share started');
1790+ }
1791+ App.state.screenStream = ms;
1792+ const track = ms.getVideoTracks()[0];
1793+ track.addEventListener('ended', () => stopScreenshare());
1794+ if (App.state.screenTransceiver) await App.state.screenTransceiver.sender.replaceTrack(track);
1795+ applyScreenSendParams(); /* sets both bitrate and degradationPreference in one roundtrip */
1796+ mirrorToLoopback();
1797+ refreshLocalDisplay();
1798+ const sBtn = document.getElementById('tb-screen');
1799+ sBtn.classList.add('on'); sBtn.classList.remove('off');
1800+ sBtn.querySelector('.nowrap').textContent = 'Sharing';
1801+ App.log.info('media', 'screenshare started');
1802+ broadcastMediaState();
1803+ }
1804+ async function stopScreenshare() {
1805+ const ms = App.state.screenStream;
1806+ if (ms) ms.getTracks().forEach(t => t.stop());
1807+ App.state.screenStream = null;
1808+ if (App.state.screenTransceiver) await App.state.screenTransceiver.sender.replaceTrack(null);
1809+ mirrorToLoopback();
1810+ refreshLocalDisplay();
1811+ const sBtn = document.getElementById('tb-screen');
1812+ sBtn.classList.remove('on'); sBtn.classList.add('off');
1813+ sBtn.querySelector('.nowrap').textContent = 'Screen off';
1814+ App.log.info('media', 'screenshare stopped');
1815+ broadcastMediaState();
1816+ }
1817+
1818+ /* Collect the relevant senders for a given track kind ('cam' | 'screen'),
1819+ including pcB's mirrored sender in loopback so the cap is visible there. */
1820+ function sendersFor(kind) {
1821+ const tIdx = kind === 'screen' ? 2 : 1;
1822+ const local = kind === 'screen' ? App.state.screenTransceiver : App.state.camTransceiver;
1823+ const out = [];
1824+ if (local) out.push(local.sender);
1825+ if (App.state.pcB) {
1826+ const t = App.state.pcB.getTransceivers()[tIdx];
1827+ if (t) out.push(t.sender);
1828+ }
1829+ return out;
1830+ }
1831+ /* setParameters is transactional via an internal transactionId attached to
1832+ the params object returned by getParameters. Two interleaved
1833+ get/mutate/set cycles on the same sender race and the second can fail
1834+ with InvalidModificationError. Coalesce bitrate + degradation into one
1835+ round-trip per sender, and serialize calls via a per-sender chain. */
1836+ const pendingByS = new WeakMap(); /* sender -> Promise (most recent set) */
1837+ function applyVideoSendParams(kind, logLabel) {
1838+ const cfg = kind === 'screen' ? App.state.settings.screen : App.state.settings.video;
1839+ const kbps = cfg.maxBitrateKbps;
1840+ const pref = cfg.degradationPreference;
1841+ const wantCodec = (App.state.settings.sendVideoCodec || 'auto').toLowerCase();
1842+ const senders = sendersFor(kind);
1843+ for (const sender of senders) {
1844+ const prev = pendingByS.get(sender) || Promise.resolve();
1845+ const next = prev.then(() => {
1846+ const params = sender.getParameters();
1847+ if (!params.encodings || !params.encodings[0]) params.encodings = [{}];
1848+ if (kbps && kbps > 0) params.encodings[0].maxBitrate = kbps * 1000;
1849+ else delete params.encodings[0].maxBitrate;
1850+ if (pref) params.degradationPreference = pref;
1851+ /* encodings[0].codec is the "set sending codec" API. Pick from the
1852+ negotiated codec list on the sender — if the codec we want isn't
1853+ there (e.g. peer didn't offer it, or browser doesn't expose
1854+ params.codecs yet), leave the field unset so the browser keeps
1855+ picking automatically. */
1856+ if (wantCodec === 'auto') {
1857+ delete params.encodings[0].codec;
1858+ } else if (params.codecs && params.codecs.length) {
1859+ const pick = params.codecs.find(c => {
1860+ const sub = (c.mimeType || '').split('/')[1] || '';
1861+ return sub.toLowerCase() === wantCodec;
1862+ });
1863+ if (pick) params.encodings[0].codec = pick;
1864+ else App.log.warn('media', logLabel, 'send codec not in negotiated set', wantCodec);
1865+ }
1866+ return sender.setParameters(params).then(
1867+ () => App.log.info('media', logLabel, 'params', kbps ? kbps + ' kbps' : 'unset', pref || '', 'send', wantCodec),
1868+ e => App.log.error('media', logLabel, 'setParameters failed', e.message)
1869+ );
1870+ });
1871+ pendingByS.set(sender, next);
1872+ }
1873+ }
1874+ /* Cam and screen each have a single "push current bitrate + degradation
1875+ preference" entry point. We don't split bitrate-only / degradation-only
1876+ because every call site already wants both, and a single setParameters
1877+ round-trip per sender is more efficient than two. */
1878+ function applyCamSendParams() { applyVideoSendParams('cam', 'cam'); }
1879+ function applyScreenSendParams() { applyVideoSendParams('screen', 'screen'); }
1880+ async function applyAudioConstraints() {
1881+ const sender = App.state.micTransceiver && App.state.micTransceiver.sender;
1882+ const t = sender && sender.track;
1883+ if (!t) return;
1884+ const a = App.state.settings.audio;
1885+ try {
1886+ await t.applyConstraints({
1887+ echoCancellation: a.echoCancellation,
1888+ noiseSuppression: a.noiseSuppression,
1889+ autoGainControl: a.autoGainControl,
1890+ });
1891+ App.log.info('media', 'audio constraints applied', t.getSettings());
1892+ } catch (e) {
1893+ App.log.warn('media', 'applyConstraints failed', e.message);
1894+ }
1895+ }
1896+
1897+ /* Live device switch: grab the new track, replaceTrack to it, then stop the
1898+ old one. No off→on cycle, so no black-frame flicker for the remote peer
1899+ and no period where the OS mic indicator drops. Returns the new deviceId
1900+ on success, or null on failure (caller keeps prior selection). */
1901+ async function switchInputDevice(kind, deviceId) {
1902+ const tr = kind === 'mic' ? App.state.micTransceiver : App.state.camTransceiver;
1903+ const sender = tr && tr.sender;
1904+ if (!sender || !sender.track) return null;
1905+ if (!gumAvailable()) return null;
1906+ const a = App.state.settings.audio;
1907+ const vs = App.state.settings.video;
1908+ let stream = null;
1909+ try {
1910+ const constraints = kind === 'mic'
1911+ ? { audio: {
1912+ echoCancellation: a.echoCancellation,
1913+ noiseSuppression: a.noiseSuppression,
1914+ autoGainControl: a.autoGainControl,
1915+ channelCount: a.channelCount || undefined,
1916+ sampleRate: a.sampleRate || undefined,
1917+ deviceId: deviceId ? { exact: deviceId } : undefined,
1918+ } }
1919+ : { video: {
1920+ width: vs.width || undefined,
1921+ height: vs.height || undefined,
1922+ frameRate: vs.frameRate || undefined,
1923+ deviceId: deviceId ? { exact: deviceId } : undefined,
1924+ } };
1925+ stream = await navigator.mediaDevices.getUserMedia(constraints);
1926+ const newTrack = kind === 'mic' ? stream.getAudioTracks()[0] : stream.getVideoTracks()[0];
1927+ if (!App.state.pc || App.state.pc.connectionState === 'closed') {
1928+ newTrack.stop();
1929+ throw new Error('call ended before device prompt resolved');
1930+ }
1931+ const oldTrack = sender.track;
1932+ await sender.replaceTrack(newTrack);
1933+ if (oldTrack && oldTrack !== newTrack) oldTrack.stop();
1934+ setLocalTrack(kind === 'mic' ? 'audio' : 'video', newTrack);
1935+ attachTrackEndedHandler(newTrack, kind);
1936+ if (kind === 'cam') applyCamSendParams();
1937+ mirrorToLoopback();
1938+ App.log.info('media', kind + ' switched', { requested: deviceId || '(default)', got: newTrack.getSettings ? newTrack.getSettings().deviceId : '?' });
1939+ return deviceId || '';
1940+ } catch (e) {
1941+ App.log.error('media', kind + ' switch failed', e.message);
1942+ App.chat.appendSystem?.('Failed to switch ' + (kind === 'mic' ? 'microphone' : 'camera') + ': ' + e.message);
1943+ if (stream) stream.getTracks().forEach(t => t.stop());
1944+ return null;
1945+ }
1946+ }
1947+
1948+ return {
1949+ preallocate, adoptTransceiversFromRemote, applyVideoCodecPreference,
1950+ startScreenshare, stopScreenshare,
1951+ setMic, setCam,
1952+ switchInputDevice,
1953+ applyCamSendParams, applyScreenSendParams, applyAudioConstraints,
1954+ refreshLocalDisplay, refreshRemoteDisplay,
1955+ };
1956+})();
1957+
1958+/* -------------------------------------------------------------------------
1959+ Chat
1960+------------------------------------------------------------------------- */
1961+App.chat = (() => {
1962+ /* Limits are measured in UTF-8 bytes (the wire size), not UTF-16 code
1963+ units. A 64 KB code-unit cap could let a non-ASCII payload through at
1964+ up to ~256 KB on the wire; checking bytes prevents that. */
1965+ const MAX_CHAT_MSG = 64 * 1024;
1966+ const MAX_TEXT = 8 * 1024;
1967+ const utf8Length = s => new TextEncoder().encode(s).length;
1968+ function attach(dc) {
1969+ dc.binaryType = 'arraybuffer';
1970+ dc.onopen = () => {
1971+ App.log.info('chat', 'data channel open');
1972+ appendSystem('connected');
1973+ /* Push our current media state so the peer's UI matches reality from
1974+ the moment the channel opens. */
1975+ broadcastMediaState();
1976+ };
1977+ dc.onclose = () => App.log.info('chat', 'data channel closed');
1978+ dc.onerror = e => App.log.error('chat', 'error', e.error ? e.error.message : e);
1979+ dc.onmessage = e => {
1980+ if (typeof e.data !== 'string') { App.log.warn('chat', 'binary payload rejected'); return; }
1981+ /* Cheap UTF-16 prefilter — if the code-unit count already exceeds the
1982+ byte cap, the byte count cannot be smaller, so skip the encode. */
1983+ if (e.data.length > MAX_CHAT_MSG) { App.log.warn('chat', 'oversized message rejected', e.data.length); return; }
1984+ if (utf8Length(e.data) > MAX_CHAT_MSG) { App.log.warn('chat', 'oversized message rejected (bytes)'); return; }
1985+ let m;
1986+ try { m = JSON.parse(e.data); }
1987+ catch (err) { App.log.warn('chat', 'bad json', err.message); return; }
1988+ if (!m || typeof m !== 'object' || typeof m.kind !== 'string') return;
1989+ if (m.kind === 'msg') {
1990+ if (typeof m.text !== 'string') return;
1991+ if (m.text.length > MAX_TEXT || utf8Length(m.text) > MAX_TEXT) return;
1992+ const ts = Number.isFinite(m.ts) ? m.ts : Date.now();
1993+ append(false, m.text, ts);
1994+ } else if (m.kind === 'media-state') {
1995+ App.state.peerMediaState = { mic: !!m.mic, cam: !!m.cam, screen: !!m.screen };
1996+ App.media.refreshRemoteDisplay();
1997+ } else if (m.kind === 'bye') {
1998+ onPeerHangup();
1999+ }
2000+ /* unknown kinds: silently ignore */
2001+ };
2002+ }
2003+ function send(text) {
2004+ const dc = App.state.dcChat;
2005+ if (!dc || dc.readyState !== 'open') { App.log.warn('chat', 'not open'); return; }
2006+ const msg = { kind: 'msg', text, ts: Date.now() };
2007+ dc.send(JSON.stringify(msg));
2008+ append(true, text, msg.ts);
2009+ }
2010+ /* Application-level hangup signal. Sent best-effort right before we tear
2011+ the connection down so the peer can react instantly instead of waiting
2012+ for ICE consent freshness to time out (~10–30 s). Skipped in loopback
2013+ because pcB echoes chat messages back to pcA — a bye would round-trip
2014+ and spuriously trigger the peer-left dialog on the local user. */
2015+ function sendBye() {
2016+ if (App.state.role === 'loopback') return;
2017+ const dc = App.state.dcChat;
2018+ if (!dc || dc.readyState !== 'open') return;
2019+ try { dc.send(JSON.stringify({ kind: 'bye' })); } catch (_) {}
2020+ }
2021+ function append(mine, text, ts) {
2022+ const log = document.getElementById('chat-log');
2023+ const el = document.createElement('div');
2024+ el.className = 'chat-msg' + (mine ? ' me' : '');
2025+ el.innerHTML = '<div class="text"></div><div class="meta"></div>';
2026+ el.querySelector('.text').textContent = text;
2027+ el.querySelector('.meta').textContent = new Date(ts).toLocaleTimeString();
2028+ log.appendChild(el);
2029+ log.scrollTop = log.scrollHeight;
2030+ }
2031+ function appendSystem(text) {
2032+ const log = document.getElementById('chat-log');
2033+ const el = document.createElement('div');
2034+ el.className = 'small';
2035+ el.style.textAlign = 'center';
2036+ el.style.color = 'var(--text-faint)';
2037+ el.textContent = '— ' + text + ' —';
2038+ log.appendChild(el);
2039+ log.scrollTop = log.scrollHeight;
2040+ }
2041+ return { attach, send, sendBye, append, appendSystem, MAX_TEXT, utf8Length };
2042+})();
2043+
2044+/* -------------------------------------------------------------------------
2045+ Files: chunked transfer with backpressure
2046+------------------------------------------------------------------------- */
2047+App.files = (() => {
2048+ const CHUNK = 16 * 1024;
2049+ const HIGH_WATER = 1024 * 1024; /* 1 MB */
2050+ const LOW_WATER = 256 * 1024;
2051+ const incoming = new Map(); /* id -> { name, size, mime, received, chunks: [] } */
2052+ const outQueue = []; /* [{ id, file }] — pending sends */
2053+ let outBusy = false;
2054+ /* Outgoing cancellation: ids in this set cause doSend's loop to bail
2055+ between chunks and emit a file-abort to the peer. Receiver-initiated
2056+ cancels arrive as a file-cancel ctl and get folded into this set. */
2057+ const abortedOut = new Set();
2058+ let currentSendId = null;
2059+
2060+ function attach(dc) {
2061+ dc.binaryType = 'arraybuffer';
2062+ dc.bufferedAmountLowThreshold = LOW_WATER;
2063+ dc.onopen = () => App.log.info('files', 'data channel open');
2064+ dc.onclose = () => App.log.info('files', 'data channel closed');
2065+ dc.onerror = e => App.log.error('files', 'error', e.error ? e.error.message : e);
2066+ dc.onmessage = e => onMessage(e.data);
2067+ }
2068+
2069+ const MAX_CTRL = 8 * 1024;
2070+ const MAX_NAME = 1024;
2071+ const MAX_MIME = 256;
2072+ const MAX_FILE = 5 * 1024 * 1024 * 1024; /* 5 GB */
2073+ const ID_RE = /^[A-Za-z0-9_\-]{1,16}$/;
2074+ function validId(id) { return typeof id === 'string' && ID_RE.test(id); }
2075+
2076+ function onMessage(data) {
2077+ if (typeof data === 'string') {
2078+ if (data.length > MAX_CTRL) { App.log.warn('files', 'ctrl too large'); return; }
2079+ /* Tighten to byte-size so a non-ASCII file name can't bypass the cap. */
2080+ if (new TextEncoder().encode(data).length > MAX_CTRL) { App.log.warn('files', 'ctrl too large (bytes)'); return; }
2081+ let m; try { m = JSON.parse(data); } catch (e) { App.log.warn('files', 'bad ctl', e.message); return; }
2082+ if (!m || typeof m !== 'object' || typeof m.kind !== 'string' || !validId(m.id)) return;
2083+ if (m.kind === 'file-start') {
2084+ if (incoming.has(m.id)) { App.log.warn('files', 'duplicate file-start ignored', m.id); return; }
2085+ const size = Number(m.size);
2086+ if (!Number.isFinite(size) || size < 0 || size > MAX_FILE) { App.log.warn('files', 'invalid size', m.size); return; }
2087+ const name = typeof m.name === 'string' ? m.name.slice(0, MAX_NAME) : 'file';
2088+ const mime = typeof m.mime === 'string' ? m.mime.slice(0, MAX_MIME) : '';
2089+ incoming.set(m.id, { name, size, mime, received: 0, chunks: [] });
2090+ addIncomingRow(m.id, name, size);
2091+ App.log.info('files', 'incoming start', name, size);
2092+ } else if (m.kind === 'file-end') {
2093+ const f = incoming.get(m.id);
2094+ if (!f) return;
2095+ const blob = new Blob(f.chunks, { type: f.mime || 'application/octet-stream' });
2096+ finishIncoming(m.id, blob, f.name);
2097+ incoming.delete(m.id);
2098+ App.log.info('files', 'incoming done', f.name);
2099+ } else if (m.kind === 'file-abort') {
2100+ const f = incoming.get(m.id);
2101+ if (f) {
2102+ App.log.warn('files', 'incoming aborted', f.name);
2103+ incoming.delete(m.id);
2104+ abortIncomingRow(m.id);
2105+ }
2106+ } else if (m.kind === 'file-cancel') {
2107+ /* Receiver-initiated cancel: stop sending if this id is in-flight
2108+ or queued. The doSend loop will see abortedOut and bail. */
2109+ const qi = outQueue.findIndex(q => q.id === m.id);
2110+ if (qi >= 0) {
2111+ outQueue.splice(qi, 1);
2112+ abortOutgoingRow(m.id, 'cancelled by peer');
2113+ } else if (currentSendId === m.id) {
2114+ abortedOut.add(m.id);
2115+ App.log.info('files', 'send cancelled by peer', m.id);
2116+ }
2117+ /* If id is unknown (already finished or never started), ignore. */
2118+ }
2119+ /* unknown kinds: silently ignore */
2120+ } else {
2121+ /* Binary: first 16 bytes ASCII id (right-padded), then payload. */
2122+ const view = new Uint8Array(data);
2123+ if (view.byteLength <= 16) return;
2124+ /* The sender pads ids to 16 ASCII bytes with '_', and the *padded* form
2125+ is what's used as the Map key. So leave underscores in place — only
2126+ strip NULs and whitespace (defensive). */
2127+ const idStr = new TextDecoder().decode(view.slice(0, 16)).replace(/\0+$/, '').trim();
2128+ if (!validId(idStr)) return;
2129+ const payload = view.slice(16);
2130+ const f = incoming.get(idStr);
2131+ if (!f) return;
2132+ /* A peer claiming size N must not be able to push more than N bytes —
2133+ otherwise it can OOM the tab by chunking unbounded data under one id. */
2134+ if (f.received + payload.byteLength > f.size) {
2135+ App.log.warn('files', 'chunk overflows declared size, aborting', idStr);
2136+ incoming.delete(idStr);
2137+ abortIncomingRow(idStr);
2138+ return;
2139+ }
2140+ f.chunks.push(payload);
2141+ f.received += payload.byteLength;
2142+ updateIncomingRow(idStr, f.received, f.size);
2143+ }
2144+ }
2145+
2146+ function sendFile(file) {
2147+ const dc = App.state.dcFiles;
2148+ if (!dc || dc.readyState !== 'open') { App.log.warn('files', 'channel not open'); return; }
2149+ if (file.size > MAX_FILE) {
2150+ App.log.warn('files', 'file too large', file.size);
2151+ App.chat.appendSystem?.(`File "${file.name}" exceeds the 5 GB limit.`);
2152+ return;
2153+ }
2154+ const id = (Date.now().toString(36) + Math.random().toString(36).slice(2, 8)).padEnd(16, '_').slice(0, 16);
2155+ addOutgoingRow(id, file.name, file.size);
2156+ outQueue.push({ id, file });
2157+ if (outQueue.length > 1 || outBusy) markQueued('#files-out', id);
2158+ pumpQueue();
2159+ }
2160+
2161+ async function pumpQueue() {
2162+ if (outBusy) return;
2163+ const next = outQueue.shift();
2164+ if (!next) return;
2165+ outBusy = true;
2166+ try {
2167+ await doSend(next.id, next.file);
2168+ } finally {
2169+ outBusy = false;
2170+ pumpQueue();
2171+ }
2172+ }
2173+
2174+ async function doSend(id, file) {
2175+ const dc = App.state.dcFiles;
2176+ if (!dc || dc.readyState !== 'open') {
2177+ App.log.warn('files', 'channel closed before send', file.name);
2178+ abortOutgoingRow(id, 'channel closed');
2179+ abortedOut.delete(id);
2180+ return;
2181+ }
2182+ if (abortedOut.has(id)) {
2183+ /* Cancelled while still queued — never went on the wire. */
2184+ abortOutgoingRow(id, 'cancelled');
2185+ abortedOut.delete(id);
2186+ return;
2187+ }
2188+ clearQueuedMark('#files-out', id);
2189+ currentSendId = id;
2190+ const start = { kind: 'file-start', id, name: file.name, size: file.size, mime: file.type };
2191+ dc.send(JSON.stringify(start));
2192+ App.log.info('files', 'sending', file.name, file.size);
2193+
2194+ const idBytes = new TextEncoder().encode(id);
2195+ let offset = 0;
2196+ let cancelled = false;
2197+ try {
2198+ while (offset < file.size) {
2199+ if (abortedOut.has(id)) { cancelled = true; break; }
2200+ if (dc.bufferedAmount > HIGH_WATER) {
2201+ await new Promise(res => {
2202+ const h = () => { dc.removeEventListener('bufferedamountlow', h); res(); };
2203+ dc.addEventListener('bufferedamountlow', h);
2204+ });
2205+ if (abortedOut.has(id)) { cancelled = true; break; }
2206+ }
2207+ const slice = await file.slice(offset, offset + CHUNK).arrayBuffer();
2208+ if (abortedOut.has(id)) { cancelled = true; break; }
2209+ const buf = new Uint8Array(16 + slice.byteLength);
2210+ buf.set(idBytes, 0);
2211+ buf.set(new Uint8Array(slice), 16);
2212+ dc.send(buf.buffer);
2213+ offset += slice.byteLength;
2214+ updateOutgoingRow(id, offset, file.size);
2215+ }
2216+ if (cancelled) {
2217+ try { dc.send(JSON.stringify({ kind: 'file-abort', id })); } catch (_) {}
2218+ abortOutgoingRow(id, 'cancelled');
2219+ App.log.info('files', 'send cancelled', file.name);
2220+ } else {
2221+ dc.send(JSON.stringify({ kind: 'file-end', id }));
2222+ finishOutgoingRow(id);
2223+ App.log.info('files', 'sent', file.name);
2224+ }
2225+ } catch (e) {
2226+ App.log.error('files', 'send failed', e.message);
2227+ try { dc.send(JSON.stringify({ kind: 'file-abort', id })); } catch (_) {}
2228+ abortOutgoingRow(id, 'send failed');
2229+ } finally {
2230+ currentSendId = null;
2231+ abortedOut.delete(id);
2232+ }
2233+ }
2234+
2235+ /* UI row helpers */
2236+ /* Track blob URLs for incoming finished downloads so Clear can revoke them. */
2237+ const incomingUrls = new Map(); /* id -> objectURL string */
2238+
2239+ function rowEl(side, id, name, size) {
2240+ const el = document.createElement('div');
2241+ el.className = 'file-item';
2242+ el.dataset.id = id;
2243+ el.innerHTML = `<button class="row-close" type="button" title="Cancel / remove" aria-label="Cancel or remove">×</button>
2244+ <div class="name"></div>
2245+ <div class="meta"><span class="bytes">0</span> / <span class="total"></span> B (<span class="pct">0</span>%)</div>
2246+ <div class="progress"><div></div></div>
2247+ <div class="dl"></div>`;
2248+ el.querySelector('.name').textContent = name;
2249+ el.querySelector('.total').textContent = size.toLocaleString();
2250+ el.querySelector('.row-close').addEventListener('click', () => removeRow(side, id));
2251+ return el;
2252+ }
2253+ function removeRow(side, id) {
2254+ const sel = side === 'in' ? '#files-in' : '#files-out';
2255+ const row = document.querySelector(sel + ' [data-id="' + CSS.escape(id) + '"]');
2256+ if (row) row.remove();
2257+ if (side === 'in') {
2258+ /* If the transfer is still arriving, ask the sender to stop. */
2259+ if (incoming.has(id)) {
2260+ const dc = App.state.dcFiles;
2261+ if (dc && dc.readyState === 'open') {
2262+ try { dc.send(JSON.stringify({ kind: 'file-cancel', id })); } catch (_) {}
2263+ }
2264+ }
2265+ const url = incomingUrls.get(id);
2266+ if (url) { URL.revokeObjectURL(url); incomingUrls.delete(id); }
2267+ incoming.delete(id);
2268+ } else {
2269+ const i = outQueue.findIndex(q => q.id === id);
2270+ if (i >= 0) outQueue.splice(i, 1);
2271+ /* If it's the in-flight send, flag it for cancellation. The doSend
2272+ loop will pick up the flag, emit file-abort, and clear it. */
2273+ if (currentSendId === id) abortedOut.add(id);
2274+ }
2275+ }
2276+ function clearAll(side) {
2277+ const sel = side === 'in' ? '#files-in' : '#files-out';
2278+ document.querySelectorAll(sel + ' .file-item').forEach(el => el.remove());
2279+ if (side === 'in') {
2280+ /* Tell the peer to stop for any in-progress receives. */
2281+ const dc = App.state.dcFiles;
2282+ if (dc && dc.readyState === 'open') {
2283+ incoming.forEach((_f, id) => {
2284+ try { dc.send(JSON.stringify({ kind: 'file-cancel', id })); } catch (_) {}
2285+ });
2286+ }
2287+ incomingUrls.forEach(url => URL.revokeObjectURL(url));
2288+ incomingUrls.clear();
2289+ incoming.clear();
2290+ } else {
2291+ outQueue.length = 0;
2292+ /* Cancel the in-flight send too, if any. */
2293+ if (currentSendId) abortedOut.add(currentSendId);
2294+ }
2295+ }
2296+ function addOutgoingRow(id, name, size) {
2297+ document.getElementById('files-out').appendChild(rowEl('out', id, name, size));
2298+ }
2299+ function updateOutgoingRow(id, sent, size) { updateRow('#files-out', id, sent, size); }
2300+ function finishOutgoingRow(id) { markDone('#files-out', id); }
2301+ function abortOutgoingRow(id, reason) {
2302+ const row = document.querySelector('#files-out [data-id="' + CSS.escape(id) + '"]');
2303+ if (row) row.querySelector('.dl').textContent = '(' + (reason || 'aborted') + ')';
2304+ }
2305+ function markQueued(sel, id) {
2306+ const row = document.querySelector(sel + ' [data-id="' + CSS.escape(id) + '"]');
2307+ if (row) row.querySelector('.dl').textContent = 'queued';
2308+ }
2309+ function clearQueuedMark(sel, id) {
2310+ const row = document.querySelector(sel + ' [data-id="' + CSS.escape(id) + '"]');
2311+ if (row && row.querySelector('.dl').textContent === 'queued') row.querySelector('.dl').textContent = '';
2312+ }
2313+ function addIncomingRow(id, name, size) {
2314+ document.getElementById('files-in').appendChild(rowEl('in', id, name, size));
2315+ }
2316+ function updateIncomingRow(id, recv, size) { updateRow('#files-in', id, recv, size); }
2317+ function abortIncomingRow(id) {
2318+ const row = document.querySelector('#files-in [data-id="' + CSS.escape(id) + '"]');
2319+ if (row) row.querySelector('.dl').textContent = '(aborted)';
2320+ }
2321+ function finishIncoming(id, blob, name) {
2322+ const row = document.querySelector('#files-in [data-id="' + CSS.escape(id) + '"]');
2323+ if (!row) return;
2324+ const url = URL.createObjectURL(blob);
2325+ incomingUrls.set(id, url);
2326+ const a = document.createElement('a');
2327+ a.href = url; a.download = name; a.textContent = 'Download';
2328+ a.style.color = 'var(--accent)';
2329+ row.querySelector('.dl').innerHTML = '';
2330+ row.querySelector('.dl').appendChild(a);
2331+ markDone('#files-in', id);
2332+ }
2333+ function updateRow(sel, id, cur, size) {
2334+ const row = document.querySelector(sel + ' [data-id="' + CSS.escape(id) + '"]');
2335+ if (!row) return;
2336+ const pct = size ? Math.floor((cur / size) * 100) : 0;
2337+ row.querySelector('.bytes').textContent = cur.toLocaleString();
2338+ row.querySelector('.pct').textContent = pct;
2339+ row.querySelector('.progress > div').style.width = pct + '%';
2340+ }
2341+ function markDone(sel, id) {
2342+ const row = document.querySelector(sel + ' [data-id="' + CSS.escape(id) + '"]');
2343+ if (!row) return;
2344+ row.querySelector('.progress > div').style.background = 'var(--ok)';
2345+ }
2346+
2347+ return { attach, sendFile, clearAll, MAX_FILE };
2348+})();
2349+
2350+/* -------------------------------------------------------------------------
2351+ Stats
2352+------------------------------------------------------------------------- */
2353+App.stats = (() => {
2354+ let timer = null;
2355+ let consoleTimer = null;
2356+ /* id -> { bytes, ts } from the previous tick. Used to compute live bitrate
2357+ as a delta — the WebRTC stats objects don't expose a current bitrate for
2358+ inbound, only cumulative bytes. */
2359+ const prev = new Map();
2360+
2361+ function fmtBytes(n) {
2362+ if (n == null || isNaN(n)) return '—';
2363+ if (n < 1024) return n + ' B';
2364+ if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB';
2365+ if (n < 1024 * 1024 * 1024) return (n / (1024 * 1024)).toFixed(2) + ' MB';
2366+ return (n / (1024 * 1024 * 1024)).toFixed(2) + ' GB';
2367+ }
2368+ function fmtBps(bps) {
2369+ if (bps == null || isNaN(bps)) return '—';
2370+ if (bps < 1000) return Math.round(bps) + ' bps';
2371+ if (bps < 1_000_000) return (bps / 1000).toFixed(1) + ' kbps';
2372+ return (bps / 1_000_000).toFixed(2) + ' Mbps';
2373+ }
2374+ function bitrateFor(id, bytes, ts) {
2375+ const p = prev.get(id);
2376+ prev.set(id, { bytes, ts });
2377+ if (!p || ts <= p.ts) return null;
2378+ return ((bytes - p.bytes) * 8 * 1000) / (ts - p.ts);
2379+ }
2380+
2381+ async function tick() {
2382+ if (!App.state.pc) return;
2383+ const stats = await App.state.pc.getStats();
2384+ const tbody = document.querySelector('#stats-table tbody');
2385+ if (!tbody) return;
2386+ const rows = collect(stats);
2387+ /* Drop prev entries for stat ids that no longer appear in this report,
2388+ so the cache doesn't grow unboundedly across long calls where the
2389+ browser internally rotates RTP stream ids. */
2390+ const liveIds = new Set();
2391+ stats.forEach(r => { if (r.type === 'outbound-rtp' || r.type === 'inbound-rtp') liveIds.add(r.id); });
2392+ for (const k of prev.keys()) if (!liveIds.has(k)) prev.delete(k);
2393+ tbody.innerHTML = rows.map(r =>
2394+ `<tr><td>${escapeHtml(r.k)}</td><td>${escapeHtml(String(r.v))}</td></tr>`).join('');
2395+ }
2396+
2397+ function collect(stats) {
2398+ const out = [];
2399+ let selected = null;
2400+ stats.forEach(r => {
2401+ if (r.type === 'transport') {
2402+ if (r.selectedCandidatePairId) selected = r.selectedCandidatePairId;
2403+ }
2404+ });
2405+ let pair = null, localCand = null, remoteCand = null;
2406+ stats.forEach(r => {
2407+ if (r.type === 'candidate-pair' && (r.nominated || r.selected || r.id === selected) && r.state === 'succeeded')
2408+ pair = r;
2409+ });
2410+ if (pair) {
2411+ stats.forEach(r => {
2412+ if (r.id === pair.localCandidateId) localCand = r;
2413+ if (r.id === pair.remoteCandidateId) remoteCand = r;
2414+ });
2415+ out.push({ k: 'rtt (ms)', v: pair.currentRoundTripTime ? Math.round(pair.currentRoundTripTime * 1000) : '—' });
2416+ out.push({ k: 'bytes sent', v: fmtBytes(pair.bytesSent) });
2417+ out.push({ k: 'bytes received', v: fmtBytes(pair.bytesReceived) });
2418+ out.push({ k: 'available outgoing bw', v: pair.availableOutgoingBitrate ? fmtBps(pair.availableOutgoingBitrate) : '—' });
2419+ if (localCand) out.push({ k: 'local candidate', v: `${localCand.candidateType} ${localCand.address || localCand.ip || ''}:${localCand.port || ''} ${localCand.protocol || ''}` });
2420+ if (remoteCand) out.push({ k: 'remote candidate', v: `${remoteCand.candidateType} ${remoteCand.address || remoteCand.ip || ''}:${remoteCand.port || ''} ${remoteCand.protocol || ''}` });
2421+ }
2422+ /* outbound-rtp / inbound-rtp report kind='video' for both the cam and the
2423+ screen-share transceivers, so we need to disambiguate by mid. */
2424+ const midRole = new Map();
2425+ if (App.state.micTransceiver && App.state.micTransceiver.mid != null) midRole.set(String(App.state.micTransceiver.mid), 'audio');
2426+ if (App.state.camTransceiver && App.state.camTransceiver.mid != null) midRole.set(String(App.state.camTransceiver.mid), 'cam');
2427+ if (App.state.screenTransceiver && App.state.screenTransceiver.mid != null) midRole.set(String(App.state.screenTransceiver.mid), 'screen');
2428+ const roleOf = r => midRole.get(String(r.mid)) || r.kind;
2429+ /* Pre-pass: build codecId → short codec label map so we can inline the
2430+ active codec into each outbound/inbound row. The 'codec' records appear
2431+ in arbitrary order relative to the rtp records, so we collect first. */
2432+ const codecOf = new Map();
2433+ stats.forEach(r => { if (r.type === 'codec') codecOf.set(r.id, r); });
2434+ const codecLabel = id => {
2435+ const c = codecOf.get(id);
2436+ if (!c || !c.mimeType) return '';
2437+ return ' [' + c.mimeType.split('/')[1] + ']';
2438+ };
2439+ stats.forEach(r => {
2440+ if (r.type === 'outbound-rtp' && !r.isRemote) {
2441+ const role = roleOf(r);
2442+ const br = bitrateFor(r.id, r.bytesSent || 0, r.timestamp);
2443+ const fps = r.framesPerSecond ? `, ${r.framesPerSecond} fps` : '';
2444+ out.push({ k: `↑ ${role} sent${codecLabel(r.codecId)}`, v: `${fmtBytes(r.bytesSent)} / ${r.packetsSent} pkts @ ${fmtBps(br)}${fps}` });
2445+ if (r.targetBitrate) out.push({ k: `↑ ${role} target br`, v: fmtBps(r.targetBitrate) });
2446+ }
2447+ if (r.type === 'inbound-rtp' && !r.isRemote) {
2448+ const role = roleOf(r);
2449+ const br = bitrateFor(r.id, r.bytesReceived || 0, r.timestamp);
2450+ const fps = r.framesPerSecond ? `, ${r.framesPerSecond} fps` : '';
2451+ const lost = r.packetsLost ?? 0;
2452+ const jit = r.jitter ? r.jitter.toFixed(3) : 0;
2453+ out.push({ k: `↓ ${role} recv${codecLabel(r.codecId)}`, v: `${fmtBytes(r.bytesReceived)} / ${r.packetsReceived} pkts @ ${fmtBps(br)}${fps} (lost ${lost}, jitter ${jit})` });
2454+ }
2455+ });
2456+ return out;
2457+ }
2458+
2459+ function start() {
2460+ if (timer) return;
2461+ timer = setInterval(tick, 1000);
2462+ tick();
2463+ }
2464+ function stop() { if (timer) { clearInterval(timer); timer = null; } prev.clear(); }
2465+
2466+ async function exportAll() {
2467+ if (!App.state.pc) return;
2468+ const stats = await App.state.pc.getStats();
2469+ const arr = [];
2470+ stats.forEach(r => arr.push(r));
2471+ const blob = new Blob([JSON.stringify(arr, null, 2)], { type: 'application/json' });
2472+ const a = document.createElement('a');
2473+ a.href = URL.createObjectURL(blob);
2474+ a.download = 'webrtc-stats-' + Date.now() + '.json';
2475+ a.click();
2476+ }
2477+
2478+ function toggleConsoleStats() {
2479+ if (consoleTimer) {
2480+ clearInterval(consoleTimer); consoleTimer = null;
2481+ App.log.info('stats', 'console poll stopped');
2482+ } else {
2483+ consoleTimer = setInterval(async () => {
2484+ if (!App.state.pc) return;
2485+ const stats = await App.state.pc.getStats();
2486+ const rows = collect(stats);
2487+ App.log.debug('stats', rows.map(r => r.k + '=' + r.v).join(' | '));
2488+ }, 2000);
2489+ App.log.info('stats', 'console poll started (2s)');
2490+ }
2491+ }
2492+
2493+ return { start, stop, exportAll, toggleConsoleStats };
2494+})();
2495+
2496+function escapeHtml(s) {
2497+ return s.replace(/[&<>"']/g, c => ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;' })[c]);
2498+}
2499+
2500+/* -------------------------------------------------------------------------
2501+ PeerConnection creation + event wiring
2502+------------------------------------------------------------------------- */
2503+/* Both Chromium and Firefox obfuscate the local IP of host ICE candidates by
2504+ default (mDNS host-name obfuscation), which makes per-interface diagnosis
2505+ of icecandidateerror impossible — the address either ends in ".local" or
2506+ is empty/0.0.0.0. Detect this on the first host candidate we see and log a
2507+ one-shot hint pointing at the relevant browser preference. */
2508+let _obfuscationHintLogged = false;
2509+function maybeWarnObfuscation(c) {
2510+ if (_obfuscationHintLogged) return;
2511+ if (c.type !== 'host') return;
2512+ const addr = c.address || '';
2513+ const obfuscated = !addr || addr.endsWith('.local') || addr === '0.0.0.0' || addr === '::';
2514+ if (!obfuscated) return;
2515+ _obfuscationHintLogged = true;
2516+ App.log.info('pc',
2517+ 'host-candidate addresses are obfuscated (got "' + (addr || 'empty') + '"); ' +
2518+ 'icecandidateerror "from=" will not identify a real interface. To see real local IPs:\n' +
2519+ ' • Firefox: about:config → media.peerconnection.ice.obfuscate_host_addresses = false\n' +
2520+ ' • Chromium: chrome://flags/#enable-webrtc-hide-local-ips-with-mdns → Disabled');
2521+}
2522+
2523+function newPc(label) {
2524+ const cfg = { iceServers: App.state.settings.iceServers || [], iceCandidatePoolSize: 0 };
2525+ const pc = new RTCPeerConnection(cfg);
2526+ pc.addEventListener('icegatheringstatechange', () =>
2527+ App.log.debug('pc', label, 'iceGatheringState', pc.iceGatheringState));
2528+ pc.addEventListener('iceconnectionstatechange', () =>
2529+ App.log.info('pc', label, 'iceConnectionState', pc.iceConnectionState));
2530+ pc.addEventListener('connectionstatechange', () => {
2531+ App.log.info('pc', label, 'connectionState', pc.connectionState);
2532+ updateConnPill();
2533+ });
2534+ pc.addEventListener('signalingstatechange', () =>
2535+ App.log.debug('pc', label, 'signalingState', pc.signalingState));
2536+ pc.addEventListener('icecandidate', e => {
2537+ if (e.candidate) maybeWarnObfuscation(e.candidate);
2538+ });
2539+ pc.addEventListener('icecandidateerror', e => {
2540+ const local = e.address ? (e.address + ':' + (e.port || '?')) : (e.hostCandidate || '?');
2541+ App.log.warn('pc', label, 'iceCandidateError',
2542+ e.errorCode, e.errorText || '(no text)',
2543+ 'server=' + (e.url || '(none)'),
2544+ 'from=' + local);
2545+ });
2546+ pc.addEventListener('negotiationneeded', () => {
2547+ /* All three m-sections (mic/cam/screen) are pre-allocated in preallocate()
2548+ with direction=sendrecv before the very first createOffer, so toggling
2549+ a track via replaceTrack() never changes the SDP shape. The codec
2550+ payload-types negotiated up front cover the encoder configurations we
2551+ can reach via in-call settings (resolution, framerate, bitrate cap,
2552+ channels). Therefore any negotiationneeded the browser fires is
2553+ spurious for this app and safely ignored. */
2554+ App.log.debug('pc', label, 'negotiationneeded fired (ignored — this app does not renegotiate)');
2555+ });
2556+ pc.addEventListener('track', e => {
2557+ App.log.info('pc', label, 'track received', e.track.kind, 'mid=' + (e.transceiver && e.transceiver.mid));
2558+ handleRemoteTrack(pc, e);
2559+ });
2560+ return pc;
2561+}
2562+
2563+/* Local media state derived from the actual sender tracks / screen stream. */
2564+function localMediaState() {
2565+ return {
2566+ mic: !!(App.state.micTransceiver && App.state.micTransceiver.sender.track),
2567+ cam: !!(App.state.camTransceiver && App.state.camTransceiver.sender.track),
2568+ screen: !!App.state.screenStream,
2569+ };
2570+}
2571+/* Tell the peer about our current mic/cam/screen state. Sent over the chat
2572+ data channel as a typed JSON message — replaceTrack(null) doesn't itself
2573+ propagate any signal across the wire, so the receiver would otherwise just
2574+ see frozen video on the last frame. */
2575+function broadcastMediaState() {
2576+ const dc = App.state.dcChat;
2577+ if (!dc || dc.readyState !== 'open') return;
2578+ const state = { kind: 'media-state', ...localMediaState() };
2579+ try { dc.send(JSON.stringify(state)); }
2580+ catch (e) { App.log.warn('media', 'state send failed', e.message); }
2581+}
2582+
2583+/* Rebuild the remote streams by inspecting the current receivers — defensive
2584+ alternative to relying solely on the 'track' event, which can fire at
2585+ slightly different points across browsers (especially on the offerer side
2586+ where receivers existed before negotiation). */
2587+/* Map a receiver-side transceiver to its role using the stored references
2588+ from preallocate() / adoptTransceiversFromRemote(). Identity comparison
2589+ is the only reliable cue — relying on indexOf-by-kind misroutes cam and
2590+ screen when only one of them has live frames (track events fire out of
2591+ order across browsers / offerer-vs-joiner roles). */
2592+function roleForTransceiver(t) {
2593+ if (!t) return null;
2594+ if (t === App.state.micTransceiver) return 'mic';
2595+ if (t === App.state.camTransceiver) return 'cam';
2596+ if (t === App.state.screenTransceiver) return 'screen';
2597+ return null;
2598+}
2599+
2600+/* Track listeners we attached, so we can remove the exact references later
2601+ instead of leaking anonymous arrow closures over the lifetime of the page. */
2602+const remoteTrackListeners = new WeakMap(); /* track -> { unmute, ended } */
2603+
2604+function attachRemoteTrackListeners(track, onEnded) {
2605+ detachRemoteTrackListeners(track);
2606+ const handlers = { unmute: App.media.refreshRemoteDisplay, ended: onEnded };
2607+ track.addEventListener('unmute', handlers.unmute);
2608+ track.addEventListener('ended', handlers.ended);
2609+ remoteTrackListeners.set(track, handlers);
2610+}
2611+function detachRemoteTrackListeners(track) {
2612+ const h = remoteTrackListeners.get(track);
2613+ if (!h) return;
2614+ track.removeEventListener('unmute', h.unmute);
2615+ track.removeEventListener('ended', h.ended);
2616+ remoteTrackListeners.delete(track);
2617+}
2618+
2619+function rebuildRemoteStreams(pc) {
2620+ if (!pc) return;
2621+ let audio = null, cam = null, screen = null;
2622+ for (const t of pc.getTransceivers()) {
2623+ const tr = t.receiver && t.receiver.track;
2624+ if (!tr) continue;
2625+ const role = roleForTransceiver(t);
2626+ if (role === 'mic' && !audio) audio = tr;
2627+ if (role === 'cam' && !cam) cam = tr;
2628+ if (role === 'screen' && !screen) screen = tr;
2629+ }
2630+ const remote = new MediaStream();
2631+ if (audio) remote.addTrack(audio);
2632+ if (cam) remote.addTrack(cam);
2633+ App.state.remoteStream = remote;
2634+ const remoteScreen = new MediaStream();
2635+ if (screen) remoteScreen.addTrack(screen);
2636+ App.state.remoteScreenStream = remoteScreen;
2637+ /* React when a so-far-muted track gets actual frames. */
2638+ const camStreamRef = remote;
2639+ const screenStreamRef = remoteScreen;
2640+ if (audio) attachRemoteTrackListeners(audio, () => { remote.removeTrack(audio); App.media.refreshRemoteDisplay(); });
2641+ if (cam) attachRemoteTrackListeners(cam, () => { camStreamRef.removeTrack(cam); App.media.refreshRemoteDisplay(); });
2642+ if (screen) attachRemoteTrackListeners(screen, () => { screenStreamRef.removeTrack(screen); App.media.refreshRemoteDisplay(); });
2643+ App.log.info('pc', 'remote streams rebuilt', 'audio', !!audio, 'cam', !!cam, 'screen', !!screen);
2644+ App.media.refreshRemoteDisplay();
2645+}
2646+
2647+function handleRemoteTrack(pc, e) {
2648+ /* In loopback, the second pc (pcB) also fires track events but those
2649+ represent our own outbound tracks being received on the synthetic peer
2650+ — they must not feed the visible "remote" tile. */
2651+ if (pc !== App.state.pc) return;
2652+ const role = roleForTransceiver(e.transceiver);
2653+ App.log.info('pc', 'remote track', e.track.kind, 'role', role, 'muted', e.track.muted, 'mid', e.transceiver && e.transceiver.mid);
2654+ if (role === 'mic' || role === 'cam') {
2655+ if (!App.state.remoteStream) App.state.remoteStream = new MediaStream();
2656+ App.state.remoteStream.addTrack(e.track);
2657+ attachRemoteTrackListeners(e.track, () => {
2658+ if (App.state.remoteStream) App.state.remoteStream.removeTrack(e.track);
2659+ App.media.refreshRemoteDisplay();
2660+ });
2661+ App.media.refreshRemoteDisplay();
2662+ } else if (role === 'screen') {
2663+ if (!App.state.remoteScreenStream) App.state.remoteScreenStream = new MediaStream();
2664+ App.state.remoteScreenStream.addTrack(e.track);
2665+ attachRemoteTrackListeners(e.track, () => {
2666+ if (App.state.remoteScreenStream) App.state.remoteScreenStream.removeTrack(e.track);
2667+ App.media.refreshRemoteDisplay();
2668+ });
2669+ App.media.refreshRemoteDisplay();
2670+ }
2671+}
2672+
2673+function updateConnPill() {
2674+ const pc = App.state.pc;
2675+ const pill = document.getElementById('conn-pill');
2676+ if (!pc) { pill.textContent = 'disconnected'; pill.className = 'pill'; return; }
2677+ const st = pc.connectionState;
2678+ pill.textContent = st;
2679+ pill.className = 'pill ' + (
2680+ st === 'connected' ? 'ok' :
2681+ st === 'connecting' || st === 'new' ? 'warn' :
2682+ 'err'
2683+ );
2684+}
2685+
2686+/* -------------------------------------------------------------------------
2687+ Signaling flows: initiator, joiner, loopback
2688+------------------------------------------------------------------------- */
2689+/* Invoked when the user clicks Cancel in the step modal. Tears down the
2690+ in-flight setup (closes pc, aborts any long-poll fetch) and bounces back
2691+ to the welcome view. The setup function will see signalAbort.aborted and
2692+ throw 'cancelled', which cfg-continue's catch silently swallows. */
2693+function cancelSetup() {
2694+ App.log.info('app', 'setup cancelled by user');
2695+ App.state.userCancelled = true;
2696+ App.progress.hide();
2697+ hangup({ sendBye: false });
2698+}
2699+
2700+async function startInitiator() {
2701+ const pc = newPc('A');
2702+ App.state.pc = pc;
2703+ App.media.preallocate(pc);
2704+ App.media.applyVideoCodecPreference(pc);
2705+ const ac = new AbortController();
2706+ App.state.signalAbort = ac;
2707+
2708+ /* Data channels MUST be created on the initiator before createOffer
2709+ so they're included in the SDP m-section list. */
2710+ App.state.dcChat = pc.createDataChannel('chat', { ordered: true });
2711+ App.state.dcFiles = pc.createDataChannel('files', { ordered: true });
2712+ App.chat.attach(App.state.dcChat);
2713+ App.files.attach(App.state.dcFiles);
2714+
2715+ App.progress.show('Creating offer…', 'Negotiating local SDP.');
2716+ let offer = await pc.createOffer();
2717+ offer.sdp = App.codec.mungeOpus(offer.sdp, App.state.settings.opus);
2718+ await pc.setLocalDescription(offer);
2719+ App.log.info('signal', 'offer created, waiting for ICE gathering…');
2720+ App.progress.showModal('Gathering ICE candidates…',
2721+ iceGatheringSubtitle(),
2722+ { onCancel: cancelSetup });
2723+ await App.signal.waitForIceComplete(pc, ac.signal);
2724+ if (ac.signal.aborted) throw new Error('cancelled');
2725+ App.log.info('signal', 'ICE gathering complete; offer ready to export');
2726+
2727+ App.progress.hide();
2728+ renderInitiatorExchange();
2729+}
2730+
2731+async function startJoiner() {
2732+ const pc = newPc('A');
2733+ App.state.pc = pc;
2734+
2735+ pc.ondatachannel = e => {
2736+ /* If the user picked a new role mid-flight, App.state.pc may already be
2737+ a different connection. Late events from the previous pc would
2738+ otherwise overwrite the current dcChat/dcFiles with a closed channel. */
2739+ if (App.state.pc !== pc) { App.log.warn('signal', 'datachannel from stale pc, ignoring'); return; }
2740+ App.log.info('signal', 'incoming data channel', e.channel.label);
2741+ if (e.channel.label === 'chat') { App.state.dcChat = e.channel; App.chat.attach(e.channel); }
2742+ if (e.channel.label === 'files') { App.state.dcFiles = e.channel; App.files.attach(e.channel); }
2743+ };
2744+
2745+ renderJoinerExchange();
2746+}
2747+
2748+async function finishJoiner(offerObj) {
2749+ const pc = App.state.pc;
2750+ const ac = App.state.signalAbort || new AbortController();
2751+ App.state.signalAbort = ac;
2752+ App.progress.show('Applying remote offer…', 'Parsing your peer\'s SDP.');
2753+ await pc.setRemoteDescription(offerObj);
2754+ App.media.adoptTransceiversFromRemote(pc);
2755+ App.media.applyVideoCodecPreference(pc);
2756+ rebuildRemoteStreams(pc);
2757+ App.progress.show('Creating answer…', 'Negotiating local SDP.');
2758+ let answer = await pc.createAnswer();
2759+ answer.sdp = App.codec.mungeOpus(answer.sdp, App.state.settings.opus);
2760+ await pc.setLocalDescription(answer);
2761+ App.progress.showModal('Gathering ICE candidates…',
2762+ iceGatheringSubtitle(),
2763+ { onCancel: cancelSetup });
2764+ await App.signal.waitForIceComplete(pc, ac.signal);
2765+ if (ac.signal.aborted) throw new Error('cancelled');
2766+ App.progress.hide();
2767+ showAnswerForJoiner();
2768+}
2769+
2770+async function applyAnswerOnInitiator(answerObj) {
2771+ const pc = App.state.pc;
2772+ await pc.setRemoteDescription(answerObj);
2773+ rebuildRemoteStreams(pc);
2774+ App.log.info('signal', 'remote answer applied; waiting to connect…');
2775+}
2776+
2777+/* -------------------------------------------------------------------------
2778+ Auto signaling: HTTP relay (POST/long-poll GET) against a tiny server.
2779+ See server/signal.c for the protocol. The relay never touches media or
2780+ the data channels — those still flow peer-to-peer.
2781+------------------------------------------------------------------------- */
2782+async function signalPost(base, code, slot, body) {
2783+ const url = base.replace(/\/+$/, '') + '/room/' + encodeURIComponent(code) + '/' + slot;
2784+ const r = await fetch(url, {
2785+ method: 'POST',
2786+ headers: { 'Content-Type': 'application/sdp' },
2787+ body,
2788+ });
2789+ if (!r.ok) throw new Error('POST ' + slot + ' failed: ' + r.status);
2790+}
2791+
2792+/* Long-poll a slot. The server holds the request open for ~10 s; on a 204
2793+ No Content we retry. The total deadline is generous so a peer can take
2794+ their time sharing the code. Aborts cleanly when hangup() is called by
2795+ tracking a shared AbortController. (Status used to be 408, but Firefox
2796+ silently auto-retries 408 internally per RFC 7231 §6.5.7 — JS only sees
2797+ one fetch and CORS-fails after ~10 retries.) */
2798+async function signalPoll(base, code, slot, totalDeadlineMs, signal) {
2799+ const url = base.replace(/\/+$/, '') + '/room/' + encodeURIComponent(code) + '/' + slot;
2800+ const start = Date.now();
2801+ while (!signal.aborted) {
2802+ let r;
2803+ try { r = await fetch(url, { signal }); }
2804+ catch (e) {
2805+ if (signal.aborted) throw e;
2806+ throw new Error('GET ' + slot + ' failed: ' + e.message);
2807+ }
2808+ if (r.status === 200) return await r.text();
2809+ if (r.status !== 204) throw new Error('GET ' + slot + ' status ' + r.status);
2810+ if (Date.now() - start > totalDeadlineMs) throw new Error('peer did not respond within ' + Math.round(totalDeadlineMs/1000) + 's');
2811+ /* 204 → server-side long-poll timed out; loop and reconnect. */
2812+ }
2813+ throw new Error('aborted');
2814+}
2815+
2816+/* Initiator side: produce the offer the normal way, push it to the relay,
2817+ then long-poll the answer slot. Skips the blob copy/paste exchange view. */
2818+async function startInitiatorAuto(code) {
2819+ const base = App.state.settings.signaling.serverUrl;
2820+ const ac = new AbortController();
2821+ App.state.signalAbort = ac;
2822+
2823+ const pc = newPc('A');
2824+ App.state.pc = pc;
2825+ App.media.preallocate(pc);
2826+ App.media.applyVideoCodecPreference(pc);
2827+ App.state.dcChat = pc.createDataChannel('chat', { ordered: true });
2828+ App.state.dcFiles = pc.createDataChannel('files', { ordered: true });
2829+ App.chat.attach(App.state.dcChat);
2830+ App.files.attach(App.state.dcFiles);
2831+
2832+ App.progress.show('Creating offer…', 'Negotiating local SDP.');
2833+ let offer = await pc.createOffer();
2834+ offer.sdp = App.codec.mungeOpus(offer.sdp, App.state.settings.opus);
2835+ await pc.setLocalDescription(offer);
2836+ App.progress.showModal('Gathering ICE candidates…',
2837+ iceGatheringSubtitle(),
2838+ { onCancel: cancelSetup });
2839+ await App.signal.waitForIceComplete(pc, ac.signal);
2840+ if (ac.signal.aborted) throw new Error('cancelled');
2841+
2842+ const offerBlob = App.signal.encode(pc.localDescription);
2843+ App.progress.show('Publishing offer…', 'Room ' + code + ' on ' + base);
2844+ await signalPost(base, code, 'offer', offerBlob);
2845+
2846+ App.progress.showModal('Waiting for peer…',
2847+ 'Share the room code with them. They have 5 minutes to join.',
2848+ { roomCode: code, onCancel: cancelSetup });
2849+ const answerText = await signalPoll(base, code, 'answer', 5 * 60 * 1000, ac.signal);
2850+ App.progress.show('Applying answer…', 'Finalizing the handshake.');
2851+ const obj = App.signal.decode(answerText);
2852+ if (obj.type !== 'answer') throw new Error('expected an answer, got ' + obj.type);
2853+ await applyAnswerOnInitiator(obj);
2854+ App.progress.hide();
2855+ goToCall();
2856+}
2857+
2858+/* Joiner side: long-poll the offer slot, apply it, push the answer back. */
2859+async function startJoinerAuto(code) {
2860+ const base = App.state.settings.signaling.serverUrl;
2861+ const ac = new AbortController();
2862+ App.state.signalAbort = ac;
2863+
2864+ const pc = newPc('A');
2865+ App.state.pc = pc;
2866+ pc.ondatachannel = e => {
2867+ if (App.state.pc !== pc) { App.log.warn('signal', 'datachannel from stale pc, ignoring'); return; }
2868+ App.log.info('signal', 'incoming data channel', e.channel.label);
2869+ if (e.channel.label === 'chat') { App.state.dcChat = e.channel; App.chat.attach(e.channel); }
2870+ if (e.channel.label === 'files') { App.state.dcFiles = e.channel; App.files.attach(e.channel); }
2871+ };
2872+
2873+ App.progress.showModal('Waiting for offer…',
2874+ 'Polling the signaling server until your peer publishes their offer.',
2875+ { roomCode: code, onCancel: cancelSetup });
2876+ const offerText = await signalPoll(base, code, 'offer', 5 * 60 * 1000, ac.signal);
2877+ if (ac.signal.aborted) throw new Error('cancelled');
2878+ const obj = App.signal.decode(offerText);
2879+ if (obj.type !== 'offer') throw new Error('expected an offer, got ' + obj.type);
2880+
2881+ App.progress.show('Applying offer…', 'Building answer.');
2882+ await pc.setRemoteDescription(obj);
2883+ App.media.adoptTransceiversFromRemote(pc);
2884+ App.media.applyVideoCodecPreference(pc);
2885+ rebuildRemoteStreams(pc);
2886+
2887+ let answer = await pc.createAnswer();
2888+ answer.sdp = App.codec.mungeOpus(answer.sdp, App.state.settings.opus);
2889+ await pc.setLocalDescription(answer);
2890+ App.progress.showModal('Gathering ICE candidates…',
2891+ iceGatheringSubtitle(),
2892+ { onCancel: cancelSetup });
2893+ await App.signal.waitForIceComplete(pc, ac.signal);
2894+ if (ac.signal.aborted) throw new Error('cancelled');
2895+
2896+ App.progress.show('Publishing answer…', 'Room ' + code + ' on ' + base);
2897+ await signalPost(base, code, 'answer', App.signal.encode(pc.localDescription));
2898+ App.progress.hide();
2899+ goToCall();
2900+}
2901+
2902+async function startLoopback() {
2903+ const pcA = newPc('A');
2904+ const pcB = newPc('B');
2905+ App.state.pc = pcA;
2906+ App.state.pcB = pcB;
2907+
2908+ /* Trickle candidates between the two local PCs. Buffer candidates that
2909+ arrive before the target has its remote description set — otherwise
2910+ addIceCandidate rejects with InvalidStateError. */
2911+ const pendingForA = [];
2912+ const pendingForB = [];
2913+ let remoteSetA = false, remoteSetB = false;
2914+ const flush = (pc, queue) => { while (queue.length) pc.addIceCandidate(queue.shift()).catch(err => App.log.warn('loopback', 'flush', err.message)); };
2915+ pcA.onicecandidate = e => {
2916+ if (!e.candidate) return;
2917+ if (remoteSetB) pcB.addIceCandidate(e.candidate).catch(err => App.log.warn('loopback', 'B add', err.message));
2918+ else pendingForB.push(e.candidate);
2919+ };
2920+ pcB.onicecandidate = e => {
2921+ if (!e.candidate) return;
2922+ if (remoteSetA) pcA.addIceCandidate(e.candidate).catch(err => App.log.warn('loopback', 'A add', err.message));
2923+ else pendingForA.push(e.candidate);
2924+ };
2925+
2926+ pcB.ondatachannel = e => {
2927+ /* In loopback the *visible* call uses pcA's POV. pcB is the synthetic
2928+ peer; echoing the chat data channel back to A is how A learns about
2929+ its "peer's" media state (which in loopback is itself) and how chat
2930+ messages round-trip for verification. Files messages must NOT be
2931+ echoed — that would feed A's outgoing file frames back into its own
2932+ incoming-file accumulator, doubling memory and producing phantom
2933+ "incoming" download rows. */
2934+ e.channel.onmessage = ev => {
2935+ App.log.debug('loopback', 'B got', e.channel.label, typeof ev.data === 'string' ? ev.data.slice(0, 80) : '(binary)');
2936+ if (e.channel.label !== 'chat') return;
2937+ try { e.channel.send(ev.data); } catch (_) {}
2938+ };
2939+ };
2940+ pcB.ontrack = e => App.log.debug('loopback', 'B got track', e.track.kind);
2941+
2942+ App.media.preallocate(pcA);
2943+ App.media.applyVideoCodecPreference(pcA);
2944+ App.state.dcChat = pcA.createDataChannel('chat', { ordered: true });
2945+ App.state.dcFiles = pcA.createDataChannel('files', { ordered: true });
2946+ App.chat.attach(App.state.dcChat);
2947+ App.files.attach(App.state.dcFiles);
2948+
2949+ App.progress.show('Negotiating local loopback…', 'Exchanging SDP between the two in-tab peers.');
2950+ const offer = await pcA.createOffer();
2951+ offer.sdp = App.codec.mungeOpus(offer.sdp, App.state.settings.opus);
2952+ await pcA.setLocalDescription(offer);
2953+ await pcB.setRemoteDescription(offer);
2954+ remoteSetB = true; flush(pcB, pendingForB);
2955+ /* Transceivers auto-created by setRemoteDescription default to recvonly
2956+ because pcB has no local tracks yet. Force them to sendrecv so pcB can
2957+ mirror tracks back to pcA when the user later toggles mic/cam/screen. */
2958+ for (const t of pcB.getTransceivers()) {
2959+ try { t.direction = 'sendrecv'; }
2960+ catch (e) { App.log.warn('loopback', 'could not upgrade transceiver to sendrecv', e.message); }
2961+ }
2962+ App.media.applyVideoCodecPreference(pcB);
2963+
2964+ const answer = await pcB.createAnswer();
2965+ answer.sdp = App.codec.mungeOpus(answer.sdp, App.state.settings.opus);
2966+ await pcB.setLocalDescription(answer);
2967+ await pcA.setRemoteDescription(answer);
2968+ remoteSetA = true; flush(pcA, pendingForA);
2969+ rebuildRemoteStreams(pcA);
2970+ App.log.info('loopback', 'offer/answer exchanged locally');
2971+ App.progress.hide();
2972+ goToCall();
2973+}
2974+
2975+/* -------------------------------------------------------------------------
2976+ UI: views + event wiring
2977+------------------------------------------------------------------------- */
2978+function showView(id) {
2979+ document.querySelectorAll('.view').forEach(v => v.classList.add('hidden'));
2980+ document.getElementById(id).classList.remove('hidden');
2981+}
2982+
2983+function updateRoleBadge() {
2984+ const badge = document.getElementById('role-badge');
2985+ const cfg = document.getElementById('role-title-cfg');
2986+ const exch = document.getElementById('role-title-exch');
2987+ const r = App.state.role;
2988+ if (!r) { badge.classList.add('hidden'); return; }
2989+ badge.classList.remove('hidden');
2990+ badge.textContent = r;
2991+ badge.className = 'role-badge ' + r;
2992+ if (cfg) { cfg.textContent = r; cfg.className = 'role-badge ' + r; }
2993+ if (exch) { exch.textContent = r; exch.className = 'role-badge ' + r; }
2994+}
2995+
2996+function pickRole(role) {
2997+ /* If a pc from a previous role attempt is still around, tear it down so
2998+ we don't leak it. Most paths reach pickRole via the welcome view where
2999+ hangup() was already called, but the defensive close here covers cases
3000+ where the user navigates back without going through exch-cancel. */
3001+ if (App.state.pc || App.state.pcB) hangup();
3002+ App.state.role = role;
3003+ updateRoleBadge();
3004+ if (role === 'loopback') {
3005+ populateConfigInputs();
3006+ showView('view-configure');
3007+ document.getElementById('cfg-lede').textContent = 'Loopback mode: both peers run in this tab and skip the paste step.';
3008+ } else {
3009+ populateConfigInputs();
3010+ showView('view-configure');
3011+ document.getElementById('cfg-lede').textContent =
3012+ role === 'initiator'
3013+ ? 'You will generate an offer; your peer pastes it and sends back an answer.'
3014+ : 'Your peer sends you an offer; you paste it and send back the generated answer.';
3015+ }
3016+ refreshInsecureWarning();
3017+}
3018+
3019+function refreshInsecureWarning() {
3020+ /* Browsers gate gUM/gDM behind a secure context. Surface this up-front so
3021+ the user knows microphone/camera/screen-share won't be available — but
3022+ the call itself still works as receive-only. */
3023+ const banner = document.getElementById('insecure-warn');
3024+ if (!banner) return;
3025+ const noMedia = !navigator.mediaDevices || !navigator.mediaDevices.getUserMedia;
3026+ if (noMedia) banner.classList.remove('hidden');
3027+ else banner.classList.add('hidden');
3028+}
3029+
3030+/* ICE rows */
3031+function renderIceRows() {
3032+ const wrap = document.getElementById('ice-rows');
3033+ wrap.innerHTML = '';
3034+ App.state.settings.iceServers.forEach((s, i) => {
3035+ const urls = Array.isArray(s.urls) ? s.urls.join(',') : (s.urls || '');
3036+ const row = document.createElement('div');
3037+ row.className = 'ice-row';
3038+ row.innerHTML = `
3039+ <input type="text" placeholder="stun:host:port or turn:host:port" value="${escapeAttr(urls)}">
3040+ <input type="text" placeholder="username (optional)" value="${escapeAttr(s.username || '')}">
3041+ <input type="text" placeholder="credential (optional)" value="${escapeAttr(s.credential || '')}">
3042+ <button class="ghost" title="Remove"><svg class="ic" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="6" y1="6" x2="18" y2="18"/><line x1="6" y1="18" x2="18" y2="6"/></svg></button>`;
3043+ const [u, un, cr, rm] = row.children;
3044+ u.addEventListener('input', () => { s.urls = u.value.includes(',') ? u.value.split(',').map(x=>x.trim()) : u.value; });
3045+ un.addEventListener('input', () => { s.username = un.value || undefined; });
3046+ cr.addEventListener('input', () => { s.credential = cr.value || undefined; });
3047+ rm.addEventListener('click', () => { App.state.settings.iceServers.splice(i, 1); renderIceRows(); });
3048+ wrap.appendChild(row);
3049+ });
3050+}
3051+function escapeAttr(s) { return String(s).replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;'); }
3052+
3053+function populateCodecDropdown(selId) {
3054+ const sel = document.getElementById(selId);
3055+ if (!sel) return;
3056+ /* Remove previously added options in reverse so each removal doesn't shift
3057+ the indices of options we still need to inspect. */
3058+ for (let i = sel.options.length - 1; i >= 0; i--) {
3059+ if (sel.options[i].value !== 'auto') sel.remove(i);
3060+ }
3061+ if (typeof RTCRtpSender === 'undefined' || !RTCRtpSender.getCapabilities) return;
3062+ const caps = RTCRtpSender.getCapabilities('video');
3063+ if (!caps || !caps.codecs) return;
3064+ /* Some codecs (H264, AV1) appear multiple times with different profile
3065+ params — dedupe by subtype. RED/ULPFEC/rtx aren't real media codecs to
3066+ pick, so filter them out. */
3067+ const seen = new Set();
3068+ const skip = new Set(['rtx', 'red', 'ulpfec', 'flexfec-03']);
3069+ for (const c of caps.codecs) {
3070+ const sub = (c.mimeType || '').split('/')[1] || '';
3071+ const key = sub.toLowerCase();
3072+ if (!sub || skip.has(key) || seen.has(key)) continue;
3073+ seen.add(key);
3074+ const opt = document.createElement('option');
3075+ opt.value = sub;
3076+ opt.textContent = sub;
3077+ sel.appendChild(opt);
3078+ }
3079+}
3080+
3081+function populateConfigInputs() {
3082+ renderIceRows();
3083+ populateCodecDropdown('preferred-codec');
3084+ populateCodecDropdown('send-codec');
3085+ const s = App.state.settings;
3086+ const $ = id => document.getElementById(id);
3087+ $('o-stereo').checked = s.opus.stereo;
3088+ $('o-fec').checked = s.opus.fec;
3089+ $('o-dtx').checked = s.opus.dtx;
3090+ $('o-cbr').checked = s.opus.cbr;
3091+ $('o-maxbr').value = s.opus.maxAverageBitrate || '';
3092+ /* Reflect the saved preference if it still exists in the populated list,
3093+ otherwise fall back to auto. Compare case-insensitively so a saved
3094+ "VP8" still matches a hypothetical browser that returns "vp8" — and
3095+ preserve whatever casing the current browser actually gave us. */
3096+ const setSel = (id, val) => {
3097+ const el = $(id);
3098+ if (!el) return;
3099+ const want = (val || 'auto').toLowerCase();
3100+ const match = Array.from(el.options).find(o => o.value.toLowerCase() === want);
3101+ el.value = match ? match.value : 'auto';
3102+ };
3103+ setSel('preferred-codec', s.preferredVideoCodec);
3104+ setSel('send-codec', s.sendVideoCodec);
3105+
3106+ /* Signaling mode UI */
3107+ $('sig-server-url').value = s.signaling.serverUrl || '';
3108+ $('sig-ice-timeout').value = Math.round((s.signaling.iceGatherTimeoutMs || 0) / 1000);
3109+ applySignalingMode(s.signaling.mode);
3110+ /* Loopback doesn't use signaling at all — hide the card so the user isn't
3111+ given irrelevant choices. */
3112+ $('signaling-card').classList.toggle('hidden', App.state.role === 'loopback');
3113+}
3114+
3115+function applySignalingMode(mode) {
3116+ const isAuto = mode === 'auto';
3117+ App.state.settings.signaling.mode = isAuto ? 'auto' : 'manual';
3118+ document.getElementById('sig-mode-manual').classList.toggle('active', !isAuto);
3119+ document.getElementById('sig-mode-auto').classList.toggle('active', isAuto);
3120+ document.getElementById('sig-help-manual').classList.toggle('hidden', isAuto);
3121+ document.getElementById('sig-help-auto').classList.toggle('hidden', !isAuto);
3122+ document.getElementById('sig-auto-fields').classList.toggle('hidden', !isAuto);
3123+ /* The Continue button's wording reflects what happens next. */
3124+ const cont = document.getElementById('cfg-continue');
3125+ if (cont) cont.textContent = isAuto ? 'Connect →' : 'Continue to signaling →';
3126+}
3127+
3128+function randomRoomCode() {
3129+ /* Short and pronounceable-ish: 8 lowercase alphanumeric chars from a
3130+ reduced alphabet that drops easily-confused glyphs. */
3131+ const alphabet = 'abcdefghjkmnpqrstuvwxyz23456789';
3132+ const a = new Uint8Array(8);
3133+ crypto.getRandomValues(a);
3134+ let out = '';
3135+ for (const b of a) out += alphabet[b % alphabet.length];
3136+ return out;
3137+}
3138+
3139+function readConfigInputs() {
3140+ const s = App.state.settings;
3141+ const $ = id => document.getElementById(id);
3142+ s.opus.stereo = $('o-stereo').checked;
3143+ s.opus.fec = $('o-fec').checked;
3144+ s.opus.dtx = $('o-dtx').checked;
3145+ s.opus.cbr = $('o-cbr').checked;
3146+ s.opus.maxAverageBitrate = parseInt($('o-maxbr').value, 10) || 0;
3147+ s.preferredVideoCodec = $('preferred-codec').value || 'auto';
3148+ s.sendVideoCodec = $('send-codec').value || 'auto';
3149+ s.signaling.serverUrl = ($('sig-server-url').value || '').trim().replace(/\/+$/, '');
3150+ /* Seconds in the UI, milliseconds in state. 0 = no timeout; clamp negatives to 0. */
3151+ const tSec = parseInt($('sig-ice-timeout').value, 10);
3152+ s.signaling.iceGatherTimeoutMs = Number.isFinite(tSec) && tSec > 0 ? tSec * 1000 : 0;
3153+ saveIce();
3154+ saveSignaling();
3155+}
3156+
3157+/* Wire the Upload button on a paste box: reads the chosen file as text into
3158+ the `blob-in` textarea so the user can then click Apply. Does not auto-apply
3159+ — the user still reviews and submits manually. */
3160+function wireUploadButton(name) {
3161+ const btn = document.getElementById('blob-upload');
3162+ const file = document.getElementById('blob-upload-file');
3163+ const ta = document.getElementById('blob-in');
3164+ const statusEl = document.getElementById('blob-in-status');
3165+ btn.addEventListener('click', () => file.click());
3166+ file.addEventListener('change', async () => {
3167+ const f = file.files && file.files[0];
3168+ if (!f) return;
3169+ try {
3170+ ta.value = await f.text();
3171+ statusEl.textContent = 'loaded ' + f.name; statusEl.className = 'pill ok';
3172+ App.log.info('signal', name + ' loaded from ' + f.name);
3173+ } catch (e) {
3174+ statusEl.textContent = 'read failed: ' + e.message; statusEl.className = 'pill err';
3175+ App.log.error('signal', 'file read failed', e.message);
3176+ } finally {
3177+ /* Reset so re-selecting the same file fires `change` again. */
3178+ file.value = '';
3179+ }
3180+ });
3181+}
3182+
3183+/* Render the outgoing-blob block (textarea + controls) into `body`. Owns its
3184+ own copy/download/base64-toggle wiring; re-encodes from pc.localDescription
3185+ when the toggle flips so the visible blob always matches the setting.
3186+ `extraButtons` is an array of {id,label,cls,onClick} appended after Download. */
3187+function mountOutgoingBlob(body, name, extraButtons) {
3188+ const extras = (extraButtons || []).map(b =>
3189+ `<button id="${b.id}" class="${b.cls || 'ghost'}">${b.label}</button>`).join('');
3190+ body.innerHTML = `
3191+ <textarea id="blob-out" readonly spellcheck="false"></textarea>
3192+ <div class="blob-controls">
3193+ <button id="blob-copy" class="primary">Copy</button>
3194+ <button id="blob-download" class="ghost">Download</button>
3195+ ${extras}
3196+ <label class="row"><input type="checkbox" id="b64-toggle"> Base64-wrap <span class="small">(safer for paste channels that mangle whitespace)</span></label>
3197+ <span class="pill" id="blob-out-stats"></span>
3198+ </div>`;
3199+
3200+ const ta = body.querySelector('#blob-out');
3201+ const stats = body.querySelector('#blob-out-stats');
3202+ const b64 = body.querySelector('#b64-toggle');
3203+ b64.checked = !!App.state.settings.base64;
3204+
3205+ let current = '';
3206+ function refresh() {
3207+ const desc = App.state.pc && App.state.pc.localDescription;
3208+ if (!desc) return;
3209+ current = App.signal.encode(desc);
3210+ ta.value = current;
3211+ stats.textContent = current.length + ' bytes';
3212+ }
3213+ refresh();
3214+
3215+ const copyBtn = body.querySelector('#blob-copy');
3216+ if (!navigator.clipboard || !navigator.clipboard.writeText) {
3217+ copyBtn.disabled = true;
3218+ copyBtn.title = 'Clipboard API not available in this context (requires HTTPS or localhost). Select the text above and copy manually.';
3219+ } else {
3220+ copyBtn.addEventListener('click', async () => {
3221+ try { await navigator.clipboard.writeText(current); App.log.info('signal', name + ' copied'); }
3222+ catch (e) { App.log.warn('signal', 'clipboard write failed', e.message); }
3223+ });
3224+ }
3225+
3226+ body.querySelector('#blob-download').addEventListener('click', () => {
3227+ const wrapped = App.state.settings.base64;
3228+ const ext = wrapped ? 'txt' : 'json';
3229+ const mime = wrapped ? 'text/plain' : 'application/json';
3230+ const blob = new Blob([current], { type: mime });
3231+ const url = URL.createObjectURL(blob);
3232+ const a = document.createElement('a');
3233+ a.href = url; a.download = `webrtc-${name}.${ext}`;
3234+ document.body.appendChild(a); a.click(); a.remove();
3235+ URL.revokeObjectURL(url);
3236+ App.log.info('signal', name + ' downloaded as ' + a.download);
3237+ });
3238+
3239+ (extraButtons || []).forEach(b => {
3240+ body.querySelector('#' + b.id).addEventListener('click', b.onClick);
3241+ });
3242+
3243+ b64.addEventListener('change', e => {
3244+ App.state.settings.base64 = e.target.checked;
3245+ refresh();
3246+ });
3247+}
3248+
3249+/* Exchange views */
3250+function renderInitiatorExchange() {
3251+ document.getElementById('step-1-h').textContent = 'Step 1: send this offer to your peer';
3252+ mountOutgoingBlob(document.getElementById('step-1-body'), 'offer');
3253+
3254+ document.getElementById('step-2-h').textContent = 'Step 2: paste your peer\'s answer';
3255+ const s2 = document.getElementById('step-2-body');
3256+ s2.innerHTML = `
3257+ <textarea id="blob-in" spellcheck="false" placeholder="Paste answer JSON here"></textarea>
3258+ <div class="blob-controls">
3259+ <button id="blob-apply" class="primary">Apply answer</button>
3260+ <button id="blob-upload" class="ghost">Upload…</button>
3261+ <input id="blob-upload-file" type="file" accept=".json,.txt,application/json,text/plain" hidden>
3262+ <span class="pill" id="blob-in-status"></span>
3263+ </div>`;
3264+ wireUploadButton('answer');
3265+ document.getElementById('blob-apply').addEventListener('click', async () => {
3266+ const text = document.getElementById('blob-in').value;
3267+ const statusEl = document.getElementById('blob-in-status');
3268+ try {
3269+ const obj = App.signal.decode(text);
3270+ if (obj.type !== 'answer') throw new Error('expected an answer, got ' + obj.type);
3271+ statusEl.textContent = 'applying…'; statusEl.className = 'pill warn';
3272+ await applyAnswerOnInitiator(obj);
3273+ statusEl.textContent = 'applied'; statusEl.className = 'pill ok';
3274+ goToCall();
3275+ } catch (e) {
3276+ statusEl.textContent = e.message; statusEl.className = 'pill err';
3277+ App.log.error('signal', 'apply answer failed', e.message);
3278+ }
3279+ });
3280+
3281+ showView('view-exchange');
3282+}
3283+
3284+function renderJoinerExchange() {
3285+ document.getElementById('step-1-h').textContent = 'Step 1: paste the offer from your peer';
3286+ const s1 = document.getElementById('step-1-body');
3287+ s1.innerHTML = `
3288+ <textarea id="blob-in" spellcheck="false" placeholder="Paste offer JSON here"></textarea>
3289+ <div class="blob-controls">
3290+ <button id="blob-apply" class="primary">Apply offer & generate answer</button>
3291+ <button id="blob-upload" class="ghost">Upload…</button>
3292+ <input id="blob-upload-file" type="file" accept=".json,.txt,application/json,text/plain" hidden>
3293+ <span class="pill" id="blob-in-status"></span>
3294+ </div>`;
3295+ document.getElementById('step-2-card').classList.add('hidden');
3296+ wireUploadButton('offer');
3297+
3298+ document.getElementById('blob-apply').addEventListener('click', async () => {
3299+ const text = document.getElementById('blob-in').value;
3300+ const statusEl = document.getElementById('blob-in-status');
3301+ try {
3302+ const obj = App.signal.decode(text);
3303+ if (obj.type !== 'offer') throw new Error('expected an offer, got ' + obj.type);
3304+ statusEl.textContent = 'working…'; statusEl.className = 'pill warn';
3305+ await finishJoiner(obj);
3306+ statusEl.textContent = 'ready'; statusEl.className = 'pill ok';
3307+ } catch (e) {
3308+ statusEl.textContent = e.message; statusEl.className = 'pill err';
3309+ App.log.error('signal', 'apply offer failed', e.message);
3310+ App.progress.hide();
3311+ }
3312+ });
3313+
3314+ showView('view-exchange');
3315+}
3316+
3317+function showAnswerForJoiner() {
3318+ document.getElementById('step-2-card').classList.remove('hidden');
3319+ document.getElementById('step-2-h').textContent = 'Step 2: send this answer back to your peer';
3320+ mountOutgoingBlob(document.getElementById('step-2-body'), 'answer', [
3321+ { id: 'blob-done', label: "I've sent it →", cls: 'ghost', onClick: () => goToCall() },
3322+ ]);
3323+}
3324+
3325+function goToCall() {
3326+ showView('view-call');
3327+ setInCallControlsEnabled(true);
3328+ /* Pre-fill runtime settings panel from current values */
3329+ const s = App.state.settings;
3330+ const $ = id => document.getElementById(id);
3331+ $('rt-v-w').value = s.video.width || 0;
3332+ $('rt-v-h').value = s.video.height || 0;
3333+ $('rt-v-fps').value = s.video.frameRate || 0;
3334+ $('rt-v-maxbr').value = s.video.maxBitrateKbps || 0;
3335+ $('rt-v-degrade').value = s.video.degradationPreference || 'balanced';
3336+ populateCodecDropdown('rt-codec');
3337+ const wantSend = (s.sendVideoCodec || 'auto').toLowerCase();
3338+ const rtCodec = $('rt-codec');
3339+ const rtMatch = Array.from(rtCodec.options).find(o => o.value.toLowerCase() === wantSend);
3340+ rtCodec.value = rtMatch ? rtMatch.value : 'auto';
3341+ $('rt-s-w').value = s.screen.width || 0;
3342+ $('rt-s-h').value = s.screen.height || 0;
3343+ $('rt-s-fps').value = s.screen.frameRate || 0;
3344+ $('rt-s-maxbr').value = s.screen.maxBitrateKbps || 0;
3345+ $('rt-s-degrade').value = s.screen.degradationPreference || 'maintain-resolution';
3346+ $('rt-a-aec').checked = s.audio.echoCancellation;
3347+ $('rt-a-ns').checked = s.audio.noiseSuppression;
3348+ $('rt-a-agc').checked = s.audio.autoGainControl;
3349+ $('rt-a-channels').value = String(s.audio.channelCount || 1);
3350+ $('rt-a-rate').value = s.audio.sampleRate || 0;
3351+ App.stats.start();
3352+ /* Refresh the displays after the view is actually visible — some browsers
3353+ don't render hidden video elements properly, so re-bind srcObject. */
3354+ App.media.refreshLocalDisplay();
3355+ App.media.refreshRemoteDisplay();
3356+ applyMediaButtonAvailability();
3357+}
3358+
3359+/* Disable mic/cam/screen toolbar buttons when they can't possibly succeed —
3360+ either because the page isn't a secure context (gUM/gDM unavailable) or
3361+ because no matching hardware is connected. Re-runs on devicechange so
3362+ plugging in a webcam mid-call re-enables the button. */
3363+let _deviceChangeBound = false;
3364+async function applyMediaButtonAvailability() {
3365+ const hasGum = !!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia);
3366+ const hasGdm = !!(navigator.mediaDevices && navigator.mediaDevices.getDisplayMedia);
3367+ const insecureTip = 'Unavailable in this context — requires HTTPS or localhost.';
3368+ const micBtn = document.getElementById('tb-mic');
3369+ const camBtn = document.getElementById('tb-cam');
3370+ const screenBtn = document.getElementById('tb-screen');
3371+ const micPick = document.getElementById('tb-mic-pick');
3372+ const camPick = document.getElementById('tb-cam-pick');
3373+
3374+ if (!hasGdm) { screenBtn.disabled = true; screenBtn.title = insecureTip; }
3375+ if (!hasGum) {
3376+ micBtn.disabled = true; micBtn.title = insecureTip;
3377+ camBtn.disabled = true; camBtn.title = insecureTip;
3378+ if (micPick) micPick.disabled = true;
3379+ if (camPick) camPick.disabled = true;
3380+ return;
3381+ }
3382+
3383+ /* Bind the devicechange listener once. Counts of audioinput/videoinput
3384+ entries reflect presence even before permission is granted (the entries
3385+ have empty labels but still exist), so this works on first call too. */
3386+ if (!_deviceChangeBound && navigator.mediaDevices.addEventListener) {
3387+ navigator.mediaDevices.addEventListener('devicechange', applyMediaButtonAvailability);
3388+ _deviceChangeBound = true;
3389+ }
3390+
3391+ let devs = [];
3392+ try {
3393+ devs = await navigator.mediaDevices.enumerateDevices();
3394+ } catch (e) {
3395+ App.log.warn('media', 'enumerateDevices failed', e.message);
3396+ /* Fall through with permissive defaults — gUM may still work. */
3397+ }
3398+ const audioInputs = devs.filter(d => d.kind === 'audioinput');
3399+ const videoInputs = devs.filter(d => d.kind === 'videoinput');
3400+ const hasMic = devs.length === 0 || audioInputs.length > 0;
3401+ const hasCam = devs.length === 0 || videoInputs.length > 0;
3402+
3403+ /* If the device disappears while in use, don't yank the button out from
3404+ under the user — leave it clickable so they can turn the active track
3405+ off. The track-end handler will reset the button when the OS releases
3406+ the device. */
3407+ const micOn = micBtn.classList.contains('on');
3408+ const camOn = camBtn.classList.contains('on');
3409+ micBtn.disabled = !hasMic && !micOn;
3410+ micBtn.title = hasMic ? 'Enable microphone' : 'No microphone detected';
3411+ camBtn.disabled = !hasCam && !camOn;
3412+ camBtn.title = hasCam ? 'Enable camera' : 'No camera detected';
3413+
3414+ /* Device labels are only populated after the user has granted permission for
3415+ that media kind. With blank labels the picker would just list anonymous
3416+ "Microphone 1 / 2", which the user can't meaningfully choose between —
3417+ gate the chevron until the input has been enabled at least once. */
3418+ const micLabelled = audioInputs.some(d => d.label);
3419+ const camLabelled = videoInputs.some(d => d.label);
3420+ if (micPick) {
3421+ micPick.disabled = !hasMic || !micLabelled;
3422+ micPick.title = micPick.disabled
3423+ ? 'Enable microphone first to choose a device'
3424+ : 'Choose microphone';
3425+ }
3426+ if (camPick) {
3427+ camPick.disabled = !hasCam || !camLabelled;
3428+ camPick.title = camPick.disabled
3429+ ? 'Enable camera first to choose a device'
3430+ : 'Choose camera';
3431+ }
3432+
3433+ renderDeviceMenus(devs);
3434+}
3435+
3436+/* Rebuild the mic and camera popover lists from an enumerateDevices() snapshot.
3437+ Devices without labels (no permission yet) show as "Microphone 1", etc., so
3438+ the user can still see *how many* devices exist before granting permission. */
3439+function renderDeviceMenus(devs) {
3440+ renderOneDeviceMenu('tb-mic-menu', 'mic', devs.filter(d => d.kind === 'audioinput'),
3441+ App.state.settings.audio.deviceId,
3442+ App.state.micTransceiver && App.state.micTransceiver.sender);
3443+ renderOneDeviceMenu('tb-cam-menu', 'cam', devs.filter(d => d.kind === 'videoinput'),
3444+ App.state.settings.video.deviceId,
3445+ App.state.camTransceiver && App.state.camTransceiver.sender);
3446+}
3447+
3448+function renderOneDeviceMenu(menuId, kind, devs, savedId, sender) {
3449+ const menu = document.getElementById(menuId);
3450+ if (!menu) return;
3451+ /* Some webcams (notably HP combo cameras) expose the RGB and IR sensors as
3452+ two enumerateDevices entries with the *same* deviceId. gUM can't tell
3453+ them apart with {exact: deviceId}, so listing both rows would let the
3454+ user click a "different" device that's actually the same one. Dedup by
3455+ deviceId, keeping the first label we saw. */
3456+ const seenIds = new Set();
3457+ devs = devs.filter(d => {
3458+ if (!d.deviceId) return true; /* pre-permission entries — keep them */
3459+ if (seenIds.has(d.deviceId)) return false;
3460+ seenIds.add(d.deviceId);
3461+ return true;
3462+ });
3463+ /* "active" = the device currently producing the live stream. If we passed
3464+ {exact: savedId} to gUM and it succeeded, savedId IS what's live — trust
3465+ that over getSettings().deviceId, which some webcams misreport. Only
3466+ fall back to getSettings() when no preference is saved. */
3467+ let activeId = '';
3468+ if (sender && sender.track) {
3469+ activeId = savedId
3470+ || (sender.track.getSettings ? sender.track.getSettings().deviceId : '')
3471+ || '';
3472+ }
3473+ const kindLabel = kind === 'mic' ? 'Microphone' : 'Camera';
3474+ const rows = [];
3475+
3476+ const activeDot = '<svg class="ic" width="8" height="8" viewBox="0 0 8 8" aria-hidden="true"><circle cx="4" cy="4" r="3" fill="currentColor"/></svg>';
3477+ const sysActive = !savedId && activeId ? ` <span class="device-active">${activeDot} active</span>` : '';
3478+ rows.push(`<label class="device-row" role="menuitemradio">
3479+ <input type="radio" name="dev-${kind}" value="" ${savedId ? '' : 'checked'}>
3480+ <span class="device-label">System default</span>${sysActive}
3481+ </label>`);
3482+
3483+ if (devs.length === 0) {
3484+ rows.push(`<div class="device-empty">No ${kindLabel.toLowerCase()}s detected.</div>`);
3485+ } else {
3486+ devs.forEach((d, i) => {
3487+ const label = d.label || `${kindLabel} ${i + 1}`;
3488+ const checked = d.deviceId === savedId ? 'checked' : '';
3489+ const isActive = d.deviceId === activeId
3490+ ? ` <span class="device-active">${activeDot} active</span>`
3491+ : '';
3492+ rows.push(`<label class="device-row" role="menuitemradio">
3493+ <input type="radio" name="dev-${kind}" value="${escapeAttr(d.deviceId)}" ${checked}>
3494+ <span class="device-label">${escapeHtml(label)}</span>${isActive}
3495+ </label>`);
3496+ });
3497+ if (!devs[0].label) {
3498+ rows.push(`<div class="device-empty">Enable ${kindLabel.toLowerCase()} to see device names.</div>`);
3499+ }
3500+ }
3501+
3502+ menu.innerHTML = rows.join('');
3503+}
3504+
3505+/* Re-renders the picker lists with the current saved/active state. Used by
3506+ the picker change handler so the radio + "active" marker reflect the new
3507+ selection immediately, without waiting for the next devicechange event. */
3508+function refreshDeviceMenus() {
3509+ navigator.mediaDevices.enumerateDevices().then(renderDeviceMenus).catch(() => {});
3510+}
3511+
3512+function setupDevicePickers() {
3513+ setupOnePicker('mic');
3514+ setupOnePicker('cam');
3515+
3516+ /* Close any open menu on outside click or Escape. */
3517+ document.addEventListener('click', (e) => {
3518+ document.querySelectorAll('.device-picker').forEach(group => {
3519+ if (!group.contains(e.target)) closeMenuInGroup(group);
3520+ });
3521+ });
3522+ document.addEventListener('keydown', (e) => {
3523+ if (e.key === 'Escape') {
3524+ document.querySelectorAll('.device-menu:not(.hidden)').forEach(m => {
3525+ const chev = m.parentElement.querySelector('.device-chevron');
3526+ m.classList.add('hidden');
3527+ if (chev) chev.setAttribute('aria-expanded', 'false');
3528+ });
3529+ }
3530+ });
3531+}
3532+
3533+function closeMenuInGroup(group) {
3534+ const menu = group.querySelector('.device-menu');
3535+ const chev = group.querySelector('.device-chevron');
3536+ if (menu) menu.classList.add('hidden');
3537+ if (chev) chev.setAttribute('aria-expanded', 'false');
3538+}
3539+
3540+function setupOnePicker(kind) {
3541+ const chev = document.getElementById(`tb-${kind}-pick`);
3542+ const menu = document.getElementById(`tb-${kind}-menu`);
3543+ if (!chev || !menu) return;
3544+
3545+ chev.addEventListener('click', (e) => {
3546+ e.stopPropagation();
3547+ /* Close any other open menus first. */
3548+ document.querySelectorAll('.device-picker').forEach(g => {
3549+ if (!g.contains(chev)) closeMenuInGroup(g);
3550+ });
3551+ const opening = menu.classList.contains('hidden');
3552+ menu.classList.toggle('hidden');
3553+ chev.setAttribute('aria-expanded', opening ? 'true' : 'false');
3554+ if (opening) {
3555+ /* Re-enumerate on open so labels are fresh after a permission grant. */
3556+ refreshDeviceMenus();
3557+ }
3558+ });
3559+
3560+ menu.addEventListener('change', async (e) => {
3561+ const input = e.target.closest('input[type="radio"]');
3562+ if (!input) return;
3563+ const settings = kind === 'mic' ? App.state.settings.audio : App.state.settings.video;
3564+ const newId = input.value || '';
3565+ menu.classList.add('hidden');
3566+ chev.setAttribute('aria-expanded', 'false');
3567+
3568+ const btn = document.getElementById(`tb-${kind}`);
3569+ if (!btn.classList.contains('on')) {
3570+ /* Input is off — just persist the choice for the next time the user
3571+ turns it on. No gUM call. */
3572+ settings.deviceId = newId;
3573+ refreshDeviceMenus();
3574+ return;
3575+ }
3576+ /* Input is live — swap the track in place. On failure, leave the saved
3577+ preference unchanged so the menu reverts to the previously-working
3578+ selection on next render. */
3579+ const got = await App.media.switchInputDevice(kind, newId);
3580+ if (got !== null) settings.deviceId = newId;
3581+ refreshDeviceMenus();
3582+ });
3583+}
3584+
3585+
3586+/* -------------------------------------------------------------------------
3587+ Console drawer rendering
3588+------------------------------------------------------------------------- */
3589+function setupConsole() {
3590+ const body = document.getElementById('console-body');
3591+ const countEl = document.getElementById('console-count');
3592+ const levelSel = document.getElementById('console-level');
3593+ const filterEl = document.getElementById('console-filter');
3594+ const drawer = document.getElementById('console-drawer');
3595+ const toggle = document.getElementById('console-toggle');
3596+
3597+ const ORDER = { debug: 0, info: 1, warn: 2, error: 3 };
3598+ function shouldShow(e) {
3599+ if (!e) return false;
3600+ if (ORDER[e.level] < ORDER[levelSel.value]) return false;
3601+ const q = filterEl.value.toLowerCase();
3602+ if (q && !(e.label.toLowerCase().includes(q) || e.args.some(a => String(a).toLowerCase().includes(q)))) return false;
3603+ return true;
3604+ }
3605+ function fmt(args) {
3606+ return args.map(a => {
3607+ if (a == null) return String(a);
3608+ if (typeof a === 'string') return a;
3609+ try { return JSON.stringify(a); } catch (_) { return String(a); }
3610+ }).join(' ');
3611+ }
3612+ function append(entry) {
3613+ if (!entry) { body.innerHTML = ''; countEl.textContent = '0 entries'; return; }
3614+ if (!shouldShow(entry)) { countEl.textContent = App.log.snapshot().length + ' entries'; return; }
3615+ const div = document.createElement('div');
3616+ div.className = 'log-line ' + entry.level;
3617+ const t = new Date(entry.ts);
3618+ const ts = t.toTimeString().slice(0, 8) + '.' + String(t.getMilliseconds()).padStart(3, '0');
3619+ div.innerHTML = `<span class="ts">${ts}</span><span class="lvl">${entry.level}</span><span class="label">${escapeHtml(entry.label)}</span><span class="text"></span>`;
3620+ div.querySelector('.text').textContent = fmt(entry.args);
3621+ const wasAtBottom = body.scrollTop + body.clientHeight >= body.scrollHeight - 20;
3622+ body.appendChild(div);
3623+ if (wasAtBottom) body.scrollTop = body.scrollHeight;
3624+ countEl.textContent = App.log.snapshot().length + ' entries';
3625+ }
3626+ function rerender() {
3627+ body.innerHTML = '';
3628+ App.log.snapshot().forEach(append);
3629+ }
3630+
3631+ App.log.subscribe(append);
3632+ levelSel.addEventListener('change', rerender);
3633+ filterEl.addEventListener('input', rerender);
3634+ toggle.addEventListener('click', () => drawer.classList.toggle('hidden'));
3635+ document.getElementById('console-close').addEventListener('click', () => drawer.classList.add('hidden'));
3636+ document.getElementById('console-clear').addEventListener('click', () => App.log.clear());
3637+ document.getElementById('console-export').addEventListener('click', () => {
3638+ const blob = new Blob([JSON.stringify(App.log.snapshot(), null, 2)], { type: 'application/json' });
3639+ const a = document.createElement('a');
3640+ a.href = URL.createObjectURL(blob);
3641+ a.download = 'webrtc-log-' + Date.now() + '.json';
3642+ a.click();
3643+ });
3644+ document.getElementById('console-stats-toggle').addEventListener('click', () => App.stats.toggleConsoleStats());
3645+
3646+ /* Keyboard shortcut */
3647+ document.addEventListener('keydown', e => {
3648+ if ((e.ctrlKey || e.metaKey) && e.key === '`') {
3649+ e.preventDefault(); drawer.classList.toggle('hidden');
3650+ }
3651+ });
3652+}
3653+
3654+/* -------------------------------------------------------------------------
3655+ SDP inspector: parse an offer/answer and render it as labelled sections
3656+ with one-line explanations for the common attributes.
3657+
3658+ Input accepted: raw SDP (starts with "v="), the {type,sdp} JSON this tool
3659+ emits, or its "b64:"-prefixed wrapped form. The renderer never uses
3660+ innerHTML with user content — everything goes through textContent — so
3661+ pasted SDP can't smuggle markup into the page.
3662+------------------------------------------------------------------------- */
3663+App.sdpInspect = (() => {
3664+ /* Short explanations for the SDP attributes we render. Missing entries
3665+ just render without help text. */
3666+ const ATTR_HELP = {
3667+ 'group': 'Groups m= sections into one transport. "BUNDLE 0 1 2" multiplexes those mids onto a single ICE/DTLS connection.',
3668+ 'msid-semantic': 'Declares the meaning of msid values (WMS = WebRTC Media Stream).',
3669+ 'ice-ufrag': 'ICE username fragment — half of the STUN binding-request credentials.',
3670+ 'ice-pwd': 'ICE password — the other half of the ICE credentials. Treat as a short-lived secret.',
3671+ 'ice-options': 'ICE feature flags (e.g. "trickle" = candidates may arrive after the SDP).',
3672+ 'fingerprint': 'DTLS certificate fingerprint. The peer authenticates the cert against this value.',
3673+ 'setup': 'DTLS role: active (client), passive (server), or actpass (will negotiate during the handshake).',
3674+ 'mid': 'Media identifier for this m= section; referenced by BUNDLE and by the "mid" RTP header extension.',
3675+ 'extmap': 'RTP header extension: numeric id → URI describing what the extension carries.',
3676+ 'rtcp-mux': 'RTP and RTCP share one UDP port. Always present in WebRTC.',
3677+ 'rtcp-rsize': 'Allows reduced-size RTCP packets.',
3678+ 'rtcp': 'Explicit RTCP port (legacy; ignored when rtcp-mux is set).',
3679+ 'sendrecv': 'This side will both send and receive media on this m=.',
3680+ 'sendonly': 'This side will only send media on this m=.',
3681+ 'recvonly': 'This side will only receive media on this m=.',
3682+ 'inactive': 'Negotiated but neither side will send/receive media on this m=.',
3683+ 'rtpmap': 'Maps an RTP payload type to a codec/clock-rate/channels triple.',
3684+ 'fmtp': 'Per-payload-type format parameters — encoder hints (e.g. opus useinbandfec=1).',
3685+ 'rtcp-fb': 'RTCP feedback messages this payload type supports (nack, pli, transport-cc, …).',
3686+ 'candidate': 'An ICE candidate — one possible source/destination address pair for media.',
3687+ 'end-of-candidates':'No more candidates will be trickled.',
3688+ 'msid': 'Binds this m= to a MediaStream id and Track id used by the JS API.',
3689+ 'ssrc': 'Synchronization source ID for an RTP stream, plus metadata (cname, msid, …).',
3690+ 'ssrc-group': 'Groups SSRCs (FID = RTX retransmission pair; SIM = simulcast layers).',
3691+ 'rid': 'Restriction identifier for one simulcast layer.',
3692+ 'simulcast': 'Declares simulcast layer ids and directions.',
3693+ 'maxptime': 'Maximum packetization time (ms) the receiver will accept.',
3694+ 'ptime': 'Preferred packetization time (ms).',
3695+ 'extmap-allow-mixed':'Receiver accepts RTP packets that mix one-byte and two-byte header extensions in the same packet (RFC 8285).',
3696+ 'rtcp': 'Explicit RTCP address/port. Legacy — ignored when rtcp-mux is in effect (RFC 3605).',
3697+ 'bundle-only': 'This m= section is only usable when bundled via the BUNDLE group; port is 0 if not selected (RFC 8843).',
3698+ 'sctp-port': 'SCTP port for the data channel association. WebRTC always uses 5000 (RFC 8841).',
3699+ 'max-message-size': 'Maximum SCTP user message size (bytes) the receiver will accept (RFC 8841).',
3700+ };
3701+
3702+ const CANDIDATE_TYPE_HELP = {
3703+ host: 'Local interface address on this machine (LAN or loopback).',
3704+ srflx: 'Server-reflexive: public address as seen by a STUN server (post-NAT).',
3705+ prflx: 'Peer-reflexive: address discovered during connectivity checks.',
3706+ relay: 'TURN relay; media flows through the TURN server.',
3707+ };
3708+
3709+ /* Accept raw SDP, JSON wrappers, or b64-prefixed JSON. Throws on garbage. */
3710+ function extractSdp(text) {
3711+ text = (text || '').trim();
3712+ if (!text) throw new Error('empty input');
3713+ if (text.startsWith('b64:')) {
3714+ try {
3715+ const bin = atob(text.slice(4));
3716+ const bytes = new Uint8Array(bin.length);
3717+ for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
3718+ text = new TextDecoder().decode(bytes);
3719+ } catch (e) { throw new Error('bad base64: ' + e.message); }
3720+ }
3721+ if (text.startsWith('{')) {
3722+ let obj;
3723+ try { obj = JSON.parse(text); } catch (e) { throw new Error('not valid JSON: ' + e.message); }
3724+ if (!obj || typeof obj.sdp !== 'string') throw new Error('JSON has no "sdp" string field');
3725+ if (!obj.sdp.includes('v=')) throw new Error('"sdp" field is not SDP (missing v=)');
3726+ return { type: obj.type || '(unknown)', sdp: obj.sdp };
3727+ }
3728+ if (/^v=/m.test(text)) return { type: '(raw)', sdp: text };
3729+ throw new Error('unrecognized input — expected SDP, JSON, or b64:…');
3730+ }
3731+
3732+ /* Tokenize SDP into a session + one block per m= section. */
3733+ function parseSdp(sdp) {
3734+ const session = { kind: 'session', lines: [], attrs: [], media: [] };
3735+ let cur = session;
3736+ for (const raw of sdp.split(/\r?\n/)) {
3737+ const line = raw.replace(/\r$/, '');
3738+ if (!line) continue;
3739+ const m = line.match(/^([a-z])=(.*)$/);
3740+ if (!m) continue;
3741+ const key = m[1], val = m[2];
3742+ if (key === 'm') {
3743+ const parts = val.split(/\s+/);
3744+ const media = {
3745+ kind: 'media', type: parts[0] || '?', port: parts[1] || '?', proto: parts[2] || '?',
3746+ payloadTypes: parts.slice(3), lines: [], attrs: [],
3747+ };
3748+ session.media.push(media);
3749+ cur = media;
3750+ cur.lines.push({ key, val });
3751+ continue;
3752+ }
3753+ cur.lines.push({ key, val });
3754+ if (key === 'a') {
3755+ const colon = val.indexOf(':');
3756+ cur.attrs.push({
3757+ name: colon === -1 ? val : val.slice(0, colon),
3758+ value: colon === -1 ? '' : val.slice(colon + 1),
3759+ });
3760+ }
3761+ }
3762+ return session;
3763+ }
3764+
3765+ /* DOM helpers — everything textContent, no innerHTML on user input. */
3766+ function el(tag, cls, text) {
3767+ const e = document.createElement(tag);
3768+ if (cls) e.className = cls;
3769+ if (text != null) e.textContent = text;
3770+ return e;
3771+ }
3772+ function kv(grid, k, v, help) {
3773+ grid.appendChild(el('span', 'k', k));
3774+ const vEl = el('span', 'v', v == null ? '' : String(v));
3775+ if (help) vEl.title = help;
3776+ grid.appendChild(vEl);
3777+ }
3778+ function section(title, sub) {
3779+ const wrap = el('div', 'sdp-section');
3780+ const head = el('div', 'sdp-head');
3781+ head.appendChild(el('h3', null, title));
3782+ if (sub) head.appendChild(el('span', 'sdp-sub', sub));
3783+ wrap.appendChild(head);
3784+ return wrap;
3785+ }
3786+ function helpLine(text) { return el('p', 'sdp-help', text); }
3787+ function rawBlock(label, lines) {
3788+ const d = el('details', 'sdp-rawblock');
3789+ d.appendChild(el('summary', null, label));
3790+ d.appendChild(el('pre', null, lines.map(l => l.key + '=' + l.val).join('\n')));
3791+ return d;
3792+ }
3793+
3794+ /* Render an a=candidate:... value into a structured single-line list item.
3795+ RFC 5245 layout: <foundation> <component> <transport> <priority>
3796+ <addr> <port> typ <type> [raddr <addr> rport <port>]
3797+ [generation N] [tcptype …] [ufrag …] */
3798+ function renderCandidate(value) {
3799+ const t = value.split(/\s+/);
3800+ const li = el('li');
3801+ const out = [];
3802+ out.push({ text: t[2] || '?', cls: 'tag', help: 'transport (udp/tcp)' });
3803+ out.push({ text: (t[4] || '?') + ':' + (t[5] || '?'), help: 'local address & port (often obfuscated by the browser)' });
3804+ const typeIdx = t.indexOf('typ');
3805+ const type = typeIdx >= 0 ? t[typeIdx + 1] : '?';
3806+ out.push({ text: type, cls: 'badge', help: CANDIDATE_TYPE_HELP[type] || 'ICE candidate type' });
3807+ const raddrIdx = t.indexOf('raddr');
3808+ if (raddrIdx >= 0) {
3809+ const rportIdx = t.indexOf('rport');
3810+ out.push({ text: 'related ' + t[raddrIdx + 1] + ':' + (rportIdx >= 0 ? t[rportIdx + 1] : '?'),
3811+ cls: 'dim', help: 'related (base) address — for srflx/relay, the host address behind it' });
3812+ }
3813+ out.push({ text: 'prio ' + (t[3] || '?'), cls: 'dim', help: 'priority — higher wins during pair selection' });
3814+ out.push({ text: 'foundation ' + (t[0] || '?'), cls: 'dim',
3815+ help: 'foundation — candidates with the same foundation share a local interface/server pair' });
3816+
3817+ out.forEach((p, i) => {
3818+ const span = el('span', p.cls || null, p.text);
3819+ if (p.help) span.title = p.help;
3820+ li.appendChild(span);
3821+ if (i < out.length - 1) li.appendChild(document.createTextNode(' '));
3822+ });
3823+ return li;
3824+ }
3825+
3826+ function attrsBy(attrs, name) { return attrs.filter(a => a.name === name); }
3827+ function attrFirst(attrs, name) { const a = attrs.find(x => x.name === name); return a ? a.value : null; }
3828+ function directionOf(attrs) {
3829+ for (const d of ['sendrecv','sendonly','recvonly','inactive'])
3830+ if (attrs.some(a => a.name === d)) return d;
3831+ return null;
3832+ }
3833+
3834+ function renderSession(parsed) {
3835+ const out = document.createDocumentFragment();
3836+
3837+ /* Session-level summary */
3838+ const sec = section('Session');
3839+ const grid = el('div', 'sdp-kv');
3840+ const o = parsed.lines.find(l => l.key === 'o');
3841+ if (o) {
3842+ const op = o.val.split(/\s+/);
3843+ kv(grid, 'origin', op.join(' '),
3844+ 'o=<username> <sess-id> <sess-version> <nettype> <addrtype> <unicast-addr>');
3845+ }
3846+ const sName = parsed.lines.find(l => l.key === 's');
3847+ if (sName) kv(grid, 'name (s=)', sName.val, 'Session name. WebRTC uses "-".');
3848+ const t = parsed.lines.find(l => l.key === 't');
3849+ if (t) kv(grid, 'time (t=)', t.val, 't=<start> <stop>; "0 0" means unbounded — usual for real-time sessions.');
3850+ const c = parsed.lines.find(l => l.key === 'c');
3851+ if (c) kv(grid, 'connection (c=)', c.val, 'c=<nettype> <addrtype> <connection-address>.');
3852+
3853+ const sessionAttrs = parsed.attrs;
3854+ const group = attrFirst(sessionAttrs, 'group');
3855+ if (group) kv(grid, 'group', group, ATTR_HELP['group']);
3856+ const msidSem = attrFirst(sessionAttrs, 'msid-semantic');
3857+ if (msidSem) kv(grid, 'msid-semantic', msidSem, ATTR_HELP['msid-semantic']);
3858+ const fp = attrFirst(sessionAttrs, 'fingerprint');
3859+ if (fp) kv(grid, 'fingerprint (session)', fp, ATTR_HELP['fingerprint']);
3860+ const setup = attrFirst(sessionAttrs, 'setup');
3861+ if (setup) kv(grid, 'setup (session)', setup, ATTR_HELP['setup']);
3862+ const ufrag = attrFirst(sessionAttrs, 'ice-ufrag');
3863+ if (ufrag) kv(grid, 'ice-ufrag (session)', ufrag, ATTR_HELP['ice-ufrag']);
3864+ const pwd = attrFirst(sessionAttrs, 'ice-pwd');
3865+ if (pwd) kv(grid, 'ice-pwd (session)', pwd, ATTR_HELP['ice-pwd']);
3866+ const iceOpts = attrFirst(sessionAttrs, 'ice-options');
3867+ if (iceOpts) kv(grid, 'ice-options', iceOpts, ATTR_HELP['ice-options']);
3868+ if (sessionAttrs.some(a => a.name === 'extmap-allow-mixed'))
3869+ kv(grid, 'extmap-allow-mixed', 'yes', ATTR_HELP['extmap-allow-mixed']);
3870+
3871+ sec.appendChild(grid);
3872+ out.appendChild(sec);
3873+
3874+ /* One section per m= */
3875+ parsed.media.forEach((media, idx) => renderMedia(media, idx, sessionAttrs, out));
3876+ return out;
3877+ }
3878+
3879+ function renderMedia(media, idx, sessionAttrs, out) {
3880+ const sub = media.proto + ' port ' + media.port + ' PTs: ' + media.payloadTypes.join(' ');
3881+ const sec = section('m=' + media.type + ' [' + idx + ']', sub);
3882+
3883+ const grid = el('div', 'sdp-kv');
3884+ const mid = attrFirst(media.attrs, 'mid');
3885+ if (mid) kv(grid, 'mid', mid, ATTR_HELP['mid']);
3886+ const dir = directionOf(media.attrs);
3887+ if (dir) kv(grid, 'direction', dir, ATTR_HELP[dir]);
3888+ const msid = attrFirst(media.attrs, 'msid');
3889+ if (msid) kv(grid, 'msid', msid, ATTR_HELP['msid']);
3890+ if (media.attrs.some(a => a.name === 'rtcp-mux')) kv(grid, 'rtcp-mux', 'yes', ATTR_HELP['rtcp-mux']);
3891+ if (media.attrs.some(a => a.name === 'rtcp-rsize')) kv(grid, 'rtcp-rsize', 'yes', ATTR_HELP['rtcp-rsize']);
3892+ if (media.attrs.some(a => a.name === 'extmap-allow-mixed'))
3893+ kv(grid, 'extmap-allow-mixed', 'yes', ATTR_HELP['extmap-allow-mixed']);
3894+ if (media.attrs.some(a => a.name === 'bundle-only'))
3895+ kv(grid, 'bundle-only', 'yes', ATTR_HELP['bundle-only']);
3896+ const rtcpAddr = attrFirst(media.attrs, 'rtcp');
3897+ if (rtcpAddr) kv(grid, 'rtcp (legacy)', rtcpAddr, ATTR_HELP['rtcp']);
3898+ const sctpPort = attrFirst(media.attrs, 'sctp-port');
3899+ if (sctpPort) kv(grid, 'sctp-port', sctpPort, ATTR_HELP['sctp-port']);
3900+ const maxMsg = attrFirst(media.attrs, 'max-message-size');
3901+ if (maxMsg) kv(grid, 'max-message-size', maxMsg + ' bytes', ATTR_HELP['max-message-size']);
3902+ const mFp = attrFirst(media.attrs, 'fingerprint');
3903+ if (mFp) kv(grid, 'fingerprint', mFp, ATTR_HELP['fingerprint']);
3904+ const mSetup = attrFirst(media.attrs, 'setup');
3905+ if (mSetup) kv(grid, 'setup', mSetup, ATTR_HELP['setup']);
3906+ const mUfrag = attrFirst(media.attrs, 'ice-ufrag');
3907+ if (mUfrag) kv(grid, 'ice-ufrag', mUfrag, ATTR_HELP['ice-ufrag']);
3908+ const mPwd = attrFirst(media.attrs, 'ice-pwd');
3909+ if (mPwd) kv(grid, 'ice-pwd', mPwd, ATTR_HELP['ice-pwd']);
3910+ sec.appendChild(grid);
3911+
3912+ /* Codecs */
3913+ const rtpmaps = attrsBy(media.attrs, 'rtpmap');
3914+ const fmtps = attrsBy(media.attrs, 'fmtp');
3915+ const fbs = attrsBy(media.attrs, 'rtcp-fb');
3916+ if (rtpmaps.length) {
3917+ sec.appendChild(el('h3', null, 'Codecs'));
3918+ sec.appendChild(helpLine('Each payload type (PT) maps to a codec definition. fmtp/rtcp-fb lines attach to a PT by id.'));
3919+ const list = el('ul', 'sdp-list');
3920+ rtpmaps.forEach(r => {
3921+ const m = r.value.match(/^(\d+)\s+(.+)$/);
3922+ if (!m) return;
3923+ const pt = m[1], spec = m[2];
3924+ const li = el('li');
3925+ const tag = el('span', 'tag', pt);
3926+ tag.title = 'RTP payload type number';
3927+ li.appendChild(tag);
3928+ li.appendChild(document.createTextNode(spec));
3929+ const fmtp = fmtps.find(f => f.value.startsWith(pt + ' '));
3930+ if (fmtp) {
3931+ const b = el('span', 'badge', 'fmtp: ' + fmtp.value.slice(pt.length + 1));
3932+ b.title = ATTR_HELP['fmtp'];
3933+ li.appendChild(document.createTextNode(' '));
3934+ li.appendChild(b);
3935+ }
3936+ const ptFbs = fbs.filter(f => f.value.startsWith(pt + ' ') || f.value.startsWith('* '));
3937+ if (ptFbs.length) {
3938+ const fbText = ptFbs.map(f => f.value.split(/\s+/).slice(1).join(' ')).join(' / ');
3939+ const b = el('span', 'dim', 'fb: ' + fbText);
3940+ b.title = ATTR_HELP['rtcp-fb'];
3941+ li.appendChild(document.createTextNode(' '));
3942+ li.appendChild(b);
3943+ }
3944+ list.appendChild(li);
3945+ });
3946+ sec.appendChild(list);
3947+ }
3948+
3949+ /* RTP header extensions */
3950+ const extmaps = attrsBy(media.attrs, 'extmap');
3951+ if (extmaps.length) {
3952+ sec.appendChild(el('h3', null, 'RTP header extensions'));
3953+ sec.appendChild(helpLine(ATTR_HELP['extmap']));
3954+ const list = el('ul', 'sdp-list');
3955+ extmaps.forEach(e => {
3956+ const m = e.value.match(/^(\d+)(?:\/(\S+))?\s+(.+)$/);
3957+ const li = el('li');
3958+ if (m) {
3959+ li.appendChild(el('span', 'tag', m[1]));
3960+ if (m[2]) { const dir = el('span', 'badge', m[2]); dir.title = 'extension direction'; li.appendChild(dir); }
3961+ li.appendChild(document.createTextNode(' ' + m[3]));
3962+ } else li.textContent = e.value;
3963+ list.appendChild(li);
3964+ });
3965+ sec.appendChild(list);
3966+ }
3967+
3968+ /* ICE candidates */
3969+ const cands = attrsBy(media.attrs, 'candidate');
3970+ if (cands.length) {
3971+ sec.appendChild(el('h3', null, 'ICE candidates (' + cands.length + ')'));
3972+ sec.appendChild(helpLine(ATTR_HELP['candidate']));
3973+ const list = el('ul', 'sdp-list');
3974+ cands.forEach(c => list.appendChild(renderCandidate(c.value)));
3975+ sec.appendChild(list);
3976+ }
3977+ if (media.attrs.some(a => a.name === 'end-of-candidates'))
3978+ sec.appendChild(helpLine('end-of-candidates present: ' + ATTR_HELP['end-of-candidates']));
3979+
3980+ /* SSRCs */
3981+ const ssrcs = attrsBy(media.attrs, 'ssrc');
3982+ const ssrcGroups = attrsBy(media.attrs, 'ssrc-group');
3983+ if (ssrcs.length || ssrcGroups.length) {
3984+ sec.appendChild(el('h3', null, 'SSRCs'));
3985+ if (ssrcGroups.length) {
3986+ const gl = el('ul', 'sdp-list');
3987+ ssrcGroups.forEach(g => {
3988+ const li = el('li');
3989+ li.appendChild(el('span', 'badge', 'group'));
3990+ li.appendChild(document.createTextNode(' ' + g.value));
3991+ li.title = ATTR_HELP['ssrc-group'];
3992+ gl.appendChild(li);
3993+ });
3994+ sec.appendChild(gl);
3995+ }
3996+ const byId = new Map();
3997+ ssrcs.forEach(s => {
3998+ const m = s.value.match(/^(\d+)\s+(\S+?)(?::(.*))?$/);
3999+ if (!m) return;
4000+ const id = m[1], attr = m[2], val = m[3] || '';
4001+ if (!byId.has(id)) byId.set(id, []);
4002+ byId.get(id).push(attr + (val ? '=' + val : ''));
4003+ });
4004+ const list = el('ul', 'sdp-list');
4005+ byId.forEach((props, id) => {
4006+ const li = el('li');
4007+ li.appendChild(el('span', 'tag', id));
4008+ li.appendChild(document.createTextNode(props.join(' ')));
4009+ li.title = ATTR_HELP['ssrc'];
4010+ list.appendChild(li);
4011+ });
4012+ sec.appendChild(list);
4013+ }
4014+
4015+ /* Simulcast / rid */
4016+ const rids = attrsBy(media.attrs, 'rid');
4017+ const sim = attrFirst(media.attrs, 'simulcast');
4018+ if (rids.length || sim) {
4019+ sec.appendChild(el('h3', null, 'Simulcast'));
4020+ sec.appendChild(helpLine(ATTR_HELP['simulcast']));
4021+ if (sim) sec.appendChild(el('p', 'sdp-help', 'simulcast: ' + sim));
4022+ if (rids.length) {
4023+ const list = el('ul', 'sdp-list');
4024+ rids.forEach(r => { const li = el('li'); li.textContent = r.value; li.title = ATTR_HELP['rid']; list.appendChild(li); });
4025+ sec.appendChild(list);
4026+ }
4027+ }
4028+
4029+ /* Anything we didn't classify — show raw for completeness. */
4030+ const handled = new Set([
4031+ 'mid','msid','rtcp-mux','rtcp-rsize','fingerprint','setup','ice-ufrag','ice-pwd',
4032+ 'sendrecv','sendonly','recvonly','inactive','rtpmap','fmtp','rtcp-fb','candidate',
4033+ 'end-of-candidates','ssrc','ssrc-group','rid','simulcast','extmap',
4034+ 'extmap-allow-mixed','bundle-only','rtcp','sctp-port','max-message-size',
4035+ ]);
4036+ const other = media.attrs.filter(a => !handled.has(a.name));
4037+ if (other.length) {
4038+ sec.appendChild(rawBlock('Other attributes (' + other.length + ')',
4039+ other.map(a => ({ key: 'a', val: a.name + (a.value ? ':' + a.value : '') }))));
4040+ }
4041+ sec.appendChild(rawBlock('Raw lines for this m= (' + media.lines.length + ')', media.lines));
4042+ out.appendChild(sec);
4043+ }
4044+
4045+ function run(input, container, statusEl) {
4046+ container.replaceChildren();
4047+ try {
4048+ const { type, sdp } = extractSdp(input);
4049+ const parsed = parseSdp(sdp);
4050+ const header = el('div', 'sdp-section');
4051+ const head = el('div', 'sdp-head');
4052+ head.appendChild(el('h3', null, 'Detected: ' + type));
4053+ head.appendChild(el('span', 'sdp-sub',
4054+ parsed.media.length + ' m= section(s) · ' + sdp.split(/\r?\n/).length + ' lines'));
4055+ header.appendChild(head);
4056+ container.appendChild(header);
4057+ container.appendChild(renderSession(parsed));
4058+ statusEl.textContent = 'parsed ' + parsed.media.length + ' section(s)';
4059+ statusEl.className = 'pill ok';
4060+ } catch (e) {
4061+ statusEl.textContent = e.message;
4062+ statusEl.className = 'pill err';
4063+ }
4064+ }
4065+
4066+ return { run };
4067+})();
4068+
4069+/* -------------------------------------------------------------------------
4070+ Wire up everything on DOMContentLoaded
4071+------------------------------------------------------------------------- */
4072+function wire() {
4073+ /* Welcome */
4074+ document.querySelectorAll('#view-welcome .role-picker button').forEach(b =>
4075+ b.addEventListener('click', () => pickRole(b.dataset.role)));
4076+ document.getElementById('welcome-sdp-inspect').addEventListener('click', () => {
4077+ /* Standalone tool — no role, no pc. Just swap the view. */
4078+ showView('view-sdp-inspect');
4079+ });
4080+
4081+ /* SDP inspector */
4082+ const sdpIn = document.getElementById('sdp-in');
4083+ const sdpOut = document.getElementById('sdp-inspect-out');
4084+ const sdpStatus = document.getElementById('sdp-inspect-status');
4085+ document.getElementById('sdp-inspect-go').addEventListener('click',
4086+ () => App.sdpInspect.run(sdpIn.value, sdpOut, sdpStatus));
4087+ document.getElementById('sdp-inspect-clear').addEventListener('click', () => {
4088+ sdpIn.value = ''; sdpOut.replaceChildren(); sdpStatus.textContent = ''; sdpStatus.className = 'pill';
4089+ });
4090+ document.getElementById('sdp-inspect-back').addEventListener('click', () => showView('view-welcome'));
4091+ const sdpFile = document.getElementById('sdp-inspect-file');
4092+ document.getElementById('sdp-inspect-upload').addEventListener('click', () => sdpFile.click());
4093+ sdpFile.addEventListener('change', async () => {
4094+ const f = sdpFile.files && sdpFile.files[0];
4095+ if (!f) return;
4096+ try { sdpIn.value = await f.text(); App.sdpInspect.run(sdpIn.value, sdpOut, sdpStatus); }
4097+ catch (e) { sdpStatus.textContent = 'read failed: ' + e.message; sdpStatus.className = 'pill err'; }
4098+ finally { sdpFile.value = ''; }
4099+ });
4100+
4101+ /* Theme */
4102+ document.getElementById('theme-toggle').addEventListener('click', () => App.theme.toggle());
4103+
4104+ /* Peer-left dialog: OK closes; backdrop/Esc also close (native behavior). */
4105+ document.getElementById('peer-left-ok').addEventListener('click', () => {
4106+ document.getElementById('peer-left-dialog').close();
4107+ });
4108+
4109+ /* Step dialog: Cancel and Esc both invoke the registered cancel handler.
4110+ Listen on 'cancel' (fired by Esc) and 'close' as a belt-and-suspenders. */
4111+ document.getElementById('step-dialog-cancel').addEventListener('click', () => {
4112+ App.progress.triggerCancel();
4113+ });
4114+ document.getElementById('step-dialog').addEventListener('cancel', e => {
4115+ /* Don't let the dialog close before we run the cancel handler — the
4116+ handler itself calls dlg.close() through App.progress.hideModal(). */
4117+ e.preventDefault();
4118+ App.progress.triggerCancel();
4119+ });
4120+
4121+ /* Best-effort hangup notification when the tab is closing or backgrounded
4122+ to bfcache. Use pagehide (more reliable than beforeunload, especially
4123+ on mobile) and only send if a chat channel is currently open. */
4124+ window.addEventListener('pagehide', () => {
4125+ if (App.chat && App.chat.sendBye) App.chat.sendBye();
4126+ });
4127+
4128+ /* Configure */
4129+ document.getElementById('ice-add').addEventListener('click', () => {
4130+ App.state.settings.iceServers.push({ urls: '' });
4131+ renderIceRows();
4132+ });
4133+ document.getElementById('ice-clear').addEventListener('click', () => {
4134+ App.state.settings.iceServers = [];
4135+ renderIceRows();
4136+ });
4137+ document.getElementById('ice-reset').addEventListener('click', () => {
4138+ App.state.settings.iceServers = [{ urls: 'stun:stun.l.google.com:19302' }];
4139+ renderIceRows();
4140+ });
4141+ document.getElementById('ice-toggle-json').addEventListener('click', () => {
4142+ const w = document.getElementById('ice-json-wrap');
4143+ document.getElementById('ice-json').value = JSON.stringify(App.state.settings.iceServers, null, 2);
4144+ w.classList.toggle('hidden');
4145+ });
4146+ document.getElementById('ice-warmup').addEventListener('click', async () => {
4147+ const status = document.getElementById('ice-warmup-status');
4148+ const btn = document.getElementById('ice-warmup');
4149+ if (App.state.iceWarmupStream) {
4150+ App.state.iceWarmupStream.getTracks().forEach(t => t.stop());
4151+ App.state.iceWarmupStream = null;
4152+ status.textContent = 'off'; status.className = 'pill';
4153+ btn.textContent = 'Enable LAN connectivity';
4154+ App.log.info('ice', 'LAN warmup stream stopped');
4155+ return;
4156+ }
4157+ if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
4158+ status.textContent = 'unavailable (needs HTTPS)'; status.className = 'pill err';
4159+ return;
4160+ }
4161+ btn.disabled = true;
4162+ status.textContent = 'requesting…'; status.className = 'pill warn';
4163+ try {
4164+ const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
4165+ /* Mute the track but keep the stream alive — Firefox exposes LAN ICE
4166+ candidates only while a gUM stream is in use. Stopping the track would
4167+ revert to restricted candidates. */
4168+ stream.getAudioTracks().forEach(t => t.enabled = false);
4169+ App.state.iceWarmupStream = stream;
4170+ status.textContent = 'on (mic in use, muted)'; status.className = 'pill ok';
4171+ btn.textContent = 'Disable LAN connectivity';
4172+ App.log.info('ice', 'LAN warmup stream active; LAN candidates unlocked');
4173+ } catch (e) {
4174+ status.textContent = 'denied: ' + e.message; status.className = 'pill err';
4175+ App.log.warn('ice', 'LAN warmup denied', e.message);
4176+ } finally {
4177+ btn.disabled = false;
4178+ }
4179+ });
4180+ document.getElementById('ice-json-apply').addEventListener('click', () => {
4181+ try {
4182+ const v = JSON.parse(document.getElementById('ice-json').value);
4183+ if (!Array.isArray(v)) throw new Error('expected an array');
4184+ App.state.settings.iceServers = v;
4185+ renderIceRows();
4186+ App.log.info('ice', 'applied JSON config', v.length, 'servers');
4187+ } catch (e) { App.log.error('ice', 'bad JSON', e.message); alert('Bad JSON: ' + e.message); }
4188+ });
4189+ document.getElementById('cfg-back').addEventListener('click', () => {
4190+ /* Tear down a partial pc/abort an in-flight signaling fetch if the
4191+ user hits Back while auto-mode is waiting for the peer. */
4192+ hangup({ sendBye: false });
4193+ });
4194+ /* Signaling mode toggle */
4195+ document.getElementById('sig-mode-manual').addEventListener('click', () => {
4196+ applySignalingMode('manual'); saveSignaling();
4197+ });
4198+ document.getElementById('sig-mode-auto').addEventListener('click', () => {
4199+ applySignalingMode('auto'); saveSignaling();
4200+ });
4201+ document.getElementById('sig-room-gen').addEventListener('click', () => {
4202+ document.getElementById('sig-room-code').value = randomRoomCode();
4203+ });
4204+ document.getElementById('sig-check').addEventListener('click', async () => {
4205+ const btn = document.getElementById('sig-check');
4206+ const status = document.getElementById('sig-check-status');
4207+ const url = (document.getElementById('sig-server-url').value || '').trim().replace(/\/+$/, '');
4208+ status.classList.remove('hidden');
4209+ if (!url) { status.textContent = 'enter a URL first'; status.className = 'pill err'; return; }
4210+ btn.disabled = true;
4211+ status.textContent = 'checking…'; status.className = 'pill warn';
4212+ /* Independent abort from the room-handshake one so cancelling Check doesn't
4213+ affect anything else. 5 s is plenty for a healthy server. */
4214+ const ac = new AbortController();
4215+ const timer = setTimeout(() => ac.abort(), 5000);
4216+ const t0 = performance.now();
4217+ try {
4218+ const r = await fetch(url + '/health', { signal: ac.signal, cache: 'no-store' });
4219+ const ms = Math.round(performance.now() - t0);
4220+ if (r.ok) { status.textContent = 'reachable (' + r.status + ', ' + ms + ' ms)'; status.className = 'pill ok'; }
4221+ else { status.textContent = 'HTTP ' + r.status; status.className = 'pill err'; }
4222+ } catch (e) {
4223+ status.textContent = ac.signal.aborted ? 'timed out (5 s)' : 'unreachable: ' + e.message;
4224+ status.className = 'pill err';
4225+ } finally {
4226+ clearTimeout(timer);
4227+ btn.disabled = false;
4228+ }
4229+ });
4230+ document.getElementById('sig-room-code').addEventListener('input', e => {
4231+ /* Server validates the same character class — keep the input clean so the
4232+ user notices invalid keystrokes immediately rather than at request time. */
4233+ e.target.value = e.target.value.toLowerCase().replace(/[^a-z0-9_-]/g, '').slice(0, 15);
4234+ });
4235+
4236+ document.getElementById('cfg-continue').addEventListener('click', async () => {
4237+ readConfigInputs();
4238+ saveIce();
4239+ const auto = App.state.settings.signaling.mode === 'auto' && App.state.role !== 'loopback';
4240+ try {
4241+ if (auto) {
4242+ const code = (document.getElementById('sig-room-code').value || '').trim();
4243+ if (!code) { alert('Room code is required for auto mode.'); return; }
4244+ if (!App.state.settings.signaling.serverUrl) { alert('Server URL is required for auto mode.'); return; }
4245+ if (App.state.role === 'initiator') await startInitiatorAuto(code);
4246+ else if (App.state.role === 'joiner') await startJoinerAuto(code);
4247+ } else {
4248+ if (App.state.role === 'initiator') await startInitiator();
4249+ else if (App.state.role === 'joiner') await startJoiner();
4250+ else if (App.state.role === 'loopback') await startLoopback();
4251+ }
4252+ } catch (e) {
4253+ App.progress.hide();
4254+ if (App.state.userCancelled) { App.state.userCancelled = false; return; }
4255+ App.log.error('setup', 'failed', e.message);
4256+ alert('Setup failed: ' + e.message);
4257+ }
4258+ });
4259+
4260+ document.getElementById('exch-cancel').addEventListener('click', () => {
4261+ hangup();
4262+ App.state.role = null;
4263+ updateRoleBadge();
4264+ showView('view-welcome');
4265+ });
4266+
4267+ /* Disable a button until its async handler resolves, so double-clicks
4268+ during gUM/gDM don't interleave. Used for both the Apply buttons in the
4269+ settings panel and the main toolbar Mic/Cam/Screen toggles. */
4270+ function withReentryGuard(btnId, fn) {
4271+ const btn = document.getElementById(btnId);
4272+ btn.addEventListener('click', async () => {
4273+ if (btn.disabled) return;
4274+ btn.disabled = true;
4275+ try { await fn(); }
4276+ catch (e) { App.log.error('media', 'action failed', e.message); }
4277+ finally { btn.disabled = false; }
4278+ });
4279+ }
4280+
4281+ /* Call: toolbar — mic/cam lazily call getUserMedia on first enable. */
4282+ withReentryGuard('tb-mic', async () => {
4283+ const btn = document.getElementById('tb-mic');
4284+ await App.media.setMic(!btn.classList.contains('on'));
4285+ });
4286+ withReentryGuard('tb-cam', async () => {
4287+ const btn = document.getElementById('tb-cam');
4288+ await App.media.setCam(!btn.classList.contains('on'));
4289+ });
4290+ withReentryGuard('tb-screen', async () => {
4291+ if (App.state.screenStream) await App.media.stopScreenshare();
4292+ else {
4293+ try { await App.media.startScreenshare(); }
4294+ catch (e) { App.log.error('media', 'screenshare', e.message); App.chat.appendSystem?.('Screen share failed: ' + e.message); }
4295+ }
4296+ });
4297+ document.getElementById('tb-hangup').addEventListener('click', hangup);
4298+
4299+ /* Click a tile that's showing a screen share → fullscreen. */
4300+ for (const id of ['tile-local', 'tile-remote']) {
4301+ document.getElementById(id).addEventListener('click', e => {
4302+ const tile = e.currentTarget;
4303+ if (!tile.classList.contains('screen')) return;
4304+ /* Don't trigger fullscreen for clicks on the PIP */
4305+ if (e.target.closest('.pip')) return;
4306+ const video = tile.querySelector(':scope > video');
4307+ if (!video) return;
4308+ const target = video;
4309+ if (!document.fullscreenElement) {
4310+ (target.requestFullscreen?.() || target.webkitRequestFullscreen?.() || Promise.resolve())
4311+ .catch?.(err => App.log.warn('ui', 'fullscreen failed', err.message));
4312+ }
4313+ });
4314+ }
4315+
4316+ /* Files: clear-all */
4317+ document.getElementById('files-out-clear').addEventListener('click', () => App.files.clearAll('out'));
4318+ document.getElementById('files-in-clear').addEventListener('click', () => App.files.clearAll('in'));
4319+
4320+ /* Call: sidebar tabs */
4321+ document.querySelectorAll('.tabs button').forEach(b => {
4322+ b.addEventListener('click', () => {
4323+ document.querySelectorAll('.tabs button').forEach(x => x.classList.remove('active'));
4324+ document.querySelectorAll('.tab-pane').forEach(x => x.classList.remove('active'));
4325+ b.classList.add('active');
4326+ document.querySelector(`.tab-pane[data-pane="${b.dataset.tab}"]`).classList.add('active');
4327+ });
4328+ });
4329+
4330+ /* Chat */
4331+ const chatInput = document.getElementById('chat-text');
4332+ const chatSend = document.getElementById('chat-send');
4333+ const chatCounter = document.getElementById('chat-counter');
4334+ const CHAT_MAX = App.chat.MAX_TEXT;
4335+ function updateChatCounter() {
4336+ const bytes = App.chat.utf8Length(chatInput.value);
4337+ chatCounter.textContent = bytes + ' / ' + CHAT_MAX + ' B';
4338+ const over = bytes > CHAT_MAX;
4339+ chatCounter.classList.toggle('over', over);
4340+ chatSend.disabled = over || bytes === 0;
4341+ }
4342+ chatSend.addEventListener('click', sendChat);
4343+ chatInput.addEventListener('input', updateChatCounter);
4344+ chatInput.addEventListener('keydown', e => {
4345+ if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendChat(); }
4346+ });
4347+ updateChatCounter();
4348+ function sendChat() {
4349+ const t = chatInput.value.trim();
4350+ if (!t) return;
4351+ if (App.chat.utf8Length(t) > CHAT_MAX) return;
4352+ App.chat.send(t);
4353+ chatInput.value = '';
4354+ updateChatCounter();
4355+ }
4356+
4357+ /* Files */
4358+ (() => {
4359+ const n = App.files.MAX_FILE;
4360+ let txt;
4361+ if (n >= 1024 ** 3) txt = (n / 1024 ** 3) + ' GB';
4362+ else if (n >= 1024 ** 2) txt = (n / 1024 ** 2) + ' MB';
4363+ else txt = (n / 1024) + ' KB';
4364+ document.getElementById('files-max').textContent = txt;
4365+ })();
4366+ const drop = document.getElementById('files-drop');
4367+ drop.addEventListener('dragover', e => { e.preventDefault(); drop.classList.add('over'); });
4368+ drop.addEventListener('dragleave', () => drop.classList.remove('over'));
4369+ drop.addEventListener('drop', e => {
4370+ e.preventDefault(); drop.classList.remove('over');
4371+ const f = e.dataTransfer.files[0];
4372+ if (f) App.files.sendFile(f);
4373+ });
4374+ document.getElementById('files-pick').addEventListener('click', e => {
4375+ e.preventDefault();
4376+ document.getElementById('files-input').click();
4377+ });
4378+ document.getElementById('files-input').addEventListener('change', e => {
4379+ const f = e.target.files[0];
4380+ if (f) App.files.sendFile(f);
4381+ e.target.value = '';
4382+ });
4383+
4384+ /* Runtime settings */
4385+ withReentryGuard('rt-v-apply', async () => {
4386+ const v = App.state.settings.video;
4387+ const prevW = v.width, prevH = v.height, prevFps = v.frameRate;
4388+ v.width = parseInt(document.getElementById('rt-v-w').value, 10) || 0;
4389+ v.height = parseInt(document.getElementById('rt-v-h').value, 10) || 0;
4390+ v.frameRate = parseInt(document.getElementById('rt-v-fps').value, 10) || 0;
4391+ v.maxBitrateKbps = parseInt(document.getElementById('rt-v-maxbr').value, 10) || 0;
4392+ v.degradationPreference = document.getElementById('rt-v-degrade').value;
4393+ App.media.applyCamSendParams();
4394+ /* Resolution / framerate only take effect on a fresh getUserMedia call —
4395+ cycle the camera if it's currently on so the new constraints apply. */
4396+ const camChanged = v.width !== prevW || v.height !== prevH || v.frameRate !== prevFps;
4397+ const camOn = document.getElementById('tb-cam').classList.contains('on');
4398+ if (camChanged && camOn) {
4399+ App.log.info('media', 'restarting camera to apply new resolution/framerate');
4400+ await App.media.setCam(false);
4401+ await App.media.setCam(true);
4402+ }
4403+ });
4404+ withReentryGuard('rt-s-apply', async () => {
4405+ const s = App.state.settings.screen;
4406+ const prevW = s.width, prevH = s.height, prevFps = s.frameRate;
4407+ s.width = parseInt(document.getElementById('rt-s-w').value, 10) || 0;
4408+ s.height = parseInt(document.getElementById('rt-s-h').value, 10) || 0;
4409+ s.frameRate = parseInt(document.getElementById('rt-s-fps').value, 10) || 0;
4410+ s.maxBitrateKbps = parseInt(document.getElementById('rt-s-maxbr').value, 10) || 0;
4411+ s.degradationPreference = document.getElementById('rt-s-degrade').value;
4412+ App.media.applyScreenSendParams();
4413+ const dimsChanged = s.width !== prevW || s.height !== prevH || s.frameRate !== prevFps;
4414+ const screenOn = document.getElementById('tb-screen').classList.contains('on');
4415+ if (dimsChanged && screenOn) {
4416+ App.log.info('media', 'restarting screen share to apply new resolution/framerate');
4417+ await App.media.stopScreenshare();
4418+ try { await App.media.startScreenshare(); }
4419+ catch (e) { App.log.warn('media', 'restart screen failed', e.message); }
4420+ }
4421+ });
4422+ withReentryGuard('rt-codec-apply', async () => {
4423+ App.state.settings.sendVideoCodec = document.getElementById('rt-codec').value || 'auto';
4424+ /* Push the new codec to both video senders at once. */
4425+ App.media.applyCamSendParams();
4426+ App.media.applyScreenSendParams();
4427+ });
4428+ withReentryGuard('rt-a-apply', async () => {
4429+ const a = App.state.settings.audio;
4430+ const prevCh = a.channelCount, prevRate = a.sampleRate;
4431+ a.echoCancellation = document.getElementById('rt-a-aec').checked;
4432+ a.noiseSuppression = document.getElementById('rt-a-ns').checked;
4433+ a.autoGainControl = document.getElementById('rt-a-agc').checked;
4434+ a.channelCount = parseInt(document.getElementById('rt-a-channels').value, 10) || 1;
4435+ a.sampleRate = parseInt(document.getElementById('rt-a-rate').value, 10) || 0;
4436+ App.media.applyAudioConstraints();
4437+ const capChanged = a.channelCount !== prevCh || a.sampleRate !== prevRate;
4438+ const micOn = document.getElementById('tb-mic').classList.contains('on');
4439+ if (capChanged && micOn) {
4440+ App.log.info('media', 'restarting microphone to apply new channels/sample rate');
4441+ await App.media.setMic(false);
4442+ await App.media.setMic(true);
4443+ }
4444+ });
4445+
4446+ /* Stats export */
4447+ document.getElementById('stats-export').addEventListener('click', () => App.stats.exportAll());
4448+
4449+ setupConsole();
4450+ setupDevicePickers();
4451+ App.log.info('app', 'ready');
4452+}
4453+
4454+/* Close the peer connection, stop local media, and reset call-tied UI
4455+ state. Shared by hangup() (user-initiated, navigates away) and
4456+ onPeerHangup() (remote-initiated, stays on call view). */
4457+function teardownConnection(opts) {
4458+ const sendBye = !opts || opts.sendBye !== false;
4459+ if (sendBye && App.chat && App.chat.sendBye) App.chat.sendBye();
4460+ /* If we're in the middle of auto-mode long-polling, cancel the fetch so
4461+ the user isn't stuck for up to 30 s after clicking Cancel/Hang up. */
4462+ if (App.state.signalAbort) { try { App.state.signalAbort.abort(); } catch (_) {} App.state.signalAbort = null; }
4463+ App.stats.stop();
4464+ try { if (App.state.dcChat) App.state.dcChat.close(); } catch (_) {}
4465+ try { if (App.state.dcFiles) App.state.dcFiles.close(); } catch (_) {}
4466+ try { if (App.state.pc) App.state.pc.close(); } catch (_) {}
4467+ try { if (App.state.pcB) App.state.pcB.close(); } catch (_) {}
4468+ if (App.state.localStream) App.state.localStream.getTracks().forEach(t => t.stop());
4469+ if (App.state.screenStream) App.state.screenStream.getTracks().forEach(t => t.stop());
4470+ if (App.state.iceWarmupStream) {
4471+ App.state.iceWarmupStream.getTracks().forEach(t => t.stop());
4472+ App.state.iceWarmupStream = null;
4473+ const wb = document.getElementById('ice-warmup');
4474+ const ws = document.getElementById('ice-warmup-status');
4475+ if (wb) wb.textContent = 'Enable LAN connectivity';
4476+ if (ws) { ws.textContent = 'off'; ws.className = 'pill'; }
4477+ }
4478+ App.state.pc = null; App.state.pcB = null;
4479+ App.state.dcChat = null; App.state.dcFiles = null;
4480+ App.state.localStream = null; App.state.screenStream = null;
4481+ App.state.remoteStream = null; App.state.remoteScreenStream = null;
4482+ App.state.peerMediaState = { mic: false, cam: false, screen: false };
4483+ App.state.micTransceiver = App.state.camTransceiver = App.state.screenTransceiver = null;
4484+ for (const id of ['vid-local-main', 'vid-local-pip', 'vid-remote-main', 'vid-remote-pip', 'audio-remote']) {
4485+ document.getElementById(id).srcObject = null;
4486+ }
4487+ for (const id of ['tile-local', 'tile-remote']) {
4488+ const tile = document.getElementById(id);
4489+ tile.classList.add('empty');
4490+ tile.classList.remove('screen');
4491+ }
4492+ document.querySelectorAll('.video-tile .pip').forEach(el => el.classList.add('hidden'));
4493+ /* Reset toolbar buttons back to the initial off state. */
4494+ for (const [id, label] of [['tb-mic', 'Mic off'], ['tb-cam', 'Cam off'], ['tb-screen', 'Screen off']]) {
4495+ const btn = document.getElementById(id);
4496+ btn.classList.remove('on');
4497+ btn.classList.add('off');
4498+ btn.querySelector('.nowrap').textContent = label;
4499+ }
4500+ updateConnPill();
4501+}
4502+
4503+/* Set the disabled state of the in-call controls that depend on an open
4504+ peer connection (toolbar media buttons, chat input, file picker). After
4505+ a peer hangup we leave the user on the call view so they can browse the
4506+ chat log and downloaded files, but everything that needs the channel is
4507+ disabled. */
4508+function setInCallControlsEnabled(enabled) {
4509+ for (const id of ['tb-mic', 'tb-cam', 'tb-screen']) {
4510+ const btn = document.getElementById(id);
4511+ if (btn) btn.disabled = !enabled;
4512+ }
4513+ const chatInput = document.getElementById('chat-text');
4514+ const chatSend = document.getElementById('chat-send');
4515+ const filesDrop = document.getElementById('files-drop');
4516+ if (chatInput) chatInput.disabled = !enabled;
4517+ /* When re-enabling, leave chatSend to updateChatCounter (which gates on
4518+ byte count). When disabling, force it off. */
4519+ if (chatSend && !enabled) chatSend.disabled = true;
4520+ if (filesDrop) filesDrop.classList.toggle('disabled', !enabled);
4521+}
4522+
4523+function hangup(opts) {
4524+ App.log.info('app', 'hangup');
4525+ teardownConnection(opts);
4526+ App.state.role = null;
4527+ updateRoleBadge();
4528+ const dlg = document.getElementById('peer-left-dialog');
4529+ if (dlg && dlg.open) dlg.close();
4530+ showView('view-welcome');
4531+}
4532+
4533+/* Peer told us they're leaving via the chat data channel. Show a modal,
4534+ drop a chat-system breadcrumb, and tear down the connection — but keep
4535+ the user on the call view so they can still browse chat history and any
4536+ files that already finished transferring. */
4537+function onPeerHangup() {
4538+ if (App.state.peerHungUp) return; /* idempotent — bye may arrive twice */
4539+ App.state.peerHungUp = true;
4540+ App.log.info('app', 'peer hung up');
4541+ if (App.chat && App.chat.appendSystem) App.chat.appendSystem('peer hung up');
4542+ teardownConnection({ sendBye: false });
4543+ setInCallControlsEnabled(false);
4544+ App.state.peerHungUp = false;
4545+ const dlg = document.getElementById('peer-left-dialog');
4546+ if (dlg && typeof dlg.showModal === 'function' && !dlg.open) {
4547+ try { dlg.showModal(); } catch (_) { /* already-open guard */ }
4548+ }
4549+}
4550+
4551+if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', wire);
4552+else wire();
4553+</script>
4554+</body>
4555+</html>
Aserver/signal.c
@@ -0,0 +1,626 @@
1+/* signal.c — single-threaded WebRTC room-code signaling server.
2+ *
3+ * Endpoints (all bodies opaque to the server):
4+ * POST /room/<code>/offer store offer, wake any waiters
5+ * GET /room/<code>/offer return offer (long-poll up to 10 s, 204 on timeout)
6+ * POST /room/<code>/answer store answer, wake any waiters
7+ * GET /room/<code>/answer return answer (long-poll up to 10 s, 204 on timeout)
8+ * OPTIONS * CORS preflight
9+ *
10+ * Build: cc -O2 signal.c -o signal
11+ * Run: ./signal [port] (defaults to 8080)
12+ *
13+ * The whole server runs on one thread with poll(). Connections are state
14+ * machines (READ → WAIT → WRITE → done). A waiting GET is parked with no
15+ * IO interest until a matching POST flips it to WRITE. The long-poll
16+ * timeout returns 204 No Content
17+ *
18+ * Portability: strict POSIX.1-2008
19+ */
20+
21+#ifndef _POSIX_C_SOURCE
22+#define _POSIX_C_SOURCE 200809L
23+#endif
24+#include <stdio.h>
25+#include <stdlib.h>
26+#include <stdint.h>
27+#include <string.h>
28+#include <strings.h>
29+#include <unistd.h>
30+#include <fcntl.h>
31+#include <errno.h>
32+#include <limits.h>
33+#include <time.h>
34+#include <signal.h>
35+#include <poll.h>
36+#include <sys/socket.h>
37+#include <sys/resource.h>
38+#include <netinet/in.h>
39+
40+#define MAX_ROOMS 1024
41+#define MAX_CONNS (MAX_ROOMS * 2) /* worst case: both peers parked on the same room */
42+#define MAX_BLOB (64 * 1024)
43+#define MAX_HEADER 4096
44+#define RBUF_SIZE (MAX_HEADER + MAX_BLOB)
45+#define ROOM_TTL_S 300
46+#define GC_INTERVAL_S 60
47+#define LONGPOLL_S 10
48+#define IDLE_TIMEOUT_S 15
49+#define CODE_MAX 15
50+#define METHOD_MAX 7
51+#define PATH_MAX_LEN 79
52+
53+enum cstate {
54+ C_FREE = 0,
55+ C_READ, /* reading request headers + body */
56+ C_WAIT, /* long-polling for a slot to fill */
57+ C_WRITE, /* sending response */
58+};
59+
60+struct conn {
61+ int fd;
62+ enum cstate state;
63+ time_t deadline; /* absolute time after which this conn is cleaned up */
64+
65+ char *rbuf; /* allocated lazily on first read, freed on release */
66+ size_t rlen;
67+ size_t body_off; /* offset of body start in rbuf (0 = headers not parsed yet) */
68+ size_t body_want; /* Content-Length */
69+
70+ char method[METHOD_MAX + 1];
71+ char path[PATH_MAX_LEN + 1];
72+
73+ char *wbuf;
74+ size_t wlen, woff;
75+
76+ int wait_room; /* index into rooms[] when C_WAIT, else -1 */
77+ char wait_slot; /* 'o' or 'a' when C_WAIT */
78+};
79+
80+struct room {
81+ char code[CODE_MAX + 1];
82+ char *offer; size_t offer_n;
83+ char *answer; size_t answer_n;
84+ time_t touched;
85+};
86+
87+static struct conn conns[MAX_CONNS];
88+static struct room rooms[MAX_ROOMS];
89+static int server_fd = -1;
90+static volatile sig_atomic_t stop_flag = 0;
91+
92+/* ------------------------------------------------------------------------ */
93+
94+static void on_stop(int sig) { (void)sig; stop_flag = 1; }
95+
96+/* Monotonic seconds — immune to NTP steps and manual wall-clock changes. */
97+static time_t monotonic_now(void) {
98+ struct timespec ts;
99+ clock_gettime(CLOCK_MONOTONIC, &ts);
100+ return ts.tv_sec;
101+}
102+
103+/* Case-insensitive substring search. Replaces the GNU strcasestr() so the
104+ * file compiles under strict POSIX (-D_POSIX_C_SOURCE=200809L, no GNU). */
105+static char *ci_strstr(const char *hay, const char *needle) {
106+ size_t nlen = strlen(needle);
107+ if (nlen == 0) return (char *)hay;
108+ for (; *hay; hay++) {
109+ if (strncasecmp(hay, needle, nlen) == 0) return (char *)hay;
110+ }
111+ return NULL;
112+}
113+
114+static const char *status_text(int s) {
115+ switch (s) {
116+ case 200: return "OK";
117+ case 204: return "No Content";
118+ case 400: return "Bad Request";
119+ case 404: return "Not Found";
120+ case 405: return "Method Not Allowed";
121+ case 408: return "Request Timeout";
122+ case 413: return "Payload Too Large";
123+ case 414: return "URI Too Long";
124+ case 503: return "Service Unavailable";
125+ default: return "Error";
126+ }
127+}
128+
129+/* Returns 0 on success, -1 on failure. We bail rather than continue with a
130+ * blocking socket: in the accept loop a blocking fd would freeze the server
131+ * on the second accept after the kernel's queue is drained. */
132+static int set_nonblock(int fd) {
133+ int f = fcntl(fd, F_GETFL, 0);
134+ if (f < 0) return -1;
135+ return fcntl(fd, F_SETFL, f | O_NONBLOCK);
136+}
137+
138+static void set_cloexec(int fd) {
139+ int f = fcntl(fd, F_GETFD, 0);
140+ if (f >= 0) fcntl(fd, F_SETFD, f | FD_CLOEXEC);
141+}
142+
143+/* ------------------------------------------------------------------ rooms */
144+
145+/* Free both SDP blobs and clear the slot so room_get can reuse it. */
146+static void room_release(struct room *r) {
147+ free(r->offer); r->offer = NULL; r->offer_n = 0;
148+ free(r->answer); r->answer = NULL; r->answer_n = 0;
149+ r->code[0] = 0;
150+}
151+
152+/* Find a room by code, optionally creating it in an empty slot. Touched is
153+ * bumped on every hit; expiry is handled separately by gc_rooms(). */
154+static struct room *room_get(const char *code, int create) {
155+ time_t now = monotonic_now();
156+ struct room *empty = NULL, *found = NULL;
157+ for (int i = 0; i < MAX_ROOMS; i++) {
158+ struct room *r = &rooms[i];
159+ if (r->code[0] == 0) { if (!empty) empty = r; continue; }
160+ if (strcmp(r->code, code) == 0) { found = r; break; }
161+ }
162+ if (!found && create && empty) {
163+ memset(empty, 0, sizeof(*empty));
164+ strncpy(empty->code, code, CODE_MAX);
165+ found = empty;
166+ }
167+ if (found) found->touched = now;
168+ return found;
169+}
170+
171+/* Reclaim rooms that haven't been touched for ROOM_TTL_S. Called once per
172+ * GC_INTERVAL_S from the main loop, independent of request traffic. */
173+static void gc_rooms(void) {
174+ time_t now = monotonic_now();
175+ for (int i = 0; i < MAX_ROOMS; i++) {
176+ struct room *r = &rooms[i];
177+ if (r->code[0] && now - r->touched > ROOM_TTL_S) room_release(r);
178+ }
179+}
180+
181+static int room_index(struct room *r) { return r ? (int)(r - rooms) : -1; }
182+
183+/* ------------------------------------------------------------ connections */
184+
185+static void conn_release(struct conn *c) {
186+ if (c->fd >= 0) close(c->fd);
187+ free(c->rbuf);
188+ free(c->wbuf);
189+ memset(c, 0, sizeof(*c));
190+ c->fd = -1;
191+ c->wait_room = -1;
192+}
193+
194+static struct conn *conn_alloc(int fd) {
195+ for (int i = 0; i < MAX_CONNS; i++) {
196+ struct conn *c = &conns[i];
197+ if (c->state == C_FREE && c->fd <= 0) {
198+ memset(c, 0, sizeof(*c));
199+ c->fd = fd;
200+ c->state = C_READ;
201+ c->deadline = monotonic_now() + IDLE_TIMEOUT_S;
202+ c->wait_room = -1;
203+ return c;
204+ }
205+ }
206+ return NULL;
207+}
208+
209+/* Stage a response on the connection (the body is copied into a freshly
210+ * allocated send buffer). Moves the conn to C_WRITE. */
211+static void respond(struct conn *c, int status, const char *ctype,
212+ const void *body, size_t body_n) {
213+ char hdr[320];
214+ int hn = snprintf(hdr, sizeof(hdr),
215+ "HTTP/1.1 %d %s\r\n"
216+ "Content-Type: %s\r\n"
217+ "Content-Length: %zu\r\n"
218+ "Access-Control-Allow-Origin: *\r\n"
219+ "Access-Control-Allow-Methods: GET, POST, OPTIONS\r\n"
220+ "Access-Control-Allow-Headers: Content-Type\r\n"
221+ "Cache-Control: no-store\r\n"
222+ "Connection: close\r\n\r\n",
223+ status, status_text(status), ctype, body_n);
224+
225+ char *buf = malloc(hn + body_n);
226+ if (!buf) { conn_release(c); return; }
227+ memcpy(buf, hdr, hn);
228+ if (body_n) memcpy(buf + hn, body, body_n);
229+
230+ free(c->wbuf);
231+ c->wbuf = buf;
232+ c->wlen = hn + body_n;
233+ c->woff = 0;
234+ c->state = C_WRITE;
235+ c->deadline = monotonic_now() + IDLE_TIMEOUT_S;
236+ c->wait_room = -1;
237+}
238+
239+/* Match-and-wake all C_WAIT connections that are parked on this room/slot.
240+ * If the answer was just delivered to at least one waiter, the handshake is
241+ * complete — drop the room. respond() has already copied `blob` into each
242+ * waker's write buffer by then, so freeing the room's answer is safe. If
243+ * nobody was parked, the blob stays so a later GET can still pick it up
244+ * (and that GET path releases the room itself). */
245+static void wake_waiters(int ri, char slot, const void *blob, size_t n) {
246+ int woke = 0;
247+ for (int i = 0; i < MAX_CONNS; i++) {
248+ struct conn *w = &conns[i];
249+ if (w->state == C_WAIT && w->wait_room == ri && w->wait_slot == slot) {
250+ respond(w, 200, "application/sdp", blob, n);
251+ woke = 1;
252+ }
253+ }
254+ if (slot == 'a' && woke) room_release(&rooms[ri]);
255+}
256+
257+/* ------------------------------------------------------------- dispatch */
258+
259+/* Called once headers + body are fully buffered. */
260+static void dispatch(struct conn *c) {
261+ if (strcmp(c->method, "OPTIONS") == 0) {
262+ respond(c, 204, "text/plain", "", 0);
263+ return;
264+ }
265+
266+ /* Liveness probe — clients hit this to confirm the relay is reachable
267+ * before committing to the full handshake. Cheap: no allocation, no
268+ * room access. */
269+ if (strcmp(c->path, "/health") == 0) {
270+ if (strcmp(c->method, "GET") != 0 && strcmp(c->method, "HEAD") != 0) {
271+ respond(c, 405, "text/plain", "no", 2);
272+ return;
273+ }
274+ respond(c, 200, "text/plain", "ok\n", 3);
275+ return;
276+ }
277+
278+ char code[CODE_MAX + 1] = {0};
279+ char slot[8] = {0};
280+ if (sscanf(c->path, "/room/%15[A-Za-z0-9_-]/%7[a-z]", code, slot) != 2) {
281+ respond(c, 404, "text/plain", "no", 2);
282+ return;
283+ }
284+ int is_offer = !strcmp(slot, "offer");
285+ int is_answer = !strcmp(slot, "answer");
286+ if (!is_offer && !is_answer) {
287+ respond(c, 404, "text/plain", "no", 2);
288+ return;
289+ }
290+ char slot_ch = is_offer ? 'o' : 'a';
291+
292+ if (!strcmp(c->method, "POST")) {
293+ struct room *r = room_get(code, 1);
294+ if (!r) { respond(c, 503, "text/plain", "full", 4); return; }
295+
296+ char **blob = is_offer ? &r->offer : &r->answer;
297+ size_t *blen = is_offer ? &r->offer_n : &r->answer_n;
298+
299+ /* malloc(0) is implementation-defined; round up to 1 byte so an
300+ * empty body POST doesn't masquerade as OOM. */
301+ size_t alloc_n = c->body_want ? c->body_want : 1;
302+ char *copy = malloc(alloc_n);
303+ if (!copy) { respond(c, 503, "text/plain", "oom", 3); return; }
304+ if (c->body_want) memcpy(copy, c->rbuf + c->body_off, c->body_want);
305+
306+ free(*blob);
307+ *blob = copy;
308+ *blen = c->body_want;
309+
310+ wake_waiters(room_index(r), slot_ch, *blob, *blen);
311+ respond(c, 204, "text/plain", "", 0);
312+ return;
313+ }
314+
315+ if (!strcmp(c->method, "GET")) {
316+ struct room *r = room_get(code, 1);
317+ if (!r) { respond(c, 503, "text/plain", "full", 4); return; }
318+
319+ char *blob = is_offer ? r->offer : r->answer;
320+ size_t blen = is_offer ? r->offer_n : r->answer_n;
321+ if (blob) {
322+ respond(c, 200, "application/sdp", blob, blen);
323+ /* Answer delivery completes the SDP exchange — peers go P2P now,
324+ * so the relay can drop the room. The offer slot stays in case
325+ * the joiner needs to refetch after a crash/retry. */
326+ if (is_answer) room_release(r);
327+ return;
328+ }
329+ /* Park the connection: no IO interest, just sit on the deadline. */
330+ c->state = C_WAIT;
331+ c->wait_room = room_index(r);
332+ c->wait_slot = slot_ch;
333+ c->deadline = monotonic_now() + LONGPOLL_S;
334+ return;
335+ }
336+
337+ respond(c, 405, "text/plain", "no", 2);
338+}
339+
340+/* ------------------------------------------------------------- IO drivers */
341+
342+/* Parse "METHOD SP PATH SP HTTP/x.y" without mutating rbuf — the subsequent
343+ * Content-Length scan needs the header block intact. Returns 0 on success,
344+ * 400 on malformed, 414 on field overflow. */
345+static int parse_request_line(struct conn *c, const char *hdr_end) {
346+ const char *p = c->rbuf;
347+ const char *sp1 = memchr(p, ' ', (size_t)(hdr_end - p));
348+ if (!sp1) return 400;
349+ size_t mlen = (size_t)(sp1 - p);
350+ if (mlen > METHOD_MAX) return 414;
351+ memcpy(c->method, p, mlen);
352+ c->method[mlen] = 0;
353+
354+ p = sp1 + 1;
355+ const char *sp2 = memchr(p, ' ', (size_t)(hdr_end - p));
356+ if (!sp2) return 400;
357+ size_t plen = (size_t)(sp2 - p);
358+ if (plen > PATH_MAX_LEN) return 414;
359+ memcpy(c->path, p, plen);
360+ c->path[plen] = 0;
361+ return 0;
362+}
363+
364+static void on_readable(struct conn *c) {
365+ if (!c->rbuf) {
366+ c->rbuf = malloc(RBUF_SIZE);
367+ if (!c->rbuf) { conn_release(c); return; }
368+ }
369+ for (;;) {
370+ size_t cap = RBUF_SIZE - c->rlen;
371+ if (cap == 0) { conn_release(c); return; }
372+ ssize_t n = recv(c->fd, c->rbuf + c->rlen, cap, 0);
373+ if (n == 0) { conn_release(c); return; }
374+ if (n < 0) {
375+ if (errno == EAGAIN || errno == EWOULDBLOCK) return;
376+ conn_release(c); return;
377+ }
378+ c->rlen += n;
379+ c->deadline = monotonic_now() + IDLE_TIMEOUT_S;
380+
381+ /* Parse headers once \r\n\r\n appears within the first MAX_HEADER bytes. */
382+ if (c->body_off == 0) {
383+ size_t scan = c->rlen < MAX_HEADER ? c->rlen : MAX_HEADER;
384+ char *eoh = NULL;
385+ for (size_t i = 0; i + 3 < scan; i++) {
386+ if (c->rbuf[i] == '\r' && c->rbuf[i+1] == '\n' &&
387+ c->rbuf[i+2] == '\r' && c->rbuf[i+3] == '\n') { eoh = c->rbuf + i; break; }
388+ }
389+ if (!eoh) {
390+ if (c->rlen >= MAX_HEADER) respond(c, 413, "text/plain", "header too large", 16);
391+ return;
392+ }
393+ *eoh = 0; /* terminate header block for ci_strstr */
394+ c->body_off = (size_t)(eoh - c->rbuf) + 4;
395+
396+ int err = parse_request_line(c, eoh);
397+ if (err == 400) { respond(c, 400, "text/plain", "bad request", 11); return; }
398+ if (err == 414) { respond(c, 414, "text/plain", "uri too long", 12); return; }
399+
400+ /* Parse Content-Length line-anchored to avoid matching header
401+ * names that merely *contain* "Content-Length:" as a substring
402+ * (e.g. X-Original-Content-Length). RFC 7230 §3.3.2 requires
403+ * rejecting duplicate Content-Length headers — important for
404+ * direct-bind deployments not fronted by a normalizing proxy. */
405+ int cl_seen = 0;
406+ const char *needle = "\nContent-Length:";
407+ const size_t needle_n = 16;
408+ for (char *p = c->rbuf; (p = ci_strstr(p, needle)) != NULL; p += needle_n) {
409+ if (cl_seen++) {
410+ respond(c, 400, "text/plain", "duplicate content-length", 24);
411+ return;
412+ }
413+ const char *v = p + needle_n;
414+ while (*v == ' ' || *v == '\t') v++;
415+ if (*v == '-' || *v == '+') {
416+ respond(c, 400, "text/plain", "bad content-length", 18);
417+ return;
418+ }
419+ char *end;
420+ errno = 0;
421+ unsigned long len = strtoul(v, &end, 10);
422+ if (end == v || errno == ERANGE) {
423+ respond(c, 400, "text/plain", "bad content-length", 18);
424+ return;
425+ }
426+ c->body_want = len;
427+ }
428+ if (c->body_want > MAX_BLOB) {
429+ respond(c, 413, "text/plain", "body too large", 14); return;
430+ }
431+ }
432+
433+ if (c->rlen - c->body_off >= c->body_want) { dispatch(c); return; }
434+ /* else loop and read more */
435+ }
436+}
437+
438+static void on_writable(struct conn *c) {
439+ while (c->woff < c->wlen) {
440+ /* SIGPIPE is suppressed via sigaction(SIGPIPE, SIG_IGN), so plain
441+ * send() is enough — no need for the non-portable MSG_NOSIGNAL. */
442+ ssize_t n = send(c->fd, c->wbuf + c->woff, c->wlen - c->woff, 0);
443+ if (n < 0) {
444+ if (errno == EAGAIN || errno == EWOULDBLOCK) return;
445+ conn_release(c); return;
446+ }
447+ c->woff += n;
448+ }
449+ conn_release(c);
450+}
451+
452+static void accept_new(void) {
453+ for (;;) {
454+ int cfd = accept(server_fd, NULL, NULL);
455+ if (cfd < 0) {
456+ if (errno == EAGAIN || errno == EWOULDBLOCK) return;
457+ return;
458+ }
459+ set_cloexec(cfd);
460+ if (set_nonblock(cfd) < 0 || !conn_alloc(cfd)) close(cfd); /* full or fcntl failed */
461+ }
462+}
463+
464+/* ------------------------------------------------------------------ main */
465+
466+int main(int argc, char **argv) {
467+ int port = 8080;
468+ if (argc > 1) {
469+ char *end;
470+ errno = 0;
471+ long lport = strtol(argv[1], &end, 10);
472+ if (errno || *end || lport < 1 || lport > 65535) {
473+ fprintf(stderr, "bad port: %s\n", argv[1]);
474+ return 1;
475+ }
476+ port = (int)lport;
477+ }
478+
479+ /* sigaction() has portable, well-defined semantics across POSIX systems;
480+ * signal()'s behavior is historically split SysV/BSD. */
481+ struct sigaction sa;
482+ memset(&sa, 0, sizeof(sa));
483+ sa.sa_handler = SIG_IGN;
484+ sigaction(SIGPIPE, &sa, NULL);
485+ sa.sa_handler = on_stop;
486+ sigaction(SIGINT, &sa, NULL);
487+ sigaction(SIGTERM, &sa, NULL);
488+
489+ /* Raise RLIMIT_NOFILE up to what MAX_CONNS needs (plus a few for stdio /
490+ * the listen socket). If the hard cap is below that, warn — accepts will
491+ * silently fail at the kernel layer otherwise. */
492+ struct rlimit rl;
493+ if (getrlimit(RLIMIT_NOFILE, &rl) == 0) {
494+ rlim_t need = (rlim_t)MAX_CONNS + 16;
495+ if (rl.rlim_cur < need) {
496+ rl.rlim_cur = rl.rlim_max < need ? rl.rlim_max : need;
497+ setrlimit(RLIMIT_NOFILE, &rl);
498+ if (rl.rlim_cur < need) {
499+ fprintf(stderr, "warning: nofile soft limit %lu < %lu — capacity reduced\n",
500+ (unsigned long)rl.rlim_cur, (unsigned long)need);
501+ }
502+ }
503+ }
504+
505+ server_fd = socket(AF_INET, SOCK_STREAM, 0);
506+ if (server_fd < 0) { perror("socket"); return 1; }
507+ set_cloexec(server_fd);
508+
509+ int one = 1;
510+ if (setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one)) < 0) {
511+ perror("setsockopt SO_REUSEADDR"); /* non-fatal */
512+ }
513+
514+ struct sockaddr_in addr = {
515+ .sin_family = AF_INET,
516+ .sin_addr.s_addr = INADDR_ANY,
517+ .sin_port = htons((uint16_t)port)
518+ };
519+ if (bind(server_fd, (struct sockaddr *)&addr, sizeof(addr)) ||
520+ listen(server_fd, 64)) {
521+ perror("bind/listen"); return 1;
522+ }
523+ /* Flip the listener non-blocking only just before entering the accept
524+ * loop — bind/listen don't care, and this keeps the setup order
525+ * conventional. */
526+ if (set_nonblock(server_fd) < 0) { perror("fcntl"); return 1; }
527+
528+ fprintf(stderr, "signaling server listening on :%d\n", port);
529+
530+ for (int i = 0; i < MAX_CONNS; i++) { conns[i].fd = -1; conns[i].wait_room = -1; }
531+
532+ time_t last_gc = monotonic_now();
533+ struct pollfd pfds[MAX_CONNS + 1];
534+ int pidx[MAX_CONNS + 1]; /* maps pfd index → conns[] index */
535+
536+ while (!stop_flag) {
537+ time_t now = monotonic_now();
538+ int nfd = 0;
539+ int timeout_ms = 1000;
540+ pfds[nfd++] = (struct pollfd){ .fd = server_fd, .events = POLLIN };
541+
542+ /* Single sweep over the conn table: fire expired deadlines, build
543+ * the pfds array, and compute the poll timeout in one pass. */
544+ for (int i = 0; i < MAX_CONNS; i++) {
545+ struct conn *c = &conns[i];
546+ if (c->state == C_FREE || c->fd < 0) continue;
547+
548+ if (c->deadline <= now) {
549+ /* C_WAIT → 204 No Content (not 408: Firefox auto-retries 408
550+ * internally per RFC 7231 §6.5.7 and only surfaces a single
551+ * fetch attempt to JS, eventually giving up with a
552+ * CORS-adjacent NetworkError. 204 carries no retry semantics). */
553+ if (c->state == C_WAIT) {
554+ respond(c, 204, "text/plain", "", 0);
555+ /* respond() moved this conn to C_WRITE with a fresh
556+ * deadline; fall through to register POLLOUT. */
557+ } else {
558+ conn_release(c);
559+ continue;
560+ }
561+ }
562+
563+ short ev = 0;
564+ switch (c->state) {
565+ case C_READ: ev = POLLIN; break;
566+ case C_WRITE: ev = POLLOUT; break;
567+ /* WAIT: register POLLIN so a client disconnect (read-side EOF)
568+ * surfaces as a poll event; we never actually read while parked. */
569+ case C_WAIT: ev = POLLIN; break;
570+ default: continue;
571+ }
572+ pfds[nfd] = (struct pollfd){ .fd = c->fd, .events = ev };
573+ pidx[nfd] = i;
574+ nfd++;
575+
576+ long dt = (long)(c->deadline - now) * 1000;
577+ if (dt < 0) dt = 0;
578+ if (dt < timeout_ms) timeout_ms = (int)dt;
579+ }
580+
581+ int rc = poll(pfds, nfd, timeout_ms);
582+ if (rc < 0) {
583+ if (errno == EINTR) continue;
584+ perror("poll"); break;
585+ }
586+
587+ if (pfds[0].revents & POLLIN) accept_new();
588+
589+ for (int k = 1; k < nfd; k++) {
590+ short rev = pfds[k].revents;
591+ if (!rev) continue;
592+ struct conn *c = &conns[pidx[k]];
593+ /* The conn may have been released this iteration (e.g. via wake);
594+ * stale entries are no-ops since fd would be -1. */
595+ if (c->fd != pfds[k].fd) continue;
596+
597+ if (c->state == C_WAIT) {
598+ /* We set Connection: close on every response, so a parked
599+ * GET should send nothing further until the next request.
600+ * Any POLLIN means either the client closed (recv == 0) or
601+ * it pipelined / sent garbage — drop the conn either way. */
602+ if (rev & (POLLIN | POLLHUP | POLLERR | POLLNVAL)) conn_release(c);
603+ continue;
604+ }
605+ if (rev & (POLLERR | POLLNVAL)) { conn_release(c); continue; }
606+ if (c->state == C_READ && (rev & POLLIN)) on_readable(c);
607+ else if (c->state == C_WRITE && (rev & POLLOUT)) on_writable(c);
608+ else if (rev & POLLHUP) conn_release(c);
609+ }
610+
611+ /* Periodic room GC. The poll timeout is clamped to 1 s so this fires
612+ * on a wall-clock cadence even when the server is otherwise idle. */
613+ time_t tick = monotonic_now();
614+ if (tick - last_gc >= GC_INTERVAL_S) { gc_rooms(); last_gc = tick; }
615+ }
616+
617+ fprintf(stderr, "signaling server shutting down\n");
618+ if (server_fd >= 0) close(server_fd);
619+ for (int i = 0; i < MAX_CONNS; i++) {
620+ if (conns[i].fd >= 0) conn_release(&conns[i]);
621+ }
622+ for (int i = 0; i < MAX_ROOMS; i++) {
623+ if (rooms[i].code[0]) room_release(&rooms[i]);
624+ }
625+ return 0;
626+}