initial setup
AContainerfile
@@ -0,0 +1,20 @@
# Multi-stage build for the WebRTC signaling server.
# Stage 1: compile signal.c statically against musl so the runtime image
# doesn't need libc at all.
FROM docker.io/library/alpine:3.20 AS build
RUN apk add --no-cache gcc musl-dev
WORKDIR /src
COPY server/signal.c ./
RUN cc -O3 -Wall -Wextra -Werror -static signal.c -o signal
# Stage 2: minimal runtime. Keep alpine (instead of scratch) so the
# entrypoint can use /bin/sh + cp to publish index.html into the bind-
# mounted /static directory on startup.
FROM docker.io/library/alpine:3.20
RUN apk add --no-cache curl
COPY --from=build /src/signal /usr/local/bin/signal
COPY index.html /opt/static/index.html
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh
EXPOSE 8080
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
AREADME.md
@@ -0,0 +1,204 @@
# WebRTC Tool
A single-page tool for direct peer-to-peer audio, video, chat, and file
transfer between two browsers, using WebRTC for the media/data path. The
client is one self-contained `index.html` with no build step. An optional
signaling server (~500 lines of C, POSIX `poll`, no dependencies) lets
peers connect by sharing a short room code instead of pasting SDP blobs.
## What it does
- **Two-peer call.** Microphone, camera, screen share. Standard WebRTC.
- **Chat** over a text data channel.
- **File transfer** over a separate data channel with backpressure,
cancellable mid-upload.
- **Manual signaling.** Generate an offer, copy/paste it to the other
peer, paste back their answer. No backend required.
- **Auto signaling.** Optional: both peers enter the same room code and
the signaling server relays the offer/answer for them. Media itself
still flows peer-to-peer.
- **Loopback mode.** Run both peers in the same tab for testing.
- **Stats** (RTP, codec, bandwidth) and an in-page console.
## How it works
WebRTC requires the two peers to exchange Session Description Protocol
(SDP) blobs (offer/answer) before media can flow. After that exchange,
the connection is peer-to-peer; the signaling channel is no longer used.
This tool offers two ways to do that exchange:
1. **Manual.** The initiator generates an offer (a few KB of text),
sends it to the joiner by any means (email, chat, etc.). The joiner
pastes it, generates an answer, sends it back, and the initiator
pastes it. No server involved.
2. **Auto.** Both peers enter the same room code into the signaling
server's UI. The server stores the offer briefly, hands it to the
other peer when they ask, and is forgotten as soon as the answer
has been delivered. Rooms expire after 5 minutes of inactivity.
The signaling server understands four endpoints:
```
POST /room/<code>/offer POST /room/<code>/answer
GET /room/<code>/offer GET /room/<code>/answer
GET /health
```
Long-polling: a `GET` that arrives before the matching blob exists is
parked for up to 10 s and answered with `204 No Content` on timeout; the
client retries. As soon as the matching `POST` arrives, the parked
request is woken with `200` and the SDP body. After the answer has been
delivered to the initiator, the room is dropped.
## Usage
### Standalone (no backend)
The full feature set works without the backend, using manual signaling.
Each peer just needs to load `index.html`; they don't need to load it
from the same place. Options:
- Send the file to the other peer and have them open it from disk
(`file://…`).
- Host it on any static web server (a personal site, GitHub Pages,
Netlify, S3, etc.) and share the URL.
- For local testing on one machine, serve it on loopback:
`python3 -m http.server 8000` and open `http://localhost:8000/`.
Then both peers pick **Manual** mode in the configure screen, the
initiator generates the offer and sends the blob to the joiner (email,
chat, etc.), and the joiner sends the answer back the same way.
For mic/camera/screen-share to work, the page must be loaded from a
secure context: either `https://…`, `http://localhost`, or `file://`
(some browsers, with limits). On plain `http://` to a remote host,
browsers will refuse to grant media access.
### With the signaling backend
The backend is needed only if you want auto signaling (peers exchange
SDPs via a room code rather than copy-paste). It does **not** see or
proxy media — that's still peer-to-peer.
```
podman compose up -d # builds the image and starts the server on :8080
# or: docker compose up -d
```
After startup, `./static/index.html` appears on the host (copied out of
the image). Point your own web server at `./static` to serve the page.
Or just build and run the binary directly:
```
cc -O2 -Wall -Wextra -Werror server/signal.c -o signal
./signal 8080
```
### Loopback (testing)
In the configure screen, pick **Loopback** and click Continue. Both
peers run in the same tab over a local RTCPeerConnection pair. Useful
for trying chat/file transfer/codec settings without a second device.
## Self-hosting at a single URL
For a production setup with both the HTML and the signaling server on
the same origin (no CORS, no `Server URL` field to fill in), reverse-
proxy `/room/*` and `/health` to the backend and serve everything else
as static files.
The backend binds to `127.0.0.1:8080` (or whatever you mapped in
`compose.yml`). Configs below assume the static files live at
`/var/www/webrtc` and the backend listens on `127.0.0.1:8080`.
### Caddy
```caddy
example.com {
encode zstd gzip
root * /var/www/webrtc
@signaling path /room/* /health
reverse_proxy @signaling 127.0.0.1:8080
file_server
}
```
### nginx
```nginx
server {
listen 443 ssl http2;
server_name example.com;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
root /var/www/webrtc;
index index.html;
# Signaling backend: long-polled, must disable response buffering
# so the 204/200 reaches the browser as soon as the server writes it.
location ~ ^/(room/|health$) {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_buffering off;
proxy_read_timeout 30s;
}
location / {
try_files $uri $uri/ =404;
}
}
```
### lighttpd
```lighttpd
server.modules += ( "mod_proxy" )
server.document-root = "/var/www/webrtc"
index-file.names = ( "index.html" )
$HTTP["url"] =~ "^/(room/|health$)" {
proxy.server = ( "" =>
(( "host" => "127.0.0.1", "port" => 8080 ))
)
proxy.header = ( "upgrade" => "disable" )
}
```
## Configuration notes
- **HTTPS is required for production.** Browsers gate microphone,
camera, and screen-share behind a secure context. The page works in
receive-only mode without media access, but most users will want at
least one side to publish.
- **Same-origin removes the CORS path entirely.** When the HTML and
signaling are served from the same scheme+host+port, the browser
doesn't issue preflights and the `Server URL` field in the configure
screen can be left at its default (`location.origin`).
- **`http://localhost` is treated as secure** by Chromium and Firefox,
so local development with mic/cam works without certificates.
- **Server capacity is hard-coded in `server/signal.c`:** 1024 rooms.
Rooms expire after 5 minutes of inactivity and are also deleted immediately
once the answer reaches the initiator; a GC sweep runs every 60 s.
Each connection pre-allocates a ~68 KB request buffer, so the BSS reaches
~140 MB — Linux only touches the pages on demand, so an idle server
uses around 10 MB resident.
## Repository layout
```
.
├── index.html # client (one self-contained page, no build)
├── server/
│ └── signal.c # signaling server (~500 lines, POSIX poll, no deps)
├── Containerfile # multi-stage build, static-musl binary, alpine runtime
├── compose.yml # podman/docker compose: builds image, exposes :8080
├── entrypoint.sh # copies index.html into the bind-mounted /static
└── README.md
```
Acompose.yml
@@ -0,0 +1,18 @@
services:
webrtc-tool:
build:
context: .
ports:
- "8080:8080"
volumes:
# Bind-mount a host directory the entrypoint will drop index.html into.
# Point your own web server (nginx/caddy/python -m http.server / etc.)
# at this directory to serve the page. Anything else placed here is
# preserved; only index.html is overwritten on container start.
- ./static:/static
healthcheck:
test: ["CMD-SHELL", "curl --show-error --silent http://localhost:8080/health -o /dev/null"]
interval: 30s
timeout: 5s
retries: 3
start_period: 5s
Aentrypoint.sh
@@ -0,0 +1,9 @@
#!/bin/sh
# Publish the bundled index.html into the (typically bind-mounted) /static
# directory so the host's web server can pick it up, then start the
# signaling server. Always overwrites so a rebuilt image updates the file.
set -e
if [ -d /static ] && [ -w /static ]; then
cp -f /opt/static/index.html /static/index.html
fi
exec /usr/local/bin/signal "${PORT:-8080}"
Aindex.html
@@ -0,0 +1,4555 @@
<!doctype html>
<html lang="en" data-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=500">
<title>WebRTC Tool</title>
<style>
:root {
--bg: #0d1117;
--bg-elev: #161b22;
--bg-elev-2: #1f2630;
--border: #30363d;
--border-strong: #484f58;
--text: #e6edf3;
--text-dim: #8b949e;
--text-faint: #6e7681;
--accent: #2f81f7;
--accent-fg: #ffffff;
--ok: #3fb950;
--warn: #d29922;
--err: #f85149;
--shadow: 0 8px 24px rgba(0,0,0,0.5);
--radius: 10px;
--radius-sm: 6px;
--mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
--sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
}
html[data-theme="light"] {
--bg: #ffffff;
--bg-elev: #f6f8fa;
--bg-elev-2: #eaeef2;
--border: #d0d7de;
--border-strong: #afb8c1;
--text: #1f2328;
--text-dim: #59636e;
--text-faint: #818b98;
--accent: #0969da;
--accent-fg: #ffffff;
--ok: #1a7f37;
--warn: #9a6700;
--err: #cf222e;
--shadow: 0 8px 24px rgba(140,149,159,0.2);
}
* { box-sizing: border-box; }
html, body { height: 100%; margin: 0; overflow: hidden; }
body {
background: var(--bg);
color: var(--text);
font-family: var(--sans);
font-size: 14px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
a { color: var(--accent); }
code, kbd, pre { font-family: var(--mono); font-size: 12.5px; }
button {
font-family: inherit;
font-size: inherit;
background: var(--bg-elev-2);
color: var(--text);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 7px 14px;
cursor: pointer;
transition: background 120ms, border-color 120ms, transform 80ms;
}
button:hover { background: var(--bg-elev); border-color: var(--border-strong); }
button:active { transform: translateY(1px); }
button:disabled { opacity: 0.5; cursor: not-allowed; }
/* Firefox adds an invisible inner border/padding to buttons via the
::-moz-focus-inner pseudo-element that shrinks the usable content box
and makes our buttons render visibly smaller than in Chromium. */
button::-moz-focus-inner { border: 0; padding: 0; }
button.primary { background: var(--accent); color: var(--accent-fg); border-color: transparent; }
button.primary:hover { filter: brightness(1.1); }
button.danger { background: var(--err); color: white; border-color: transparent; }
button.danger:hover { filter: brightness(1.1); }
button.ghost { background: transparent; }
button.icon { padding: 7px 10px; }
input[type="text"], input[type="number"], input[type="url"], select, textarea {
font-family: inherit;
font-size: inherit;
background: var(--bg);
color: var(--text);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 7px 10px;
outline: none;
transition: border-color 120ms;
}
input:focus, select:focus, textarea:focus { border-color: var(--accent); }
textarea { display: block; width: 100%; font-family: var(--mono); font-size: 12.5px; resize: vertical; min-height: 110px; }
label.row { display: flex; align-items: center; gap: 8px; padding: 4px 0; }
label.row input[type="checkbox"] { accent-color: var(--accent); }
label.field { display: block; margin-bottom: 12px; }
label.field > span { display: block; font-size: 12px; color: var(--text-dim); margin-bottom: 4px; }
.hidden { display: none !important; }
/* Inline icon: inherits the surrounding text color, doesn't shrink in flex
rows, never hijacks pointer events from its parent button. */
.ic { flex: none; display: inline-block; vertical-align: middle; pointer-events: none; }
/* Layout — body never scrolls; each view either scrolls internally
(welcome/configure/exchange) or is overflow:hidden (call). */
#app { height: 100%; display: flex; flex-direction: column; overflow: hidden; }
.view { flex: 1; min-height: 0; display: flex; flex-direction: column; }
.view.hidden { display: none; }
#view-welcome, #view-configure, #view-exchange, #view-sdp-inspect { overflow-y: auto; }
/* SDP inspector */
.sdp-section { background: var(--bg-elev); border: 1px solid var(--border); border-radius: var(--radius); padding: 14px 18px; margin-bottom: 14px; }
.sdp-section > .sdp-head { display: flex; gap: 10px; align-items: baseline; flex-wrap: wrap; margin-bottom: 8px; }
.sdp-section > .sdp-head h3 { margin: 0; text-transform: none; letter-spacing: 0; font-size: 14px; color: var(--text); }
.sdp-section > .sdp-head .sdp-sub { color: var(--text-dim); font-size: 12px; font-family: var(--mono); }
.sdp-kv { display: grid; grid-template-columns: 170px 1fr; gap: 4px 12px; font-size: 13px; }
.sdp-kv .k { color: var(--text-dim); }
.sdp-kv .v { font-family: var(--mono); word-break: break-all; }
.sdp-kv .v.code { font-family: var(--mono); }
.sdp-help { color: var(--text-dim); font-size: 12px; margin: 6px 0 0; font-style: italic; }
.sdp-list { list-style: none; padding: 0; margin: 6px 0 0; display: grid; gap: 4px; font-size: 12.5px; font-family: var(--mono); }
.sdp-list li { padding: 4px 8px; border-radius: var(--radius-sm); background: var(--bg-elev-2); }
.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; }
.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); }
.sdp-list li .dim { color: var(--text-dim); }
details.sdp-rawblock { margin-top: 12px; }
details.sdp-rawblock > summary { font-size: 12px; color: var(--text-dim); cursor: pointer; user-select: none; }
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; }
.topbar {
min-height: 48px; flex: none;
display: flex; align-items: center; justify-content: space-between;
gap: 8px;
padding: 6px 16px;
border-bottom: 1px solid var(--border);
background: var(--bg-elev);
}
.topbar .brand { font-weight: 600; letter-spacing: 0.2px; min-width: 0; }
.topbar .brand small { color: var(--text-faint); font-weight: 400; margin-left: 8px; }
.topbar .right { display: flex; gap: 6px; align-items: center; flex-shrink: 0; }
/* Theme toggle shows the current theme: moon in dark mode, sun in light. */
html[data-theme="light"] #theme-toggle .theme-icon-moon { display: none; }
html:not([data-theme="light"]) #theme-toggle .theme-icon-sun { display: none; }
@media (max-width: 720px) {
.topbar { padding: 6px 10px; }
}
.role-badge {
display: inline-block; padding: 2px 8px; border-radius: 12px;
background: var(--bg-elev-2); color: var(--text-dim);
font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px;
}
.role-badge.initiator { background: rgba(47,129,247,0.15); color: var(--accent); }
.role-badge.joiner { background: rgba(63,185,80,0.15); color: var(--ok); }
.role-badge.loopback { background: rgba(210,153,34,0.15); color: var(--warn); }
.container { max-width: 880px; width: 100%; margin: 0 auto; padding: 32px 24px; }
.container.wide { max-width: 1280px; }
h1 { font-size: 24px; margin: 0 0 8px; }
h2 { font-size: 16px; margin: 24px 0 12px; padding-bottom: 6px; border-bottom: 1px solid var(--border); }
h3 { font-size: 13px; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-dim); margin: 16px 0 8px; }
.lede { color: var(--text-dim); margin-bottom: 24px; }
.card {
background: var(--bg-elev);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 18px;
margin-bottom: 16px;
}
.card h2:first-child { margin-top: 0; }
details { margin-bottom: 16px; }
details > summary { cursor: pointer; padding: 10px 14px; background: var(--bg-elev); border: 1px solid var(--border); border-radius: var(--radius-sm); user-select: none; }
details[open] > summary { border-bottom-left-radius: 0; border-bottom-right-radius: 0; }
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); }
/* Collapsible card: <details class="card card-collapsible"> */
details.card-collapsible { padding: 0; margin-bottom: 16px; background: var(--bg-elev); }
details.card-collapsible > summary {
list-style: none;
cursor: pointer;
padding: 14px 18px;
user-select: none;
display: flex;
align-items: center;
gap: 10px;
background: transparent;
border: none;
border-radius: var(--radius);
}
details.card-collapsible > summary::-webkit-details-marker { display: none; }
details.card-collapsible > summary::before {
content: '';
width: 12px; height: 12px;
background: var(--text-dim);
-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;
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;
flex: none;
transition: transform 120ms ease;
}
details.card-collapsible[open] > summary::before { transform: rotate(90deg); }
details.card-collapsible > summary h2 {
margin: 0;
padding: 0;
border-bottom: 0;
display: inline-block;
}
details.card-collapsible[open] > summary {
border-bottom: 1px solid var(--border);
border-radius: var(--radius) var(--radius) 0 0;
}
details.card-collapsible > .card-body { padding: 14px 18px 18px; }
/* Prominent room code "hero" callout. */
.room-code-hero {
background: rgba(47,129,247,0.08);
border: 1px solid rgba(47,129,247,0.35);
border-radius: var(--radius);
padding: 18px 20px;
margin: 8px 0 4px;
}
.room-code-hero .hero-label {
display: block;
font-size: 12px;
font-weight: 600;
color: var(--accent);
text-transform: uppercase;
letter-spacing: 0.6px;
margin-bottom: 10px;
}
.room-code-input-row { display: flex; gap: 8px; align-items: stretch; }
.room-code-input-row input {
flex: 1;
font-family: var(--mono);
font-size: 15px;
font-weight: 600;
padding: 9px 12px;
letter-spacing: 0.5px;
text-transform: lowercase;
background: var(--bg);
}
.room-code-input-row button {
padding: 0 14px;
font-size: 16px;
background: var(--bg);
}
.room-code-hero .hero-help { margin: 10px 0 0; font-size: 12px; color: var(--text-dim); }
.room-code-hero .hero-help code { background: rgba(47,129,247,0.12); padding: 1px 4px; border-radius: 3px; }
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.opus-list { display: flex; flex-direction: column; gap: 14px; }
.opus-item { display: flex; flex-direction: column; gap: 2px; }
.opus-item > .small { margin: 0 0 0 22px; color: var(--text-dim); }
.grid-3 { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 12px; }
@media (max-width: 720px) {
.grid-2, .grid-3 { grid-template-columns: 1fr; }
}
.actions { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; margin-top: 16px; }
.role-picker { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; margin-top: 16px; }
.role-picker button {
padding: 22px;
text-align: left;
border-radius: var(--radius);
border: 1px solid var(--border);
background: var(--bg-elev);
display: flex; flex-direction: column; gap: 6px;
transition: border-color 120ms, transform 120ms;
}
.role-picker button:hover { border-color: var(--accent); transform: translateY(-1px); }
.role-picker .role-title { font-size: 16px; font-weight: 600; }
.role-picker .role-desc { color: var(--text-dim); font-size: 13px; }
.role-picker .role-loopback { grid-column: 1 / -1; }
/* Two-option segmented toggle (signaling mode). */
.seg-toggle { display: inline-flex; border: 1px solid var(--border); border-radius: var(--radius-sm); overflow: hidden; margin: 4px 0 10px; }
.seg-toggle button {
border: none; border-radius: 0; background: transparent; color: var(--text-dim);
padding: 8px 14px; font-weight: 500;
}
.seg-toggle button + button { border-left: 1px solid var(--border); }
.seg-toggle button.active { background: rgba(47,129,247,0.15); color: var(--accent); }
.seg-toggle button:hover:not(.active) { background: var(--bg-elev-2); }
.ice-row { display: grid; grid-template-columns: 2fr 1fr 1fr auto; gap: 8px; margin-bottom: 6px; }
.ice-row input { width: 100%; }
@media (max-width: 720px) { .ice-row { grid-template-columns: 1fr; } }
.blob-area { display: flex; flex-direction: column; gap: 8px; }
.blob-area textarea { min-height: 180px; }
.blob-controls { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; margin-top: 10px; }
.pill { padding: 2px 8px; border-radius: 12px; font-size: 11px; background: var(--bg-elev-2); color: var(--text-dim); }
.pill:empty { display: none; }
.pill.ok { background: rgba(63,185,80,0.15); color: var(--ok); }
.pill.warn { background: rgba(210,153,34,0.15); color: var(--warn); }
.pill.err { background: rgba(248,81,73,0.15); color: var(--err); }
.progress { height: 6px; background: var(--bg-elev-2); border-radius: 3px; overflow: hidden; margin: 8px 0; }
.progress > div { height: 100%; background: var(--accent); width: 0%; transition: width 200ms ease; }
/* Call view — flex:1 inside #app fills viewport minus the topbar; nothing
scrolls except internal panes (chat log, files list, settings, stats). */
#view-call { display: flex; flex-direction: column; overflow: hidden; min-height: 0; }
.call-body {
flex: 1; min-height: 0; min-width: 0;
display: grid;
grid-template-columns: 1fr 360px;
grid-template-rows: minmax(0, 1fr);
overflow: hidden;
}
@media (max-width: 900px) {
.call-body { grid-template-columns: 1fr; grid-template-rows: minmax(0, 1fr) minmax(0, 1fr); }
}
.video-area {
background: #000;
position: relative;
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-rows: minmax(0, 1fr);
gap: 8px;
padding: 12px;
min-height: 0;
min-width: 0;
overflow: hidden;
}
.video-tile { position: relative; background: #050608; border-radius: var(--radius); overflow: hidden; min-height: 0; display: flex; align-items: center; justify-content: center; }
.video-tile > video { width: 100%; height: 100%; object-fit: contain; background: #050608; display: block; }
.video-tile.screen > video { cursor: zoom-in; }
.video-tile .tile-label {
position: absolute; left: 8px; bottom: 8px;
background: rgba(0,0,0,0.55); color: white;
padding: 3px 8px; border-radius: 12px; font-size: 11px;
letter-spacing: 0.4px;
pointer-events: none;
}
.video-tile .empty-state {
position: absolute; inset: 0;
display: none; align-items: center; justify-content: center;
color: var(--text-faint);
pointer-events: none;
}
.video-tile.empty .empty-state { display: flex; }
.video-tile.empty > video { display: none; }
.video-tile .mic-muted {
position: absolute;
bottom: 8px; right: 8px;
background: rgba(0,0,0,0.55);
color: white;
border-radius: 50%;
width: 28px; height: 28px;
display: flex; align-items: center; justify-content: center;
pointer-events: none;
z-index: 2;
}
/* When a PIP is visible in the bottom-right corner, move the mic-muted
badge to the top-right so they don't overlap. */
.video-tile:has(.pip:not(.hidden)) .mic-muted { bottom: auto; top: 8px; }
.video-tile .mic-muted.hidden { display: none; }
.video-tile .pip {
position: absolute;
right: 10px; bottom: 10px;
width: 22%; max-width: 200px; min-width: 120px;
aspect-ratio: 16 / 9;
border-radius: 6px;
overflow: hidden;
background: #050608;
border: 1px solid rgba(255,255,255,0.15);
box-shadow: 0 4px 12px rgba(0,0,0,0.5);
z-index: 1;
}
.video-tile .pip.hidden { display: none; }
.video-tile .pip video { width: 100%; height: 100%; object-fit: cover; background: #050608; display: block; }
@media (max-width: 900px) {
.video-area { grid-template-columns: 1fr; grid-template-rows: repeat(2, minmax(0, 1fr)); }
}
.sidebar { background: var(--bg-elev); border-left: 1px solid var(--border); display: flex; flex-direction: column; min-height: 0; min-width: 0; overflow: hidden; }
.tabs { display: flex; border-bottom: 1px solid var(--border); }
.tabs button {
flex: 1;
border: none;
background: transparent;
border-radius: 0;
border-bottom: 2px solid transparent;
padding: 12px 10px;
color: var(--text-dim);
}
.tabs button.active { color: var(--text); border-bottom-color: var(--accent); }
.tab-pane { flex: 1; overflow-y: auto; padding: 14px; min-height: 0; display: none; }
.tab-pane.active { display: flex; flex-direction: column; }
.toolbar {
flex: none;
display: flex; gap: 8px; justify-content: center; align-items: center;
flex-wrap: wrap;
padding: 10px 16px;
background: var(--bg-elev);
border-top: 1px solid var(--border);
}
.toolbar button { padding: 0 16px; height: 40px; min-width: 44px; display: flex; align-items: center; justify-content: center; gap: 6px; line-height: 1; }
.toolbar button > * { line-height: 1; }
.toolbar #tb-mic, .toolbar #tb-cam, .toolbar #tb-screen { min-width: 140px; }
.toolbar .spacer { flex: 1; }
.toolbar button.on { background: rgba(47,129,247,0.15); border-color: var(--accent); color: var(--accent); }
.toolbar button.off { background: rgba(248,81,73,0.12); border-color: var(--err); color: var(--err); }
/* Device picker: chevron sits flush next to its primary toolbar button. */
.toolbar .device-picker { position: relative; display: flex; gap: 2px; }
.toolbar .device-chevron { min-width: 28px; padding: 0 6px; }
.toolbar .device-chevron::before {
content: '';
width: 12px; height: 12px;
background: currentColor;
-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;
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;
transition: transform 120ms ease;
}
.toolbar .device-chevron[aria-expanded="true"]::before { transform: rotate(180deg); }
.toolbar .device-chevron[aria-expanded="true"] { background: rgba(47,129,247,0.15); border-color: var(--accent); color: var(--accent); }
.device-menu {
position: absolute; bottom: calc(100% + 6px); left: 0;
min-width: 240px; max-width: min(360px, calc(100vw - 24px));
max-height: 50vh; overflow: auto;
background: var(--bg-elev); border: 1px solid var(--border); border-radius: 6px;
padding: 6px; z-index: 50;
box-shadow: 0 8px 24px rgba(0,0,0,0.4);
display: flex; flex-direction: column; gap: 2px;
}
.device-menu.hidden { display: none; }
.device-menu .device-row {
display: flex; align-items: center; gap: 8px;
padding: 6px 8px; border-radius: 4px; cursor: pointer;
font-size: 13px; line-height: 1.3;
}
.device-menu .device-row:hover { background: rgba(255,255,255,0.05); }
.device-menu .device-row input { margin: 0; flex: none; }
.device-menu .device-row .device-label { flex: 1; word-break: break-word; }
.device-menu .device-row .device-active {
color: var(--accent); font-size: 11px; flex: none;
display: inline-flex; align-items: center; gap: 4px;
}
.device-menu .device-empty { padding: 6px 8px; font-size: 12px; font-style: italic; color: var(--muted, #888); }
@media (max-width: 720px) {
/* Drop the spacer so the row collapses to its content, shrink button
padding/widths so all five buttons fit on one line at 500 px wide. */
.toolbar { padding: 8px 10px; gap: 6px; }
.toolbar .spacer { display: none; }
.toolbar button { padding: 0 10px; }
.toolbar #tb-mic, .toolbar #tb-cam, .toolbar #tb-screen { min-width: 0; }
.toolbar .device-chevron { min-width: 24px; padding: 0 4px; }
}
/* Chat */
.chat-log { flex: 1; overflow: auto; padding-right: 4px; display: flex; flex-direction: column; gap: 8px; min-height: 0; }
.chat-msg { padding: 8px 10px; border-radius: var(--radius-sm); background: var(--bg-elev-2); max-width: 80%; word-wrap: break-word; }
.chat-msg.me { align-self: flex-end; background: rgba(47,129,247,0.15); }
.chat-msg .meta { font-size: 10px; color: var(--text-faint); margin-top: 3px; }
.chat-input { display: flex; gap: 6px; margin-top: 8px; }
.chat-input input { flex: 1; }
.chat-counter { color: var(--text-faint); margin-top: 4px; text-align: right; font-variant-numeric: tabular-nums; }
.chat-counter.over { color: var(--err); }
/* Files */
.files-drop {
border: 2px dashed var(--border-strong);
border-radius: var(--radius);
padding: 18px;
text-align: center;
color: var(--text-dim);
margin-bottom: 12px;
transition: border-color 120ms, background 120ms;
}
.files-drop.over { border-color: var(--accent); background: rgba(47,129,247,0.06); }
.files-drop.disabled { opacity: 0.5; pointer-events: none; }
.files-section-head { display: flex; align-items: center; justify-content: space-between; margin-top: 16px; }
.files-section-head h3 { margin: 0; }
.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; }
.file-item .name { font-weight: 500; }
.file-item .meta { color: var(--text-dim); font-size: 11px; }
.file-item .row-close {
position: absolute; top: 4px; right: 4px;
background: transparent; border: none; color: var(--text-faint);
width: 22px; height: 22px; padding: 0; border-radius: 4px;
cursor: pointer; font-size: 14px; line-height: 1;
}
.file-item .row-close:hover { background: var(--bg-elev); color: var(--text); }
/* Stats */
.stats-table { width: 100%; border-collapse: collapse; font-family: var(--mono); font-size: 12px; }
.stats-table td { padding: 4px 6px; border-bottom: 1px solid var(--border); }
.stats-table td:first-child { color: var(--text-dim); width: 45%; }
/* Modal dialog (native <dialog>) — used for peer-left notification. */
dialog {
background: var(--bg-elev);
color: var(--text);
border: 1px solid var(--border-strong);
border-radius: var(--radius);
padding: 22px 24px;
max-width: 420px;
box-shadow: 0 12px 40px rgba(0,0,0,0.5);
}
dialog::backdrop { background: rgba(0,0,0,0.55); }
dialog h2 { margin: 0 0 8px; padding: 0; border: none; font-size: 17px; }
dialog p { margin: 0 0 16px; color: var(--text-dim); }
.dialog-actions { display: flex; justify-content: flex-end; gap: 8px; }
.step-dialog-head { display: flex; align-items: center; gap: 10px; margin-bottom: 6px; }
.step-dialog-head h2 { margin: 0; }
.step-dialog-room {
display: flex; align-items: center; gap: 10px;
margin: 0 0 16px;
padding: 10px 12px;
background: var(--bg-elev-2);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
}
.step-dialog-room.hidden { display: none; }
.step-dialog-room-label { color: var(--text-dim); font-size: 12px; }
.step-dialog-room code {
font-family: var(--mono); font-size: 14px;
color: var(--text); user-select: all;
}
/* Console drawer — sits below the toolbar inside #view-call so opening it
shrinks the call area instead of overlapping it. */
#console-drawer {
flex: none;
height: 35vh; min-height: 200px; max-height: 50vh;
background: var(--bg-elev); border-top: 1px solid var(--border-strong);
display: flex; flex-direction: column;
min-width: 0;
}
#console-drawer.hidden { display: none; }
.console-head { display: flex; align-items: center; gap: 8px; padding: 8px 12px; border-bottom: 1px solid var(--border); flex-wrap: wrap; }
.console-head .spacer { flex: 1; }
.console-head select { padding: 4px 8px; font-size: 12px; }
.console-head label.small { display: inline-flex; align-items: center; gap: 4px; }
.console-head #console-filter { width: 120px; min-width: 80px; }
@media (max-width: 720px) {
.console-head { padding: 6px 10px; gap: 6px; }
/* Hide the "Level" / "Filter" inline label text — the dropdown and the
placeholder make the controls self-evident. */
.console-head label.small { font-size: 0; }
.console-head label.small > * { font-size: 12px; }
.console-head #console-filter { width: 100px; }
}
.console-body { flex: 1; overflow: auto; padding: 6px 12px; font-family: var(--mono); font-size: 11px; }
.log-line { padding: 2px 0; white-space: pre-wrap; word-break: break-word; }
.log-line .ts { color: var(--text-faint); margin-right: 6px; }
.log-line .lvl { display: inline-block; min-width: 34px; padding: 0 5px; border-radius: 3px; margin-right: 6px; font-size: 9px; text-align: center; }
.log-line.debug .lvl { background: var(--bg-elev-2); color: var(--text-dim); }
.log-line.info .lvl { background: rgba(47,129,247,0.15); color: var(--accent); }
.log-line.warn .lvl { background: rgba(210,153,34,0.15); color: var(--warn); }
.log-line.error .lvl { background: rgba(248,81,73,0.15); color: var(--err); }
.log-line .label { color: var(--text-dim); margin-right: 6px; }
.kbd { font-family: var(--mono); padding: 1px 5px; border-radius: 4px; background: var(--bg-elev-2); border: 1px solid var(--border); font-size: 11px; }
.small { font-size: 12px; color: var(--text-dim); }
.nowrap { white-space: nowrap; }
.mono { font-family: var(--mono); }
/* Inline progress (Continue to signaling, Apply offer) */
.progress-row {
display: flex; align-items: center; gap: 10px;
margin-top: 12px;
padding: 10px 12px;
background: var(--bg-elev);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text);
font-size: 13px;
}
.progress-row.hidden { display: none; }
.spinner {
width: 14px; height: 14px; flex: none;
border: 2px solid var(--border-strong);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.progress-row .sub { color: var(--text-dim); font-size: 12px; }
/* Insecure-context warning */
.warn-banner {
margin-bottom: 16px;
padding: 12px 14px;
background: rgba(210,153,34,0.1);
border: 1px solid rgba(210,153,34,0.4);
border-radius: var(--radius-sm);
color: var(--warn);
font-size: 13px;
}
.warn-banner.hidden { display: none; }
.warn-banner strong { color: var(--warn); }
::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 5px; }
::-webkit-scrollbar-thumb:hover { background: var(--border-strong); }
</style>
</head>
<body>
<main id="app">
<header class="topbar">
<div class="brand">WebRTC Tool <small>direct peer-to-peer audio, video, chat, and files</small></div>
<div class="right">
<span id="role-badge" class="role-badge hidden"></span>
<span id="conn-pill" class="pill">disconnected</span>
<button class="icon" id="theme-toggle" title="Toggle theme" aria-label="Toggle theme">
<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>
<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>
</button>
</div>
</header>
<!-- ============== WELCOME ============== -->
<section id="view-welcome" class="view">
<div class="container">
<h1>WebRTC Tool</h1>
<p class="lede">
A direct peer-to-peer call tool — a fallback for video, screen share, chat, and file transfer
when your usual conferencing software isn't cooperating. Two peers can connect either via a
shared room code (using a small relay server that only shuttles the offer/answer) or by
pasting two short JSON blobs to each other. Browser-only on each end: no install, no account.
</p>
<div class="card">
<h2>Pick your role</h2>
<p class="small">One side starts the call (creates the offer); the other side joins it.</p>
<div class="role-picker">
<button data-role="initiator">
<span class="role-title">Start a call</span>
<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>
</button>
<button data-role="joiner">
<span class="role-title">Join a call</span>
<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>
</button>
<button data-role="loopback" class="role-loopback">
<span class="role-title">Loopback test (same tab)</span>
<span class="role-desc">Run both peers in this tab. Useful for verifying that media and the call UI work locally.</span>
</button>
</div>
</div>
<div class="card">
<h2>Tools</h2>
<div class="actions">
<button id="welcome-sdp-inspect" class="ghost">SDP inspector →</button>
</div>
<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>
</div>
</div>
</section>
<!-- ============== CONFIGURE ============== -->
<section id="view-configure" class="view hidden">
<div class="container">
<h1>Configure <span id="role-title-cfg" class="role-badge"></span></h1>
<p class="lede" id="cfg-lede">Set up media and ICE servers, then continue to the signaling step.</p>
<div id="insecure-warn" class="warn-banner hidden">
<strong>Insecure context:</strong>
<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>
</div>
<div class="card" id="signaling-card">
<h2>Signaling</h2>
<div class="seg-toggle" role="tablist" aria-label="Signaling mode">
<button type="button" id="sig-mode-auto" role="tab" class="active">Auto via server (room code)</button>
<button type="button" id="sig-mode-manual" role="tab">Manual SDP exchange</button>
</div>
<p class="small hidden" id="sig-help-manual">
You and your peer copy two JSON blobs (offer and answer) between yourselves through any
channel — email, chat, paper. Nothing leaves the browser except the call itself. Use this when
you don't want to (or can't) run a server, or when you want to inspect the SDP.
</p>
<p class="small" id="sig-help-auto">
Both peers enter the same short <em>room code</em> on a small relay server that just shuttles
the offer/answer pair. The server never sees media or chat — those still flow peer-to-peer.
Whoever opens the room first becomes the initiator; the second peer joins. The relay holds
each blob for 10 minutes and forgets it after.
</p>
<div id="sig-auto-fields" class="hidden">
<div class="room-code-hero">
<label class="hero-label" for="sig-room-code">Enter a room code to connect</label>
<div class="room-code-input-row">
<input type="text" id="sig-room-code" placeholder="e.g. blue-fish-42" spellcheck="false" autocomplete="off" maxlength="15">
<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>
</div>
<p class="hero-help">Any short alphanumeric string (max 15 chars, letters/digits/<code>-_</code>). Share it with your peer through any channel.</p>
</div>
<label class="field" style="max-width:420px; margin-top:14px">
<span>Server URL</span>
<div class="row" style="gap:6px; align-items:stretch">
<input type="url" id="sig-server-url" placeholder="https://example.com:8080" spellcheck="false" autocomplete="off" style="flex:1">
<button type="button" id="sig-check" class="ghost" title="Probe the server's /health endpoint">Check</button>
</div>
<span id="sig-check-status" class="pill hidden" style="margin-top:6px; align-self:flex-start"></span>
</label>
<p class="small">Base URL of the signaling server. The page will POST/GET to <code><url>/room/<code>/offer</code> and <code>/answer</code>.</p>
</div>
<details style="margin-top:14px">
<summary>Advanced</summary>
<div class="details-body">
<label class="field" style="max-width:260px">
<span>ICE gathering timeout (seconds)</span>
<input type="number" id="sig-ice-timeout" min="0" step="1" placeholder="8">
</label>
<p class="small">
Hard cap on how long the page waits for ICE candidate gathering before exporting the SDP.
Use <code>0</code> to wait indefinitely — useful when you want every candidate (e.g. slow TURN
relays) but expect to abort manually if gathering stalls. Default is 8 seconds.
</p>
</div>
</details>
</div>
<details class="card card-collapsible">
<summary><h2>ICE / TURN servers</h2></summary>
<div class="card-body">
<p class="small">
Leave the STUN default for typical LAN/internet use. Add TURN servers for cross-NAT peers.
Empty list means fully-local (host candidates only) — works on the same LAN or same machine.
</p>
<div id="ice-rows"></div>
<div class="actions">
<button id="ice-add" class="ghost">+ Add server</button>
<button id="ice-clear" class="ghost">Clear all</button>
<button id="ice-reset" class="ghost">Reset to default</button>
<button id="ice-toggle-json" class="ghost">Edit as JSON…</button>
</div>
<div id="ice-json-wrap" class="hidden" style="margin-top:10px">
<label class="field">
<span>RTCIceServer[] JSON</span>
<textarea id="ice-json" spellcheck="false"></textarea>
</label>
<div class="actions">
<button id="ice-json-apply" class="primary">Apply JSON</button>
</div>
</div>
<h3 style="margin-top:18px">LAN connectivity</h3>
<p class="small">
For privacy, browsers (especially Firefox) restrict WebRTC ICE candidates to the default network
interface while no microphone or camera stream is active on the page. If both peers are on the
same network and you don't have a TURN server, enabling this opens a muted microphone stream
(no audio is captured or sent) so the ICE agent can see all your local interfaces and direct
LAN candidates can be exchanged. The OS will indicate the microphone is in use; stop it from
here, the toolbar, or by hanging up.
</p>
<div class="actions">
<button id="ice-warmup" class="ghost">Enable LAN connectivity</button>
<span id="ice-warmup-status" class="pill">off</span>
</div>
</div>
</details>
<details class="card card-collapsible">
<summary><h2>Media</h2></summary>
<div class="card-body">
<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>
<label class="field" style="max-width:260px">
<span>Receive codec preference</span>
<select id="preferred-codec">
<option value="auto">auto (browser default)</option>
</select>
</label>
<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>
<label class="field" style="max-width:260px">
<span>Send codec</span>
<select id="send-codec">
<option value="auto">auto (browser default)</option>
</select>
</label>
<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>
<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>
<details>
<summary>Advanced Opus settings (SDP)</summary>
<div class="details-body">
<div class="opus-list">
<div class="opus-item">
<label class="row"><input type="checkbox" id="o-stereo"> stereo / sprop-stereo</label>
<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>
</div>
<div class="opus-item">
<label class="row"><input type="checkbox" id="o-fec" checked> useinbandfec</label>
<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>
</div>
<div class="opus-item">
<label class="row"><input type="checkbox" id="o-dtx" checked> usedtx</label>
<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>
</div>
<div class="opus-item">
<label class="row"><input type="checkbox" id="o-cbr"> cbr (constant bitrate)</label>
<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>
</div>
<div class="opus-item">
<label class="field">
<span>maxaveragebitrate (bits/s; 0 = unset)</span>
<input type="number" id="o-maxbr" placeholder="0" min="0" max="510000" step="1000">
</label>
<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>
</div>
</div>
</div>
</details>
</div>
</details>
<div class="actions">
<button id="cfg-back" class="ghost">← Back</button>
<button id="cfg-continue" class="primary">Continue to signaling →</button>
</div>
<div id="cfg-progress" class="progress-row hidden" role="status" aria-live="polite">
<span class="spinner"></span>
<span>
<span id="cfg-progress-label">Working…</span>
<span id="cfg-progress-sub" class="sub"></span>
</span>
</div>
</div>
</section>
<!-- ============== EXCHANGE ============== -->
<section id="view-exchange" class="view hidden">
<div class="container">
<h1>Signaling exchange <span id="role-title-exch" class="role-badge"></span></h1>
<p class="lede" id="exch-lede"></p>
<div class="card">
<h2 id="step-1-h">Step 1</h2>
<div id="step-1-body" class="blob-area">
<!-- filled by JS -->
</div>
</div>
<div class="card" id="step-2-card">
<h2 id="step-2-h">Step 2</h2>
<div id="step-2-body" class="blob-area">
<!-- filled by JS -->
</div>
</div>
<div class="actions">
<button id="exch-cancel" class="ghost">Cancel</button>
</div>
<div id="exch-progress" class="progress-row hidden" role="status" aria-live="polite">
<span class="spinner"></span>
<span>
<span id="exch-progress-label">Working…</span>
<span id="exch-progress-sub" class="sub"></span>
</span>
</div>
</div>
</section>
<!-- ============== SDP INSPECTOR ============== -->
<section id="view-sdp-inspect" class="view hidden">
<div class="container">
<h1>SDP inspector</h1>
<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>
<div class="card">
<h2>Input</h2>
<textarea id="sdp-in" spellcheck="false" placeholder="Paste SDP / JSON / b64:… here"></textarea>
<div class="blob-controls">
<button id="sdp-inspect-go" class="primary">Inspect</button>
<button id="sdp-inspect-clear" class="ghost">Clear</button>
<button id="sdp-inspect-upload" class="ghost">Upload…</button>
<input id="sdp-inspect-file" type="file" accept=".json,.txt,application/json,text/plain" hidden>
<span class="pill" id="sdp-inspect-status"></span>
</div>
</div>
<div id="sdp-inspect-out"></div>
<div class="actions">
<button id="sdp-inspect-back" class="ghost">← Back</button>
</div>
</div>
</section>
<!-- ============== CALL ============== -->
<section id="view-call" class="view hidden">
<div class="call-body">
<div class="video-area">
<div class="video-tile empty" id="tile-local">
<video id="vid-local-main" autoplay muted playsinline></video>
<div class="pip hidden" id="pip-local">
<video id="vid-local-pip" autoplay muted playsinline></video>
</div>
<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>
<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>
<span class="tile-label">you</span>
</div>
<div class="video-tile empty" id="tile-remote">
<video id="vid-remote-main" autoplay muted playsinline></video>
<div class="pip hidden" id="pip-remote">
<video id="vid-remote-pip" autoplay muted playsinline></video>
</div>
<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>
<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>
<span class="tile-label">peer</span>
</div>
</div>
<!-- Plays the peer's audio independently of any video element so it
keeps working even when the remote tile is showing the empty state
(i.e. mic-only call) — display:none on a <video> suppresses audio
output in some browsers (notably Firefox). -->
<audio id="audio-remote" autoplay></audio>
<aside class="sidebar">
<div class="tabs">
<button data-tab="chat" class="active">Chat</button>
<button data-tab="files">Files</button>
<button data-tab="settings">Settings</button>
<button data-tab="stats">Stats</button>
</div>
<div class="tab-pane active" data-pane="chat">
<div id="chat-log" class="chat-log"></div>
<div class="chat-input">
<input type="text" id="chat-text" placeholder="Type a message and press Enter" autocomplete="off">
<button id="chat-send" class="primary">Send</button>
</div>
<div id="chat-counter" class="chat-counter small">0 / 8192 B</div>
</div>
<div class="tab-pane" data-pane="files">
<div id="files-drop" class="files-drop">
<p><strong>Drop a file here</strong> or <a href="#" id="files-pick">pick one</a>.</p>
<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>
<input type="file" id="files-input" class="hidden">
</div>
<div class="files-section-head"><h3>Outgoing</h3><button class="ghost small" id="files-out-clear">Clear all</button></div>
<div id="files-out"></div>
<div class="files-section-head"><h3>Incoming</h3><button class="ghost small" id="files-in-clear">Clear all</button></div>
<div id="files-in"></div>
</div>
<div class="tab-pane" data-pane="settings">
<h3>Video</h3>
<p class="small">Resolution and framerate are applied by restarting the camera. Bitrate and degradation preference apply instantly without touching the camera.</p>
<div class="grid-2">
<label class="field">
<span>Width (px, 0 = auto)</span>
<input type="number" id="rt-v-w" min="0" max="3840">
</label>
<label class="field">
<span>Height (px, 0 = auto)</span>
<input type="number" id="rt-v-h" min="0" max="2160">
</label>
<label class="field">
<span>Frame rate (fps, 0 = auto)</span>
<input type="number" id="rt-v-fps" min="0" max="120">
</label>
<label class="field">
<span>Max send bitrate (kbps, 0 = unset)</span>
<input type="number" id="rt-v-maxbr" min="0" max="20000" step="50">
</label>
<label class="field">
<span>Degradation preference</span>
<select id="rt-v-degrade">
<option value="balanced">balanced</option>
<option value="maintain-framerate">maintain-framerate</option>
<option value="maintain-resolution">maintain-resolution</option>
</select>
</label>
</div>
<button id="rt-v-apply" class="ghost">Apply</button>
<h3>Send codec</h3>
<p class="small">Applies to both camera and screen-share encoders. Picks from the codecs negotiated at signaling time.</p>
<div class="grid-2">
<label class="field">
<span>Send codec</span>
<select id="rt-codec">
<option value="auto">auto</option>
</select>
</label>
</div>
<button id="rt-codec-apply" class="ghost">Apply</button>
<h3>Screen share</h3>
<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>
<div class="grid-2">
<label class="field">
<span>Width (px, 0 = auto)</span>
<input type="number" id="rt-s-w" min="0" max="7680">
</label>
<label class="field">
<span>Height (px, 0 = auto)</span>
<input type="number" id="rt-s-h" min="0" max="4320">
</label>
<label class="field">
<span>Frame rate (fps, 0 = auto)</span>
<input type="number" id="rt-s-fps" min="0" max="120">
</label>
<label class="field">
<span>Max send bitrate (kbps, 0 = unset)</span>
<input type="number" id="rt-s-maxbr" min="0" max="50000" step="100">
</label>
<label class="field">
<span>Degradation preference</span>
<select id="rt-s-degrade">
<option value="balanced">balanced</option>
<option value="maintain-framerate">maintain-framerate</option>
<option value="maintain-resolution">maintain-resolution</option>
</select>
</label>
</div>
<button id="rt-s-apply" class="ghost">Apply</button>
<h3>Audio</h3>
<p class="small">Echo/noise/AGC apply live. Channels and sample rate take effect by restarting the microphone.</p>
<label class="row"><input type="checkbox" id="rt-a-aec"> Echo cancellation</label>
<label class="row"><input type="checkbox" id="rt-a-ns" > Noise suppression</label>
<label class="row"><input type="checkbox" id="rt-a-agc"> Auto gain control</label>
<div class="grid-2">
<label class="field">
<span>Channels</span>
<select id="rt-a-channels">
<option value="1">1 (mono)</option>
<option value="2">2 (stereo)</option>
</select>
</label>
<label class="field">
<span>Sample rate (Hz, 0 = auto)</span>
<input type="number" id="rt-a-rate" min="0" max="96000" step="1000">
</label>
</div>
<button id="rt-a-apply" class="ghost">Apply</button>
</div>
<div class="tab-pane" data-pane="stats">
<h3>Peer connection <button id="stats-export" class="ghost small" style="float:right">Export</button></h3>
<table class="stats-table" id="stats-table"><tbody></tbody></table>
</div>
</aside>
</div>
<div class="toolbar">
<div class="device-picker">
<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>
<button id="tb-mic-pick" class="device-chevron" title="Choose microphone" aria-haspopup="true" aria-expanded="false"></button>
<div id="tb-mic-menu" class="device-menu hidden" role="menu" aria-label="Microphone"></div>
</div>
<div class="device-picker">
<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>
<button id="tb-cam-pick" class="device-chevron" title="Choose camera" aria-haspopup="true" aria-expanded="false"></button>
<div id="tb-cam-menu" class="device-menu hidden" role="menu" aria-label="Camera"></div>
</div>
<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>
<span class="spacer"></span>
<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>
<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>
</div>
<aside id="console-drawer" class="hidden">
<div class="console-head">
<strong>Debug console</strong>
<span class="small" id="console-count">0 entries</span>
<span class="spacer"></span>
<label class="small">Level
<select id="console-level">
<option value="debug">debug</option>
<option value="info" selected>info</option>
<option value="warn">warn</option>
<option value="error">error</option>
</select>
</label>
<label class="small">Filter <input type="text" id="console-filter" placeholder="text"></label>
<button class="ghost small" id="console-clear">Clear</button>
<button class="ghost small" id="console-export">Export</button>
<button class="ghost small" id="console-stats-toggle">Stats poll</button>
<button class="ghost small" id="console-close">×</button>
</div>
<div class="console-body" id="console-body"></div>
</aside>
</section>
<dialog id="peer-left-dialog">
<h2>Peer left the call</h2>
<p>The other person hung up. The call has ended.</p>
<div class="dialog-actions">
<button id="peer-left-ok" class="primary">OK</button>
</div>
</dialog>
<dialog id="step-dialog">
<div class="step-dialog-head">
<span class="spinner" aria-hidden="true"></span>
<h2 id="step-dialog-label">Working…</h2>
</div>
<p id="step-dialog-sub"></p>
<div id="step-dialog-room" class="step-dialog-room hidden">
<span class="step-dialog-room-label">Room code</span>
<code id="step-dialog-room-code"></code>
</div>
<div class="dialog-actions">
<button id="step-dialog-cancel" class="ghost">Cancel</button>
</div>
</dialog>
</main>
<script>
'use strict';
/* =========================================================================
WebRTC Playground — single-file, backend-less.
Module layout (all under one `App` namespace):
App.log — ring-buffer log + console drawer rendering
App.theme — light/dark
App.state — current view / role / pc / dc / settings
App.signal — blob format + wait for ICE complete
App.codec — Opus SDP munging
App.media — gUM/gDM, pre-allocated transceivers, replaceTrack
App.chat — text over the chat data channel
App.files — chunked file transfer over the files data channel
App.stats — getStats() polling
App.ui — view rendering, event wiring
========================================================================= */
const App = {};
window.App = App; /* useful for poking from devtools */
/* -------------------------------------------------------------------------
Log
------------------------------------------------------------------------- */
App.log = (() => {
const MAX = 1000;
const buf = [];
const subs = new Set();
function push(level, label, args) {
const entry = { level, label, ts: Date.now(), args };
buf.push(entry);
if (buf.length > MAX) buf.shift();
subs.forEach(fn => { try { fn(entry); } catch (e) { /* swallow */ } });
const m = level === 'debug' ? 'log' : level;
try { console[m](`[${label}]`, ...args); } catch (_) {}
}
return {
debug: (l, ...a) => push('debug', l, a),
info: (l, ...a) => push('info', l, a),
warn: (l, ...a) => push('warn', l, a),
error: (l, ...a) => push('error', l, a),
subscribe: fn => { subs.add(fn); return () => subs.delete(fn); },
snapshot: () => buf.slice(),
clear: () => { buf.length = 0; subs.forEach(fn => fn(null)); },
};
})();
window.addEventListener('error', e => App.log.error('window', e.message, e.filename + ':' + e.lineno));
window.addEventListener('unhandledrejection', e => App.log.error('promise', String(e.reason)));
/* -------------------------------------------------------------------------
Theme
------------------------------------------------------------------------- */
App.theme = (() => {
const stored = localStorage.getItem('webrtc-tool.theme');
if (stored) document.documentElement.setAttribute('data-theme', stored);
return {
toggle() {
const cur = document.documentElement.getAttribute('data-theme') || 'dark';
const next = cur === 'dark' ? 'light' : 'dark';
document.documentElement.setAttribute('data-theme', next);
localStorage.setItem('webrtc-tool.theme', next);
}
};
})();
/* -------------------------------------------------------------------------
Progress: inline labeled spinner shown during slow setup steps (gUM
prompt, ICE gathering, etc.). Two hosts: configure view + exchange view.
------------------------------------------------------------------------- */
App.progress = (() => {
let modalCancelHandler = null;
function active() {
/* Pick the progress widget inside the currently visible view. */
const candidates = ['cfg-progress', 'exch-progress'];
for (const id of candidates) {
const el = document.getElementById(id);
if (!el) continue;
const view = el.closest('.view');
if (view && !view.classList.contains('hidden')) return el;
}
return null;
}
function setBusy(busy) {
/* Disable the primary action button(s) while a step is running so the
user can't double-fire ICE gathering. */
const ids = ['cfg-continue', 'cfg-back', 'blob-apply'];
ids.forEach(id => {
const el = document.getElementById(id);
if (el) el.disabled = busy;
});
}
function show(label, sub) {
/* If the modal is up from a previous step, close it before falling back
to the inline widget. */
hideModal();
const host = active();
if (host) {
host.classList.remove('hidden');
host.querySelector('#' + host.id + '-label').textContent = label || 'Working…';
host.querySelector('#' + host.id + '-sub').textContent = sub || '';
}
setBusy(true);
}
/* Modal version: used for long blocking steps (ICE gathering, waiting for
peer) where the user needs an explicit Cancel affordance and where the
inline widget would otherwise sit under a Continue button they can't
reach. opts.roomCode, when set, is rendered in a highlighted block. */
function showModal(label, sub, opts) {
opts = opts || {};
const dlg = document.getElementById('step-dialog');
if (!dlg) return;
/* Hide the inline widget if it happened to be up. */
document.getElementById('cfg-progress')?.classList.add('hidden');
document.getElementById('exch-progress')?.classList.add('hidden');
document.getElementById('step-dialog-label').textContent = label || 'Working…';
document.getElementById('step-dialog-sub').textContent = sub || '';
const roomWrap = document.getElementById('step-dialog-room');
if (opts.roomCode) {
document.getElementById('step-dialog-room-code').textContent = opts.roomCode;
roomWrap.classList.remove('hidden');
} else {
roomWrap.classList.add('hidden');
}
modalCancelHandler = opts.onCancel || null;
setBusy(true);
if (!dlg.open && typeof dlg.showModal === 'function') {
try { dlg.showModal(); } catch (_) { /* already open */ }
}
}
function hideModal() {
const dlg = document.getElementById('step-dialog');
if (dlg && dlg.open) { try { dlg.close(); } catch (_) {} }
modalCancelHandler = null;
}
function hide() {
document.getElementById('cfg-progress')?.classList.add('hidden');
document.getElementById('exch-progress')?.classList.add('hidden');
hideModal();
setBusy(false);
}
function triggerCancel() {
const fn = modalCancelHandler;
modalCancelHandler = null;
if (fn) fn();
}
return { show, showModal, hide, hideModal, triggerCancel };
})();
/* -------------------------------------------------------------------------
State
------------------------------------------------------------------------- */
App.state = {
role: null, /* 'initiator' | 'joiner' | 'loopback' */
pc: null, /* primary RTCPeerConnection */
pcB: null, /* loopback only: secondary RTCPeerConnection */
dcChat: null,
dcFiles: null,
micTransceiver: null,
camTransceiver: null,
screenTransceiver: null,
localStream: null, /* gUM result */
screenStream: null,
iceWarmupStream: null, /* kept alive (muted) to unlock LAN ICE candidates in Firefox */
remoteStream: null, /* assembled from incoming audio + cam tracks */
remoteScreenStream: null,/* assembled from incoming screen track */
peerMediaState: { mic: false, cam: false, screen: false }, /* sent by peer over the chat dc */
settings: defaultSettings(),
};
function defaultSettings() {
const stored = localStorage.getItem('webrtc-tool.iceServers');
let ice;
try { ice = stored ? JSON.parse(stored) : [{ urls: 'stun:stun.l.google.com:19302' }]; }
catch (_) { ice = [{ urls: 'stun:stun.l.google.com:19302' }]; }
return {
iceServers: ice,
audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true, channelCount: 1, sampleRate: 0, deviceId: '' },
opus: { stereo: false, fec: true, dtx: true, cbr: false, maxAverageBitrate: 0 },
video: { width: 0, height: 0, frameRate: 0, maxBitrateKbps: 0, degradationPreference: 'balanced', deviceId: '' },
screen: { width: 0, height: 0, frameRate: 0, maxBitrateKbps: 0, degradationPreference: 'maintain-resolution' },
preferredVideoCodec: 'auto',
sendVideoCodec: 'auto',
base64: false,
signaling: loadSignaling(),
};
}
function loadSignaling() {
/* Default server URL is the page's own origin — the natural assumption is
that the relay is colocated with the static page. For file:// loads
location.origin is "null"; fall back to blank so the user fills it in. */
const defaultUrl = (location.protocol === 'http:' || location.protocol === 'https:')
? location.origin : '';
const defaults = { mode: 'auto', serverUrl: defaultUrl, iceGatherTimeoutMs: 8000 };
try {
const stored = localStorage.getItem('webrtc-tool.signaling');
if (stored) {
const s = JSON.parse(stored);
const t = Number(s.iceGatherTimeoutMs);
return {
mode: s.mode === 'manual' ? 'manual' : 'auto',
serverUrl: typeof s.serverUrl === 'string' ? s.serverUrl : defaultUrl,
/* 0 is a valid value (= no timeout); only fall back when missing/NaN/negative. */
iceGatherTimeoutMs: Number.isFinite(t) && t >= 0 ? t : defaults.iceGatherTimeoutMs,
};
}
} catch (_) {}
return defaults;
}
function saveSignaling() {
try {
localStorage.setItem('webrtc-tool.signaling', JSON.stringify({
mode: App.state.settings.signaling.mode,
serverUrl: App.state.settings.signaling.serverUrl,
iceGatherTimeoutMs: App.state.settings.signaling.iceGatherTimeoutMs,
}));
} catch (_) {}
}
function saveIce() {
try { localStorage.setItem('webrtc-tool.iceServers', JSON.stringify(App.state.settings.iceServers)); } catch (_) {}
}
/* -------------------------------------------------------------------------
Signal: blob format + ICE-complete wait
------------------------------------------------------------------------- */
App.signal = (() => {
function waitForIceComplete(pc, signal) {
return new Promise(resolve => {
if (pc.iceGatheringState === 'complete') return resolve();
if (signal && signal.aborted) return resolve();
let timeoutId = null;
function done() {
if (timeoutId !== null) { clearTimeout(timeoutId); timeoutId = null; }
pc.removeEventListener('icegatheringstatechange', check);
if (signal) signal.removeEventListener('abort', done);
resolve();
}
function check() { if (pc.iceGatheringState === 'complete') done(); }
pc.addEventListener('icegatheringstatechange', check);
if (signal) signal.addEventListener('abort', done, { once: true });
/* Hard cap so a never-completing gathering doesn't stall the UI forever.
User-configurable in the Signaling → Advanced panel; 0 disables the
cap entirely (the abort signal / setRemoteDescription is then the
only way out). */
const cap = App.state.settings.signaling.iceGatherTimeoutMs;
if (cap > 0) {
timeoutId = setTimeout(() => {
if (pc.iceGatheringState !== 'complete')
App.log.warn('signal', 'ICE gathering timed out at ' + (cap / 1000) + 's; exporting partial SDP');
timeoutId = null;
done();
}, cap);
}
});
}
function utf8ToBase64(s) {
const bytes = new TextEncoder().encode(s);
/* btoa works on binary strings (one char = one byte). Convert through
String.fromCharCode in 8 KB chunks to avoid blowing the argument limit. */
let bin = '';
for (let i = 0; i < bytes.length; i += 0x2000)
bin += String.fromCharCode.apply(null, bytes.subarray(i, i + 0x2000));
return btoa(bin);
}
function base64ToUtf8(b64) {
const bin = atob(b64);
const bytes = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
return new TextDecoder().decode(bytes);
}
function encode(desc) {
const obj = { v: 1, type: desc.type, sdp: desc.sdp, ts: Date.now() };
let text = JSON.stringify(obj);
if (App.state.settings.base64) text = 'b64:' + utf8ToBase64(text);
return text;
}
function decode(text) {
text = (text || '').trim();
if (!text) throw new Error('empty');
if (text.startsWith('b64:')) {
try { text = base64ToUtf8(text.slice(4)); }
catch (e) { throw new Error('bad base64'); }
}
let obj;
try { obj = JSON.parse(text); } catch (e) { throw new Error('not JSON: ' + e.message); }
if (!obj || (obj.type !== 'offer' && obj.type !== 'answer'))
throw new Error('expected {type:"offer"|"answer", sdp:...}');
if (typeof obj.sdp !== 'string' || !obj.sdp.includes('v=')) throw new Error('missing SDP');
return obj;
}
return { waitForIceComplete, encode, decode };
})();
function iceGatheringSubtitle() {
const cap = App.state.settings.signaling.iceGatherTimeoutMs;
if (cap > 0) return 'Probing the network for routable addresses. Up to ~' + Math.round(cap / 1000) + ' seconds.';
return 'Probing the network for routable addresses. No timeout — cancel manually if it stalls.';
}
/* -------------------------------------------------------------------------
Codec: Opus SDP munging
------------------------------------------------------------------------- */
App.codec = (() => {
/* Find Opus payload type in the m=audio section, then ensure an fmtp line
exists for it with the params we want. */
function mungeOpus(sdp, opus) {
if (!opus) return sdp;
const lines = sdp.split(/\r?\n/);
/* Locate audio m-section bounds */
let audioStart = -1, audioEnd = lines.length;
for (let i = 0; i < lines.length; i++) {
if (lines[i].startsWith('m=audio')) { audioStart = i; }
else if (audioStart >= 0 && lines[i].startsWith('m=') && i > audioStart) { audioEnd = i; break; }
}
if (audioStart < 0) return sdp;
/* Find Opus payload types */
const opusPts = [];
for (let i = audioStart; i < audioEnd; i++) {
const m = lines[i].match(/^a=rtpmap:(\d+)\s+opus\/(\d+)(?:\/(\d+))?/i);
if (m) opusPts.push(m[1]);
}
if (!opusPts.length) return sdp;
const params = [];
if (opus.stereo) { params.push('stereo=1'); params.push('sprop-stereo=1'); }
params.push('useinbandfec=' + (opus.fec ? 1 : 0));
if (opus.dtx) params.push('usedtx=1');
if (opus.cbr) params.push('cbr=1');
if (opus.maxAverageBitrate && opus.maxAverageBitrate > 0)
params.push('maxaveragebitrate=' + opus.maxAverageBitrate);
const want = params.join(';');
for (const pt of opusPts) {
let found = false;
for (let i = audioStart; i < audioEnd; i++) {
if (lines[i].startsWith('a=fmtp:' + pt + ' ')) {
/* Merge: drop any of the keys we're setting, keep the rest, append ours */
const existing = lines[i].slice(('a=fmtp:' + pt + ' ').length);
const kept = existing.split(';')
.map(s => s.trim()).filter(Boolean)
.filter(kv => {
const k = kv.split('=')[0].toLowerCase();
return !['stereo','sprop-stereo','useinbandfec','usedtx','cbr','maxaveragebitrate'].includes(k);
});
const merged = [...kept, ...params.filter(Boolean)].join(';');
lines[i] = 'a=fmtp:' + pt + ' ' + merged;
found = true; break;
}
}
if (!found) {
/* Insert after the matching rtpmap */
for (let i = audioStart; i < audioEnd; i++) {
if (lines[i].match(new RegExp('^a=rtpmap:' + pt + '\\b'))) {
lines.splice(i + 1, 0, 'a=fmtp:' + pt + ' ' + want);
audioEnd++; break;
}
}
}
}
return lines.join('\r\n');
}
return { mungeOpus };
})();
/* -------------------------------------------------------------------------
Media: gUM/gDM, pre-allocated transceivers, replaceTrack
------------------------------------------------------------------------- */
App.media = (() => {
/* Reorder the codec list so the user's preferred video codec is first.
Must be called before createOffer (initiator/loopback A) or createAnswer
(joiner / loopback B). With 'auto' we don't touch the list, letting the
browser's default order win. */
function applyVideoCodecPreference(pc) {
const pref = App.state.settings.preferredVideoCodec;
if (!pref || pref === 'auto') return;
if (typeof RTCRtpSender === 'undefined' || !RTCRtpSender.getCapabilities) return;
const caps = RTCRtpSender.getCapabilities('video');
if (!caps || !caps.codecs) return;
const wanted = pref.toLowerCase();
const head = [], tail = [];
for (const c of caps.codecs) {
const sub = (c.mimeType || '').toLowerCase().split('/')[1] || '';
(sub === wanted ? head : tail).push(c);
}
if (!head.length) { App.log.warn('media', 'preferred codec not available', pref); return; }
const ordered = [...head, ...tail];
for (const t of pc.getTransceivers()) {
const kind = (t.receiver && t.receiver.track && t.receiver.track.kind)
|| (t.sender && t.sender.track && t.sender.track.kind);
const isVideo = kind === 'video'
|| t === App.state.camTransceiver
|| t === App.state.screenTransceiver;
if (!isVideo || !t.setCodecPreferences) continue;
try { t.setCodecPreferences(ordered); }
catch (e) { App.log.warn('media', 'setCodecPreferences failed', e.message); }
}
App.log.info('media', 'preferred video codec', pref);
}
/* Pre-allocate three transceivers on the initiator so all toolbar actions
are renegotiation-free (see plan). The joiner gets matching m-sections
from setRemoteDescription and we index transceivers by position. */
function preallocate(pc) {
App.state.micTransceiver = pc.addTransceiver('audio', { direction: 'sendrecv' });
App.state.camTransceiver = pc.addTransceiver('video', { direction: 'sendrecv' });
App.state.screenTransceiver = pc.addTransceiver('video', { direction: 'sendrecv' });
App.log.debug('media', 'pre-allocated 1 audio + 2 video transceivers');
}
function adoptTransceiversFromRemote(pc) {
/* For the joiner: after setRemoteDescription, transceivers exist in
the same order the initiator added them (mid 0, 1, 2). They were
auto-created by SRD and default to recvonly because the joiner has
no local tracks yet — but later enabling mic/cam on a recvonly
transceiver would never send. Force sendrecv so the answer SDP
advertises bidirectional intent. */
const ts = pc.getTransceivers();
for (const t of ts) {
try { t.direction = 'sendrecv'; }
catch (e) { App.log.warn('media', 'could not upgrade transceiver to sendrecv', e.message); }
}
App.state.micTransceiver = ts[0] || null;
App.state.camTransceiver = ts[1] || null;
App.state.screenTransceiver = ts[2] || null;
App.log.debug('media', 'adopted', ts.length, 'transceivers from remote SDP');
}
/* The local preview MediaStream is a synthetic view: we add/remove tracks
to it as the user enables/disables mic and camera from the toolbar. */
function localStream() {
if (!App.state.localStream) App.state.localStream = new MediaStream();
return App.state.localStream;
}
function setLocalTrack(kind, track) {
const s = localStream();
s.getTracks().filter(t => t.kind === kind).forEach(t => s.removeTrack(t));
if (track) s.addTrack(track);
refreshLocalDisplay();
}
/* Decide what plays in the main tile vs the corner PIP for the local side.
Screen share takes the main tile; the cam moves to the PIP. */
function refreshLocalDisplay() {
const main = document.getElementById('vid-local-main');
const pip = document.getElementById('vid-local-pip');
const pipWrap = document.getElementById('pip-local');
const tile = document.getElementById('tile-local');
const camStream = App.state.localStream;
const hasCam = camStream && camStream.getVideoTracks().length > 0;
const screenStream = App.state.screenStream;
const hasScreen = !!screenStream;
if (hasScreen) {
main.srcObject = screenStream;
tile.classList.add('screen');
if (hasCam) { pip.srcObject = camStream; pipWrap.classList.remove('hidden'); }
else { pip.srcObject = null; pipWrap.classList.add('hidden'); }
} else if (hasCam) {
main.srcObject = camStream;
tile.classList.remove('screen');
pip.srcObject = null; pipWrap.classList.add('hidden');
} else {
main.srcObject = null;
tile.classList.remove('screen');
pip.srcObject = null; pipWrap.classList.add('hidden');
}
tile.classList.toggle('empty', !hasCam && !hasScreen);
const micOn = !!(App.state.micTransceiver && App.state.micTransceiver.sender.track);
document.getElementById('mic-muted-local').classList.toggle('hidden', micOn);
}
function refreshRemoteDisplay() {
const main = document.getElementById('vid-remote-main');
const pip = document.getElementById('vid-remote-pip');
const pipWrap = document.getElementById('pip-remote');
const tile = document.getElementById('tile-remote');
const audioEl = document.getElementById('audio-remote');
const camStream = App.state.remoteStream;
const screenStream = App.state.remoteScreenStream;
/* Always route the peer's audio through the dedicated audio element so it
plays regardless of whether any video is currently visible. */
if (audioEl && audioEl.srcObject !== camStream) audioEl.srcObject = camStream || null;
/* Track presence isn't enough — replaceTrack(null) on the sender leaves
the receiver's track in place (frozen on last frame). Trust the peer's
broadcast media state when deciding whether to show video. */
const peer = App.state.peerMediaState || { mic: false, cam: false, screen: false };
const hasCam = peer.cam && camStream && camStream.getVideoTracks().length > 0;
const hasScreen = peer.screen && screenStream && screenStream.getVideoTracks().length > 0;
if (hasScreen) {
main.srcObject = screenStream;
tile.classList.add('screen');
if (hasCam) { pip.srcObject = camStream; pipWrap.classList.remove('hidden'); }
else { pip.srcObject = null; pipWrap.classList.add('hidden'); }
} else if (hasCam) {
main.srcObject = camStream;
tile.classList.remove('screen');
pip.srcObject = null; pipWrap.classList.add('hidden');
} else {
/* Keep audio attached even when not showing video, so the peer's mic
still plays through. */
main.srcObject = camStream || null;
tile.classList.remove('screen');
pip.srcObject = null; pipWrap.classList.add('hidden');
}
tile.classList.toggle('empty', !hasCam && !hasScreen);
document.getElementById('mic-muted-remote').classList.toggle('hidden', peer.mic);
}
function mirrorToLoopback() {
/* In loopback mode, pcB needs to "see" the same tracks pcA is sending so
the remote tile gets video back. Called whenever a sender track changes. */
if (!App.state.pcB) return;
const ts = App.state.pcB.getTransceivers();
const a = App.state.micTransceiver && App.state.micTransceiver.sender.track;
const v = App.state.camTransceiver && App.state.camTransceiver.sender.track;
const s = App.state.screenTransceiver && App.state.screenTransceiver.sender.track;
const fail = label => err => App.log.warn('loopback', 'mirror ' + label + ' failed', err.message);
if (ts[0]) ts[0].sender.replaceTrack(a || null).catch(fail('mic'));
if (ts[1]) ts[1].sender.replaceTrack(v || null).catch(fail('cam'));
if (ts[2]) ts[2].sender.replaceTrack(s || null).catch(fail('screen'));
}
function gumAvailable() {
return !!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia);
}
/* Detect "device unplugged while active": the track fires 'ended'. Flip the
toolbar button off so the UI reflects reality and the user can pick a
different device. We guard with sender.track === track to ignore the
ended event that fires when *we* swap the track via replaceTrack(). */
function attachTrackEndedHandler(track, kind) {
const sender = kind === 'mic'
? App.state.micTransceiver && App.state.micTransceiver.sender
: App.state.camTransceiver && App.state.camTransceiver.sender;
track.addEventListener('ended', () => {
if (!sender || sender.track !== track) return;
const label = kind === 'mic' ? 'Microphone' : 'Camera';
App.log.warn('media', kind + ' track ended unexpectedly');
App.chat.appendSystem?.(label + ' disconnected.');
if (kind === 'mic') setMic(false); else setCam(false);
});
}
async function setMic(on) {
const sender = App.state.micTransceiver && App.state.micTransceiver.sender;
if (!sender) return;
const btn = document.getElementById('tb-mic');
if (on) {
if (!gumAvailable()) {
App.log.warn('media', 'mic unavailable (insecure context?)');
App.chat.appendSystem?.('Microphone unavailable — this page must be served over HTTPS or localhost.');
return;
}
const a = App.state.settings.audio;
let stream = null;
try {
stream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: a.echoCancellation,
noiseSuppression: a.noiseSuppression,
autoGainControl: a.autoGainControl,
channelCount: a.channelCount || undefined,
sampleRate: a.sampleRate || undefined,
deviceId: a.deviceId ? { exact: a.deviceId } : undefined,
},
});
const track = stream.getAudioTracks()[0];
/* If the call was torn down while gUM was waiting on the user's
permission prompt, the sender's pc is now closed and replaceTrack
would reject — stop the track instead so the OS-level mic
indicator turns off. */
if (!App.state.pc || App.state.pc.connectionState === 'closed') {
throw new Error('call ended before mic prompt resolved');
}
await sender.replaceTrack(track);
setLocalTrack('audio', track);
attachTrackEndedHandler(track, 'mic');
mirrorToLoopback();
btn.classList.add('on'); btn.classList.remove('off');
btn.querySelector('.nowrap').textContent = 'Mic on';
App.log.info('media', 'mic on');
applyMediaButtonAvailability?.();
} catch (e) {
/* If the saved device disappeared between sessions, fall back to the
OS default rather than trapping the user with a broken preference. */
if (e && e.name === 'OverconstrainedError' && a.deviceId) {
App.log.warn('media', 'saved mic deviceId no longer available, clearing');
a.deviceId = '';
}
App.log.error('media', 'mic getUserMedia failed', e.message);
App.chat.appendSystem?.('Microphone failed: ' + e.message);
if (stream) stream.getTracks().forEach(t => t.stop());
}
} else {
if (sender.track) sender.track.stop();
await sender.replaceTrack(null);
setLocalTrack('audio', null);
mirrorToLoopback();
btn.classList.remove('on'); btn.classList.add('off');
btn.querySelector('.nowrap').textContent = 'Mic off';
App.log.info('media', 'mic off');
}
broadcastMediaState();
}
async function setCam(on) {
const sender = App.state.camTransceiver && App.state.camTransceiver.sender;
if (!sender) return;
const btn = document.getElementById('tb-cam');
if (on) {
if (!gumAvailable()) {
App.log.warn('media', 'camera unavailable (insecure context?)');
App.chat.appendSystem?.('Camera unavailable — this page must be served over HTTPS or localhost.');
return;
}
const vs = App.state.settings.video;
let stream = null;
try {
stream = await navigator.mediaDevices.getUserMedia({
video: {
width: vs.width || undefined,
height: vs.height || undefined,
frameRate: vs.frameRate || undefined,
deviceId: vs.deviceId ? { exact: vs.deviceId } : undefined,
},
});
const track = stream.getVideoTracks()[0];
if (!App.state.pc || App.state.pc.connectionState === 'closed') {
throw new Error('call ended before camera prompt resolved');
}
await sender.replaceTrack(track);
setLocalTrack('video', track);
attachTrackEndedHandler(track, 'cam');
applyCamSendParams(); /* sets both bitrate and degradationPreference in one roundtrip */
mirrorToLoopback();
btn.classList.add('on'); btn.classList.remove('off');
btn.querySelector('.nowrap').textContent = 'Cam on';
App.log.info('media', 'cam on');
applyMediaButtonAvailability?.();
} catch (e) {
if (e && e.name === 'OverconstrainedError' && vs.deviceId) {
App.log.warn('media', 'saved cam deviceId no longer available, clearing');
vs.deviceId = '';
}
App.log.error('media', 'camera getUserMedia failed', e.message);
App.chat.appendSystem?.('Camera failed: ' + e.message);
if (stream) stream.getTracks().forEach(t => t.stop());
}
} else {
if (sender.track) sender.track.stop();
await sender.replaceTrack(null);
setLocalTrack('video', null);
mirrorToLoopback();
btn.classList.remove('on'); btn.classList.add('off');
btn.querySelector('.nowrap').textContent = 'Cam off';
App.log.info('media', 'cam off');
}
broadcastMediaState();
}
async function startScreenshare() {
if (!navigator.mediaDevices || !navigator.mediaDevices.getDisplayMedia) {
throw new Error('Screen share requires HTTPS (or localhost). Open this page over a secure context.');
}
const ss = App.state.settings.screen;
const ms = await navigator.mediaDevices.getDisplayMedia({
video: {
width: ss.width || undefined,
height: ss.height || undefined,
frameRate: ss.frameRate || undefined,
},
audio: false,
});
/* If the call ended while the source picker was open, the user has
already selected a source — release it instead of leaving it active. */
if (!App.state.pc || App.state.pc.connectionState === 'closed') {
ms.getTracks().forEach(t => t.stop());
throw new Error('call ended before screen share started');
}
App.state.screenStream = ms;
const track = ms.getVideoTracks()[0];
track.addEventListener('ended', () => stopScreenshare());
if (App.state.screenTransceiver) await App.state.screenTransceiver.sender.replaceTrack(track);
applyScreenSendParams(); /* sets both bitrate and degradationPreference in one roundtrip */
mirrorToLoopback();
refreshLocalDisplay();
const sBtn = document.getElementById('tb-screen');
sBtn.classList.add('on'); sBtn.classList.remove('off');
sBtn.querySelector('.nowrap').textContent = 'Sharing';
App.log.info('media', 'screenshare started');
broadcastMediaState();
}
async function stopScreenshare() {
const ms = App.state.screenStream;
if (ms) ms.getTracks().forEach(t => t.stop());
App.state.screenStream = null;
if (App.state.screenTransceiver) await App.state.screenTransceiver.sender.replaceTrack(null);
mirrorToLoopback();
refreshLocalDisplay();
const sBtn = document.getElementById('tb-screen');
sBtn.classList.remove('on'); sBtn.classList.add('off');
sBtn.querySelector('.nowrap').textContent = 'Screen off';
App.log.info('media', 'screenshare stopped');
broadcastMediaState();
}
/* Collect the relevant senders for a given track kind ('cam' | 'screen'),
including pcB's mirrored sender in loopback so the cap is visible there. */
function sendersFor(kind) {
const tIdx = kind === 'screen' ? 2 : 1;
const local = kind === 'screen' ? App.state.screenTransceiver : App.state.camTransceiver;
const out = [];
if (local) out.push(local.sender);
if (App.state.pcB) {
const t = App.state.pcB.getTransceivers()[tIdx];
if (t) out.push(t.sender);
}
return out;
}
/* setParameters is transactional via an internal transactionId attached to
the params object returned by getParameters. Two interleaved
get/mutate/set cycles on the same sender race and the second can fail
with InvalidModificationError. Coalesce bitrate + degradation into one
round-trip per sender, and serialize calls via a per-sender chain. */
const pendingByS = new WeakMap(); /* sender -> Promise (most recent set) */
function applyVideoSendParams(kind, logLabel) {
const cfg = kind === 'screen' ? App.state.settings.screen : App.state.settings.video;
const kbps = cfg.maxBitrateKbps;
const pref = cfg.degradationPreference;
const wantCodec = (App.state.settings.sendVideoCodec || 'auto').toLowerCase();
const senders = sendersFor(kind);
for (const sender of senders) {
const prev = pendingByS.get(sender) || Promise.resolve();
const next = prev.then(() => {
const params = sender.getParameters();
if (!params.encodings || !params.encodings[0]) params.encodings = [{}];
if (kbps && kbps > 0) params.encodings[0].maxBitrate = kbps * 1000;
else delete params.encodings[0].maxBitrate;
if (pref) params.degradationPreference = pref;
/* encodings[0].codec is the "set sending codec" API. Pick from the
negotiated codec list on the sender — if the codec we want isn't
there (e.g. peer didn't offer it, or browser doesn't expose
params.codecs yet), leave the field unset so the browser keeps
picking automatically. */
if (wantCodec === 'auto') {
delete params.encodings[0].codec;
} else if (params.codecs && params.codecs.length) {
const pick = params.codecs.find(c => {
const sub = (c.mimeType || '').split('/')[1] || '';
return sub.toLowerCase() === wantCodec;
});
if (pick) params.encodings[0].codec = pick;
else App.log.warn('media', logLabel, 'send codec not in negotiated set', wantCodec);
}
return sender.setParameters(params).then(
() => App.log.info('media', logLabel, 'params', kbps ? kbps + ' kbps' : 'unset', pref || '', 'send', wantCodec),
e => App.log.error('media', logLabel, 'setParameters failed', e.message)
);
});
pendingByS.set(sender, next);
}
}
/* Cam and screen each have a single "push current bitrate + degradation
preference" entry point. We don't split bitrate-only / degradation-only
because every call site already wants both, and a single setParameters
round-trip per sender is more efficient than two. */
function applyCamSendParams() { applyVideoSendParams('cam', 'cam'); }
function applyScreenSendParams() { applyVideoSendParams('screen', 'screen'); }
async function applyAudioConstraints() {
const sender = App.state.micTransceiver && App.state.micTransceiver.sender;
const t = sender && sender.track;
if (!t) return;
const a = App.state.settings.audio;
try {
await t.applyConstraints({
echoCancellation: a.echoCancellation,
noiseSuppression: a.noiseSuppression,
autoGainControl: a.autoGainControl,
});
App.log.info('media', 'audio constraints applied', t.getSettings());
} catch (e) {
App.log.warn('media', 'applyConstraints failed', e.message);
}
}
/* Live device switch: grab the new track, replaceTrack to it, then stop the
old one. No off→on cycle, so no black-frame flicker for the remote peer
and no period where the OS mic indicator drops. Returns the new deviceId
on success, or null on failure (caller keeps prior selection). */
async function switchInputDevice(kind, deviceId) {
const tr = kind === 'mic' ? App.state.micTransceiver : App.state.camTransceiver;
const sender = tr && tr.sender;
if (!sender || !sender.track) return null;
if (!gumAvailable()) return null;
const a = App.state.settings.audio;
const vs = App.state.settings.video;
let stream = null;
try {
const constraints = kind === 'mic'
? { audio: {
echoCancellation: a.echoCancellation,
noiseSuppression: a.noiseSuppression,
autoGainControl: a.autoGainControl,
channelCount: a.channelCount || undefined,
sampleRate: a.sampleRate || undefined,
deviceId: deviceId ? { exact: deviceId } : undefined,
} }
: { video: {
width: vs.width || undefined,
height: vs.height || undefined,
frameRate: vs.frameRate || undefined,
deviceId: deviceId ? { exact: deviceId } : undefined,
} };
stream = await navigator.mediaDevices.getUserMedia(constraints);
const newTrack = kind === 'mic' ? stream.getAudioTracks()[0] : stream.getVideoTracks()[0];
if (!App.state.pc || App.state.pc.connectionState === 'closed') {
newTrack.stop();
throw new Error('call ended before device prompt resolved');
}
const oldTrack = sender.track;
await sender.replaceTrack(newTrack);
if (oldTrack && oldTrack !== newTrack) oldTrack.stop();
setLocalTrack(kind === 'mic' ? 'audio' : 'video', newTrack);
attachTrackEndedHandler(newTrack, kind);
if (kind === 'cam') applyCamSendParams();
mirrorToLoopback();
App.log.info('media', kind + ' switched', { requested: deviceId || '(default)', got: newTrack.getSettings ? newTrack.getSettings().deviceId : '?' });
return deviceId || '';
} catch (e) {
App.log.error('media', kind + ' switch failed', e.message);
App.chat.appendSystem?.('Failed to switch ' + (kind === 'mic' ? 'microphone' : 'camera') + ': ' + e.message);
if (stream) stream.getTracks().forEach(t => t.stop());
return null;
}
}
return {
preallocate, adoptTransceiversFromRemote, applyVideoCodecPreference,
startScreenshare, stopScreenshare,
setMic, setCam,
switchInputDevice,
applyCamSendParams, applyScreenSendParams, applyAudioConstraints,
refreshLocalDisplay, refreshRemoteDisplay,
};
})();
/* -------------------------------------------------------------------------
Chat
------------------------------------------------------------------------- */
App.chat = (() => {
/* Limits are measured in UTF-8 bytes (the wire size), not UTF-16 code
units. A 64 KB code-unit cap could let a non-ASCII payload through at
up to ~256 KB on the wire; checking bytes prevents that. */
const MAX_CHAT_MSG = 64 * 1024;
const MAX_TEXT = 8 * 1024;
const utf8Length = s => new TextEncoder().encode(s).length;
function attach(dc) {
dc.binaryType = 'arraybuffer';
dc.onopen = () => {
App.log.info('chat', 'data channel open');
appendSystem('connected');
/* Push our current media state so the peer's UI matches reality from
the moment the channel opens. */
broadcastMediaState();
};
dc.onclose = () => App.log.info('chat', 'data channel closed');
dc.onerror = e => App.log.error('chat', 'error', e.error ? e.error.message : e);
dc.onmessage = e => {
if (typeof e.data !== 'string') { App.log.warn('chat', 'binary payload rejected'); return; }
/* Cheap UTF-16 prefilter — if the code-unit count already exceeds the
byte cap, the byte count cannot be smaller, so skip the encode. */
if (e.data.length > MAX_CHAT_MSG) { App.log.warn('chat', 'oversized message rejected', e.data.length); return; }
if (utf8Length(e.data) > MAX_CHAT_MSG) { App.log.warn('chat', 'oversized message rejected (bytes)'); return; }
let m;
try { m = JSON.parse(e.data); }
catch (err) { App.log.warn('chat', 'bad json', err.message); return; }
if (!m || typeof m !== 'object' || typeof m.kind !== 'string') return;
if (m.kind === 'msg') {
if (typeof m.text !== 'string') return;
if (m.text.length > MAX_TEXT || utf8Length(m.text) > MAX_TEXT) return;
const ts = Number.isFinite(m.ts) ? m.ts : Date.now();
append(false, m.text, ts);
} else if (m.kind === 'media-state') {
App.state.peerMediaState = { mic: !!m.mic, cam: !!m.cam, screen: !!m.screen };
App.media.refreshRemoteDisplay();
} else if (m.kind === 'bye') {
onPeerHangup();
}
/* unknown kinds: silently ignore */
};
}
function send(text) {
const dc = App.state.dcChat;
if (!dc || dc.readyState !== 'open') { App.log.warn('chat', 'not open'); return; }
const msg = { kind: 'msg', text, ts: Date.now() };
dc.send(JSON.stringify(msg));
append(true, text, msg.ts);
}
/* Application-level hangup signal. Sent best-effort right before we tear
the connection down so the peer can react instantly instead of waiting
for ICE consent freshness to time out (~10–30 s). Skipped in loopback
because pcB echoes chat messages back to pcA — a bye would round-trip
and spuriously trigger the peer-left dialog on the local user. */
function sendBye() {
if (App.state.role === 'loopback') return;
const dc = App.state.dcChat;
if (!dc || dc.readyState !== 'open') return;
try { dc.send(JSON.stringify({ kind: 'bye' })); } catch (_) {}
}
function append(mine, text, ts) {
const log = document.getElementById('chat-log');
const el = document.createElement('div');
el.className = 'chat-msg' + (mine ? ' me' : '');
el.innerHTML = '<div class="text"></div><div class="meta"></div>';
el.querySelector('.text').textContent = text;
el.querySelector('.meta').textContent = new Date(ts).toLocaleTimeString();
log.appendChild(el);
log.scrollTop = log.scrollHeight;
}
function appendSystem(text) {
const log = document.getElementById('chat-log');
const el = document.createElement('div');
el.className = 'small';
el.style.textAlign = 'center';
el.style.color = 'var(--text-faint)';
el.textContent = '— ' + text + ' —';
log.appendChild(el);
log.scrollTop = log.scrollHeight;
}
return { attach, send, sendBye, append, appendSystem, MAX_TEXT, utf8Length };
})();
/* -------------------------------------------------------------------------
Files: chunked transfer with backpressure
------------------------------------------------------------------------- */
App.files = (() => {
const CHUNK = 16 * 1024;
const HIGH_WATER = 1024 * 1024; /* 1 MB */
const LOW_WATER = 256 * 1024;
const incoming = new Map(); /* id -> { name, size, mime, received, chunks: [] } */
const outQueue = []; /* [{ id, file }] — pending sends */
let outBusy = false;
/* Outgoing cancellation: ids in this set cause doSend's loop to bail
between chunks and emit a file-abort to the peer. Receiver-initiated
cancels arrive as a file-cancel ctl and get folded into this set. */
const abortedOut = new Set();
let currentSendId = null;
function attach(dc) {
dc.binaryType = 'arraybuffer';
dc.bufferedAmountLowThreshold = LOW_WATER;
dc.onopen = () => App.log.info('files', 'data channel open');
dc.onclose = () => App.log.info('files', 'data channel closed');
dc.onerror = e => App.log.error('files', 'error', e.error ? e.error.message : e);
dc.onmessage = e => onMessage(e.data);
}
const MAX_CTRL = 8 * 1024;
const MAX_NAME = 1024;
const MAX_MIME = 256;
const MAX_FILE = 5 * 1024 * 1024 * 1024; /* 5 GB */
const ID_RE = /^[A-Za-z0-9_\-]{1,16}$/;
function validId(id) { return typeof id === 'string' && ID_RE.test(id); }
function onMessage(data) {
if (typeof data === 'string') {
if (data.length > MAX_CTRL) { App.log.warn('files', 'ctrl too large'); return; }
/* Tighten to byte-size so a non-ASCII file name can't bypass the cap. */
if (new TextEncoder().encode(data).length > MAX_CTRL) { App.log.warn('files', 'ctrl too large (bytes)'); return; }
let m; try { m = JSON.parse(data); } catch (e) { App.log.warn('files', 'bad ctl', e.message); return; }
if (!m || typeof m !== 'object' || typeof m.kind !== 'string' || !validId(m.id)) return;
if (m.kind === 'file-start') {
if (incoming.has(m.id)) { App.log.warn('files', 'duplicate file-start ignored', m.id); return; }
const size = Number(m.size);
if (!Number.isFinite(size) || size < 0 || size > MAX_FILE) { App.log.warn('files', 'invalid size', m.size); return; }
const name = typeof m.name === 'string' ? m.name.slice(0, MAX_NAME) : 'file';
const mime = typeof m.mime === 'string' ? m.mime.slice(0, MAX_MIME) : '';
incoming.set(m.id, { name, size, mime, received: 0, chunks: [] });
addIncomingRow(m.id, name, size);
App.log.info('files', 'incoming start', name, size);
} else if (m.kind === 'file-end') {
const f = incoming.get(m.id);
if (!f) return;
const blob = new Blob(f.chunks, { type: f.mime || 'application/octet-stream' });
finishIncoming(m.id, blob, f.name);
incoming.delete(m.id);
App.log.info('files', 'incoming done', f.name);
} else if (m.kind === 'file-abort') {
const f = incoming.get(m.id);
if (f) {
App.log.warn('files', 'incoming aborted', f.name);
incoming.delete(m.id);
abortIncomingRow(m.id);
}
} else if (m.kind === 'file-cancel') {
/* Receiver-initiated cancel: stop sending if this id is in-flight
or queued. The doSend loop will see abortedOut and bail. */
const qi = outQueue.findIndex(q => q.id === m.id);
if (qi >= 0) {
outQueue.splice(qi, 1);
abortOutgoingRow(m.id, 'cancelled by peer');
} else if (currentSendId === m.id) {
abortedOut.add(m.id);
App.log.info('files', 'send cancelled by peer', m.id);
}
/* If id is unknown (already finished or never started), ignore. */
}
/* unknown kinds: silently ignore */
} else {
/* Binary: first 16 bytes ASCII id (right-padded), then payload. */
const view = new Uint8Array(data);
if (view.byteLength <= 16) return;
/* The sender pads ids to 16 ASCII bytes with '_', and the *padded* form
is what's used as the Map key. So leave underscores in place — only
strip NULs and whitespace (defensive). */
const idStr = new TextDecoder().decode(view.slice(0, 16)).replace(/\0+$/, '').trim();
if (!validId(idStr)) return;
const payload = view.slice(16);
const f = incoming.get(idStr);
if (!f) return;
/* A peer claiming size N must not be able to push more than N bytes —
otherwise it can OOM the tab by chunking unbounded data under one id. */
if (f.received + payload.byteLength > f.size) {
App.log.warn('files', 'chunk overflows declared size, aborting', idStr);
incoming.delete(idStr);
abortIncomingRow(idStr);
return;
}
f.chunks.push(payload);
f.received += payload.byteLength;
updateIncomingRow(idStr, f.received, f.size);
}
}
function sendFile(file) {
const dc = App.state.dcFiles;
if (!dc || dc.readyState !== 'open') { App.log.warn('files', 'channel not open'); return; }
if (file.size > MAX_FILE) {
App.log.warn('files', 'file too large', file.size);
App.chat.appendSystem?.(`File "${file.name}" exceeds the 5 GB limit.`);
return;
}
const id = (Date.now().toString(36) + Math.random().toString(36).slice(2, 8)).padEnd(16, '_').slice(0, 16);
addOutgoingRow(id, file.name, file.size);
outQueue.push({ id, file });
if (outQueue.length > 1 || outBusy) markQueued('#files-out', id);
pumpQueue();
}
async function pumpQueue() {
if (outBusy) return;
const next = outQueue.shift();
if (!next) return;
outBusy = true;
try {
await doSend(next.id, next.file);
} finally {
outBusy = false;
pumpQueue();
}
}
async function doSend(id, file) {
const dc = App.state.dcFiles;
if (!dc || dc.readyState !== 'open') {
App.log.warn('files', 'channel closed before send', file.name);
abortOutgoingRow(id, 'channel closed');
abortedOut.delete(id);
return;
}
if (abortedOut.has(id)) {
/* Cancelled while still queued — never went on the wire. */
abortOutgoingRow(id, 'cancelled');
abortedOut.delete(id);
return;
}
clearQueuedMark('#files-out', id);
currentSendId = id;
const start = { kind: 'file-start', id, name: file.name, size: file.size, mime: file.type };
dc.send(JSON.stringify(start));
App.log.info('files', 'sending', file.name, file.size);
const idBytes = new TextEncoder().encode(id);
let offset = 0;
let cancelled = false;
try {
while (offset < file.size) {
if (abortedOut.has(id)) { cancelled = true; break; }
if (dc.bufferedAmount > HIGH_WATER) {
await new Promise(res => {
const h = () => { dc.removeEventListener('bufferedamountlow', h); res(); };
dc.addEventListener('bufferedamountlow', h);
});
if (abortedOut.has(id)) { cancelled = true; break; }
}
const slice = await file.slice(offset, offset + CHUNK).arrayBuffer();
if (abortedOut.has(id)) { cancelled = true; break; }
const buf = new Uint8Array(16 + slice.byteLength);
buf.set(idBytes, 0);
buf.set(new Uint8Array(slice), 16);
dc.send(buf.buffer);
offset += slice.byteLength;
updateOutgoingRow(id, offset, file.size);
}
if (cancelled) {
try { dc.send(JSON.stringify({ kind: 'file-abort', id })); } catch (_) {}
abortOutgoingRow(id, 'cancelled');
App.log.info('files', 'send cancelled', file.name);
} else {
dc.send(JSON.stringify({ kind: 'file-end', id }));
finishOutgoingRow(id);
App.log.info('files', 'sent', file.name);
}
} catch (e) {
App.log.error('files', 'send failed', e.message);
try { dc.send(JSON.stringify({ kind: 'file-abort', id })); } catch (_) {}
abortOutgoingRow(id, 'send failed');
} finally {
currentSendId = null;
abortedOut.delete(id);
}
}
/* UI row helpers */
/* Track blob URLs for incoming finished downloads so Clear can revoke them. */
const incomingUrls = new Map(); /* id -> objectURL string */
function rowEl(side, id, name, size) {
const el = document.createElement('div');
el.className = 'file-item';
el.dataset.id = id;
el.innerHTML = `<button class="row-close" type="button" title="Cancel / remove" aria-label="Cancel or remove">×</button>
<div class="name"></div>
<div class="meta"><span class="bytes">0</span> / <span class="total"></span> B (<span class="pct">0</span>%)</div>
<div class="progress"><div></div></div>
<div class="dl"></div>`;
el.querySelector('.name').textContent = name;
el.querySelector('.total').textContent = size.toLocaleString();
el.querySelector('.row-close').addEventListener('click', () => removeRow(side, id));
return el;
}
function removeRow(side, id) {
const sel = side === 'in' ? '#files-in' : '#files-out';
const row = document.querySelector(sel + ' [data-id="' + CSS.escape(id) + '"]');
if (row) row.remove();
if (side === 'in') {
/* If the transfer is still arriving, ask the sender to stop. */
if (incoming.has(id)) {
const dc = App.state.dcFiles;
if (dc && dc.readyState === 'open') {
try { dc.send(JSON.stringify({ kind: 'file-cancel', id })); } catch (_) {}
}
}
const url = incomingUrls.get(id);
if (url) { URL.revokeObjectURL(url); incomingUrls.delete(id); }
incoming.delete(id);
} else {
const i = outQueue.findIndex(q => q.id === id);
if (i >= 0) outQueue.splice(i, 1);
/* If it's the in-flight send, flag it for cancellation. The doSend
loop will pick up the flag, emit file-abort, and clear it. */
if (currentSendId === id) abortedOut.add(id);
}
}
function clearAll(side) {
const sel = side === 'in' ? '#files-in' : '#files-out';
document.querySelectorAll(sel + ' .file-item').forEach(el => el.remove());
if (side === 'in') {
/* Tell the peer to stop for any in-progress receives. */
const dc = App.state.dcFiles;
if (dc && dc.readyState === 'open') {
incoming.forEach((_f, id) => {
try { dc.send(JSON.stringify({ kind: 'file-cancel', id })); } catch (_) {}
});
}
incomingUrls.forEach(url => URL.revokeObjectURL(url));
incomingUrls.clear();
incoming.clear();
} else {
outQueue.length = 0;
/* Cancel the in-flight send too, if any. */
if (currentSendId) abortedOut.add(currentSendId);
}
}
function addOutgoingRow(id, name, size) {
document.getElementById('files-out').appendChild(rowEl('out', id, name, size));
}
function updateOutgoingRow(id, sent, size) { updateRow('#files-out', id, sent, size); }
function finishOutgoingRow(id) { markDone('#files-out', id); }
function abortOutgoingRow(id, reason) {
const row = document.querySelector('#files-out [data-id="' + CSS.escape(id) + '"]');
if (row) row.querySelector('.dl').textContent = '(' + (reason || 'aborted') + ')';
}
function markQueued(sel, id) {
const row = document.querySelector(sel + ' [data-id="' + CSS.escape(id) + '"]');
if (row) row.querySelector('.dl').textContent = 'queued';
}
function clearQueuedMark(sel, id) {
const row = document.querySelector(sel + ' [data-id="' + CSS.escape(id) + '"]');
if (row && row.querySelector('.dl').textContent === 'queued') row.querySelector('.dl').textContent = '';
}
function addIncomingRow(id, name, size) {
document.getElementById('files-in').appendChild(rowEl('in', id, name, size));
}
function updateIncomingRow(id, recv, size) { updateRow('#files-in', id, recv, size); }
function abortIncomingRow(id) {
const row = document.querySelector('#files-in [data-id="' + CSS.escape(id) + '"]');
if (row) row.querySelector('.dl').textContent = '(aborted)';
}
function finishIncoming(id, blob, name) {
const row = document.querySelector('#files-in [data-id="' + CSS.escape(id) + '"]');
if (!row) return;
const url = URL.createObjectURL(blob);
incomingUrls.set(id, url);
const a = document.createElement('a');
a.href = url; a.download = name; a.textContent = 'Download';
a.style.color = 'var(--accent)';
row.querySelector('.dl').innerHTML = '';
row.querySelector('.dl').appendChild(a);
markDone('#files-in', id);
}
function updateRow(sel, id, cur, size) {
const row = document.querySelector(sel + ' [data-id="' + CSS.escape(id) + '"]');
if (!row) return;
const pct = size ? Math.floor((cur / size) * 100) : 0;
row.querySelector('.bytes').textContent = cur.toLocaleString();
row.querySelector('.pct').textContent = pct;
row.querySelector('.progress > div').style.width = pct + '%';
}
function markDone(sel, id) {
const row = document.querySelector(sel + ' [data-id="' + CSS.escape(id) + '"]');
if (!row) return;
row.querySelector('.progress > div').style.background = 'var(--ok)';
}
return { attach, sendFile, clearAll, MAX_FILE };
})();
/* -------------------------------------------------------------------------
Stats
------------------------------------------------------------------------- */
App.stats = (() => {
let timer = null;
let consoleTimer = null;
/* id -> { bytes, ts } from the previous tick. Used to compute live bitrate
as a delta — the WebRTC stats objects don't expose a current bitrate for
inbound, only cumulative bytes. */
const prev = new Map();
function fmtBytes(n) {
if (n == null || isNaN(n)) return '—';
if (n < 1024) return n + ' B';
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB';
if (n < 1024 * 1024 * 1024) return (n / (1024 * 1024)).toFixed(2) + ' MB';
return (n / (1024 * 1024 * 1024)).toFixed(2) + ' GB';
}
function fmtBps(bps) {
if (bps == null || isNaN(bps)) return '—';
if (bps < 1000) return Math.round(bps) + ' bps';
if (bps < 1_000_000) return (bps / 1000).toFixed(1) + ' kbps';
return (bps / 1_000_000).toFixed(2) + ' Mbps';
}
function bitrateFor(id, bytes, ts) {
const p = prev.get(id);
prev.set(id, { bytes, ts });
if (!p || ts <= p.ts) return null;
return ((bytes - p.bytes) * 8 * 1000) / (ts - p.ts);
}
async function tick() {
if (!App.state.pc) return;
const stats = await App.state.pc.getStats();
const tbody = document.querySelector('#stats-table tbody');
if (!tbody) return;
const rows = collect(stats);
/* Drop prev entries for stat ids that no longer appear in this report,
so the cache doesn't grow unboundedly across long calls where the
browser internally rotates RTP stream ids. */
const liveIds = new Set();
stats.forEach(r => { if (r.type === 'outbound-rtp' || r.type === 'inbound-rtp') liveIds.add(r.id); });
for (const k of prev.keys()) if (!liveIds.has(k)) prev.delete(k);
tbody.innerHTML = rows.map(r =>
`<tr><td>${escapeHtml(r.k)}</td><td>${escapeHtml(String(r.v))}</td></tr>`).join('');
}
function collect(stats) {
const out = [];
let selected = null;
stats.forEach(r => {
if (r.type === 'transport') {
if (r.selectedCandidatePairId) selected = r.selectedCandidatePairId;
}
});
let pair = null, localCand = null, remoteCand = null;
stats.forEach(r => {
if (r.type === 'candidate-pair' && (r.nominated || r.selected || r.id === selected) && r.state === 'succeeded')
pair = r;
});
if (pair) {
stats.forEach(r => {
if (r.id === pair.localCandidateId) localCand = r;
if (r.id === pair.remoteCandidateId) remoteCand = r;
});
out.push({ k: 'rtt (ms)', v: pair.currentRoundTripTime ? Math.round(pair.currentRoundTripTime * 1000) : '—' });
out.push({ k: 'bytes sent', v: fmtBytes(pair.bytesSent) });
out.push({ k: 'bytes received', v: fmtBytes(pair.bytesReceived) });
out.push({ k: 'available outgoing bw', v: pair.availableOutgoingBitrate ? fmtBps(pair.availableOutgoingBitrate) : '—' });
if (localCand) out.push({ k: 'local candidate', v: `${localCand.candidateType} ${localCand.address || localCand.ip || ''}:${localCand.port || ''} ${localCand.protocol || ''}` });
if (remoteCand) out.push({ k: 'remote candidate', v: `${remoteCand.candidateType} ${remoteCand.address || remoteCand.ip || ''}:${remoteCand.port || ''} ${remoteCand.protocol || ''}` });
}
/* outbound-rtp / inbound-rtp report kind='video' for both the cam and the
screen-share transceivers, so we need to disambiguate by mid. */
const midRole = new Map();
if (App.state.micTransceiver && App.state.micTransceiver.mid != null) midRole.set(String(App.state.micTransceiver.mid), 'audio');
if (App.state.camTransceiver && App.state.camTransceiver.mid != null) midRole.set(String(App.state.camTransceiver.mid), 'cam');
if (App.state.screenTransceiver && App.state.screenTransceiver.mid != null) midRole.set(String(App.state.screenTransceiver.mid), 'screen');
const roleOf = r => midRole.get(String(r.mid)) || r.kind;
/* Pre-pass: build codecId → short codec label map so we can inline the
active codec into each outbound/inbound row. The 'codec' records appear
in arbitrary order relative to the rtp records, so we collect first. */
const codecOf = new Map();
stats.forEach(r => { if (r.type === 'codec') codecOf.set(r.id, r); });
const codecLabel = id => {
const c = codecOf.get(id);
if (!c || !c.mimeType) return '';
return ' [' + c.mimeType.split('/')[1] + ']';
};
stats.forEach(r => {
if (r.type === 'outbound-rtp' && !r.isRemote) {
const role = roleOf(r);
const br = bitrateFor(r.id, r.bytesSent || 0, r.timestamp);
const fps = r.framesPerSecond ? `, ${r.framesPerSecond} fps` : '';
out.push({ k: `↑ ${role} sent${codecLabel(r.codecId)}`, v: `${fmtBytes(r.bytesSent)} / ${r.packetsSent} pkts @ ${fmtBps(br)}${fps}` });
if (r.targetBitrate) out.push({ k: `↑ ${role} target br`, v: fmtBps(r.targetBitrate) });
}
if (r.type === 'inbound-rtp' && !r.isRemote) {
const role = roleOf(r);
const br = bitrateFor(r.id, r.bytesReceived || 0, r.timestamp);
const fps = r.framesPerSecond ? `, ${r.framesPerSecond} fps` : '';
const lost = r.packetsLost ?? 0;
const jit = r.jitter ? r.jitter.toFixed(3) : 0;
out.push({ k: `↓ ${role} recv${codecLabel(r.codecId)}`, v: `${fmtBytes(r.bytesReceived)} / ${r.packetsReceived} pkts @ ${fmtBps(br)}${fps} (lost ${lost}, jitter ${jit})` });
}
});
return out;
}
function start() {
if (timer) return;
timer = setInterval(tick, 1000);
tick();
}
function stop() { if (timer) { clearInterval(timer); timer = null; } prev.clear(); }
async function exportAll() {
if (!App.state.pc) return;
const stats = await App.state.pc.getStats();
const arr = [];
stats.forEach(r => arr.push(r));
const blob = new Blob([JSON.stringify(arr, null, 2)], { type: 'application/json' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'webrtc-stats-' + Date.now() + '.json';
a.click();
}
function toggleConsoleStats() {
if (consoleTimer) {
clearInterval(consoleTimer); consoleTimer = null;
App.log.info('stats', 'console poll stopped');
} else {
consoleTimer = setInterval(async () => {
if (!App.state.pc) return;
const stats = await App.state.pc.getStats();
const rows = collect(stats);
App.log.debug('stats', rows.map(r => r.k + '=' + r.v).join(' | '));
}, 2000);
App.log.info('stats', 'console poll started (2s)');
}
}
return { start, stop, exportAll, toggleConsoleStats };
})();
function escapeHtml(s) {
return s.replace(/[&<>"']/g, c => ({ '&':'&','<':'<','>':'>','"':'"',"'":''' })[c]);
}
/* -------------------------------------------------------------------------
PeerConnection creation + event wiring
------------------------------------------------------------------------- */
/* Both Chromium and Firefox obfuscate the local IP of host ICE candidates by
default (mDNS host-name obfuscation), which makes per-interface diagnosis
of icecandidateerror impossible — the address either ends in ".local" or
is empty/0.0.0.0. Detect this on the first host candidate we see and log a
one-shot hint pointing at the relevant browser preference. */
let _obfuscationHintLogged = false;
function maybeWarnObfuscation(c) {
if (_obfuscationHintLogged) return;
if (c.type !== 'host') return;
const addr = c.address || '';
const obfuscated = !addr || addr.endsWith('.local') || addr === '0.0.0.0' || addr === '::';
if (!obfuscated) return;
_obfuscationHintLogged = true;
App.log.info('pc',
'host-candidate addresses are obfuscated (got "' + (addr || 'empty') + '"); ' +
'icecandidateerror "from=" will not identify a real interface. To see real local IPs:\n' +
' • Firefox: about:config → media.peerconnection.ice.obfuscate_host_addresses = false\n' +
' • Chromium: chrome://flags/#enable-webrtc-hide-local-ips-with-mdns → Disabled');
}
function newPc(label) {
const cfg = { iceServers: App.state.settings.iceServers || [], iceCandidatePoolSize: 0 };
const pc = new RTCPeerConnection(cfg);
pc.addEventListener('icegatheringstatechange', () =>
App.log.debug('pc', label, 'iceGatheringState', pc.iceGatheringState));
pc.addEventListener('iceconnectionstatechange', () =>
App.log.info('pc', label, 'iceConnectionState', pc.iceConnectionState));
pc.addEventListener('connectionstatechange', () => {
App.log.info('pc', label, 'connectionState', pc.connectionState);
updateConnPill();
});
pc.addEventListener('signalingstatechange', () =>
App.log.debug('pc', label, 'signalingState', pc.signalingState));
pc.addEventListener('icecandidate', e => {
if (e.candidate) maybeWarnObfuscation(e.candidate);
});
pc.addEventListener('icecandidateerror', e => {
const local = e.address ? (e.address + ':' + (e.port || '?')) : (e.hostCandidate || '?');
App.log.warn('pc', label, 'iceCandidateError',
e.errorCode, e.errorText || '(no text)',
'server=' + (e.url || '(none)'),
'from=' + local);
});
pc.addEventListener('negotiationneeded', () => {
/* All three m-sections (mic/cam/screen) are pre-allocated in preallocate()
with direction=sendrecv before the very first createOffer, so toggling
a track via replaceTrack() never changes the SDP shape. The codec
payload-types negotiated up front cover the encoder configurations we
can reach via in-call settings (resolution, framerate, bitrate cap,
channels). Therefore any negotiationneeded the browser fires is
spurious for this app and safely ignored. */
App.log.debug('pc', label, 'negotiationneeded fired (ignored — this app does not renegotiate)');
});
pc.addEventListener('track', e => {
App.log.info('pc', label, 'track received', e.track.kind, 'mid=' + (e.transceiver && e.transceiver.mid));
handleRemoteTrack(pc, e);
});
return pc;
}
/* Local media state derived from the actual sender tracks / screen stream. */
function localMediaState() {
return {
mic: !!(App.state.micTransceiver && App.state.micTransceiver.sender.track),
cam: !!(App.state.camTransceiver && App.state.camTransceiver.sender.track),
screen: !!App.state.screenStream,
};
}
/* Tell the peer about our current mic/cam/screen state. Sent over the chat
data channel as a typed JSON message — replaceTrack(null) doesn't itself
propagate any signal across the wire, so the receiver would otherwise just
see frozen video on the last frame. */
function broadcastMediaState() {
const dc = App.state.dcChat;
if (!dc || dc.readyState !== 'open') return;
const state = { kind: 'media-state', ...localMediaState() };
try { dc.send(JSON.stringify(state)); }
catch (e) { App.log.warn('media', 'state send failed', e.message); }
}
/* Rebuild the remote streams by inspecting the current receivers — defensive
alternative to relying solely on the 'track' event, which can fire at
slightly different points across browsers (especially on the offerer side
where receivers existed before negotiation). */
/* Map a receiver-side transceiver to its role using the stored references
from preallocate() / adoptTransceiversFromRemote(). Identity comparison
is the only reliable cue — relying on indexOf-by-kind misroutes cam and
screen when only one of them has live frames (track events fire out of
order across browsers / offerer-vs-joiner roles). */
function roleForTransceiver(t) {
if (!t) return null;
if (t === App.state.micTransceiver) return 'mic';
if (t === App.state.camTransceiver) return 'cam';
if (t === App.state.screenTransceiver) return 'screen';
return null;
}
/* Track listeners we attached, so we can remove the exact references later
instead of leaking anonymous arrow closures over the lifetime of the page. */
const remoteTrackListeners = new WeakMap(); /* track -> { unmute, ended } */
function attachRemoteTrackListeners(track, onEnded) {
detachRemoteTrackListeners(track);
const handlers = { unmute: App.media.refreshRemoteDisplay, ended: onEnded };
track.addEventListener('unmute', handlers.unmute);
track.addEventListener('ended', handlers.ended);
remoteTrackListeners.set(track, handlers);
}
function detachRemoteTrackListeners(track) {
const h = remoteTrackListeners.get(track);
if (!h) return;
track.removeEventListener('unmute', h.unmute);
track.removeEventListener('ended', h.ended);
remoteTrackListeners.delete(track);
}
function rebuildRemoteStreams(pc) {
if (!pc) return;
let audio = null, cam = null, screen = null;
for (const t of pc.getTransceivers()) {
const tr = t.receiver && t.receiver.track;
if (!tr) continue;
const role = roleForTransceiver(t);
if (role === 'mic' && !audio) audio = tr;
if (role === 'cam' && !cam) cam = tr;
if (role === 'screen' && !screen) screen = tr;
}
const remote = new MediaStream();
if (audio) remote.addTrack(audio);
if (cam) remote.addTrack(cam);
App.state.remoteStream = remote;
const remoteScreen = new MediaStream();
if (screen) remoteScreen.addTrack(screen);
App.state.remoteScreenStream = remoteScreen;
/* React when a so-far-muted track gets actual frames. */
const camStreamRef = remote;
const screenStreamRef = remoteScreen;
if (audio) attachRemoteTrackListeners(audio, () => { remote.removeTrack(audio); App.media.refreshRemoteDisplay(); });
if (cam) attachRemoteTrackListeners(cam, () => { camStreamRef.removeTrack(cam); App.media.refreshRemoteDisplay(); });
if (screen) attachRemoteTrackListeners(screen, () => { screenStreamRef.removeTrack(screen); App.media.refreshRemoteDisplay(); });
App.log.info('pc', 'remote streams rebuilt', 'audio', !!audio, 'cam', !!cam, 'screen', !!screen);
App.media.refreshRemoteDisplay();
}
function handleRemoteTrack(pc, e) {
/* In loopback, the second pc (pcB) also fires track events but those
represent our own outbound tracks being received on the synthetic peer
— they must not feed the visible "remote" tile. */
if (pc !== App.state.pc) return;
const role = roleForTransceiver(e.transceiver);
App.log.info('pc', 'remote track', e.track.kind, 'role', role, 'muted', e.track.muted, 'mid', e.transceiver && e.transceiver.mid);
if (role === 'mic' || role === 'cam') {
if (!App.state.remoteStream) App.state.remoteStream = new MediaStream();
App.state.remoteStream.addTrack(e.track);
attachRemoteTrackListeners(e.track, () => {
if (App.state.remoteStream) App.state.remoteStream.removeTrack(e.track);
App.media.refreshRemoteDisplay();
});
App.media.refreshRemoteDisplay();
} else if (role === 'screen') {
if (!App.state.remoteScreenStream) App.state.remoteScreenStream = new MediaStream();
App.state.remoteScreenStream.addTrack(e.track);
attachRemoteTrackListeners(e.track, () => {
if (App.state.remoteScreenStream) App.state.remoteScreenStream.removeTrack(e.track);
App.media.refreshRemoteDisplay();
});
App.media.refreshRemoteDisplay();
}
}
function updateConnPill() {
const pc = App.state.pc;
const pill = document.getElementById('conn-pill');
if (!pc) { pill.textContent = 'disconnected'; pill.className = 'pill'; return; }
const st = pc.connectionState;
pill.textContent = st;
pill.className = 'pill ' + (
st === 'connected' ? 'ok' :
st === 'connecting' || st === 'new' ? 'warn' :
'err'
);
}
/* -------------------------------------------------------------------------
Signaling flows: initiator, joiner, loopback
------------------------------------------------------------------------- */
/* Invoked when the user clicks Cancel in the step modal. Tears down the
in-flight setup (closes pc, aborts any long-poll fetch) and bounces back
to the welcome view. The setup function will see signalAbort.aborted and
throw 'cancelled', which cfg-continue's catch silently swallows. */
function cancelSetup() {
App.log.info('app', 'setup cancelled by user');
App.state.userCancelled = true;
App.progress.hide();
hangup({ sendBye: false });
}
async function startInitiator() {
const pc = newPc('A');
App.state.pc = pc;
App.media.preallocate(pc);
App.media.applyVideoCodecPreference(pc);
const ac = new AbortController();
App.state.signalAbort = ac;
/* Data channels MUST be created on the initiator before createOffer
so they're included in the SDP m-section list. */
App.state.dcChat = pc.createDataChannel('chat', { ordered: true });
App.state.dcFiles = pc.createDataChannel('files', { ordered: true });
App.chat.attach(App.state.dcChat);
App.files.attach(App.state.dcFiles);
App.progress.show('Creating offer…', 'Negotiating local SDP.');
let offer = await pc.createOffer();
offer.sdp = App.codec.mungeOpus(offer.sdp, App.state.settings.opus);
await pc.setLocalDescription(offer);
App.log.info('signal', 'offer created, waiting for ICE gathering…');
App.progress.showModal('Gathering ICE candidates…',
iceGatheringSubtitle(),
{ onCancel: cancelSetup });
await App.signal.waitForIceComplete(pc, ac.signal);
if (ac.signal.aborted) throw new Error('cancelled');
App.log.info('signal', 'ICE gathering complete; offer ready to export');
App.progress.hide();
renderInitiatorExchange();
}
async function startJoiner() {
const pc = newPc('A');
App.state.pc = pc;
pc.ondatachannel = e => {
/* If the user picked a new role mid-flight, App.state.pc may already be
a different connection. Late events from the previous pc would
otherwise overwrite the current dcChat/dcFiles with a closed channel. */
if (App.state.pc !== pc) { App.log.warn('signal', 'datachannel from stale pc, ignoring'); return; }
App.log.info('signal', 'incoming data channel', e.channel.label);
if (e.channel.label === 'chat') { App.state.dcChat = e.channel; App.chat.attach(e.channel); }
if (e.channel.label === 'files') { App.state.dcFiles = e.channel; App.files.attach(e.channel); }
};
renderJoinerExchange();
}
async function finishJoiner(offerObj) {
const pc = App.state.pc;
const ac = App.state.signalAbort || new AbortController();
App.state.signalAbort = ac;
App.progress.show('Applying remote offer…', 'Parsing your peer\'s SDP.');
await pc.setRemoteDescription(offerObj);
App.media.adoptTransceiversFromRemote(pc);
App.media.applyVideoCodecPreference(pc);
rebuildRemoteStreams(pc);
App.progress.show('Creating answer…', 'Negotiating local SDP.');
let answer = await pc.createAnswer();
answer.sdp = App.codec.mungeOpus(answer.sdp, App.state.settings.opus);
await pc.setLocalDescription(answer);
App.progress.showModal('Gathering ICE candidates…',
iceGatheringSubtitle(),
{ onCancel: cancelSetup });
await App.signal.waitForIceComplete(pc, ac.signal);
if (ac.signal.aborted) throw new Error('cancelled');
App.progress.hide();
showAnswerForJoiner();
}
async function applyAnswerOnInitiator(answerObj) {
const pc = App.state.pc;
await pc.setRemoteDescription(answerObj);
rebuildRemoteStreams(pc);
App.log.info('signal', 'remote answer applied; waiting to connect…');
}
/* -------------------------------------------------------------------------
Auto signaling: HTTP relay (POST/long-poll GET) against a tiny server.
See server/signal.c for the protocol. The relay never touches media or
the data channels — those still flow peer-to-peer.
------------------------------------------------------------------------- */
async function signalPost(base, code, slot, body) {
const url = base.replace(/\/+$/, '') + '/room/' + encodeURIComponent(code) + '/' + slot;
const r = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/sdp' },
body,
});
if (!r.ok) throw new Error('POST ' + slot + ' failed: ' + r.status);
}
/* Long-poll a slot. The server holds the request open for ~10 s; on a 204
No Content we retry. The total deadline is generous so a peer can take
their time sharing the code. Aborts cleanly when hangup() is called by
tracking a shared AbortController. (Status used to be 408, but Firefox
silently auto-retries 408 internally per RFC 7231 §6.5.7 — JS only sees
one fetch and CORS-fails after ~10 retries.) */
async function signalPoll(base, code, slot, totalDeadlineMs, signal) {
const url = base.replace(/\/+$/, '') + '/room/' + encodeURIComponent(code) + '/' + slot;
const start = Date.now();
while (!signal.aborted) {
let r;
try { r = await fetch(url, { signal }); }
catch (e) {
if (signal.aborted) throw e;
throw new Error('GET ' + slot + ' failed: ' + e.message);
}
if (r.status === 200) return await r.text();
if (r.status !== 204) throw new Error('GET ' + slot + ' status ' + r.status);
if (Date.now() - start > totalDeadlineMs) throw new Error('peer did not respond within ' + Math.round(totalDeadlineMs/1000) + 's');
/* 204 → server-side long-poll timed out; loop and reconnect. */
}
throw new Error('aborted');
}
/* Initiator side: produce the offer the normal way, push it to the relay,
then long-poll the answer slot. Skips the blob copy/paste exchange view. */
async function startInitiatorAuto(code) {
const base = App.state.settings.signaling.serverUrl;
const ac = new AbortController();
App.state.signalAbort = ac;
const pc = newPc('A');
App.state.pc = pc;
App.media.preallocate(pc);
App.media.applyVideoCodecPreference(pc);
App.state.dcChat = pc.createDataChannel('chat', { ordered: true });
App.state.dcFiles = pc.createDataChannel('files', { ordered: true });
App.chat.attach(App.state.dcChat);
App.files.attach(App.state.dcFiles);
App.progress.show('Creating offer…', 'Negotiating local SDP.');
let offer = await pc.createOffer();
offer.sdp = App.codec.mungeOpus(offer.sdp, App.state.settings.opus);
await pc.setLocalDescription(offer);
App.progress.showModal('Gathering ICE candidates…',
iceGatheringSubtitle(),
{ onCancel: cancelSetup });
await App.signal.waitForIceComplete(pc, ac.signal);
if (ac.signal.aborted) throw new Error('cancelled');
const offerBlob = App.signal.encode(pc.localDescription);
App.progress.show('Publishing offer…', 'Room ' + code + ' on ' + base);
await signalPost(base, code, 'offer', offerBlob);
App.progress.showModal('Waiting for peer…',
'Share the room code with them. They have 5 minutes to join.',
{ roomCode: code, onCancel: cancelSetup });
const answerText = await signalPoll(base, code, 'answer', 5 * 60 * 1000, ac.signal);
App.progress.show('Applying answer…', 'Finalizing the handshake.');
const obj = App.signal.decode(answerText);
if (obj.type !== 'answer') throw new Error('expected an answer, got ' + obj.type);
await applyAnswerOnInitiator(obj);
App.progress.hide();
goToCall();
}
/* Joiner side: long-poll the offer slot, apply it, push the answer back. */
async function startJoinerAuto(code) {
const base = App.state.settings.signaling.serverUrl;
const ac = new AbortController();
App.state.signalAbort = ac;
const pc = newPc('A');
App.state.pc = pc;
pc.ondatachannel = e => {
if (App.state.pc !== pc) { App.log.warn('signal', 'datachannel from stale pc, ignoring'); return; }
App.log.info('signal', 'incoming data channel', e.channel.label);
if (e.channel.label === 'chat') { App.state.dcChat = e.channel; App.chat.attach(e.channel); }
if (e.channel.label === 'files') { App.state.dcFiles = e.channel; App.files.attach(e.channel); }
};
App.progress.showModal('Waiting for offer…',
'Polling the signaling server until your peer publishes their offer.',
{ roomCode: code, onCancel: cancelSetup });
const offerText = await signalPoll(base, code, 'offer', 5 * 60 * 1000, ac.signal);
if (ac.signal.aborted) throw new Error('cancelled');
const obj = App.signal.decode(offerText);
if (obj.type !== 'offer') throw new Error('expected an offer, got ' + obj.type);
App.progress.show('Applying offer…', 'Building answer.');
await pc.setRemoteDescription(obj);
App.media.adoptTransceiversFromRemote(pc);
App.media.applyVideoCodecPreference(pc);
rebuildRemoteStreams(pc);
let answer = await pc.createAnswer();
answer.sdp = App.codec.mungeOpus(answer.sdp, App.state.settings.opus);
await pc.setLocalDescription(answer);
App.progress.showModal('Gathering ICE candidates…',
iceGatheringSubtitle(),
{ onCancel: cancelSetup });
await App.signal.waitForIceComplete(pc, ac.signal);
if (ac.signal.aborted) throw new Error('cancelled');
App.progress.show('Publishing answer…', 'Room ' + code + ' on ' + base);
await signalPost(base, code, 'answer', App.signal.encode(pc.localDescription));
App.progress.hide();
goToCall();
}
async function startLoopback() {
const pcA = newPc('A');
const pcB = newPc('B');
App.state.pc = pcA;
App.state.pcB = pcB;
/* Trickle candidates between the two local PCs. Buffer candidates that
arrive before the target has its remote description set — otherwise
addIceCandidate rejects with InvalidStateError. */
const pendingForA = [];
const pendingForB = [];
let remoteSetA = false, remoteSetB = false;
const flush = (pc, queue) => { while (queue.length) pc.addIceCandidate(queue.shift()).catch(err => App.log.warn('loopback', 'flush', err.message)); };
pcA.onicecandidate = e => {
if (!e.candidate) return;
if (remoteSetB) pcB.addIceCandidate(e.candidate).catch(err => App.log.warn('loopback', 'B add', err.message));
else pendingForB.push(e.candidate);
};
pcB.onicecandidate = e => {
if (!e.candidate) return;
if (remoteSetA) pcA.addIceCandidate(e.candidate).catch(err => App.log.warn('loopback', 'A add', err.message));
else pendingForA.push(e.candidate);
};
pcB.ondatachannel = e => {
/* In loopback the *visible* call uses pcA's POV. pcB is the synthetic
peer; echoing the chat data channel back to A is how A learns about
its "peer's" media state (which in loopback is itself) and how chat
messages round-trip for verification. Files messages must NOT be
echoed — that would feed A's outgoing file frames back into its own
incoming-file accumulator, doubling memory and producing phantom
"incoming" download rows. */
e.channel.onmessage = ev => {
App.log.debug('loopback', 'B got', e.channel.label, typeof ev.data === 'string' ? ev.data.slice(0, 80) : '(binary)');
if (e.channel.label !== 'chat') return;
try { e.channel.send(ev.data); } catch (_) {}
};
};
pcB.ontrack = e => App.log.debug('loopback', 'B got track', e.track.kind);
App.media.preallocate(pcA);
App.media.applyVideoCodecPreference(pcA);
App.state.dcChat = pcA.createDataChannel('chat', { ordered: true });
App.state.dcFiles = pcA.createDataChannel('files', { ordered: true });
App.chat.attach(App.state.dcChat);
App.files.attach(App.state.dcFiles);
App.progress.show('Negotiating local loopback…', 'Exchanging SDP between the two in-tab peers.');
const offer = await pcA.createOffer();
offer.sdp = App.codec.mungeOpus(offer.sdp, App.state.settings.opus);
await pcA.setLocalDescription(offer);
await pcB.setRemoteDescription(offer);
remoteSetB = true; flush(pcB, pendingForB);
/* Transceivers auto-created by setRemoteDescription default to recvonly
because pcB has no local tracks yet. Force them to sendrecv so pcB can
mirror tracks back to pcA when the user later toggles mic/cam/screen. */
for (const t of pcB.getTransceivers()) {
try { t.direction = 'sendrecv'; }
catch (e) { App.log.warn('loopback', 'could not upgrade transceiver to sendrecv', e.message); }
}
App.media.applyVideoCodecPreference(pcB);
const answer = await pcB.createAnswer();
answer.sdp = App.codec.mungeOpus(answer.sdp, App.state.settings.opus);
await pcB.setLocalDescription(answer);
await pcA.setRemoteDescription(answer);
remoteSetA = true; flush(pcA, pendingForA);
rebuildRemoteStreams(pcA);
App.log.info('loopback', 'offer/answer exchanged locally');
App.progress.hide();
goToCall();
}
/* -------------------------------------------------------------------------
UI: views + event wiring
------------------------------------------------------------------------- */
function showView(id) {
document.querySelectorAll('.view').forEach(v => v.classList.add('hidden'));
document.getElementById(id).classList.remove('hidden');
}
function updateRoleBadge() {
const badge = document.getElementById('role-badge');
const cfg = document.getElementById('role-title-cfg');
const exch = document.getElementById('role-title-exch');
const r = App.state.role;
if (!r) { badge.classList.add('hidden'); return; }
badge.classList.remove('hidden');
badge.textContent = r;
badge.className = 'role-badge ' + r;
if (cfg) { cfg.textContent = r; cfg.className = 'role-badge ' + r; }
if (exch) { exch.textContent = r; exch.className = 'role-badge ' + r; }
}
function pickRole(role) {
/* If a pc from a previous role attempt is still around, tear it down so
we don't leak it. Most paths reach pickRole via the welcome view where
hangup() was already called, but the defensive close here covers cases
where the user navigates back without going through exch-cancel. */
if (App.state.pc || App.state.pcB) hangup();
App.state.role = role;
updateRoleBadge();
if (role === 'loopback') {
populateConfigInputs();
showView('view-configure');
document.getElementById('cfg-lede').textContent = 'Loopback mode: both peers run in this tab and skip the paste step.';
} else {
populateConfigInputs();
showView('view-configure');
document.getElementById('cfg-lede').textContent =
role === 'initiator'
? 'You will generate an offer; your peer pastes it and sends back an answer.'
: 'Your peer sends you an offer; you paste it and send back the generated answer.';
}
refreshInsecureWarning();
}
function refreshInsecureWarning() {
/* Browsers gate gUM/gDM behind a secure context. Surface this up-front so
the user knows microphone/camera/screen-share won't be available — but
the call itself still works as receive-only. */
const banner = document.getElementById('insecure-warn');
if (!banner) return;
const noMedia = !navigator.mediaDevices || !navigator.mediaDevices.getUserMedia;
if (noMedia) banner.classList.remove('hidden');
else banner.classList.add('hidden');
}
/* ICE rows */
function renderIceRows() {
const wrap = document.getElementById('ice-rows');
wrap.innerHTML = '';
App.state.settings.iceServers.forEach((s, i) => {
const urls = Array.isArray(s.urls) ? s.urls.join(',') : (s.urls || '');
const row = document.createElement('div');
row.className = 'ice-row';
row.innerHTML = `
<input type="text" placeholder="stun:host:port or turn:host:port" value="${escapeAttr(urls)}">
<input type="text" placeholder="username (optional)" value="${escapeAttr(s.username || '')}">
<input type="text" placeholder="credential (optional)" value="${escapeAttr(s.credential || '')}">
<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>`;
const [u, un, cr, rm] = row.children;
u.addEventListener('input', () => { s.urls = u.value.includes(',') ? u.value.split(',').map(x=>x.trim()) : u.value; });
un.addEventListener('input', () => { s.username = un.value || undefined; });
cr.addEventListener('input', () => { s.credential = cr.value || undefined; });
rm.addEventListener('click', () => { App.state.settings.iceServers.splice(i, 1); renderIceRows(); });
wrap.appendChild(row);
});
}
function escapeAttr(s) { return String(s).replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<'); }
function populateCodecDropdown(selId) {
const sel = document.getElementById(selId);
if (!sel) return;
/* Remove previously added options in reverse so each removal doesn't shift
the indices of options we still need to inspect. */
for (let i = sel.options.length - 1; i >= 0; i--) {
if (sel.options[i].value !== 'auto') sel.remove(i);
}
if (typeof RTCRtpSender === 'undefined' || !RTCRtpSender.getCapabilities) return;
const caps = RTCRtpSender.getCapabilities('video');
if (!caps || !caps.codecs) return;
/* Some codecs (H264, AV1) appear multiple times with different profile
params — dedupe by subtype. RED/ULPFEC/rtx aren't real media codecs to
pick, so filter them out. */
const seen = new Set();
const skip = new Set(['rtx', 'red', 'ulpfec', 'flexfec-03']);
for (const c of caps.codecs) {
const sub = (c.mimeType || '').split('/')[1] || '';
const key = sub.toLowerCase();
if (!sub || skip.has(key) || seen.has(key)) continue;
seen.add(key);
const opt = document.createElement('option');
opt.value = sub;
opt.textContent = sub;
sel.appendChild(opt);
}
}
function populateConfigInputs() {
renderIceRows();
populateCodecDropdown('preferred-codec');
populateCodecDropdown('send-codec');
const s = App.state.settings;
const $ = id => document.getElementById(id);
$('o-stereo').checked = s.opus.stereo;
$('o-fec').checked = s.opus.fec;
$('o-dtx').checked = s.opus.dtx;
$('o-cbr').checked = s.opus.cbr;
$('o-maxbr').value = s.opus.maxAverageBitrate || '';
/* Reflect the saved preference if it still exists in the populated list,
otherwise fall back to auto. Compare case-insensitively so a saved
"VP8" still matches a hypothetical browser that returns "vp8" — and
preserve whatever casing the current browser actually gave us. */
const setSel = (id, val) => {
const el = $(id);
if (!el) return;
const want = (val || 'auto').toLowerCase();
const match = Array.from(el.options).find(o => o.value.toLowerCase() === want);
el.value = match ? match.value : 'auto';
};
setSel('preferred-codec', s.preferredVideoCodec);
setSel('send-codec', s.sendVideoCodec);
/* Signaling mode UI */
$('sig-server-url').value = s.signaling.serverUrl || '';
$('sig-ice-timeout').value = Math.round((s.signaling.iceGatherTimeoutMs || 0) / 1000);
applySignalingMode(s.signaling.mode);
/* Loopback doesn't use signaling at all — hide the card so the user isn't
given irrelevant choices. */
$('signaling-card').classList.toggle('hidden', App.state.role === 'loopback');
}
function applySignalingMode(mode) {
const isAuto = mode === 'auto';
App.state.settings.signaling.mode = isAuto ? 'auto' : 'manual';
document.getElementById('sig-mode-manual').classList.toggle('active', !isAuto);
document.getElementById('sig-mode-auto').classList.toggle('active', isAuto);
document.getElementById('sig-help-manual').classList.toggle('hidden', isAuto);
document.getElementById('sig-help-auto').classList.toggle('hidden', !isAuto);
document.getElementById('sig-auto-fields').classList.toggle('hidden', !isAuto);
/* The Continue button's wording reflects what happens next. */
const cont = document.getElementById('cfg-continue');
if (cont) cont.textContent = isAuto ? 'Connect →' : 'Continue to signaling →';
}
function randomRoomCode() {
/* Short and pronounceable-ish: 8 lowercase alphanumeric chars from a
reduced alphabet that drops easily-confused glyphs. */
const alphabet = 'abcdefghjkmnpqrstuvwxyz23456789';
const a = new Uint8Array(8);
crypto.getRandomValues(a);
let out = '';
for (const b of a) out += alphabet[b % alphabet.length];
return out;
}
function readConfigInputs() {
const s = App.state.settings;
const $ = id => document.getElementById(id);
s.opus.stereo = $('o-stereo').checked;
s.opus.fec = $('o-fec').checked;
s.opus.dtx = $('o-dtx').checked;
s.opus.cbr = $('o-cbr').checked;
s.opus.maxAverageBitrate = parseInt($('o-maxbr').value, 10) || 0;
s.preferredVideoCodec = $('preferred-codec').value || 'auto';
s.sendVideoCodec = $('send-codec').value || 'auto';
s.signaling.serverUrl = ($('sig-server-url').value || '').trim().replace(/\/+$/, '');
/* Seconds in the UI, milliseconds in state. 0 = no timeout; clamp negatives to 0. */
const tSec = parseInt($('sig-ice-timeout').value, 10);
s.signaling.iceGatherTimeoutMs = Number.isFinite(tSec) && tSec > 0 ? tSec * 1000 : 0;
saveIce();
saveSignaling();
}
/* Wire the Upload button on a paste box: reads the chosen file as text into
the `blob-in` textarea so the user can then click Apply. Does not auto-apply
— the user still reviews and submits manually. */
function wireUploadButton(name) {
const btn = document.getElementById('blob-upload');
const file = document.getElementById('blob-upload-file');
const ta = document.getElementById('blob-in');
const statusEl = document.getElementById('blob-in-status');
btn.addEventListener('click', () => file.click());
file.addEventListener('change', async () => {
const f = file.files && file.files[0];
if (!f) return;
try {
ta.value = await f.text();
statusEl.textContent = 'loaded ' + f.name; statusEl.className = 'pill ok';
App.log.info('signal', name + ' loaded from ' + f.name);
} catch (e) {
statusEl.textContent = 'read failed: ' + e.message; statusEl.className = 'pill err';
App.log.error('signal', 'file read failed', e.message);
} finally {
/* Reset so re-selecting the same file fires `change` again. */
file.value = '';
}
});
}
/* Render the outgoing-blob block (textarea + controls) into `body`. Owns its
own copy/download/base64-toggle wiring; re-encodes from pc.localDescription
when the toggle flips so the visible blob always matches the setting.
`extraButtons` is an array of {id,label,cls,onClick} appended after Download. */
function mountOutgoingBlob(body, name, extraButtons) {
const extras = (extraButtons || []).map(b =>
`<button id="${b.id}" class="${b.cls || 'ghost'}">${b.label}</button>`).join('');
body.innerHTML = `
<textarea id="blob-out" readonly spellcheck="false"></textarea>
<div class="blob-controls">
<button id="blob-copy" class="primary">Copy</button>
<button id="blob-download" class="ghost">Download</button>
${extras}
<label class="row"><input type="checkbox" id="b64-toggle"> Base64-wrap <span class="small">(safer for paste channels that mangle whitespace)</span></label>
<span class="pill" id="blob-out-stats"></span>
</div>`;
const ta = body.querySelector('#blob-out');
const stats = body.querySelector('#blob-out-stats');
const b64 = body.querySelector('#b64-toggle');
b64.checked = !!App.state.settings.base64;
let current = '';
function refresh() {
const desc = App.state.pc && App.state.pc.localDescription;
if (!desc) return;
current = App.signal.encode(desc);
ta.value = current;
stats.textContent = current.length + ' bytes';
}
refresh();
const copyBtn = body.querySelector('#blob-copy');
if (!navigator.clipboard || !navigator.clipboard.writeText) {
copyBtn.disabled = true;
copyBtn.title = 'Clipboard API not available in this context (requires HTTPS or localhost). Select the text above and copy manually.';
} else {
copyBtn.addEventListener('click', async () => {
try { await navigator.clipboard.writeText(current); App.log.info('signal', name + ' copied'); }
catch (e) { App.log.warn('signal', 'clipboard write failed', e.message); }
});
}
body.querySelector('#blob-download').addEventListener('click', () => {
const wrapped = App.state.settings.base64;
const ext = wrapped ? 'txt' : 'json';
const mime = wrapped ? 'text/plain' : 'application/json';
const blob = new Blob([current], { type: mime });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = `webrtc-${name}.${ext}`;
document.body.appendChild(a); a.click(); a.remove();
URL.revokeObjectURL(url);
App.log.info('signal', name + ' downloaded as ' + a.download);
});
(extraButtons || []).forEach(b => {
body.querySelector('#' + b.id).addEventListener('click', b.onClick);
});
b64.addEventListener('change', e => {
App.state.settings.base64 = e.target.checked;
refresh();
});
}
/* Exchange views */
function renderInitiatorExchange() {
document.getElementById('step-1-h').textContent = 'Step 1: send this offer to your peer';
mountOutgoingBlob(document.getElementById('step-1-body'), 'offer');
document.getElementById('step-2-h').textContent = 'Step 2: paste your peer\'s answer';
const s2 = document.getElementById('step-2-body');
s2.innerHTML = `
<textarea id="blob-in" spellcheck="false" placeholder="Paste answer JSON here"></textarea>
<div class="blob-controls">
<button id="blob-apply" class="primary">Apply answer</button>
<button id="blob-upload" class="ghost">Upload…</button>
<input id="blob-upload-file" type="file" accept=".json,.txt,application/json,text/plain" hidden>
<span class="pill" id="blob-in-status"></span>
</div>`;
wireUploadButton('answer');
document.getElementById('blob-apply').addEventListener('click', async () => {
const text = document.getElementById('blob-in').value;
const statusEl = document.getElementById('blob-in-status');
try {
const obj = App.signal.decode(text);
if (obj.type !== 'answer') throw new Error('expected an answer, got ' + obj.type);
statusEl.textContent = 'applying…'; statusEl.className = 'pill warn';
await applyAnswerOnInitiator(obj);
statusEl.textContent = 'applied'; statusEl.className = 'pill ok';
goToCall();
} catch (e) {
statusEl.textContent = e.message; statusEl.className = 'pill err';
App.log.error('signal', 'apply answer failed', e.message);
}
});
showView('view-exchange');
}
function renderJoinerExchange() {
document.getElementById('step-1-h').textContent = 'Step 1: paste the offer from your peer';
const s1 = document.getElementById('step-1-body');
s1.innerHTML = `
<textarea id="blob-in" spellcheck="false" placeholder="Paste offer JSON here"></textarea>
<div class="blob-controls">
<button id="blob-apply" class="primary">Apply offer & generate answer</button>
<button id="blob-upload" class="ghost">Upload…</button>
<input id="blob-upload-file" type="file" accept=".json,.txt,application/json,text/plain" hidden>
<span class="pill" id="blob-in-status"></span>
</div>`;
document.getElementById('step-2-card').classList.add('hidden');
wireUploadButton('offer');
document.getElementById('blob-apply').addEventListener('click', async () => {
const text = document.getElementById('blob-in').value;
const statusEl = document.getElementById('blob-in-status');
try {
const obj = App.signal.decode(text);
if (obj.type !== 'offer') throw new Error('expected an offer, got ' + obj.type);
statusEl.textContent = 'working…'; statusEl.className = 'pill warn';
await finishJoiner(obj);
statusEl.textContent = 'ready'; statusEl.className = 'pill ok';
} catch (e) {
statusEl.textContent = e.message; statusEl.className = 'pill err';
App.log.error('signal', 'apply offer failed', e.message);
App.progress.hide();
}
});
showView('view-exchange');
}
function showAnswerForJoiner() {
document.getElementById('step-2-card').classList.remove('hidden');
document.getElementById('step-2-h').textContent = 'Step 2: send this answer back to your peer';
mountOutgoingBlob(document.getElementById('step-2-body'), 'answer', [
{ id: 'blob-done', label: "I've sent it →", cls: 'ghost', onClick: () => goToCall() },
]);
}
function goToCall() {
showView('view-call');
setInCallControlsEnabled(true);
/* Pre-fill runtime settings panel from current values */
const s = App.state.settings;
const $ = id => document.getElementById(id);
$('rt-v-w').value = s.video.width || 0;
$('rt-v-h').value = s.video.height || 0;
$('rt-v-fps').value = s.video.frameRate || 0;
$('rt-v-maxbr').value = s.video.maxBitrateKbps || 0;
$('rt-v-degrade').value = s.video.degradationPreference || 'balanced';
populateCodecDropdown('rt-codec');
const wantSend = (s.sendVideoCodec || 'auto').toLowerCase();
const rtCodec = $('rt-codec');
const rtMatch = Array.from(rtCodec.options).find(o => o.value.toLowerCase() === wantSend);
rtCodec.value = rtMatch ? rtMatch.value : 'auto';
$('rt-s-w').value = s.screen.width || 0;
$('rt-s-h').value = s.screen.height || 0;
$('rt-s-fps').value = s.screen.frameRate || 0;
$('rt-s-maxbr').value = s.screen.maxBitrateKbps || 0;
$('rt-s-degrade').value = s.screen.degradationPreference || 'maintain-resolution';
$('rt-a-aec').checked = s.audio.echoCancellation;
$('rt-a-ns').checked = s.audio.noiseSuppression;
$('rt-a-agc').checked = s.audio.autoGainControl;
$('rt-a-channels').value = String(s.audio.channelCount || 1);
$('rt-a-rate').value = s.audio.sampleRate || 0;
App.stats.start();
/* Refresh the displays after the view is actually visible — some browsers
don't render hidden video elements properly, so re-bind srcObject. */
App.media.refreshLocalDisplay();
App.media.refreshRemoteDisplay();
applyMediaButtonAvailability();
}
/* Disable mic/cam/screen toolbar buttons when they can't possibly succeed —
either because the page isn't a secure context (gUM/gDM unavailable) or
because no matching hardware is connected. Re-runs on devicechange so
plugging in a webcam mid-call re-enables the button. */
let _deviceChangeBound = false;
async function applyMediaButtonAvailability() {
const hasGum = !!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia);
const hasGdm = !!(navigator.mediaDevices && navigator.mediaDevices.getDisplayMedia);
const insecureTip = 'Unavailable in this context — requires HTTPS or localhost.';
const micBtn = document.getElementById('tb-mic');
const camBtn = document.getElementById('tb-cam');
const screenBtn = document.getElementById('tb-screen');
const micPick = document.getElementById('tb-mic-pick');
const camPick = document.getElementById('tb-cam-pick');
if (!hasGdm) { screenBtn.disabled = true; screenBtn.title = insecureTip; }
if (!hasGum) {
micBtn.disabled = true; micBtn.title = insecureTip;
camBtn.disabled = true; camBtn.title = insecureTip;
if (micPick) micPick.disabled = true;
if (camPick) camPick.disabled = true;
return;
}
/* Bind the devicechange listener once. Counts of audioinput/videoinput
entries reflect presence even before permission is granted (the entries
have empty labels but still exist), so this works on first call too. */
if (!_deviceChangeBound && navigator.mediaDevices.addEventListener) {
navigator.mediaDevices.addEventListener('devicechange', applyMediaButtonAvailability);
_deviceChangeBound = true;
}
let devs = [];
try {
devs = await navigator.mediaDevices.enumerateDevices();
} catch (e) {
App.log.warn('media', 'enumerateDevices failed', e.message);
/* Fall through with permissive defaults — gUM may still work. */
}
const audioInputs = devs.filter(d => d.kind === 'audioinput');
const videoInputs = devs.filter(d => d.kind === 'videoinput');
const hasMic = devs.length === 0 || audioInputs.length > 0;
const hasCam = devs.length === 0 || videoInputs.length > 0;
/* If the device disappears while in use, don't yank the button out from
under the user — leave it clickable so they can turn the active track
off. The track-end handler will reset the button when the OS releases
the device. */
const micOn = micBtn.classList.contains('on');
const camOn = camBtn.classList.contains('on');
micBtn.disabled = !hasMic && !micOn;
micBtn.title = hasMic ? 'Enable microphone' : 'No microphone detected';
camBtn.disabled = !hasCam && !camOn;
camBtn.title = hasCam ? 'Enable camera' : 'No camera detected';
/* Device labels are only populated after the user has granted permission for
that media kind. With blank labels the picker would just list anonymous
"Microphone 1 / 2", which the user can't meaningfully choose between —
gate the chevron until the input has been enabled at least once. */
const micLabelled = audioInputs.some(d => d.label);
const camLabelled = videoInputs.some(d => d.label);
if (micPick) {
micPick.disabled = !hasMic || !micLabelled;
micPick.title = micPick.disabled
? 'Enable microphone first to choose a device'
: 'Choose microphone';
}
if (camPick) {
camPick.disabled = !hasCam || !camLabelled;
camPick.title = camPick.disabled
? 'Enable camera first to choose a device'
: 'Choose camera';
}
renderDeviceMenus(devs);
}
/* Rebuild the mic and camera popover lists from an enumerateDevices() snapshot.
Devices without labels (no permission yet) show as "Microphone 1", etc., so
the user can still see *how many* devices exist before granting permission. */
function renderDeviceMenus(devs) {
renderOneDeviceMenu('tb-mic-menu', 'mic', devs.filter(d => d.kind === 'audioinput'),
App.state.settings.audio.deviceId,
App.state.micTransceiver && App.state.micTransceiver.sender);
renderOneDeviceMenu('tb-cam-menu', 'cam', devs.filter(d => d.kind === 'videoinput'),
App.state.settings.video.deviceId,
App.state.camTransceiver && App.state.camTransceiver.sender);
}
function renderOneDeviceMenu(menuId, kind, devs, savedId, sender) {
const menu = document.getElementById(menuId);
if (!menu) return;
/* Some webcams (notably HP combo cameras) expose the RGB and IR sensors as
two enumerateDevices entries with the *same* deviceId. gUM can't tell
them apart with {exact: deviceId}, so listing both rows would let the
user click a "different" device that's actually the same one. Dedup by
deviceId, keeping the first label we saw. */
const seenIds = new Set();
devs = devs.filter(d => {
if (!d.deviceId) return true; /* pre-permission entries — keep them */
if (seenIds.has(d.deviceId)) return false;
seenIds.add(d.deviceId);
return true;
});
/* "active" = the device currently producing the live stream. If we passed
{exact: savedId} to gUM and it succeeded, savedId IS what's live — trust
that over getSettings().deviceId, which some webcams misreport. Only
fall back to getSettings() when no preference is saved. */
let activeId = '';
if (sender && sender.track) {
activeId = savedId
|| (sender.track.getSettings ? sender.track.getSettings().deviceId : '')
|| '';
}
const kindLabel = kind === 'mic' ? 'Microphone' : 'Camera';
const rows = [];
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>';
const sysActive = !savedId && activeId ? ` <span class="device-active">${activeDot} active</span>` : '';
rows.push(`<label class="device-row" role="menuitemradio">
<input type="radio" name="dev-${kind}" value="" ${savedId ? '' : 'checked'}>
<span class="device-label">System default</span>${sysActive}
</label>`);
if (devs.length === 0) {
rows.push(`<div class="device-empty">No ${kindLabel.toLowerCase()}s detected.</div>`);
} else {
devs.forEach((d, i) => {
const label = d.label || `${kindLabel} ${i + 1}`;
const checked = d.deviceId === savedId ? 'checked' : '';
const isActive = d.deviceId === activeId
? ` <span class="device-active">${activeDot} active</span>`
: '';
rows.push(`<label class="device-row" role="menuitemradio">
<input type="radio" name="dev-${kind}" value="${escapeAttr(d.deviceId)}" ${checked}>
<span class="device-label">${escapeHtml(label)}</span>${isActive}
</label>`);
});
if (!devs[0].label) {
rows.push(`<div class="device-empty">Enable ${kindLabel.toLowerCase()} to see device names.</div>`);
}
}
menu.innerHTML = rows.join('');
}
/* Re-renders the picker lists with the current saved/active state. Used by
the picker change handler so the radio + "active" marker reflect the new
selection immediately, without waiting for the next devicechange event. */
function refreshDeviceMenus() {
navigator.mediaDevices.enumerateDevices().then(renderDeviceMenus).catch(() => {});
}
function setupDevicePickers() {
setupOnePicker('mic');
setupOnePicker('cam');
/* Close any open menu on outside click or Escape. */
document.addEventListener('click', (e) => {
document.querySelectorAll('.device-picker').forEach(group => {
if (!group.contains(e.target)) closeMenuInGroup(group);
});
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
document.querySelectorAll('.device-menu:not(.hidden)').forEach(m => {
const chev = m.parentElement.querySelector('.device-chevron');
m.classList.add('hidden');
if (chev) chev.setAttribute('aria-expanded', 'false');
});
}
});
}
function closeMenuInGroup(group) {
const menu = group.querySelector('.device-menu');
const chev = group.querySelector('.device-chevron');
if (menu) menu.classList.add('hidden');
if (chev) chev.setAttribute('aria-expanded', 'false');
}
function setupOnePicker(kind) {
const chev = document.getElementById(`tb-${kind}-pick`);
const menu = document.getElementById(`tb-${kind}-menu`);
if (!chev || !menu) return;
chev.addEventListener('click', (e) => {
e.stopPropagation();
/* Close any other open menus first. */
document.querySelectorAll('.device-picker').forEach(g => {
if (!g.contains(chev)) closeMenuInGroup(g);
});
const opening = menu.classList.contains('hidden');
menu.classList.toggle('hidden');
chev.setAttribute('aria-expanded', opening ? 'true' : 'false');
if (opening) {
/* Re-enumerate on open so labels are fresh after a permission grant. */
refreshDeviceMenus();
}
});
menu.addEventListener('change', async (e) => {
const input = e.target.closest('input[type="radio"]');
if (!input) return;
const settings = kind === 'mic' ? App.state.settings.audio : App.state.settings.video;
const newId = input.value || '';
menu.classList.add('hidden');
chev.setAttribute('aria-expanded', 'false');
const btn = document.getElementById(`tb-${kind}`);
if (!btn.classList.contains('on')) {
/* Input is off — just persist the choice for the next time the user
turns it on. No gUM call. */
settings.deviceId = newId;
refreshDeviceMenus();
return;
}
/* Input is live — swap the track in place. On failure, leave the saved
preference unchanged so the menu reverts to the previously-working
selection on next render. */
const got = await App.media.switchInputDevice(kind, newId);
if (got !== null) settings.deviceId = newId;
refreshDeviceMenus();
});
}
/* -------------------------------------------------------------------------
Console drawer rendering
------------------------------------------------------------------------- */
function setupConsole() {
const body = document.getElementById('console-body');
const countEl = document.getElementById('console-count');
const levelSel = document.getElementById('console-level');
const filterEl = document.getElementById('console-filter');
const drawer = document.getElementById('console-drawer');
const toggle = document.getElementById('console-toggle');
const ORDER = { debug: 0, info: 1, warn: 2, error: 3 };
function shouldShow(e) {
if (!e) return false;
if (ORDER[e.level] < ORDER[levelSel.value]) return false;
const q = filterEl.value.toLowerCase();
if (q && !(e.label.toLowerCase().includes(q) || e.args.some(a => String(a).toLowerCase().includes(q)))) return false;
return true;
}
function fmt(args) {
return args.map(a => {
if (a == null) return String(a);
if (typeof a === 'string') return a;
try { return JSON.stringify(a); } catch (_) { return String(a); }
}).join(' ');
}
function append(entry) {
if (!entry) { body.innerHTML = ''; countEl.textContent = '0 entries'; return; }
if (!shouldShow(entry)) { countEl.textContent = App.log.snapshot().length + ' entries'; return; }
const div = document.createElement('div');
div.className = 'log-line ' + entry.level;
const t = new Date(entry.ts);
const ts = t.toTimeString().slice(0, 8) + '.' + String(t.getMilliseconds()).padStart(3, '0');
div.innerHTML = `<span class="ts">${ts}</span><span class="lvl">${entry.level}</span><span class="label">${escapeHtml(entry.label)}</span><span class="text"></span>`;
div.querySelector('.text').textContent = fmt(entry.args);
const wasAtBottom = body.scrollTop + body.clientHeight >= body.scrollHeight - 20;
body.appendChild(div);
if (wasAtBottom) body.scrollTop = body.scrollHeight;
countEl.textContent = App.log.snapshot().length + ' entries';
}
function rerender() {
body.innerHTML = '';
App.log.snapshot().forEach(append);
}
App.log.subscribe(append);
levelSel.addEventListener('change', rerender);
filterEl.addEventListener('input', rerender);
toggle.addEventListener('click', () => drawer.classList.toggle('hidden'));
document.getElementById('console-close').addEventListener('click', () => drawer.classList.add('hidden'));
document.getElementById('console-clear').addEventListener('click', () => App.log.clear());
document.getElementById('console-export').addEventListener('click', () => {
const blob = new Blob([JSON.stringify(App.log.snapshot(), null, 2)], { type: 'application/json' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'webrtc-log-' + Date.now() + '.json';
a.click();
});
document.getElementById('console-stats-toggle').addEventListener('click', () => App.stats.toggleConsoleStats());
/* Keyboard shortcut */
document.addEventListener('keydown', e => {
if ((e.ctrlKey || e.metaKey) && e.key === '`') {
e.preventDefault(); drawer.classList.toggle('hidden');
}
});
}
/* -------------------------------------------------------------------------
SDP inspector: parse an offer/answer and render it as labelled sections
with one-line explanations for the common attributes.
Input accepted: raw SDP (starts with "v="), the {type,sdp} JSON this tool
emits, or its "b64:"-prefixed wrapped form. The renderer never uses
innerHTML with user content — everything goes through textContent — so
pasted SDP can't smuggle markup into the page.
------------------------------------------------------------------------- */
App.sdpInspect = (() => {
/* Short explanations for the SDP attributes we render. Missing entries
just render without help text. */
const ATTR_HELP = {
'group': 'Groups m= sections into one transport. "BUNDLE 0 1 2" multiplexes those mids onto a single ICE/DTLS connection.',
'msid-semantic': 'Declares the meaning of msid values (WMS = WebRTC Media Stream).',
'ice-ufrag': 'ICE username fragment — half of the STUN binding-request credentials.',
'ice-pwd': 'ICE password — the other half of the ICE credentials. Treat as a short-lived secret.',
'ice-options': 'ICE feature flags (e.g. "trickle" = candidates may arrive after the SDP).',
'fingerprint': 'DTLS certificate fingerprint. The peer authenticates the cert against this value.',
'setup': 'DTLS role: active (client), passive (server), or actpass (will negotiate during the handshake).',
'mid': 'Media identifier for this m= section; referenced by BUNDLE and by the "mid" RTP header extension.',
'extmap': 'RTP header extension: numeric id → URI describing what the extension carries.',
'rtcp-mux': 'RTP and RTCP share one UDP port. Always present in WebRTC.',
'rtcp-rsize': 'Allows reduced-size RTCP packets.',
'rtcp': 'Explicit RTCP port (legacy; ignored when rtcp-mux is set).',
'sendrecv': 'This side will both send and receive media on this m=.',
'sendonly': 'This side will only send media on this m=.',
'recvonly': 'This side will only receive media on this m=.',
'inactive': 'Negotiated but neither side will send/receive media on this m=.',
'rtpmap': 'Maps an RTP payload type to a codec/clock-rate/channels triple.',
'fmtp': 'Per-payload-type format parameters — encoder hints (e.g. opus useinbandfec=1).',
'rtcp-fb': 'RTCP feedback messages this payload type supports (nack, pli, transport-cc, …).',
'candidate': 'An ICE candidate — one possible source/destination address pair for media.',
'end-of-candidates':'No more candidates will be trickled.',
'msid': 'Binds this m= to a MediaStream id and Track id used by the JS API.',
'ssrc': 'Synchronization source ID for an RTP stream, plus metadata (cname, msid, …).',
'ssrc-group': 'Groups SSRCs (FID = RTX retransmission pair; SIM = simulcast layers).',
'rid': 'Restriction identifier for one simulcast layer.',
'simulcast': 'Declares simulcast layer ids and directions.',
'maxptime': 'Maximum packetization time (ms) the receiver will accept.',
'ptime': 'Preferred packetization time (ms).',
'extmap-allow-mixed':'Receiver accepts RTP packets that mix one-byte and two-byte header extensions in the same packet (RFC 8285).',
'rtcp': 'Explicit RTCP address/port. Legacy — ignored when rtcp-mux is in effect (RFC 3605).',
'bundle-only': 'This m= section is only usable when bundled via the BUNDLE group; port is 0 if not selected (RFC 8843).',
'sctp-port': 'SCTP port for the data channel association. WebRTC always uses 5000 (RFC 8841).',
'max-message-size': 'Maximum SCTP user message size (bytes) the receiver will accept (RFC 8841).',
};
const CANDIDATE_TYPE_HELP = {
host: 'Local interface address on this machine (LAN or loopback).',
srflx: 'Server-reflexive: public address as seen by a STUN server (post-NAT).',
prflx: 'Peer-reflexive: address discovered during connectivity checks.',
relay: 'TURN relay; media flows through the TURN server.',
};
/* Accept raw SDP, JSON wrappers, or b64-prefixed JSON. Throws on garbage. */
function extractSdp(text) {
text = (text || '').trim();
if (!text) throw new Error('empty input');
if (text.startsWith('b64:')) {
try {
const bin = atob(text.slice(4));
const bytes = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
text = new TextDecoder().decode(bytes);
} catch (e) { throw new Error('bad base64: ' + e.message); }
}
if (text.startsWith('{')) {
let obj;
try { obj = JSON.parse(text); } catch (e) { throw new Error('not valid JSON: ' + e.message); }
if (!obj || typeof obj.sdp !== 'string') throw new Error('JSON has no "sdp" string field');
if (!obj.sdp.includes('v=')) throw new Error('"sdp" field is not SDP (missing v=)');
return { type: obj.type || '(unknown)', sdp: obj.sdp };
}
if (/^v=/m.test(text)) return { type: '(raw)', sdp: text };
throw new Error('unrecognized input — expected SDP, JSON, or b64:…');
}
/* Tokenize SDP into a session + one block per m= section. */
function parseSdp(sdp) {
const session = { kind: 'session', lines: [], attrs: [], media: [] };
let cur = session;
for (const raw of sdp.split(/\r?\n/)) {
const line = raw.replace(/\r$/, '');
if (!line) continue;
const m = line.match(/^([a-z])=(.*)$/);
if (!m) continue;
const key = m[1], val = m[2];
if (key === 'm') {
const parts = val.split(/\s+/);
const media = {
kind: 'media', type: parts[0] || '?', port: parts[1] || '?', proto: parts[2] || '?',
payloadTypes: parts.slice(3), lines: [], attrs: [],
};
session.media.push(media);
cur = media;
cur.lines.push({ key, val });
continue;
}
cur.lines.push({ key, val });
if (key === 'a') {
const colon = val.indexOf(':');
cur.attrs.push({
name: colon === -1 ? val : val.slice(0, colon),
value: colon === -1 ? '' : val.slice(colon + 1),
});
}
}
return session;
}
/* DOM helpers — everything textContent, no innerHTML on user input. */
function el(tag, cls, text) {
const e = document.createElement(tag);
if (cls) e.className = cls;
if (text != null) e.textContent = text;
return e;
}
function kv(grid, k, v, help) {
grid.appendChild(el('span', 'k', k));
const vEl = el('span', 'v', v == null ? '' : String(v));
if (help) vEl.title = help;
grid.appendChild(vEl);
}
function section(title, sub) {
const wrap = el('div', 'sdp-section');
const head = el('div', 'sdp-head');
head.appendChild(el('h3', null, title));
if (sub) head.appendChild(el('span', 'sdp-sub', sub));
wrap.appendChild(head);
return wrap;
}
function helpLine(text) { return el('p', 'sdp-help', text); }
function rawBlock(label, lines) {
const d = el('details', 'sdp-rawblock');
d.appendChild(el('summary', null, label));
d.appendChild(el('pre', null, lines.map(l => l.key + '=' + l.val).join('\n')));
return d;
}
/* Render an a=candidate:... value into a structured single-line list item.
RFC 5245 layout: <foundation> <component> <transport> <priority>
<addr> <port> typ <type> [raddr <addr> rport <port>]
[generation N] [tcptype …] [ufrag …] */
function renderCandidate(value) {
const t = value.split(/\s+/);
const li = el('li');
const out = [];
out.push({ text: t[2] || '?', cls: 'tag', help: 'transport (udp/tcp)' });
out.push({ text: (t[4] || '?') + ':' + (t[5] || '?'), help: 'local address & port (often obfuscated by the browser)' });
const typeIdx = t.indexOf('typ');
const type = typeIdx >= 0 ? t[typeIdx + 1] : '?';
out.push({ text: type, cls: 'badge', help: CANDIDATE_TYPE_HELP[type] || 'ICE candidate type' });
const raddrIdx = t.indexOf('raddr');
if (raddrIdx >= 0) {
const rportIdx = t.indexOf('rport');
out.push({ text: 'related ' + t[raddrIdx + 1] + ':' + (rportIdx >= 0 ? t[rportIdx + 1] : '?'),
cls: 'dim', help: 'related (base) address — for srflx/relay, the host address behind it' });
}
out.push({ text: 'prio ' + (t[3] || '?'), cls: 'dim', help: 'priority — higher wins during pair selection' });
out.push({ text: 'foundation ' + (t[0] || '?'), cls: 'dim',
help: 'foundation — candidates with the same foundation share a local interface/server pair' });
out.forEach((p, i) => {
const span = el('span', p.cls || null, p.text);
if (p.help) span.title = p.help;
li.appendChild(span);
if (i < out.length - 1) li.appendChild(document.createTextNode(' '));
});
return li;
}
function attrsBy(attrs, name) { return attrs.filter(a => a.name === name); }
function attrFirst(attrs, name) { const a = attrs.find(x => x.name === name); return a ? a.value : null; }
function directionOf(attrs) {
for (const d of ['sendrecv','sendonly','recvonly','inactive'])
if (attrs.some(a => a.name === d)) return d;
return null;
}
function renderSession(parsed) {
const out = document.createDocumentFragment();
/* Session-level summary */
const sec = section('Session');
const grid = el('div', 'sdp-kv');
const o = parsed.lines.find(l => l.key === 'o');
if (o) {
const op = o.val.split(/\s+/);
kv(grid, 'origin', op.join(' '),
'o=<username> <sess-id> <sess-version> <nettype> <addrtype> <unicast-addr>');
}
const sName = parsed.lines.find(l => l.key === 's');
if (sName) kv(grid, 'name (s=)', sName.val, 'Session name. WebRTC uses "-".');
const t = parsed.lines.find(l => l.key === 't');
if (t) kv(grid, 'time (t=)', t.val, 't=<start> <stop>; "0 0" means unbounded — usual for real-time sessions.');
const c = parsed.lines.find(l => l.key === 'c');
if (c) kv(grid, 'connection (c=)', c.val, 'c=<nettype> <addrtype> <connection-address>.');
const sessionAttrs = parsed.attrs;
const group = attrFirst(sessionAttrs, 'group');
if (group) kv(grid, 'group', group, ATTR_HELP['group']);
const msidSem = attrFirst(sessionAttrs, 'msid-semantic');
if (msidSem) kv(grid, 'msid-semantic', msidSem, ATTR_HELP['msid-semantic']);
const fp = attrFirst(sessionAttrs, 'fingerprint');
if (fp) kv(grid, 'fingerprint (session)', fp, ATTR_HELP['fingerprint']);
const setup = attrFirst(sessionAttrs, 'setup');
if (setup) kv(grid, 'setup (session)', setup, ATTR_HELP['setup']);
const ufrag = attrFirst(sessionAttrs, 'ice-ufrag');
if (ufrag) kv(grid, 'ice-ufrag (session)', ufrag, ATTR_HELP['ice-ufrag']);
const pwd = attrFirst(sessionAttrs, 'ice-pwd');
if (pwd) kv(grid, 'ice-pwd (session)', pwd, ATTR_HELP['ice-pwd']);
const iceOpts = attrFirst(sessionAttrs, 'ice-options');
if (iceOpts) kv(grid, 'ice-options', iceOpts, ATTR_HELP['ice-options']);
if (sessionAttrs.some(a => a.name === 'extmap-allow-mixed'))
kv(grid, 'extmap-allow-mixed', 'yes', ATTR_HELP['extmap-allow-mixed']);
sec.appendChild(grid);
out.appendChild(sec);
/* One section per m= */
parsed.media.forEach((media, idx) => renderMedia(media, idx, sessionAttrs, out));
return out;
}
function renderMedia(media, idx, sessionAttrs, out) {
const sub = media.proto + ' port ' + media.port + ' PTs: ' + media.payloadTypes.join(' ');
const sec = section('m=' + media.type + ' [' + idx + ']', sub);
const grid = el('div', 'sdp-kv');
const mid = attrFirst(media.attrs, 'mid');
if (mid) kv(grid, 'mid', mid, ATTR_HELP['mid']);
const dir = directionOf(media.attrs);
if (dir) kv(grid, 'direction', dir, ATTR_HELP[dir]);
const msid = attrFirst(media.attrs, 'msid');
if (msid) kv(grid, 'msid', msid, ATTR_HELP['msid']);
if (media.attrs.some(a => a.name === 'rtcp-mux')) kv(grid, 'rtcp-mux', 'yes', ATTR_HELP['rtcp-mux']);
if (media.attrs.some(a => a.name === 'rtcp-rsize')) kv(grid, 'rtcp-rsize', 'yes', ATTR_HELP['rtcp-rsize']);
if (media.attrs.some(a => a.name === 'extmap-allow-mixed'))
kv(grid, 'extmap-allow-mixed', 'yes', ATTR_HELP['extmap-allow-mixed']);
if (media.attrs.some(a => a.name === 'bundle-only'))
kv(grid, 'bundle-only', 'yes', ATTR_HELP['bundle-only']);
const rtcpAddr = attrFirst(media.attrs, 'rtcp');
if (rtcpAddr) kv(grid, 'rtcp (legacy)', rtcpAddr, ATTR_HELP['rtcp']);
const sctpPort = attrFirst(media.attrs, 'sctp-port');
if (sctpPort) kv(grid, 'sctp-port', sctpPort, ATTR_HELP['sctp-port']);
const maxMsg = attrFirst(media.attrs, 'max-message-size');
if (maxMsg) kv(grid, 'max-message-size', maxMsg + ' bytes', ATTR_HELP['max-message-size']);
const mFp = attrFirst(media.attrs, 'fingerprint');
if (mFp) kv(grid, 'fingerprint', mFp, ATTR_HELP['fingerprint']);
const mSetup = attrFirst(media.attrs, 'setup');
if (mSetup) kv(grid, 'setup', mSetup, ATTR_HELP['setup']);
const mUfrag = attrFirst(media.attrs, 'ice-ufrag');
if (mUfrag) kv(grid, 'ice-ufrag', mUfrag, ATTR_HELP['ice-ufrag']);
const mPwd = attrFirst(media.attrs, 'ice-pwd');
if (mPwd) kv(grid, 'ice-pwd', mPwd, ATTR_HELP['ice-pwd']);
sec.appendChild(grid);
/* Codecs */
const rtpmaps = attrsBy(media.attrs, 'rtpmap');
const fmtps = attrsBy(media.attrs, 'fmtp');
const fbs = attrsBy(media.attrs, 'rtcp-fb');
if (rtpmaps.length) {
sec.appendChild(el('h3', null, 'Codecs'));
sec.appendChild(helpLine('Each payload type (PT) maps to a codec definition. fmtp/rtcp-fb lines attach to a PT by id.'));
const list = el('ul', 'sdp-list');
rtpmaps.forEach(r => {
const m = r.value.match(/^(\d+)\s+(.+)$/);
if (!m) return;
const pt = m[1], spec = m[2];
const li = el('li');
const tag = el('span', 'tag', pt);
tag.title = 'RTP payload type number';
li.appendChild(tag);
li.appendChild(document.createTextNode(spec));
const fmtp = fmtps.find(f => f.value.startsWith(pt + ' '));
if (fmtp) {
const b = el('span', 'badge', 'fmtp: ' + fmtp.value.slice(pt.length + 1));
b.title = ATTR_HELP['fmtp'];
li.appendChild(document.createTextNode(' '));
li.appendChild(b);
}
const ptFbs = fbs.filter(f => f.value.startsWith(pt + ' ') || f.value.startsWith('* '));
if (ptFbs.length) {
const fbText = ptFbs.map(f => f.value.split(/\s+/).slice(1).join(' ')).join(' / ');
const b = el('span', 'dim', 'fb: ' + fbText);
b.title = ATTR_HELP['rtcp-fb'];
li.appendChild(document.createTextNode(' '));
li.appendChild(b);
}
list.appendChild(li);
});
sec.appendChild(list);
}
/* RTP header extensions */
const extmaps = attrsBy(media.attrs, 'extmap');
if (extmaps.length) {
sec.appendChild(el('h3', null, 'RTP header extensions'));
sec.appendChild(helpLine(ATTR_HELP['extmap']));
const list = el('ul', 'sdp-list');
extmaps.forEach(e => {
const m = e.value.match(/^(\d+)(?:\/(\S+))?\s+(.+)$/);
const li = el('li');
if (m) {
li.appendChild(el('span', 'tag', m[1]));
if (m[2]) { const dir = el('span', 'badge', m[2]); dir.title = 'extension direction'; li.appendChild(dir); }
li.appendChild(document.createTextNode(' ' + m[3]));
} else li.textContent = e.value;
list.appendChild(li);
});
sec.appendChild(list);
}
/* ICE candidates */
const cands = attrsBy(media.attrs, 'candidate');
if (cands.length) {
sec.appendChild(el('h3', null, 'ICE candidates (' + cands.length + ')'));
sec.appendChild(helpLine(ATTR_HELP['candidate']));
const list = el('ul', 'sdp-list');
cands.forEach(c => list.appendChild(renderCandidate(c.value)));
sec.appendChild(list);
}
if (media.attrs.some(a => a.name === 'end-of-candidates'))
sec.appendChild(helpLine('end-of-candidates present: ' + ATTR_HELP['end-of-candidates']));
/* SSRCs */
const ssrcs = attrsBy(media.attrs, 'ssrc');
const ssrcGroups = attrsBy(media.attrs, 'ssrc-group');
if (ssrcs.length || ssrcGroups.length) {
sec.appendChild(el('h3', null, 'SSRCs'));
if (ssrcGroups.length) {
const gl = el('ul', 'sdp-list');
ssrcGroups.forEach(g => {
const li = el('li');
li.appendChild(el('span', 'badge', 'group'));
li.appendChild(document.createTextNode(' ' + g.value));
li.title = ATTR_HELP['ssrc-group'];
gl.appendChild(li);
});
sec.appendChild(gl);
}
const byId = new Map();
ssrcs.forEach(s => {
const m = s.value.match(/^(\d+)\s+(\S+?)(?::(.*))?$/);
if (!m) return;
const id = m[1], attr = m[2], val = m[3] || '';
if (!byId.has(id)) byId.set(id, []);
byId.get(id).push(attr + (val ? '=' + val : ''));
});
const list = el('ul', 'sdp-list');
byId.forEach((props, id) => {
const li = el('li');
li.appendChild(el('span', 'tag', id));
li.appendChild(document.createTextNode(props.join(' ')));
li.title = ATTR_HELP['ssrc'];
list.appendChild(li);
});
sec.appendChild(list);
}
/* Simulcast / rid */
const rids = attrsBy(media.attrs, 'rid');
const sim = attrFirst(media.attrs, 'simulcast');
if (rids.length || sim) {
sec.appendChild(el('h3', null, 'Simulcast'));
sec.appendChild(helpLine(ATTR_HELP['simulcast']));
if (sim) sec.appendChild(el('p', 'sdp-help', 'simulcast: ' + sim));
if (rids.length) {
const list = el('ul', 'sdp-list');
rids.forEach(r => { const li = el('li'); li.textContent = r.value; li.title = ATTR_HELP['rid']; list.appendChild(li); });
sec.appendChild(list);
}
}
/* Anything we didn't classify — show raw for completeness. */
const handled = new Set([
'mid','msid','rtcp-mux','rtcp-rsize','fingerprint','setup','ice-ufrag','ice-pwd',
'sendrecv','sendonly','recvonly','inactive','rtpmap','fmtp','rtcp-fb','candidate',
'end-of-candidates','ssrc','ssrc-group','rid','simulcast','extmap',
'extmap-allow-mixed','bundle-only','rtcp','sctp-port','max-message-size',
]);
const other = media.attrs.filter(a => !handled.has(a.name));
if (other.length) {
sec.appendChild(rawBlock('Other attributes (' + other.length + ')',
other.map(a => ({ key: 'a', val: a.name + (a.value ? ':' + a.value : '') }))));
}
sec.appendChild(rawBlock('Raw lines for this m= (' + media.lines.length + ')', media.lines));
out.appendChild(sec);
}
function run(input, container, statusEl) {
container.replaceChildren();
try {
const { type, sdp } = extractSdp(input);
const parsed = parseSdp(sdp);
const header = el('div', 'sdp-section');
const head = el('div', 'sdp-head');
head.appendChild(el('h3', null, 'Detected: ' + type));
head.appendChild(el('span', 'sdp-sub',
parsed.media.length + ' m= section(s) · ' + sdp.split(/\r?\n/).length + ' lines'));
header.appendChild(head);
container.appendChild(header);
container.appendChild(renderSession(parsed));
statusEl.textContent = 'parsed ' + parsed.media.length + ' section(s)';
statusEl.className = 'pill ok';
} catch (e) {
statusEl.textContent = e.message;
statusEl.className = 'pill err';
}
}
return { run };
})();
/* -------------------------------------------------------------------------
Wire up everything on DOMContentLoaded
------------------------------------------------------------------------- */
function wire() {
/* Welcome */
document.querySelectorAll('#view-welcome .role-picker button').forEach(b =>
b.addEventListener('click', () => pickRole(b.dataset.role)));
document.getElementById('welcome-sdp-inspect').addEventListener('click', () => {
/* Standalone tool — no role, no pc. Just swap the view. */
showView('view-sdp-inspect');
});
/* SDP inspector */
const sdpIn = document.getElementById('sdp-in');
const sdpOut = document.getElementById('sdp-inspect-out');
const sdpStatus = document.getElementById('sdp-inspect-status');
document.getElementById('sdp-inspect-go').addEventListener('click',
() => App.sdpInspect.run(sdpIn.value, sdpOut, sdpStatus));
document.getElementById('sdp-inspect-clear').addEventListener('click', () => {
sdpIn.value = ''; sdpOut.replaceChildren(); sdpStatus.textContent = ''; sdpStatus.className = 'pill';
});
document.getElementById('sdp-inspect-back').addEventListener('click', () => showView('view-welcome'));
const sdpFile = document.getElementById('sdp-inspect-file');
document.getElementById('sdp-inspect-upload').addEventListener('click', () => sdpFile.click());
sdpFile.addEventListener('change', async () => {
const f = sdpFile.files && sdpFile.files[0];
if (!f) return;
try { sdpIn.value = await f.text(); App.sdpInspect.run(sdpIn.value, sdpOut, sdpStatus); }
catch (e) { sdpStatus.textContent = 'read failed: ' + e.message; sdpStatus.className = 'pill err'; }
finally { sdpFile.value = ''; }
});
/* Theme */
document.getElementById('theme-toggle').addEventListener('click', () => App.theme.toggle());
/* Peer-left dialog: OK closes; backdrop/Esc also close (native behavior). */
document.getElementById('peer-left-ok').addEventListener('click', () => {
document.getElementById('peer-left-dialog').close();
});
/* Step dialog: Cancel and Esc both invoke the registered cancel handler.
Listen on 'cancel' (fired by Esc) and 'close' as a belt-and-suspenders. */
document.getElementById('step-dialog-cancel').addEventListener('click', () => {
App.progress.triggerCancel();
});
document.getElementById('step-dialog').addEventListener('cancel', e => {
/* Don't let the dialog close before we run the cancel handler — the
handler itself calls dlg.close() through App.progress.hideModal(). */
e.preventDefault();
App.progress.triggerCancel();
});
/* Best-effort hangup notification when the tab is closing or backgrounded
to bfcache. Use pagehide (more reliable than beforeunload, especially
on mobile) and only send if a chat channel is currently open. */
window.addEventListener('pagehide', () => {
if (App.chat && App.chat.sendBye) App.chat.sendBye();
});
/* Configure */
document.getElementById('ice-add').addEventListener('click', () => {
App.state.settings.iceServers.push({ urls: '' });
renderIceRows();
});
document.getElementById('ice-clear').addEventListener('click', () => {
App.state.settings.iceServers = [];
renderIceRows();
});
document.getElementById('ice-reset').addEventListener('click', () => {
App.state.settings.iceServers = [{ urls: 'stun:stun.l.google.com:19302' }];
renderIceRows();
});
document.getElementById('ice-toggle-json').addEventListener('click', () => {
const w = document.getElementById('ice-json-wrap');
document.getElementById('ice-json').value = JSON.stringify(App.state.settings.iceServers, null, 2);
w.classList.toggle('hidden');
});
document.getElementById('ice-warmup').addEventListener('click', async () => {
const status = document.getElementById('ice-warmup-status');
const btn = document.getElementById('ice-warmup');
if (App.state.iceWarmupStream) {
App.state.iceWarmupStream.getTracks().forEach(t => t.stop());
App.state.iceWarmupStream = null;
status.textContent = 'off'; status.className = 'pill';
btn.textContent = 'Enable LAN connectivity';
App.log.info('ice', 'LAN warmup stream stopped');
return;
}
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
status.textContent = 'unavailable (needs HTTPS)'; status.className = 'pill err';
return;
}
btn.disabled = true;
status.textContent = 'requesting…'; status.className = 'pill warn';
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
/* Mute the track but keep the stream alive — Firefox exposes LAN ICE
candidates only while a gUM stream is in use. Stopping the track would
revert to restricted candidates. */
stream.getAudioTracks().forEach(t => t.enabled = false);
App.state.iceWarmupStream = stream;
status.textContent = 'on (mic in use, muted)'; status.className = 'pill ok';
btn.textContent = 'Disable LAN connectivity';
App.log.info('ice', 'LAN warmup stream active; LAN candidates unlocked');
} catch (e) {
status.textContent = 'denied: ' + e.message; status.className = 'pill err';
App.log.warn('ice', 'LAN warmup denied', e.message);
} finally {
btn.disabled = false;
}
});
document.getElementById('ice-json-apply').addEventListener('click', () => {
try {
const v = JSON.parse(document.getElementById('ice-json').value);
if (!Array.isArray(v)) throw new Error('expected an array');
App.state.settings.iceServers = v;
renderIceRows();
App.log.info('ice', 'applied JSON config', v.length, 'servers');
} catch (e) { App.log.error('ice', 'bad JSON', e.message); alert('Bad JSON: ' + e.message); }
});
document.getElementById('cfg-back').addEventListener('click', () => {
/* Tear down a partial pc/abort an in-flight signaling fetch if the
user hits Back while auto-mode is waiting for the peer. */
hangup({ sendBye: false });
});
/* Signaling mode toggle */
document.getElementById('sig-mode-manual').addEventListener('click', () => {
applySignalingMode('manual'); saveSignaling();
});
document.getElementById('sig-mode-auto').addEventListener('click', () => {
applySignalingMode('auto'); saveSignaling();
});
document.getElementById('sig-room-gen').addEventListener('click', () => {
document.getElementById('sig-room-code').value = randomRoomCode();
});
document.getElementById('sig-check').addEventListener('click', async () => {
const btn = document.getElementById('sig-check');
const status = document.getElementById('sig-check-status');
const url = (document.getElementById('sig-server-url').value || '').trim().replace(/\/+$/, '');
status.classList.remove('hidden');
if (!url) { status.textContent = 'enter a URL first'; status.className = 'pill err'; return; }
btn.disabled = true;
status.textContent = 'checking…'; status.className = 'pill warn';
/* Independent abort from the room-handshake one so cancelling Check doesn't
affect anything else. 5 s is plenty for a healthy server. */
const ac = new AbortController();
const timer = setTimeout(() => ac.abort(), 5000);
const t0 = performance.now();
try {
const r = await fetch(url + '/health', { signal: ac.signal, cache: 'no-store' });
const ms = Math.round(performance.now() - t0);
if (r.ok) { status.textContent = 'reachable (' + r.status + ', ' + ms + ' ms)'; status.className = 'pill ok'; }
else { status.textContent = 'HTTP ' + r.status; status.className = 'pill err'; }
} catch (e) {
status.textContent = ac.signal.aborted ? 'timed out (5 s)' : 'unreachable: ' + e.message;
status.className = 'pill err';
} finally {
clearTimeout(timer);
btn.disabled = false;
}
});
document.getElementById('sig-room-code').addEventListener('input', e => {
/* Server validates the same character class — keep the input clean so the
user notices invalid keystrokes immediately rather than at request time. */
e.target.value = e.target.value.toLowerCase().replace(/[^a-z0-9_-]/g, '').slice(0, 15);
});
document.getElementById('cfg-continue').addEventListener('click', async () => {
readConfigInputs();
saveIce();
const auto = App.state.settings.signaling.mode === 'auto' && App.state.role !== 'loopback';
try {
if (auto) {
const code = (document.getElementById('sig-room-code').value || '').trim();
if (!code) { alert('Room code is required for auto mode.'); return; }
if (!App.state.settings.signaling.serverUrl) { alert('Server URL is required for auto mode.'); return; }
if (App.state.role === 'initiator') await startInitiatorAuto(code);
else if (App.state.role === 'joiner') await startJoinerAuto(code);
} else {
if (App.state.role === 'initiator') await startInitiator();
else if (App.state.role === 'joiner') await startJoiner();
else if (App.state.role === 'loopback') await startLoopback();
}
} catch (e) {
App.progress.hide();
if (App.state.userCancelled) { App.state.userCancelled = false; return; }
App.log.error('setup', 'failed', e.message);
alert('Setup failed: ' + e.message);
}
});
document.getElementById('exch-cancel').addEventListener('click', () => {
hangup();
App.state.role = null;
updateRoleBadge();
showView('view-welcome');
});
/* Disable a button until its async handler resolves, so double-clicks
during gUM/gDM don't interleave. Used for both the Apply buttons in the
settings panel and the main toolbar Mic/Cam/Screen toggles. */
function withReentryGuard(btnId, fn) {
const btn = document.getElementById(btnId);
btn.addEventListener('click', async () => {
if (btn.disabled) return;
btn.disabled = true;
try { await fn(); }
catch (e) { App.log.error('media', 'action failed', e.message); }
finally { btn.disabled = false; }
});
}
/* Call: toolbar — mic/cam lazily call getUserMedia on first enable. */
withReentryGuard('tb-mic', async () => {
const btn = document.getElementById('tb-mic');
await App.media.setMic(!btn.classList.contains('on'));
});
withReentryGuard('tb-cam', async () => {
const btn = document.getElementById('tb-cam');
await App.media.setCam(!btn.classList.contains('on'));
});
withReentryGuard('tb-screen', async () => {
if (App.state.screenStream) await App.media.stopScreenshare();
else {
try { await App.media.startScreenshare(); }
catch (e) { App.log.error('media', 'screenshare', e.message); App.chat.appendSystem?.('Screen share failed: ' + e.message); }
}
});
document.getElementById('tb-hangup').addEventListener('click', hangup);
/* Click a tile that's showing a screen share → fullscreen. */
for (const id of ['tile-local', 'tile-remote']) {
document.getElementById(id).addEventListener('click', e => {
const tile = e.currentTarget;
if (!tile.classList.contains('screen')) return;
/* Don't trigger fullscreen for clicks on the PIP */
if (e.target.closest('.pip')) return;
const video = tile.querySelector(':scope > video');
if (!video) return;
const target = video;
if (!document.fullscreenElement) {
(target.requestFullscreen?.() || target.webkitRequestFullscreen?.() || Promise.resolve())
.catch?.(err => App.log.warn('ui', 'fullscreen failed', err.message));
}
});
}
/* Files: clear-all */
document.getElementById('files-out-clear').addEventListener('click', () => App.files.clearAll('out'));
document.getElementById('files-in-clear').addEventListener('click', () => App.files.clearAll('in'));
/* Call: sidebar tabs */
document.querySelectorAll('.tabs button').forEach(b => {
b.addEventListener('click', () => {
document.querySelectorAll('.tabs button').forEach(x => x.classList.remove('active'));
document.querySelectorAll('.tab-pane').forEach(x => x.classList.remove('active'));
b.classList.add('active');
document.querySelector(`.tab-pane[data-pane="${b.dataset.tab}"]`).classList.add('active');
});
});
/* Chat */
const chatInput = document.getElementById('chat-text');
const chatSend = document.getElementById('chat-send');
const chatCounter = document.getElementById('chat-counter');
const CHAT_MAX = App.chat.MAX_TEXT;
function updateChatCounter() {
const bytes = App.chat.utf8Length(chatInput.value);
chatCounter.textContent = bytes + ' / ' + CHAT_MAX + ' B';
const over = bytes > CHAT_MAX;
chatCounter.classList.toggle('over', over);
chatSend.disabled = over || bytes === 0;
}
chatSend.addEventListener('click', sendChat);
chatInput.addEventListener('input', updateChatCounter);
chatInput.addEventListener('keydown', e => {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendChat(); }
});
updateChatCounter();
function sendChat() {
const t = chatInput.value.trim();
if (!t) return;
if (App.chat.utf8Length(t) > CHAT_MAX) return;
App.chat.send(t);
chatInput.value = '';
updateChatCounter();
}
/* Files */
(() => {
const n = App.files.MAX_FILE;
let txt;
if (n >= 1024 ** 3) txt = (n / 1024 ** 3) + ' GB';
else if (n >= 1024 ** 2) txt = (n / 1024 ** 2) + ' MB';
else txt = (n / 1024) + ' KB';
document.getElementById('files-max').textContent = txt;
})();
const drop = document.getElementById('files-drop');
drop.addEventListener('dragover', e => { e.preventDefault(); drop.classList.add('over'); });
drop.addEventListener('dragleave', () => drop.classList.remove('over'));
drop.addEventListener('drop', e => {
e.preventDefault(); drop.classList.remove('over');
const f = e.dataTransfer.files[0];
if (f) App.files.sendFile(f);
});
document.getElementById('files-pick').addEventListener('click', e => {
e.preventDefault();
document.getElementById('files-input').click();
});
document.getElementById('files-input').addEventListener('change', e => {
const f = e.target.files[0];
if (f) App.files.sendFile(f);
e.target.value = '';
});
/* Runtime settings */
withReentryGuard('rt-v-apply', async () => {
const v = App.state.settings.video;
const prevW = v.width, prevH = v.height, prevFps = v.frameRate;
v.width = parseInt(document.getElementById('rt-v-w').value, 10) || 0;
v.height = parseInt(document.getElementById('rt-v-h').value, 10) || 0;
v.frameRate = parseInt(document.getElementById('rt-v-fps').value, 10) || 0;
v.maxBitrateKbps = parseInt(document.getElementById('rt-v-maxbr').value, 10) || 0;
v.degradationPreference = document.getElementById('rt-v-degrade').value;
App.media.applyCamSendParams();
/* Resolution / framerate only take effect on a fresh getUserMedia call —
cycle the camera if it's currently on so the new constraints apply. */
const camChanged = v.width !== prevW || v.height !== prevH || v.frameRate !== prevFps;
const camOn = document.getElementById('tb-cam').classList.contains('on');
if (camChanged && camOn) {
App.log.info('media', 'restarting camera to apply new resolution/framerate');
await App.media.setCam(false);
await App.media.setCam(true);
}
});
withReentryGuard('rt-s-apply', async () => {
const s = App.state.settings.screen;
const prevW = s.width, prevH = s.height, prevFps = s.frameRate;
s.width = parseInt(document.getElementById('rt-s-w').value, 10) || 0;
s.height = parseInt(document.getElementById('rt-s-h').value, 10) || 0;
s.frameRate = parseInt(document.getElementById('rt-s-fps').value, 10) || 0;
s.maxBitrateKbps = parseInt(document.getElementById('rt-s-maxbr').value, 10) || 0;
s.degradationPreference = document.getElementById('rt-s-degrade').value;
App.media.applyScreenSendParams();
const dimsChanged = s.width !== prevW || s.height !== prevH || s.frameRate !== prevFps;
const screenOn = document.getElementById('tb-screen').classList.contains('on');
if (dimsChanged && screenOn) {
App.log.info('media', 'restarting screen share to apply new resolution/framerate');
await App.media.stopScreenshare();
try { await App.media.startScreenshare(); }
catch (e) { App.log.warn('media', 'restart screen failed', e.message); }
}
});
withReentryGuard('rt-codec-apply', async () => {
App.state.settings.sendVideoCodec = document.getElementById('rt-codec').value || 'auto';
/* Push the new codec to both video senders at once. */
App.media.applyCamSendParams();
App.media.applyScreenSendParams();
});
withReentryGuard('rt-a-apply', async () => {
const a = App.state.settings.audio;
const prevCh = a.channelCount, prevRate = a.sampleRate;
a.echoCancellation = document.getElementById('rt-a-aec').checked;
a.noiseSuppression = document.getElementById('rt-a-ns').checked;
a.autoGainControl = document.getElementById('rt-a-agc').checked;
a.channelCount = parseInt(document.getElementById('rt-a-channels').value, 10) || 1;
a.sampleRate = parseInt(document.getElementById('rt-a-rate').value, 10) || 0;
App.media.applyAudioConstraints();
const capChanged = a.channelCount !== prevCh || a.sampleRate !== prevRate;
const micOn = document.getElementById('tb-mic').classList.contains('on');
if (capChanged && micOn) {
App.log.info('media', 'restarting microphone to apply new channels/sample rate');
await App.media.setMic(false);
await App.media.setMic(true);
}
});
/* Stats export */
document.getElementById('stats-export').addEventListener('click', () => App.stats.exportAll());
setupConsole();
setupDevicePickers();
App.log.info('app', 'ready');
}
/* Close the peer connection, stop local media, and reset call-tied UI
state. Shared by hangup() (user-initiated, navigates away) and
onPeerHangup() (remote-initiated, stays on call view). */
function teardownConnection(opts) {
const sendBye = !opts || opts.sendBye !== false;
if (sendBye && App.chat && App.chat.sendBye) App.chat.sendBye();
/* If we're in the middle of auto-mode long-polling, cancel the fetch so
the user isn't stuck for up to 30 s after clicking Cancel/Hang up. */
if (App.state.signalAbort) { try { App.state.signalAbort.abort(); } catch (_) {} App.state.signalAbort = null; }
App.stats.stop();
try { if (App.state.dcChat) App.state.dcChat.close(); } catch (_) {}
try { if (App.state.dcFiles) App.state.dcFiles.close(); } catch (_) {}
try { if (App.state.pc) App.state.pc.close(); } catch (_) {}
try { if (App.state.pcB) App.state.pcB.close(); } catch (_) {}
if (App.state.localStream) App.state.localStream.getTracks().forEach(t => t.stop());
if (App.state.screenStream) App.state.screenStream.getTracks().forEach(t => t.stop());
if (App.state.iceWarmupStream) {
App.state.iceWarmupStream.getTracks().forEach(t => t.stop());
App.state.iceWarmupStream = null;
const wb = document.getElementById('ice-warmup');
const ws = document.getElementById('ice-warmup-status');
if (wb) wb.textContent = 'Enable LAN connectivity';
if (ws) { ws.textContent = 'off'; ws.className = 'pill'; }
}
App.state.pc = null; App.state.pcB = null;
App.state.dcChat = null; App.state.dcFiles = null;
App.state.localStream = null; App.state.screenStream = null;
App.state.remoteStream = null; App.state.remoteScreenStream = null;
App.state.peerMediaState = { mic: false, cam: false, screen: false };
App.state.micTransceiver = App.state.camTransceiver = App.state.screenTransceiver = null;
for (const id of ['vid-local-main', 'vid-local-pip', 'vid-remote-main', 'vid-remote-pip', 'audio-remote']) {
document.getElementById(id).srcObject = null;
}
for (const id of ['tile-local', 'tile-remote']) {
const tile = document.getElementById(id);
tile.classList.add('empty');
tile.classList.remove('screen');
}
document.querySelectorAll('.video-tile .pip').forEach(el => el.classList.add('hidden'));
/* Reset toolbar buttons back to the initial off state. */
for (const [id, label] of [['tb-mic', 'Mic off'], ['tb-cam', 'Cam off'], ['tb-screen', 'Screen off']]) {
const btn = document.getElementById(id);
btn.classList.remove('on');
btn.classList.add('off');
btn.querySelector('.nowrap').textContent = label;
}
updateConnPill();
}
/* Set the disabled state of the in-call controls that depend on an open
peer connection (toolbar media buttons, chat input, file picker). After
a peer hangup we leave the user on the call view so they can browse the
chat log and downloaded files, but everything that needs the channel is
disabled. */
function setInCallControlsEnabled(enabled) {
for (const id of ['tb-mic', 'tb-cam', 'tb-screen']) {
const btn = document.getElementById(id);
if (btn) btn.disabled = !enabled;
}
const chatInput = document.getElementById('chat-text');
const chatSend = document.getElementById('chat-send');
const filesDrop = document.getElementById('files-drop');
if (chatInput) chatInput.disabled = !enabled;
/* When re-enabling, leave chatSend to updateChatCounter (which gates on
byte count). When disabling, force it off. */
if (chatSend && !enabled) chatSend.disabled = true;
if (filesDrop) filesDrop.classList.toggle('disabled', !enabled);
}
function hangup(opts) {
App.log.info('app', 'hangup');
teardownConnection(opts);
App.state.role = null;
updateRoleBadge();
const dlg = document.getElementById('peer-left-dialog');
if (dlg && dlg.open) dlg.close();
showView('view-welcome');
}
/* Peer told us they're leaving via the chat data channel. Show a modal,
drop a chat-system breadcrumb, and tear down the connection — but keep
the user on the call view so they can still browse chat history and any
files that already finished transferring. */
function onPeerHangup() {
if (App.state.peerHungUp) return; /* idempotent — bye may arrive twice */
App.state.peerHungUp = true;
App.log.info('app', 'peer hung up');
if (App.chat && App.chat.appendSystem) App.chat.appendSystem('peer hung up');
teardownConnection({ sendBye: false });
setInCallControlsEnabled(false);
App.state.peerHungUp = false;
const dlg = document.getElementById('peer-left-dialog');
if (dlg && typeof dlg.showModal === 'function' && !dlg.open) {
try { dlg.showModal(); } catch (_) { /* already-open guard */ }
}
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', wire);
else wire();
</script>
</body>
</html>
Aserver/signal.c
@@ -0,0 +1,626 @@
/* signal.c — single-threaded WebRTC room-code signaling server.
*
* Endpoints (all bodies opaque to the server):
* POST /room/<code>/offer store offer, wake any waiters
* GET /room/<code>/offer return offer (long-poll up to 10 s, 204 on timeout)
* POST /room/<code>/answer store answer, wake any waiters
* GET /room/<code>/answer return answer (long-poll up to 10 s, 204 on timeout)
* OPTIONS * CORS preflight
*
* Build: cc -O2 signal.c -o signal
* Run: ./signal [port] (defaults to 8080)
*
* The whole server runs on one thread with poll(). Connections are state
* machines (READ → WAIT → WRITE → done). A waiting GET is parked with no
* IO interest until a matching POST flips it to WRITE. The long-poll
* timeout returns 204 No Content
*
* Portability: strict POSIX.1-2008
*/
#ifndef _POSIX_C_SOURCE
#define _POSIX_C_SOURCE 200809L
#endif
#include <stdio.h>
#include <stdlib.h>
#include <stdint.h>
#include <string.h>
#include <strings.h>
#include <unistd.h>
#include <fcntl.h>
#include <errno.h>
#include <limits.h>
#include <time.h>
#include <signal.h>
#include <poll.h>
#include <sys/socket.h>
#include <sys/resource.h>
#include <netinet/in.h>
#define MAX_ROOMS 1024
#define MAX_CONNS (MAX_ROOMS * 2) /* worst case: both peers parked on the same room */
#define MAX_BLOB (64 * 1024)
#define MAX_HEADER 4096
#define RBUF_SIZE (MAX_HEADER + MAX_BLOB)
#define ROOM_TTL_S 300
#define GC_INTERVAL_S 60
#define LONGPOLL_S 10
#define IDLE_TIMEOUT_S 15
#define CODE_MAX 15
#define METHOD_MAX 7
#define PATH_MAX_LEN 79
enum cstate {
C_FREE = 0,
C_READ, /* reading request headers + body */
C_WAIT, /* long-polling for a slot to fill */
C_WRITE, /* sending response */
};
struct conn {
int fd;
enum cstate state;
time_t deadline; /* absolute time after which this conn is cleaned up */
char *rbuf; /* allocated lazily on first read, freed on release */
size_t rlen;
size_t body_off; /* offset of body start in rbuf (0 = headers not parsed yet) */
size_t body_want; /* Content-Length */
char method[METHOD_MAX + 1];
char path[PATH_MAX_LEN + 1];
char *wbuf;
size_t wlen, woff;
int wait_room; /* index into rooms[] when C_WAIT, else -1 */
char wait_slot; /* 'o' or 'a' when C_WAIT */
};
struct room {
char code[CODE_MAX + 1];
char *offer; size_t offer_n;
char *answer; size_t answer_n;
time_t touched;
};
static struct conn conns[MAX_CONNS];
static struct room rooms[MAX_ROOMS];
static int server_fd = -1;
static volatile sig_atomic_t stop_flag = 0;
/* ------------------------------------------------------------------------ */
static void on_stop(int sig) { (void)sig; stop_flag = 1; }
/* Monotonic seconds — immune to NTP steps and manual wall-clock changes. */
static time_t monotonic_now(void) {
struct timespec ts;
clock_gettime(CLOCK_MONOTONIC, &ts);
return ts.tv_sec;
}
/* Case-insensitive substring search. Replaces the GNU strcasestr() so the
* file compiles under strict POSIX (-D_POSIX_C_SOURCE=200809L, no GNU). */
static char *ci_strstr(const char *hay, const char *needle) {
size_t nlen = strlen(needle);
if (nlen == 0) return (char *)hay;
for (; *hay; hay++) {
if (strncasecmp(hay, needle, nlen) == 0) return (char *)hay;
}
return NULL;
}
static const char *status_text(int s) {
switch (s) {
case 200: return "OK";
case 204: return "No Content";
case 400: return "Bad Request";
case 404: return "Not Found";
case 405: return "Method Not Allowed";
case 408: return "Request Timeout";
case 413: return "Payload Too Large";
case 414: return "URI Too Long";
case 503: return "Service Unavailable";
default: return "Error";
}
}
/* Returns 0 on success, -1 on failure. We bail rather than continue with a
* blocking socket: in the accept loop a blocking fd would freeze the server
* on the second accept after the kernel's queue is drained. */
static int set_nonblock(int fd) {
int f = fcntl(fd, F_GETFL, 0);
if (f < 0) return -1;
return fcntl(fd, F_SETFL, f | O_NONBLOCK);
}
static void set_cloexec(int fd) {
int f = fcntl(fd, F_GETFD, 0);
if (f >= 0) fcntl(fd, F_SETFD, f | FD_CLOEXEC);
}
/* ------------------------------------------------------------------ rooms */
/* Free both SDP blobs and clear the slot so room_get can reuse it. */
static void room_release(struct room *r) {
free(r->offer); r->offer = NULL; r->offer_n = 0;
free(r->answer); r->answer = NULL; r->answer_n = 0;
r->code[0] = 0;
}
/* Find a room by code, optionally creating it in an empty slot. Touched is
* bumped on every hit; expiry is handled separately by gc_rooms(). */
static struct room *room_get(const char *code, int create) {
time_t now = monotonic_now();
struct room *empty = NULL, *found = NULL;
for (int i = 0; i < MAX_ROOMS; i++) {
struct room *r = &rooms[i];
if (r->code[0] == 0) { if (!empty) empty = r; continue; }
if (strcmp(r->code, code) == 0) { found = r; break; }
}
if (!found && create && empty) {
memset(empty, 0, sizeof(*empty));
strncpy(empty->code, code, CODE_MAX);
found = empty;
}
if (found) found->touched = now;
return found;
}
/* Reclaim rooms that haven't been touched for ROOM_TTL_S. Called once per
* GC_INTERVAL_S from the main loop, independent of request traffic. */
static void gc_rooms(void) {
time_t now = monotonic_now();
for (int i = 0; i < MAX_ROOMS; i++) {
struct room *r = &rooms[i];
if (r->code[0] && now - r->touched > ROOM_TTL_S) room_release(r);
}
}
static int room_index(struct room *r) { return r ? (int)(r - rooms) : -1; }
/* ------------------------------------------------------------ connections */
static void conn_release(struct conn *c) {
if (c->fd >= 0) close(c->fd);
free(c->rbuf);
free(c->wbuf);
memset(c, 0, sizeof(*c));
c->fd = -1;
c->wait_room = -1;
}
static struct conn *conn_alloc(int fd) {
for (int i = 0; i < MAX_CONNS; i++) {
struct conn *c = &conns[i];
if (c->state == C_FREE && c->fd <= 0) {
memset(c, 0, sizeof(*c));
c->fd = fd;
c->state = C_READ;
c->deadline = monotonic_now() + IDLE_TIMEOUT_S;
c->wait_room = -1;
return c;
}
}
return NULL;
}
/* Stage a response on the connection (the body is copied into a freshly
* allocated send buffer). Moves the conn to C_WRITE. */
static void respond(struct conn *c, int status, const char *ctype,
const void *body, size_t body_n) {
char hdr[320];
int hn = snprintf(hdr, sizeof(hdr),
"HTTP/1.1 %d %s\r\n"
"Content-Type: %s\r\n"
"Content-Length: %zu\r\n"
"Access-Control-Allow-Origin: *\r\n"
"Access-Control-Allow-Methods: GET, POST, OPTIONS\r\n"
"Access-Control-Allow-Headers: Content-Type\r\n"
"Cache-Control: no-store\r\n"
"Connection: close\r\n\r\n",
status, status_text(status), ctype, body_n);
char *buf = malloc(hn + body_n);
if (!buf) { conn_release(c); return; }
memcpy(buf, hdr, hn);
if (body_n) memcpy(buf + hn, body, body_n);
free(c->wbuf);
c->wbuf = buf;
c->wlen = hn + body_n;
c->woff = 0;
c->state = C_WRITE;
c->deadline = monotonic_now() + IDLE_TIMEOUT_S;
c->wait_room = -1;
}
/* Match-and-wake all C_WAIT connections that are parked on this room/slot.
* If the answer was just delivered to at least one waiter, the handshake is
* complete — drop the room. respond() has already copied `blob` into each
* waker's write buffer by then, so freeing the room's answer is safe. If
* nobody was parked, the blob stays so a later GET can still pick it up
* (and that GET path releases the room itself). */
static void wake_waiters(int ri, char slot, const void *blob, size_t n) {
int woke = 0;
for (int i = 0; i < MAX_CONNS; i++) {
struct conn *w = &conns[i];
if (w->state == C_WAIT && w->wait_room == ri && w->wait_slot == slot) {
respond(w, 200, "application/sdp", blob, n);
woke = 1;
}
}
if (slot == 'a' && woke) room_release(&rooms[ri]);
}
/* ------------------------------------------------------------- dispatch */
/* Called once headers + body are fully buffered. */
static void dispatch(struct conn *c) {
if (strcmp(c->method, "OPTIONS") == 0) {
respond(c, 204, "text/plain", "", 0);
return;
}
/* Liveness probe — clients hit this to confirm the relay is reachable
* before committing to the full handshake. Cheap: no allocation, no
* room access. */
if (strcmp(c->path, "/health") == 0) {
if (strcmp(c->method, "GET") != 0 && strcmp(c->method, "HEAD") != 0) {
respond(c, 405, "text/plain", "no", 2);
return;
}
respond(c, 200, "text/plain", "ok\n", 3);
return;
}
char code[CODE_MAX + 1] = {0};
char slot[8] = {0};
if (sscanf(c->path, "/room/%15[A-Za-z0-9_-]/%7[a-z]", code, slot) != 2) {
respond(c, 404, "text/plain", "no", 2);
return;
}
int is_offer = !strcmp(slot, "offer");
int is_answer = !strcmp(slot, "answer");
if (!is_offer && !is_answer) {
respond(c, 404, "text/plain", "no", 2);
return;
}
char slot_ch = is_offer ? 'o' : 'a';
if (!strcmp(c->method, "POST")) {
struct room *r = room_get(code, 1);
if (!r) { respond(c, 503, "text/plain", "full", 4); return; }
char **blob = is_offer ? &r->offer : &r->answer;
size_t *blen = is_offer ? &r->offer_n : &r->answer_n;
/* malloc(0) is implementation-defined; round up to 1 byte so an
* empty body POST doesn't masquerade as OOM. */
size_t alloc_n = c->body_want ? c->body_want : 1;
char *copy = malloc(alloc_n);
if (!copy) { respond(c, 503, "text/plain", "oom", 3); return; }
if (c->body_want) memcpy(copy, c->rbuf + c->body_off, c->body_want);
free(*blob);
*blob = copy;
*blen = c->body_want;
wake_waiters(room_index(r), slot_ch, *blob, *blen);
respond(c, 204, "text/plain", "", 0);
return;
}
if (!strcmp(c->method, "GET")) {
struct room *r = room_get(code, 1);
if (!r) { respond(c, 503, "text/plain", "full", 4); return; }
char *blob = is_offer ? r->offer : r->answer;
size_t blen = is_offer ? r->offer_n : r->answer_n;
if (blob) {
respond(c, 200, "application/sdp", blob, blen);
/* Answer delivery completes the SDP exchange — peers go P2P now,
* so the relay can drop the room. The offer slot stays in case
* the joiner needs to refetch after a crash/retry. */
if (is_answer) room_release(r);
return;
}
/* Park the connection: no IO interest, just sit on the deadline. */
c->state = C_WAIT;
c->wait_room = room_index(r);
c->wait_slot = slot_ch;
c->deadline = monotonic_now() + LONGPOLL_S;
return;
}
respond(c, 405, "text/plain", "no", 2);
}
/* ------------------------------------------------------------- IO drivers */
/* Parse "METHOD SP PATH SP HTTP/x.y" without mutating rbuf — the subsequent
* Content-Length scan needs the header block intact. Returns 0 on success,
* 400 on malformed, 414 on field overflow. */
static int parse_request_line(struct conn *c, const char *hdr_end) {
const char *p = c->rbuf;
const char *sp1 = memchr(p, ' ', (size_t)(hdr_end - p));
if (!sp1) return 400;
size_t mlen = (size_t)(sp1 - p);
if (mlen > METHOD_MAX) return 414;
memcpy(c->method, p, mlen);
c->method[mlen] = 0;
p = sp1 + 1;
const char *sp2 = memchr(p, ' ', (size_t)(hdr_end - p));
if (!sp2) return 400;
size_t plen = (size_t)(sp2 - p);
if (plen > PATH_MAX_LEN) return 414;
memcpy(c->path, p, plen);
c->path[plen] = 0;
return 0;
}
static void on_readable(struct conn *c) {
if (!c->rbuf) {
c->rbuf = malloc(RBUF_SIZE);
if (!c->rbuf) { conn_release(c); return; }
}
for (;;) {
size_t cap = RBUF_SIZE - c->rlen;
if (cap == 0) { conn_release(c); return; }
ssize_t n = recv(c->fd, c->rbuf + c->rlen, cap, 0);
if (n == 0) { conn_release(c); return; }
if (n < 0) {
if (errno == EAGAIN || errno == EWOULDBLOCK) return;
conn_release(c); return;
}
c->rlen += n;
c->deadline = monotonic_now() + IDLE_TIMEOUT_S;
/* Parse headers once \r\n\r\n appears within the first MAX_HEADER bytes. */
if (c->body_off == 0) {
size_t scan = c->rlen < MAX_HEADER ? c->rlen : MAX_HEADER;
char *eoh = NULL;
for (size_t i = 0; i + 3 < scan; i++) {
if (c->rbuf[i] == '\r' && c->rbuf[i+1] == '\n' &&
c->rbuf[i+2] == '\r' && c->rbuf[i+3] == '\n') { eoh = c->rbuf + i; break; }
}
if (!eoh) {
if (c->rlen >= MAX_HEADER) respond(c, 413, "text/plain", "header too large", 16);
return;
}
*eoh = 0; /* terminate header block for ci_strstr */
c->body_off = (size_t)(eoh - c->rbuf) + 4;
int err = parse_request_line(c, eoh);
if (err == 400) { respond(c, 400, "text/plain", "bad request", 11); return; }
if (err == 414) { respond(c, 414, "text/plain", "uri too long", 12); return; }
/* Parse Content-Length line-anchored to avoid matching header
* names that merely *contain* "Content-Length:" as a substring
* (e.g. X-Original-Content-Length). RFC 7230 §3.3.2 requires
* rejecting duplicate Content-Length headers — important for
* direct-bind deployments not fronted by a normalizing proxy. */
int cl_seen = 0;
const char *needle = "\nContent-Length:";
const size_t needle_n = 16;
for (char *p = c->rbuf; (p = ci_strstr(p, needle)) != NULL; p += needle_n) {
if (cl_seen++) {
respond(c, 400, "text/plain", "duplicate content-length", 24);
return;
}
const char *v = p + needle_n;
while (*v == ' ' || *v == '\t') v++;
if (*v == '-' || *v == '+') {
respond(c, 400, "text/plain", "bad content-length", 18);
return;
}
char *end;
errno = 0;
unsigned long len = strtoul(v, &end, 10);
if (end == v || errno == ERANGE) {
respond(c, 400, "text/plain", "bad content-length", 18);
return;
}
c->body_want = len;
}
if (c->body_want > MAX_BLOB) {
respond(c, 413, "text/plain", "body too large", 14); return;
}
}
if (c->rlen - c->body_off >= c->body_want) { dispatch(c); return; }
/* else loop and read more */
}
}
static void on_writable(struct conn *c) {
while (c->woff < c->wlen) {
/* SIGPIPE is suppressed via sigaction(SIGPIPE, SIG_IGN), so plain
* send() is enough — no need for the non-portable MSG_NOSIGNAL. */
ssize_t n = send(c->fd, c->wbuf + c->woff, c->wlen - c->woff, 0);
if (n < 0) {
if (errno == EAGAIN || errno == EWOULDBLOCK) return;
conn_release(c); return;
}
c->woff += n;
}
conn_release(c);
}
static void accept_new(void) {
for (;;) {
int cfd = accept(server_fd, NULL, NULL);
if (cfd < 0) {
if (errno == EAGAIN || errno == EWOULDBLOCK) return;
return;
}
set_cloexec(cfd);
if (set_nonblock(cfd) < 0 || !conn_alloc(cfd)) close(cfd); /* full or fcntl failed */
}
}
/* ------------------------------------------------------------------ main */
int main(int argc, char **argv) {
int port = 8080;
if (argc > 1) {
char *end;
errno = 0;
long lport = strtol(argv[1], &end, 10);
if (errno || *end || lport < 1 || lport > 65535) {
fprintf(stderr, "bad port: %s\n", argv[1]);
return 1;
}
port = (int)lport;
}
/* sigaction() has portable, well-defined semantics across POSIX systems;
* signal()'s behavior is historically split SysV/BSD. */
struct sigaction sa;
memset(&sa, 0, sizeof(sa));
sa.sa_handler = SIG_IGN;
sigaction(SIGPIPE, &sa, NULL);
sa.sa_handler = on_stop;
sigaction(SIGINT, &sa, NULL);
sigaction(SIGTERM, &sa, NULL);
/* Raise RLIMIT_NOFILE up to what MAX_CONNS needs (plus a few for stdio /
* the listen socket). If the hard cap is below that, warn — accepts will
* silently fail at the kernel layer otherwise. */
struct rlimit rl;
if (getrlimit(RLIMIT_NOFILE, &rl) == 0) {
rlim_t need = (rlim_t)MAX_CONNS + 16;
if (rl.rlim_cur < need) {
rl.rlim_cur = rl.rlim_max < need ? rl.rlim_max : need;
setrlimit(RLIMIT_NOFILE, &rl);
if (rl.rlim_cur < need) {
fprintf(stderr, "warning: nofile soft limit %lu < %lu — capacity reduced\n",
(unsigned long)rl.rlim_cur, (unsigned long)need);
}
}
}
server_fd = socket(AF_INET, SOCK_STREAM, 0);
if (server_fd < 0) { perror("socket"); return 1; }
set_cloexec(server_fd);
int one = 1;
if (setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one)) < 0) {
perror("setsockopt SO_REUSEADDR"); /* non-fatal */
}
struct sockaddr_in addr = {
.sin_family = AF_INET,
.sin_addr.s_addr = INADDR_ANY,
.sin_port = htons((uint16_t)port)
};
if (bind(server_fd, (struct sockaddr *)&addr, sizeof(addr)) ||
listen(server_fd, 64)) {
perror("bind/listen"); return 1;
}
/* Flip the listener non-blocking only just before entering the accept
* loop — bind/listen don't care, and this keeps the setup order
* conventional. */
if (set_nonblock(server_fd) < 0) { perror("fcntl"); return 1; }
fprintf(stderr, "signaling server listening on :%d\n", port);
for (int i = 0; i < MAX_CONNS; i++) { conns[i].fd = -1; conns[i].wait_room = -1; }
time_t last_gc = monotonic_now();
struct pollfd pfds[MAX_CONNS + 1];
int pidx[MAX_CONNS + 1]; /* maps pfd index → conns[] index */
while (!stop_flag) {
time_t now = monotonic_now();
int nfd = 0;
int timeout_ms = 1000;
pfds[nfd++] = (struct pollfd){ .fd = server_fd, .events = POLLIN };
/* Single sweep over the conn table: fire expired deadlines, build
* the pfds array, and compute the poll timeout in one pass. */
for (int i = 0; i < MAX_CONNS; i++) {
struct conn *c = &conns[i];
if (c->state == C_FREE || c->fd < 0) continue;
if (c->deadline <= now) {
/* C_WAIT → 204 No Content (not 408: Firefox auto-retries 408
* internally per RFC 7231 §6.5.7 and only surfaces a single
* fetch attempt to JS, eventually giving up with a
* CORS-adjacent NetworkError. 204 carries no retry semantics). */
if (c->state == C_WAIT) {
respond(c, 204, "text/plain", "", 0);
/* respond() moved this conn to C_WRITE with a fresh
* deadline; fall through to register POLLOUT. */
} else {
conn_release(c);
continue;
}
}
short ev = 0;
switch (c->state) {
case C_READ: ev = POLLIN; break;
case C_WRITE: ev = POLLOUT; break;
/* WAIT: register POLLIN so a client disconnect (read-side EOF)
* surfaces as a poll event; we never actually read while parked. */
case C_WAIT: ev = POLLIN; break;
default: continue;
}
pfds[nfd] = (struct pollfd){ .fd = c->fd, .events = ev };
pidx[nfd] = i;
nfd++;
long dt = (long)(c->deadline - now) * 1000;
if (dt < 0) dt = 0;
if (dt < timeout_ms) timeout_ms = (int)dt;
}
int rc = poll(pfds, nfd, timeout_ms);
if (rc < 0) {
if (errno == EINTR) continue;
perror("poll"); break;
}
if (pfds[0].revents & POLLIN) accept_new();
for (int k = 1; k < nfd; k++) {
short rev = pfds[k].revents;
if (!rev) continue;
struct conn *c = &conns[pidx[k]];
/* The conn may have been released this iteration (e.g. via wake);
* stale entries are no-ops since fd would be -1. */
if (c->fd != pfds[k].fd) continue;
if (c->state == C_WAIT) {
/* We set Connection: close on every response, so a parked
* GET should send nothing further until the next request.
* Any POLLIN means either the client closed (recv == 0) or
* it pipelined / sent garbage — drop the conn either way. */
if (rev & (POLLIN | POLLHUP | POLLERR | POLLNVAL)) conn_release(c);
continue;
}
if (rev & (POLLERR | POLLNVAL)) { conn_release(c); continue; }
if (c->state == C_READ && (rev & POLLIN)) on_readable(c);
else if (c->state == C_WRITE && (rev & POLLOUT)) on_writable(c);
else if (rev & POLLHUP) conn_release(c);
}
/* Periodic room GC. The poll timeout is clamped to 1 s so this fires
* on a wall-clock cadence even when the server is otherwise idle. */
time_t tick = monotonic_now();
if (tick - last_gc >= GC_INTERVAL_S) { gc_rooms(); last_gc = tick; }
}
fprintf(stderr, "signaling server shutting down\n");
if (server_fd >= 0) close(server_fd);
for (int i = 0; i < MAX_CONNS; i++) {
if (conns[i].fd >= 0) conn_release(&conns[i]);
}
for (int i = 0; i < MAX_ROOMS; i++) {
if (rooms[i].code[0]) room_release(&rooms[i]);
}
return 0;
}