Nimbin[12]?Web & Tools / Browser / commits / fa68184

Browser git · main

Minimal browser in C (mini and big variant)

browser c · first commit 2026-08-25 · last commit 2026-10-05 (4 days ago) · synced 3 days ago · upstream: github.com/nimbin2/Browser

C 95.8% Markdown 3.9%
git clone https://git.christianimmanuel.de/web-tools/Browser.gitwget https://git.christianimmanuel.de/web-tools/Browser/archive/Browser.tar.gz

Webcam stream is working (patch webkit and gst...)

Christian Immanuel · 2026-10-05 09:49 · fa68184a5f50e964d337f17435d06d4da08dd30a

 HANDOVER.md                          |  393 +++++-----
 Makefile                             |    2 +-
 README.md                            | 1272 ++++---------------------------
 browser-big.c                        | 1365 +++++++++++++++++++++++-----------
 browser_core.c                       |  372 +++++++--
 browser_core.h                       |   11 +-
 gst-glupload-null-meta.patch         |   15 +
 webkit-caps-normalize.patch          |   16 +
 webkit-videorate-skip-to-first.patch |   13 +
 9 files changed, 1674 insertions(+), 1785 deletions(-)

diff --git a/HANDOVER.md b/HANDOVER.md
index 2de5b08..ad6a5aa 100644
--- a/HANDOVER.md
+++ b/HANDOVER.md
@@ -1,170 +1,223 @@
-# browser-mini / browser-big: handover notes
-
-State at **4.18.0 (build 4bbf154)**. Written for a fresh chat whose job is
-to clean the code up. Read this first, then the README.
-
-## 1. The project
-
-| File | Lines | What |
-| --- | --- | --- |
-| `browser_core.c` | ~7800 | window, popups, keys, history, downloads, search, media mode, config, watchdogs |
-| `browser_core.h` | ~180 | `Win` struct, `BrowserApp` hooks, `LOG` macro |
-| `browser-big.c` | ~2660 | camera / GStreamer / WebRTC layer on top of the core |
-| `browser-mini.c` | 22 | fills in `BrowserApp`, calls `browser_main()` |
-| `Makefile` | | `PREFIX ?= /usr`, `install`, `uninstall`, `clean`, `version` |
-
-- WebKitGTK 6.0 (GTK4) only. `make` builds both binaries.
-- Build id = md5 of the sources (`make version`).
-- The user's preferences apply: md5 build versioning reported at the end of
-  each reply, Makefile defaults to `/usr/bin` with `clean` and `uninstall`,
-  a short README branded "100% Vibecode but tested", no Ubuntu in the README,
-  no extra files unless asked, minimal testing.
-
-## 2. The user's system (verified in the logs)
-
-- Linux From Scratch, pkgusr package users, **no systemd**, native Wayland,
-  plain ALSA (no PulseAudio/PipeWire running), no desktop portal,
-  `XDG_RUNTIME_DIR=/tmp/xdg-n76310` (mode 042770).
-- Lenovo 20UD (AMD Renoir): Mesa radeonsi, RADV Vulkan, VA-API H.264/HEVC
-  work; firmware complete.
-- **WebKitGTK 2.52.5** (upgraded from 2.50.5 during this work),
-  GTK 4.20.3, GLib 2.88.3, GStreamer 1.28.6. 2.52 defaults to librice
-  instead of libnice for ICE.
-- WebKit build flags: `USE_GSTREAMER_WEBRTC=ON` (the **only** WebRTC backend
-  the GTK port has; OFF means no WebRTC at all), `USE_LIBRICE=ON`,
-  `ENABLE_WEB_RTC=ON`, `ENABLE_MEDIA_STREAM=ON`, no bubblewrap sandbox.
-  The pkgusr script had `USE_GTK4=OFF`, which builds the wrong library for
-  these browsers; it must be `ON`.
-- Camera `/dev/video1`: MJPEG up to 1280x720@30, raw YUY2/DMA_DRM 1280x720
-  only @10. Plain `gst-launch v4l2src` starts instantly (60 frames in 2.3 s).
-- gdb is **not** installed, and `wchan` reads 0 for every thread on this
-  kernel. Neither can be used for thread dumps.
-- The user's wrapper `su - user -c 'cmd "$@"' -- "$@"` ate the first
-  argument (it becomes `$0`). Fixed by `-- browser-mini "$@"`.
-
-## 3. Done, and working
-
-**Core**
-- `-h` answered after the config is read, so it prints effective values.
-- Ctrl+K rows no longer load search templates as URLs.
-- Popups unified: things you type into are top centre, messages top right,
-  the address bottom left. Non-interactive panels fade on hover.
-- Quiet config: unknown keys in swov's shared file are counted, not listed.
-- Camera/mic permission prompt (Enter = allow, Esc = deny), remembered per
-  site in `permissions.tsv`; `media = ask|allow|deny`; `--forget-perms`.
-- Find-in-page no longer freezes big pages: 1000-match cap, 120 ms
-  debounce, one `search()` pass.
-- Fatal messages survive `abort()`: a signal handler drains the log pipe;
-  backtraces via `-rdynamic`.
-- Watchdogs: web process spinning (every 5 s; from 4.15 with timestamps,
-  a "calm again after Ns" line, and a GStreamer-vs-JIT hint), UI main loop
-  blocked, and a missing Wayland frame callback.
-- `--gsk` defaults to `gl` (GTK's Vulkan renderer deadlocked on resize).
-- `--paths` (4.12+) lists every file and directory written, including the
-  download rules, search keywords (with built-ins), start page colour and
-  media temp dir.
-- Start page: flat colour, with a toggleable swatch palette at the top;
-  the choice is saved in `~/.local/share/wkview/start-bg`.
-- Ctrl+P shows the whole URL, wrapped, and toggles (4.13).
-- Media mode (4.16/4.17): F2 toggles it and a frame shows it. Ctrl+click
-  sends a video to `player` (mpv) and an image to `image_viewer` (imv);
-  images are downloaded with libsoup first. `--player` on the command line
-  starts in the mode.
-- Built-in search keywords (4.18): g Google (default), d DuckDuckGo,
-  w Wikipedia, s SDL3 wiki via DuckDuckGo. The user's own keywords win by
-  name.
-
-**browser-big**
-- Media workarounds are opt-in only (the user's rule: clean by default).
-- 4.14 fix: the tracing and compat flags (`--rtc-trace`, `--web-compat`,
-  `--no-mic`, offer fixes) used to silently turn on camera *cloning and
-  holding*. On 2.52 that froze the page's camera after ~1 s. Now only
-  `--cam-fix`, `--cam-share`, `--fix-media` and `cam_share=yes` touch the
-  camera; the log's `shim` line says `repair:true|false`.
-- `--list-cameras`, `--media-debug`, `--gl-info`, `--list-features`,
-  `--feature NAME[=on|off]` all verified useful.
-
-## 4. Root causes found
-
-| Symptom | Cause | Status |
-| --- | --- | --- |
-| WebRTC never connects, `createAnswer` never settles | WebKitGTK **2.50.5** bug: `create-answer` never emitted to webrtcbin (log proves `_create_answer_task` absent, zero warnings) | **fixed by 2.52.5**, pc1 sample works |
-| pc1 camera freezes after 1 s on 2.52 | **our** shim cloned the track (see 4.14) | fixed |
-| Zoom crash: `Trying to dispose element … video-frame-converter-gl … PAUSED`, segfaults in libc/libgstbase | WebKit 2.52 use-after-free in its GL video frame converter | avoided with `--feature WebCodecsVideo=off` |
-| Zoom still crashes with WebCodecs off: camera freezes, Zoom releases it, the process dies on teardown | WebKit capture teardown bug (heap corruption, `free(): invalid pointer`) | **open** |
-| 20–30 s frozen preview at 400 % CPU (`queue*:src`, `multiqueue*:src`) | `v4l2src` blocked downstream for ~28 s, then "Timestamp does not correlate with any clock". Seen in `-n` runs, where playback went to a **PulseAudio** sink with clock-skew warnings (libpulse autospawn is suspected) | **open**: the run with `audio = alsa` was never reported |
-| `--cam-exact 640x480@30` ignored, 1280x720 returned with no error | WebKit 2.52 ignores exact capture constraints | WebKit bug; nothing to do |
-| `screen.orientation` missing | WebKitGTK does not implement it; Zoom throws | `--web-compat` supplies it |
-| GoDaddy login refused (`fp` → 429) | bot detection; changing the UA did not help | not ours; use another browser |
-| TTY switch aborts every browser | GTK Wayland dispatch → WebKit abort | not ours |
-
-## 5. Tried, and wrong or useless (do not repeat)
-
-- A "stale binary" theory for the argument bug: it was the `su` wrapper.
-- `--audio-alsa` aimed at the device provider: `GST_PLUGIN_FEATURE_RANK`
-  does not rank device providers. Retargeted at the elements.
-- Requiring a `nicesrc` element: wrong. The real check builds a webrtcbin
-  and queries its `ice-agent`.
-- "gst-debug compiled out", claimed twice: wrong both times. Use the
-  `GST_DISABLE_GST_DEBUG` macro, not grep or `gst_debug_is_active()`.
-- `dbus-launch`: caused a 61 s hang (a bus with no portal behind it).
-- The GPU driver theory: an all-software run froze the same way.
-- `--fix-webrtc` does not enable tracing (misread once as a regression).
-- "Switch to the libwebrtc backend": that option does not exist for GTK.
-- `WebCodecsVideoEnabled`: wrong feature name; it is `WebCodecsVideo`.
-- `--cam-force` (ideal) and `--cam-exact`: ignored by WebKit 2.52.
-- `--no-hw-decode` and `--no-gpu`: no help with the freeze or the crash.
-- `wchan` thread dumps: always 0 on this kernel.
-- GStreamer's log has colour codes when redirected; strip them with
-  `sed -E 's/\x1b\[[0-9;]*m//g'` before grepping.
-
-## 6. Cleanup candidates for the next chat
-
-**Likely obsolete on 2.52**, so check each and remove what is dead:
-- `--sdp-ssrc-fix`, `--rtc-params-fix`, `--fix-webrtc`: written against
-  2.50 behaviour, before the real bug (the `createAnswer` hang) was known.
-- The camera repair shim: `--cam-fix`, `--cam-share`, relax/retry/keepalive/
-  hold/drop-audio, `--cam-scale*`, `--cam-force`, `--cam-exact`,
-  `--cam-match`, `--cam-retries`, `--cam-hold`. Cloning is actively harmful
-  on 2.52, and exact constraints are ignored.
-- `--prewarm`, `--warm-cam`, `--media-watchdog`, `--auto-reload`,
-  `--load-timeout`, `--max-reloads`, `--stall-timeout`: never shown to help.
-- `--no-mic`: its diagnostic question has been answered.
-
-**Keep:** `--rtc-trace`, `--media-debug`, `--list-cameras`, `--gst-*`,
-`--web-compat` (screen.orientation), `--feature`, `--list-features`,
-`--gl-info`, the watchdogs, `--paths`, the permission prompt, media mode,
-search keywords, `--audio-alsa` / `audio = alsa`.
-
-**Code smells worth a pass:**
-- `browser_core.c` is ~7800 lines in one file. Natural splits: config,
-  history, downloads, search, popup/omni, media mode, watchdogs, start page.
-- `LOG` lines are inconsistent: only browser-big's `mlog` and the watchdog
-  carry timestamps.
-- The shim JS is one large C string in `browser-big.c`; it could be a
-  separate `.js` embedded at build time.
-- The README is ~1200 lines, with long debugging narratives from the 2.50
-  era; trim it to what is still true on 2.52.
-
-## 7. Open questions to settle first
-
-1. pc1 **with** the config (`audio = alsa`, no `-n`): is the 20–30 s freeze
-   gone? If yes, the PulseAudio sink clock was the cause, and browser-big
-   should force ALSA or warn when `autoaudiosink` picks pulse.
-2. `command -v pulseaudio`: is libpulse autospawning a daemon?
-3. Zoom with `--web-compat --feature WebCodecsVideo=off`, then
-   `dmesg | grep -iE 'segfault|WebKitWeb'`: which library dies now?
-4. arte.tv videos fail in both browsers. The decoder check and the three
-   test pages were proposed but never run (see the last messages: check
-   for `avdec_aac`/`faad`/`fdkaacdec`, `avdec_h264`/`vah264dec`,
-   `qtdemux`, `h264parse`, `aacparse`).
-5. A gdb build would give real backtraces for the WebKit crash reports.
-
-## 8. Useful test pages
-
-- WebRTC loopback: `https://webrtc.github.io/samples/src/content/peerconnection/pc1/`
-- No camera needed: `…/datachannel/basic/` and `…/capture/canvas-pc/`
-- Camera only: `…/getusermedia/gum/`
-- Zoom test meeting: `zoom.us/test`
-- Plain MP4: `https://www.w3schools.com/html/mov_bbb.mp4`
-- MSE/HLS: `https://hlsjs.video-dev.org/demo/`
+# Handover
+
+The state of browser-mini / browser-big at 4.39.0, for whoever picks it up
+next. The short version: video calls work on WebKitGTK 2.54.0 with
+GStreamer 1.28.7, given three upstream patches and the defaults in
+browser-big.
+
+## Test setup
+
+- ThinkPad T14 Gen1 (AMD). Self-built WebKitGTK 2.54.0, GStreamer 1.28.7,
+  GTK 4.20.3. No sound server (`audio = alsa`).
+- Site: a mediasoup-based chat (mediasoup-client, `Safari12` handler,
+  VP8-only router, socket.io signalling).
+- Reference: the WebRTC samples, `peerconnection/pc1` for a call without a
+  server.
+
+## Upstream bugs (need patching, not configuration)
+
+### 1. WebKit: camera frame flood (WebKit PR 74373, open)
+
+`GStreamerVideoCapturer::createConverter` sets `drop-only` on `videorate`
+only for GStreamer < 1.28. The capture pipeline runs with base time 0, so
+buffer PTS are absolute CLOCK_MONOTONIC values, and `videorate` fills the
+gap from 0 with uptime x fps copies. The result is a frozen or black camera,
+400+ encoded frames per second, and a pinned CPU. It also affects 2.52.
+
+```
+--- a/Source/WebCore/platform/mediastream/gstreamer/GStreamerVideoCapturer.cpp
++++ b/Source/WebCore/platform/mediastream/gstreamer/GStreamerVideoCapturer.cpp
+@@ -135,6 +135,10 @@
+ 
+     auto* bin = gst_bin_new(nullptr);
+     auto* videorate = makeGStreamerElement("videorate"_s, "videorate"_s);
++    // The capture pipeline runs with base time 0, so buffer PTS are absolute
++    // CLOCK_MONOTONIC values. Without skip-to-first, videorate fills the gap
++    // from segment start with uptime x fps duplicate frames (WebKit PR 74373).
++    g_object_set(videorate, "skip-to-first", TRUE, nullptr);
+ 
+     // The workaround below doesn't seem necessary anymore in GStreamer 1.28 and beyond.
+     // Fixed by: https://gitlab.freedesktop.org/gstreamer/gstreamer/-/commit/6f623af4d745efaacd0c8639b99536def4a65c78
+```
+
+Check: `pc1` encodes about 90 frames per 3 s report (30 fps).
+
+### 2. GStreamer 1.28: glupload NULL video meta (fixed on main)
+
+`_dma_buf_upload_accept` in `gst-libs/gst/gl/gstglupload.c` does
+`out_info->width = meta->width` without checking `meta`. It runs on a format
+change with a dma-buf buffer that has no video meta, which is exactly the
+site's second `getUserMedia`. The web process crashes (SIGSEGV on the
+`vqueue:src` thread). 1.26 had the check.
+
+```
+--- a/gst-libs/gst/gl/gstglupload.c
++++ b/gst-libs/gst/gl/gstglupload.c
+@@ -1695,8 +1695,10 @@
+      * matches the size we use to import the dmabuf. @outcaps will remains
+      * display resolution as expected.
+      */
+-    out_info->width = meta->width;
+-    out_info->height = meta->height;
++    if (meta) {
++      out_info->width = meta->width;
++      out_info->height = meta->height;
++    }
+ 
+     /*
+      * When we zero-copy tiles, we need to propagate the strides, which contains
+```
+
+Without the patch: `WEBKIT_GST_DISABLE_GL_SINK=1`.
+
+### 3. WebKit: camera sizes given as a list are skipped
+
+`GStreamerVideoCaptureSource::generatePresets` reads each caps structure
+with `gst_structure_get(..., "width", G_TYPE_INT, ..., "height",
+G_TYPE_INT, ...)` and skips it when that fails. V4L2 lists some modes as a
+size list in one structure. The T14 camera (`--list-cameras`):
+
+```
+640 x { (int)480, (int)360 }   30/1
+320 x { (int)240, (int)180 }   30/1
+```
+
+These are all of its 4:3 modes, in every format (DMA_DRM, MJPEG, YUY2).
+WebKit had no 4:3 preset, so `{max: 20}` gave 848x480@20 and no frame rate
+gave 1280x720@10. `gst_caps_normalize` splits them into discrete
+structures (checked with the local GStreamer: 640x480, 640x360, 320x240,
+320x180).
+
+```
+--- a/Source/WebCore/platform/mediastream/gstreamer/GStreamerVideoCaptureSource.cpp
++++ b/Source/WebCore/platform/mediastream/gstreamer/GStreamerVideoCaptureSource.cpp
+@@ -298,7 +298,13 @@
+ void GStreamerVideoCaptureSource::generatePresets()
+ {
+     Vector<VideoPreset> presets;
++    // A V4L2 device may list several sizes in one structure, for instance
++    // width=640, height={ 480, 360 }. Split them into one structure each,
++    // or every size listed that way is skipped below as not discrete - on
++    // a typical laptop camera that is every 4:3 mode.
+     auto caps = m_capturer->caps();
++    if (caps)
++        caps = adoptGRef(gst_caps_normalize(gst_caps_copy(caps.get())));
+     for (unsigned i = 0; i < gst_caps_get_size(caps.get()); i++) {
+         GstStructure* str = gst_caps_get_structure(caps.get(), i);
+```
+
+Check: `--rtc-trace` shows the video track at 320x240, aspect 1.333.
+
+All three patches are needed again for each new WebKit or GStreamer until
+upstream ships them.
+
+## WebKit behaviour browser-big works around (all default)
+
+Each item was found in the WebKit 2.52.6 / 2.54.0 sources and confirmed by
+a run.
+
+1. **librice finds no srflx candidate.** `RiceBackend::resolveAddress`
+   keeps only the first DNS answer (upstream FIXME), and the STUN address is
+   built as `host:port` without IPv6 brackets. Measured: librice host-only,
+   libnice srflx in 0.11 s. Default is libnice
+   (`WEBKIT_GST_DISABLE_WEBRTC_NETWORK_SANDBOX=1`); `ice = rice` undoes it.
+2. **Encoder fixed to the first codec of the own offer.**
+   `doSetLocalDescription` -> `linkOutgoingSources` ->
+   `configurePacketizers` links the first codec it can encode. The answer
+   is never consulted, and `codecPreferencesChanged` refuses once the bin
+   runs. When a track is added to an existing connection (renegotiation),
+   the source is configured at `addTransceiver` time, so only
+   `setCodecPreferences` switches it.
+   - Fix: `setCodecPreferences(VP8 first)` on transceivers with a video
+     sender track, plus VP8 moved first in the SDP (`sdpPreferCodecs`).
+   - The mediasoup probe pc (no tracks) is left alone; reordering it broke
+     mediasoup's device caps.
+3. **No `a=ssrc` lines, and the real SSRC is unknown until connected.**
+   mediasoup-client reads the SSRC from `pc.localDescription` after SLD and
+   registers the producer under it. A mismatch drops every packet.
+   - Fix: inject a placeholder SSRC (`sdpAddSsrc`). After SLD, learn the
+     real one from `sender.getStats()` in the background (outbound-rtp, up
+     to 15 s).
+   - Rewrite `localDescription` on the pc (`hookLD`), and hold the one
+     WebSocket message that contains the placeholder until it can be
+     rewritten (`ssrc-ws`).
+4. **Payload types differ between the probe and the real pc.** The answer
+   carries the probe's numbers (VP8 = 111); WebKit sends its own (96).
+   Fix: in the same held message, `codecs[0].payloadType` and the rtx
+   `apt` are set to what the stats say is sent (`pt-fixed`).
+5. **`RTCRtpSendParameters.codecs` required.** Filled in from
+   `getParameters()` when a library omits it.
+6. **A MediaStream player never starts while an audio track delivers
+   nothing.** Remote cameras without a microphone stay at readyState 0
+   although frames are decoded.
+   - Fix: after 2.5 s at readyState 0, with live video and silent audio, the
+     element gets a video-only stream; the `srcObject` getter keeps
+     returning the page's stream.
+   - The original goes back on audio `unmute`. Confirmed: every such cam
+     played right after `video-audio-split`.
+7. **`query-permission-state`** (new in 2.54) is answered from
+   `permissions.tsv`. Unanswered means "prompt", and sites get no device
+   labels.
+8. **Microphone choice.** Device labels are hidden until the first grant,
+   so `mic_match` swaps the matching input into the stream after the first
+   successful `getUserMedia` (`mic-swapped`).
+
+9. **Camera frame rate.** `bestSupportedSizeFrameRateAndZoom` skips
+   every preset whose frame-rate list lacks the exact requested rate. The
+   site asks 320x240 with `frameRate {max: 20}`; 320x240 only runs at 30,
+   so even with patch 3 a 16:9 mode (848x480@20) won.
+   - Fix: `looseFps()` drops a soft frameRate in `getUserMedia` and
+     `applyConstraints` (exact and min are kept, `gum-fps-loosened`).
+     `cam_size_fix = no` undoes it.
+   - Chrome scales per track and drops frames instead.
+10. **`mic_match` wins** over the site's own audio deviceId (`exact`). The
+    site's second request named the headset jack again.
+
+`fix_webrtc = no` turns off 3-6. `--no-cam-fix` injects no script at all.
+
+## Removed (proven not to work)
+
+- `--cam-exact`: WebKit answered an exact 320x240 with 424x240 instead of
+  failing.
+- `--cam-force`: a preference that WebKit ignored.
+- `--cam-scale`: the canvas track has no deviceId or label, and the site
+  rejected it at once.
+- `--max-bitrate`, `--max-fps`: `setParameters` was accepted, and the
+  send rate did not change.
+- `fixSize` (4.38, re-apply the size once the probe track ended): the log
+  showed no other track open (`waited:0`) and still 1280x720. The real
+  cause was patch 3.
+- `--cam-share-clone`, `--cam-hold`: `MediaStreamTrack.clone()` goes black
+  after about 1 s on 2.52. Sharing the track itself works.
+
+## Open issues
+
+- **No keyframe after loss.** A remote stream that loses a few packets
+  stops decoding. FIRs are sent (`in-video.fir` climbs) but `keys` does not
+  move. Either the sender or server throttles keyframes, or the depayloader
+  waits for one it does not recognise. `rtpvp8depay2` (gst-plugins-rs) is
+  installed alongside `rtpvp8depay`; ranking it out changed nothing.
+- `GStreamer-RTP-CRITICAL gst_rtp_header_extension_get_id` on receiving
+  streams: `gstrtpbasedepayload.c:646` walks a header extension list with a
+  NULL entry on a caps event. Harmless so far.
+- The three patches above are not upstream yet.
+
+## Checks that pin a problem down
+
+- `--rtc-trace`: `sdp` (full `pt name` lists), `rtc-stats` (`out-*`,
+  `far-*`, `in-*` with fir/keys), `video-state`, `video-attach`,
+  `ssrc-real` / `ssrc-ws` / `pt-fixed`, `codec-mismatch`.
+- `pc1` at 30 fps: the camera path and the WebKit patch are fine.
+- A LibreWolf run of the same room separates site bugs from ours.
+- gdb attached to `WebKitWebProcess` before the camera starts (see README).
+
+## Build notes
+
+- On 2.54, `WebKitPointerLockPermissionRequest` is only declared when
+  WebKit is built with pointer lock (`#if ENABLE(POINTER_LOCK)` in
+  `webkit.h.in`), so both uses are behind
+  `#ifdef WEBKIT_TYPE_POINTER_LOCK_PERMISSION_REQUEST`.
+- `MAX`/`MIN`/`CLAMP` evaluate their argument twice. Option values are read
+  into a local first (`argv[++i]` inside `MAX` swallowed the next option).
+- Always ship all seven files together; `make version` identifies the
+  build.
diff --git a/Makefile b/Makefile
index c9b8404..674ab6a 100644
--- a/Makefile
+++ b/Makefile
@@ -2,7 +2,7 @@ CC      ?= gcc
 CFLAGS  ?= -O2 -Wall -Wextra -rdynamic
 PREFIX  ?= /usr
 
-VERSION  := 4.18.0
+VERSION  := 4.39.0
 SOURCES  := browser_core.c browser_core.h browser-mini.c browser-big.c Makefile
 BUILD_ID := $(shell cat $(SOURCES) 2>/dev/null | md5sum | cut -c1-7)
 
diff --git a/README.md b/README.md
index d9259f3..659cc7e 100644
--- a/README.md
+++ b/README.md
@@ -1,432 +1,36 @@
 # browser-mini / browser-big
 
-Two WebKitGTK 6.0 (GTK4) page viewers sharing one core.
+Two WebKitGTK 6.0 (GTK4) browsers sharing one core.
 
-**100% Vibecode but tested** — built warning-free with `-Wall -Wextra` against
-GTK 4.14 / WebKitGTK 2.52.3, and run headless under Xvfb.
+**100% Vibecode but tested.** Builds warning-free with `-Wall -Wextra`.
+Used for real video calls on WebKitGTK 2.54.0 with GStreamer 1.28.7.
 
-## Layout
+- **browser-mini**: a plain, fast page viewer.
+- **browser-big**: the same, plus camera, microphone and WebRTC repairs and
+  diagnostics.
 
-| File | What it is |
-| --- | --- |
-| `browser_core.h` | Window state, the `BrowserApp` hook struct, shared helpers |
-| `browser_core.c` | Window, popup, toast, zoom, downloads, profiles, keys, history |
-| `browser-mini.c` | 20 lines. Fills in four fields and calls `browser_main()` |
-| `browser-big.c` | Camera / GStreamer / watchdogs / diagnostics on top of the core |
-
-browser-mini adds nothing to the core. Every hook is optional.
-
-## WebKit runtime features
-
-WebKit carries a few hundred runtime switches, and some a real site needs
-are off by default. The one that matters most here is
-**`AllowWebGLInWorkers`**: without it a library that renders in a worker
-gets `Cannot create a canvas in this context`, falls back to the CPU, and
-the result looks like a freeze rather than an error. Zoom's video pipeline
-does exactly this.
-
-```
-browser-big zoom.us/test --feature AllowWebGLInWorkers --web-compat
-browser-big --list-features webgl        # what exists and its default
-```
-
-`--feature` is repeatable and takes `NAME=off` too. Measured effect:
-`offscreenWebgl` in the `rtc-caps` line goes from `false` to `true`.
-
-Enabling it can expose a second problem underneath:
-
-```
-Failed to create a graphics context for WebGL using GBM, falling back to textures
-```
-
-That is WebKit failing to make a GBM-backed GL context in the web process.
-`--feature UseGPUProcessForWebGL` moves WebGL to the GPU process instead,
-which is the first thing to try; then `--no-dmabuf`, then `--no-gpu` to
-take hardware paths out entirely.
+## Files
 
-If none of those help, there is a better fallback than the CPU path. A
-library decides whether to attempt worker-side rendering by checking for
-`OffscreenCanvas`; if that exists but WebGL inside it does not, it starts
-the work anyway and falls back to the CPU, which is the freeze. Removing
-the API removes the attempt:
-
-```
-browser-big zoom.us/test --feature OffscreenCanvas=off
-```
-
-Zoom's virtual-background worker (`vb_worker.min.js`) is exactly this
-case. With OffscreenCanvas absent it does not try, the feature is
-unavailable, and the meeting runs.
-
-`--gst-debug` can only report anything if GStreamer was built with its
-debug system, and `--media-debug` distinguishes the two things that are
-easy to confuse:
-
-```
-gst-debug: supported; currently off, set GST_DEBUG to use it (threshold 0)
-gst-debug: supported; currently ON (threshold 3)
-gst-debug: COMPILED OUT of this GStreamer
-```
-
-`gst_debug_is_active()` is false whenever no level is set, even in a build
-that fully supports debugging — reading it as "compiled out" is wrong. The
-compile-time answer is the `GST_DISABLE_GST_DEBUG` macro, and in
-`gstconfig.h` that name also appears inside an `#if 0` documentation
-block, so grepping for it without context misleads too.
-
-`LIBGL_ALWAYS_SOFTWARE=1` is only a useful test if Mesa was built with a
-software renderer. Without one it fails as
-
-```
-libEGL warning: egl: failed to create dri2 screen
-Could not create surfaceless EGL display: EGL_NOT_INITIALIZED. Aborting...
-web process crashed
-```
-
-which says the fallback is missing, not that software rendering is slow.
-
-## SharedArrayBuffer
-
-Off by default in JavaScriptCore, and a site whose media engine uses
-threaded WebAssembly needs it — without it the page hangs at a spinner
-rather than reporting anything.
-
-```
-browser-big zoom.us/test --shared-array-buffer --web-compat
-```
-
-In the config, so it cannot be forgotten:
-
-```
-shared_array_buffer = yes
-web_compat = yes
-```
-
-`--jsc NAME=VALUE` sets any other JavaScriptCore option the same way
-(`--jsc useJIT=0`, and so on). Measured: `sharedArrayBuffer` in the
-`rtc-caps` line goes from `false` to `true`. The page must also be
-cross-origin isolated, which `rtc-caps` reports separately.
-
-## Isolating a freeze that happens after a call connects
-
-Once ICE has completed and the devices are live, two pipelines start that
-were idle before: the video encoder and the audio path. Each can be taken
-out on its own, so one run says which side it is:
-
-```
-browser-big URL --no-mic                       # camera only, no audio path at all
-browser-big URL --video-codecs VP8             # software VP8 instead of VA-API H.264
-browser-big URL --no-hw-decode                 # software decoding
-browser-big URL --gst-rank vah264enc:NONE,vah264lpenc:NONE   # no VA-API encoding, keep H.264
-```
-
-And the decisive test for a GPU driver problem is to take the GPU out of
-the media path entirely:
-
-```
-browser-big URL --no-dmabuf --no-hw-decode --no-va --video-codecs VP8
-```
-
-Slow, but if it is *stable* where the hardware path freezes, the driver is
-the fault. `dmesg | grep -iE 'amdgpu|gpu reset|ring .* timeout'` during a
-freeze is the other half of that proof: a hung GPU says so there.
-
-The audio track here reports `sampleRate: 0`, which is not a valid rate;
-a library that sizes its buffers from it can spin. `--no-mic` is the test
-for that.
-
-## APIs WebKitGTK does not implement
-
-`--web-compat` supplies them. Currently `screen.orientation`: Zoom's media
-code reads `screen.orientation.type` and throws before it starts, even
-though its WebRTC negotiation completes fine.
-
-## A site that will not accept the stream
-
-WebKitGTK's WebRTC is GStreamer-based and its offer differs from the one a
-site tested against Chrome. Two of those differences stop a publish before
-it starts, and both are repaired in the offer rather than in the site:
-
-| Flag | |
+| File | What it is |
 | --- | --- |
-| `--fix-webrtc` | `--sdp-ssrc-fix` and `--rtc-params-fix` together |
-| `--rtc-params-fix` | supplies `RTCRtpSendParameters.codecs` when a library omits it. WebKit requires it, Firefox does not, so a library on its Firefox path throws `Member RTCRtpSendParameters.codecs is required` |
-| `--rtc-trace` | also reports an `rtc-caps` line up front: user agent, every WebRTC API, sendable codecs, plus WebGL, OffscreenCanvas, WebAssembly, SharedArrayBuffer, WebCodecs and `requestVideoFrameCallback`. A conferencing client processes frames in WebAssembly and renders with WebGL, so a missing one shows up as a freeze rather than an error. Emitted even when WebRTC is absent |
-| `--rtc-trace` | reports every WebRTC step — connection made, tracks attached, offer created, answer set. The last event logged is the step that failed. Included in `--media-trace` |
-| `--sdp-ssrc-fix` | adds the `a=ssrc:<n> cname:<v>` lines WebKit leaves out. A site parsing the offer for the CNAME throws `CNAME value not found` and never connects |
-| `--video-codecs VP8` | sets the sending codec order. WebKit may offer something this machine has no encoder for — `--media-debug` lists the encoders you have |
-
-The user agent comes first: a publisher library picks its code path from
-it, and WebKitGTK's own string may get no path at all — in which case no
-connection is ever built and nothing else matters.
-
-```
-browser-big room7.com --css no.css --ua-firefox --fix-webrtc --video-codecs VP8
-```
-
-`--fix-webrtc` does not turn tracing on, so add `--rtc-trace` when you want
-to watch it work. With `--sdp-ssrc-fix` the log also reports
-`sdp-ssrc-verify`, which says whether the injected lines were still present
-in `pc.localDescription` afterwards — WebKit reparses the description, and
-a library reads the lines back from there, so injecting them is not the
-same as them surviving.
-
-Check `--rtc-trace` first: the SDP rewrite only helps if an offer is ever
-made, and a site that fails earlier will show no `createOffer` at all. No
-`rtc-new` means the site never built a connection — it rejected the browser
-before WebRTC, and the `rtc-caps` event reports what it would have looked
-at: the user agent, and whether each API a publisher library needs exists.
-In that case the lever is `--ua-chrome` or `--ua-firefox`, not the offer.
-
-Both are off unless asked for. The SDP rewrite only touches sections that
-send and that have no ssrc lines already, and is idempotent.
-
-## A page that opens the same camera twice
-
-Some pages request a camera, enumerate devices with the labels that grants
-them, drop the stream and request the same device again — twice. A camera
-cannot be opened twice: the second open returns a track that reports
-`readyState: live` and never delivers a frame, and the `<video>` sits at
-`readyState 0` reporting `waiting` then `stalled` forever.
-
-This is a gap in WebKitGTK rather than a broken site: Chrome and Firefox
-multiplex one device open into several tracks internally, so a page asking
-twice just works there. Sharing does the same thing — it clones the live
-track, so each caller gets an independent track over a single open, and
-stopping one does not disturb the other.
-
-The hold expires on its own. Keeping the capture open is the point, but
-keeping it open after the page has stopped every clone leaves the camera
-light on with nothing watching it — so once no handed-out track is live,
-the source is released after `--cam-hold` seconds (3 by default, 0 to
-release at once).
-
-`--cam-share` hands the repeated request the stream that is already open
-and changes nothing else, or permanently:
-
-```
-cam_share = yes
-``` `--cam-fix` includes it, along with constraint
-relaxing that the page did not ask for — `--cam-share` when the sharing is
-all that is wanted.
-
-## Slow resize, or slow after regaining focus
-
-Measured, so it can be ruled out: the overlay positioning this browser does
-on every allocation costs **11 microseconds per call, about 23 per
-allocation** — 0.1% of a frame at 60Hz. It is not the cause of a stuttering
-resize.
-
-What does dominate a resize is GTK's own renderer, and the default is not
-always the best one for a given driver:
-
-```
-browser-mini URL --gsk cairo      # software, no GPU involved
-browser-mini URL --gsk gl         # the older GL renderer
-browser-mini URL --gsk ngl        # the newer one, usually the default
-browser-mini URL --gsk vulkan
-```
-
-In the config, so it holds:
-
-```
-gsk = gl
-```
-
-GTK 4.20 defaults to its Vulkan renderer where the driver claims support,
-and a Vulkan stack that deadlocks while recreating its swapchain freezes
-the whole browser the moment the window changes size — with the web
-process idle and nothing in any log. The GL renderer has been GTK's
-workhorse for years and loses nothing here: WebKit composites the page
-itself, and GTK only draws the final surface and the overlays on top.
-
-Then WebKit's own compositing, which is separate: `--no-dmabuf`,
-`--no-compositing`, `--no-gpu`. WebKit also throttles a page that loses
-focus and has to catch up when it returns, which no flag here changes.
-
-## A page that logs heavily
-
-WebKit writes the page's console output synchronously from the web process,
-and writing to a terminal costs about six times what writing to a file does
-(measured: 4000 lines, 4.3 ms to a file, 26.7 ms to a pty). A chatty
-application therefore makes its own web process wait.
-
-`--no-console` drops the page's logging and keeps ours. `-q` silences both.
-
-## A page that freezes with the CPU pinned
-
-Both browsers watch their web process. Every five seconds they sample its
-CPU time and memory, and when it has been spinning at 90% or more for
-fifteen seconds they print the threads that are running or blocked, with
-the kernel function each is waiting in:
-
-```
-watch: [16:31:43.102] web process 19976 is spinning: 401% cpu, 0.2 GB resident, for 15s.
-watch:   19976  R  WebKitWebProces    (running)
-watch:   20031  R  queue1:src         (running)
-watch:   20040  R  multiqueue0:src    (running)
-watch: the busy threads are GStreamer's (media), not
-watch: JavaScript - --no-jit will not help with this one
-watch: [16:31:58.117] web process 19976 calm again after about 30s (4% cpu now)
-```
-
-Nothing is asked of the web process, so this works while it is frozen.
-The `calm again` line closes the report, so the log shows how long the
-page was frozen. Threads named `something:src` are GStreamer streaming
-threads, and the hint says so instead of pointing at the JIT.
-
-The browser watches itself the same way. A heartbeat thread notices when
-the main loop has not run for five seconds — the one freeze the main loop
-cannot report — and prints this process's main thread and anything busy
-or blocked, straight to stderr:
-
-```
-watch: THIS PROCESS (13843) main loop has not run for 5s. Its running/blocked threads:
-watch:   13843  S  browser-mini       vk_wait_fence
-```
-
-A main thread waiting in a GPU, Vulkan or DRM call is GTK's renderer:
-`--gsk gl`, then `--gsk cairo`.
-
-There is a third kind of freeze that is not one. GTK paints only when the
-compositor returns a frame callback, and if one never arrives after a
-resize the main loop keeps running, input keeps working, and nothing is
-ever drawn again. The watch tells this case apart too:
-
-```
-watch: main loop is running but NO FRAME HAS BEEN PAINTED for 12s although one was requested.
-watch: GTK is waiting for a frame callback the compositor never sent
-```
-
-That one is between GTK's Wayland backend and the compositor, not the
-browser or the page. Resizing the window again usually shakes a new
-callback loose.
-
-And a fourth: the popups draw, `load:` lines keep arriving, the frame
-watch stays quiet — and the page area itself is stale. GTK is painting
-and the web process is answering; what stopped is the hand-off between
-them. On Wayland that is the dmabuf renderer: the web process exports a
-buffer, the widget imports it, and after a resize the import goes stale.
-`--no-dmabuf` takes that path out. **F6** re-presents the view without a
-reload for the case at hand — the view is hidden for one loop iteration
-and shown again with a fresh allocation, the page is kept. A
-thread that is `R` with no wait and no GStreamer name is JavaScript or
-WebAssembly running away — on a self-built WebKit, try `--no-jit` first. A thread in `D` names the
-device or pipe it is stuck on. `--no-proc-watch` turns it off.
-
-## Find on a large page
-
-WebKit is asked for at most 1000 matches (the hint says `1000+` past that)
-and the search runs on the pause after typing, not on every keystroke. A
-one-letter search on a page of thousands of lines used to walk every match
-twice and freeze the browser; now it is a few milliseconds.
-
-## Repeated messages
-
-WebKit and GLib write to stderr from several processes, and some lines
-repeat dozens of times a run — "the desktop portal is unreachable" is
-printed on every attempt. We cannot stop WebKit asking, so stderr is
-threaded through a pipe: each distinct line is shown twice, then counted,
-and the totals print at exit.
-
-```
---- repeated messages, shown twice each above ---
-     12 x  Unable to connect to the Deskop portal: ...
-```
-
-Nothing is hidden and nothing is guessed at. `Ctrl+C` ends the run the same
-way closing the window does, so the totals still appear. A fatal error —
-`g_error`, `abort()`, a crash — drains the pipe from the signal handler
-before the process dies, so its last message is printed rather than lost
-in the pipe: `Aborted` on its own tells nobody anything.
-
-Some aborts say nothing at all. For those the handler prints the stack the
-process was on, which names the layer even without symbols for every frame:
-
-```
----- fatal: abort in this process, stack at the time: ----
-/lib/libc.so.6(abort+0xdf)
-/lib/libEGL_mesa.so.0(+0x1a2f0)          ← the driver's EGL
-/lib/libgtk-4.so.1(gdk_gl_context_...)   ← GTK's renderer asked for it
-/lib/libglib-2.0.so.0(g_main_loop_run+0x127)
-```
-
-A frame in `libgtk`/`libgdk` is the toolkit, `libEGL`/`libGL`/`*_dri.so`
-the driver, `libwayland-client` the compositor link, `libwebkit` the
-engine. Built with `-rdynamic`, so the browser's own functions are named.
-
-## Is the graphics stack wired up at all?
-
-```
-browser-mini --gl-info
-```
-
-Checks the stack the way this browser will use it and exits: the DRM
-device nodes and whether *this user* can open them, membership of the
-`video` and `render` groups, the GL context GTK actually gets (API and
-version), the renderer in use, and the environment variables that steer
-all of it. A render node the user cannot open is the single most common
-cause of "everything falls back to software and some of it aborts" — the
-web process is where the GPU work happens, and it runs as you. It ends
-with the commands for the layers below GTK: `dmesg`, `glxinfo`,
-`vulkaninfo`, `vainfo`, and the firmware directory.
-
-## Switching virtual terminals
-
-One real backtrace from a VT switch, bottom-up: `wl_display_dispatch` →
-GTK → `g_signal_emit` → **libwebkitgtk** → `abort`. No renderer, no
-driver: a Wayland event that GTK relays as a signal, and a release
-assertion in WebKit's own handler. That is a WebKitGTK bug, and `gsk`
-cannot touch it. What can:
-
-```
-no_dmabuf = yes          # the other backing-store path; different code, may not assert
-```
-
-and a newer WebKitGTK, which is where such assertions get fixed. To name
-the function, resolve the WebKit frames if your build kept symbols:
-
-```
-gdb -batch -ex 'info symbol 0xf8d93d' /usr/lib/libwebkitgtk-6.0.so.4
-```
-
-Switching away from the compositor's VT releases the GPU, and a renderer
-holding a GL context can treat that as fatal. If the browser aborts on a
-VT switch, the message above the `Aborted` line now says which layer did
-it. `gsk = cairo` draws GTK's surface in software, with no GL context to
-lose; WebKit composites the page in its own process regardless, so that is
-the whole cost.
-`--all-messages` turns the whole thing off.
-
-## Version
-
-```
-browser-mini --version     # browser-mini 4.18.0 (build 4bbf154)
-make version
-```
-
-The build id is an md5 of the sources, so two builds can be told apart
-without guessing.
-
-`-h`, `-V` and `--paths` are answered before anything else on the command
-line is looked at, so they work even next to a mistyped option.
+| `browser_core.c` / `.h` | Window, keys, history, downloads, profiles, config |
+| `browser-mini.c` | Fills in the hooks and calls `browser_main()` |
+| `browser-big.c` | Camera, GStreamer and WebRTC repairs, diagnostics |
+| `patches/` | Three upstream fixes for WebKitGTK and GStreamer (see *Patches*) |
+| `HANDOVER.md` | Background: each upstream bug and workaround, with evidence |
 
 ## Build
 
-Debian and derivatives:
+Debian or any Linux distro:
 
 ```
 apt install build-essential pkg-config libgtk-4-dev libwebkitgtk-6.0-dev \
             libsoup-3.0-dev libgstreamer1.0-dev
-make
-```
-
-`libgstreamer1.0-dev` is only needed for browser-big.
-
-```
-make install      # both into /usr/bin, PREFIX=... to move it
+make                # build both
+make install        # into /usr/bin (PREFIX=... to change)
 make uninstall
 make clean
+make version        # 4.39.0 (build …), md5 of the sources
 ```
 
 ## Usage
@@ -436,779 +40,193 @@ browser-mini [URL] [options]
 browser-big  [URL|PATH|diag] [options]
 ```
 
-With no address the window comes up on the start page (below). A local
-file needs three slashes — `file:///tmp/index.html` — though a bare path
-works too (`./index.html`, `/tmp/index.html`), and anything without a
-scheme is tried as https.
-
-Run either with `-h` for the full option and key list.
-
-## Start page
-
-The program's name set large, a rule under it, the version and build id,
-and the three keys worth knowing — on one flat colour. It is served
-without a base URI, so it stays out of the history by itself.
-
-The dot at the top opens a row of swatches. Click one and it becomes the
-default from then on, remembered in `~/.local/share/wkview/start-bg`.
-Pure white and pure black are both there; between them are soft flat
-tones. The text follows the colour, dark on a light background and light
-on a dark one, so every swatch reads.
-
-```
-start_bg = f3f0e9      # the default, until you click a swatch
-```
-
-The file belongs to you rather than to a profile: every profile and both
-browsers share it. Delete it to go back to `start_bg`.
-
-## Media mode
-
-`F2` toggles it. While it is on, a frame in the accent colour runs round
-the page, and `Ctrl+click` sends media out instead of opening it:
-
-| Ctrl+click on | Goes to |
-| --- | --- |
-| a YouTube link, or a `.mp4` `.webm` `.mkv` `.m3u8` … link | `player` |
-| an image, or a link to a `.jpg` `.png` `.webp` … | `image_viewer` |
-| anything else | opened as usual |
-
-Set the programs once in the config:
-
-```
-player       = mpv
-image_viewer = imv
-```
-
-imv opens files, not addresses, so an image is downloaded first (with the
-page as referer) into `$XDG_RUNTIME_DIR/wkview-media/`, and the viewer gets
-the file. Files there are removed after an hour.
-
-| Option | Effect |
-| --- | --- |
-| `--player CMD` | the video player; given on the command line it also starts in media mode |
-| `--image-viewer CMD` | the image viewer |
-| `--media`, `media_mode = yes` | start in media mode |
-| `player_match = youtube.com/watch, vimeo.com/` | which links count as video (default: YouTube videos, shorts, live) |
-
-It follows `--mod`: with `--mod alt` it is `Alt+click`. If a program
-cannot start, a message says why and the click works as it normally would.
-
-## Where everything lives
-
-```
-browser-mini --paths
-```
-
-Prints every directory and file either browser writes, with what is on
-disk now. It follows `--profile` and `--private` wherever they sit on the
-line.
-
-| Path | What |
-| --- | --- |
-| `~/.config/swov/config` | Shared palette, read first |
-| `~/.config/browser-mini/config` | Our settings, on top of it |
-| `~/.local/share/wkview/<profile>/cookies.sqlite` | **Cookies — this is your logins** |
-| `~/.local/share/wkview/<profile>/history.tsv` | Addresses visited |
-| `~/.local/share/wkview/<profile>/searches.tsv` | Search keywords (`Ctrl+K`), per profile |
-| `~/.local/share/wkview/<profile>/permissions.tsv` | Camera / microphone answers per site |
-| `~/.local/share/wkview/<profile>/` | Also WebKit's own storage: local storage, IndexedDB, service workers |
-| `~/.cache/wkview/<profile>/` | Cache, safe to delete |
-| `~/.local/share/wkview/download-dirs.tsv` | Per-site download rules |
-| `~/.local/share/wkview/start-bg` | Start page background |
-
-The last two are yours, not a profile's: every profile and both browsers
-share them. Nothing else is written.
-
-Downloads are two separate things and `--paths` keeps them apart: the
-directory a file **lands in** (`--download-dir`, or the XDG download
-directory), and `download-dirs.tsv`, the one file holding every per-site
-**rule** that overrides it. The report prints the rules themselves, so you
-can see which site goes where without opening the file.
-
-Search keywords get the same treatment: `--paths` lists each keyword with
-its URL and marks the default. They are per profile, so another
-`--profile` shows a different list.
-
-`--clear-data` wipes the profile and cache directories, `--forget-perms`
-drops `permissions.tsv` alone, `--private` skips all of it and keeps the
-session in memory.
-
-Because `--paths` walks the directories instead of guessing, it also shows
-WebKit's own storage under the profile, whatever the release names it.
+No address opens the start page. No scheme means https; a path opens as a
+file. `-h` lists every option and key.
 
 ## Keys
 
 | Key | Action |
 | --- | --- |
-| `Ctrl+O`, `Ctrl+L` | Type an address |
-| `Ctrl+J` | Back |
-| `Ctrl+Shift+J` | Forward |
-| `Ctrl+H` | The same popup, opened on the history |
-| `Ctrl+F` | Find in page. `Enter`/`Down`/`Ctrl+N` next, `Shift+Enter`/`Up`/`Ctrl+Shift+N` previous, `Esc` out |
-| `Ctrl+S` | Download directory: this page, this site, or everything |
-| `Ctrl+K` | Add a search keyword |
-| `F1`, `Ctrl+/` | The key list, on screen |
-| `Ctrl+G` | Scroll to top |
-| `Ctrl+Shift+G` | Scroll to bottom |
-| `Ctrl+R`, `F5` | Reload |
+| `Ctrl+O`, `Ctrl+L` | Address (history below it) |
+| `Ctrl+H` | History |
+| `Ctrl+J` / `Ctrl+Shift+J` | Back / forward |
+| `Ctrl+F` | Find |
+| `Ctrl+S` | Download directory for page, site or everything |
+| `Ctrl+K` | Search keywords |
 | `Ctrl+D` | Recent downloads |
-| `Ctrl+P` | Show the whole current URL, wrapped; again to hide |
-| `F2` | Media mode on/off |
-| `Ctrl+Y` | Copy URL, and show what was copied |
+| `Ctrl+P` / `Ctrl+Y` | Show / copy the URL |
+| `Ctrl+G` / `Ctrl+Shift+G` | Top / bottom |
+| `Ctrl+R`, `F5` | Reload |
 | `Ctrl+plus/minus/0` | Zoom |
+| `F1` | Key list |
+| `F2` | Media mode (`Ctrl+click` sends videos to `player`, images to `image_viewer`) |
+| `F6` | Redraw without reloading |
 | `F12` | Developer tools |
 
-Use `--mod alt|super|meta` to move the modifier off Ctrl. Nothing else is
-intercepted, so copy/paste keeps working inside the page.
-
-browser-big adds `Ctrl+Shift+M` (diagnostics page), `Ctrl+Shift+C` (warm the
-camera), `Ctrl+Shift+X` (release it), `Ctrl+Shift+V` (dump video state).
-
-## History
-
-Stored at:
-
-```
-~/.local/share/wkview/<profile>/history.tsv
-```
-
-One tab separated line per page: timestamp, URL, title. Only successful
-loads of `http`, `https` and `file` are written, so every line is a link
-that worked. Failed loads, error pages and internal pages are skipped.
-Trimmed to the last 2000 lines at startup.
-
-`--private` turns it off. The file is plain text — grep it, edit it, delete it.
-
-One popup does both jobs, because they are the same job: an input line with
-the matching history under it.
-
-- `Ctrl+O` types an address. The history is listed straight away — eight
-  lines by default, `list_rows` in a config file — and narrows as you type,
-  so the two are one thing: pick a line, or press `Enter` and what you typed
-  is loaded.
-- `Ctrl+H` opens the same popup on the history.
-
-Every panel — address, history, find, download directory, key list — is the
-same width: `popup_width`, 550px by default, which is about fifty
-characters of the mono font — enough for most addresses without the panel
-taking over the window. It shrinks to fit a small window rather than
-clipping, and the history list takes up to about two thirds of the window's
-height.
-
-```
-popup_width = 900         # in swov's config, or the browser's
-```
-
-`Tab` and `Down` walk forward through the matches, `Shift+Tab` and `Up`
-back, the wheel scrolls, a click opens. Whatever is selected is written into
-the input, so `Enter` always loads exactly what you can read. `Esc`,
-`Ctrl+H` or a click outside closes it.
+`--mod alt|super|meta` moves the modifier off Ctrl. browser-big adds
+`Ctrl+Shift+M` (diagnostics page), `Ctrl+Shift+T` (web process threads),
+`Ctrl+Shift+V` (video element state), `Ctrl+Shift+C` / `X` (warm / release
+the camera).
 
-Anything the popup can save shows a **Save** and a **Cancel** button with
-the keys named on them (`Save (Enter)`, `Cancel (Esc)`) — Enter is never the
-only way in. They are text, not boxes: Cancel sits at the left in `subtext`,
-Save at the right in the accent and bold, and either underlines and
-brightens on hover. When the directory popup asks about a clash the same two
-buttons become `Drop that rule` and `Keep both`, so both answers are on
-screen rather than implied.
+## Features
 
-While a page loads, its address appears top left and becomes the page title
-as soon as one arrives, with a thin accent line across the very top showing
-progress.
+- **History** survives restarts; back and forward continue into it.
+  `--private` keeps nothing.
+- **Downloads**: `Ctrl+S` sets a directory per page, site or everything;
+  the narrowest rule wins.
+- **Search keywords**: `g` (default), `d`, `w`, `s`. `w tree` searches
+  Wikipedia. Add one with `Ctrl+K`: `k https://example.org/?q={}`.
+- **Lock**: `--lock-page` or `--lock-site` refuses other addresses.
+- **Popups**: `--allow-popups` lets pages open windows without a click
+  (Zoom needs it).
+- **Errors** show top right in red for 10 s, and on stderr.
 
-`Ctrl+A` selects all — in a popup's input, and in the page. WebKitGTK maps
-that key to move-to-start-of-line, an Emacs habit that surprises anyone
-typing into a web text field, so it is turned into WebKit's own select-all
-command. `select_all = no` gives the key back to the page.
+## Paths
 
-`Esc` closes whatever is on screen — popup, key list, download list or a
-message — and reaches the page only when nothing of ours is up.
+`browser-mini --paths` prints them all.
 
-Back and forward walk WebKit's session list first, which keeps scroll
-position and form state. Once that runs out they continue into the stored
-history, so back still works on the first page after a restart.
-
-## Download directories
-
-Downloads land in `--download-dir` unless a rule says otherwise. `Ctrl+S`
-sets one: type a directory, and the select decides how far it reaches —
-this address only, everything on the site, or everything. The narrower rule
-wins, so one page can go somewhere its site does not.
-
-```
-~/.local/share/wkview/download-dirs.tsv    key <TAB> directory [<TAB> overwrite]
-```
-
-The file belongs to you, not to a profile: every profile and both browsers
-read the same rules, since which directory a site's files go in has nothing
-to do with which cookie jar is in use. Rules written by an older build are
-carried over from the profile on first run.
-
-The key is a whole address or a bare host. The `Ctrl+S` popup lists everything
-stored, so a directory can be picked with the arrows or the mouse instead
-of typed, and the rule under the cursor dropped with `Delete` or `Ctrl+X`. The scopes are independent and the
-narrower one always wins: a rule on `a.de/b` keeps its own directory when
-`a.de` gets one. Setting a rule only ever writes the key for the scope you
-picked. A rule set on a page also covers
-the files that page hands out, which usually live on another address — a
-CDN, or just a different path — so a rule on a download page catches what
-it serves.
-
-Ticking **always replace existing files** turns the question off for
-whatever that rule covers: files take the name they ask for. It follows the
-same select, so it can apply to one page, a whole site, or everything —
-`always_overwrite` in a config file does the last one permanently.
-
-If something else already points at the directory you set, you are asked
-first: `Enter` drops the older rule, `Esc` lets both use it. Sharing a
-directory is usually meant, but not always.
-
-Setting the scope to **everything** lasts for that run only — put
-`download_dir` in a config file to make it permanent.
-
-Two browsers saving into one directory do not collide either: a name is
-claimed by creating the file, so the second one moves to the next number
-rather than landing on top of the first. Tested with two instances pulling
-the same file into the same directory at once.
-
-Rules are merged on save, not overwritten: a second browser on the same
-profile re-reads the file and keeps what the first one wrote, so two
-instances cannot trample each other's rules. The same goes for search
-keywords.
-
-A file whose name is already taken is downloaded beside the old one and the
-question is asked in the download panel itself — no second popup. `Enter`
-puts it in the old one's place, `Esc` keeps both, and the transfer only
-reads as done once the file is where it is going to stay. A directory that does not exist
-is created group writable (`0770`). The panel appears the moment the server answers, and reads `starting` until
-the first byte lands. It normally fades out under the pointer so the page
-can be read through it, but a download that has just appeared outranks that
-for a few seconds — otherwise one starting while the pointer happened to
-rest there would arrive invisibly. The panel shows the
-destination in small text on the right of its header, so it is clear where
-a file is going before it lands.
-
-## A config that needs no flags
+| Path | What |
+| --- | --- |
+| `~/.config/swov/config` | Shared palette, read first |
+| `~/.config/browser-{mini,big}/config` | Settings |
+| `~/.local/share/wkview/<profile>/` | Cookies, history, search keywords, permissions |
+| `~/.local/share/wkview/download-dirs.tsv` | Download rules (all profiles) |
+| `~/.cache/wkview/<profile>/` | Cache, safe to delete |
 
-Everything a conferencing site has needed so far, as settled preferences:
+`--clear-data` wipes the profile. `--forget-permissions` drops camera and
+microphone answers.
 
-```
-# ~/.config/browser-big/config
-audio = alsa                     # plain-ALSA machine, no sound server
-web_compat = yes                 # screen.orientation for Zoom's media engine
-feature = OffscreenCanvas=off    # no worker-side WebGL attempt, so no CPU fallback
-```
-
-Two things that used to be here are now the default, because they are safe
-on every machine and match what other browsers do: GTK's **GL renderer**
-(`gsk = auto` hands the choice back to GTK) and **SharedArrayBuffer**
-(`shared_array_buffer = no` to disable; WebKit still hands it only to
-cross-origin-isolated pages). The three above stay in the config because
-they are specific to the machine or the site.
+## Config
 
-`audio` and `web_compat` are browser-big's; browser-mini notes them as
-unknown and carries on. The other three apply to both.
+`key = value`, `#` comments, colours as `RRGGBB[AA]`. The command line wins.
+Every key is also an option: `--hl=ff8800`. `-c PATH` reads one file only,
+`-n` reads none.
 
-## A test ladder that isolates each layer
+## Patches
 
-The WebRTC samples at `webrtc.github.io/samples` are the reference
-implementation: unobfuscated, one thing per page, and they print what
-they got. Each rung adds one layer, so the first one that fails names it.
+Video calls need three upstream fixes. They are real bugs in current
+releases, not configuration, and none is upstream yet.
 
-| Page | Tests | Flags to try if it fails |
+| File | Applies to | Without it |
 | --- | --- | --- |
-| `src/content/getusermedia/gum/` | camera only, no audio, no WebRTC | `--rtc-trace` to see it |
-| `src/content/getusermedia/audio/` | microphone only | `--audio-alsa` |
-| `src/content/getusermedia/resolution/` | asks for QVGA, VGA, HD by size, some `exact` | `--cam-scale` |
-| `src/content/getusermedia/record/` | MediaRecorder from the camera | — |
-| `src/content/peerconnection/pc1/` | **a full WebRTC call inside one page**, no server | `--fix-webrtc`, `--video-codecs VP8` |
-| `src/content/peerconnection/constraints/` | codec and bitrate negotiation | `--video-codecs` |
-
-`pc1` is the important one: two peer connections in one page, offer,
-answer, ICE, media flowing — every part of the WebRTC stack with no site
-logic in the way. If it works, the browser's WebRTC is sound and anything a
-real site does differently is the site's own code.
-
-```
-browser-big https://webrtc.github.io/samples/src/content/getusermedia/gum/ --rtc-trace
-browser-big https://webrtc.github.io/samples/src/content/peerconnection/pc1/ --rtc-trace
-```
-
-## Camera and microphone permission
-
-A page asking for the camera or microphone is **asked about**, the way a
-browser does: a panel at the top names the site and what it wants, `Enter`
-allows, `Esc` denies, and the answer is remembered per site in
-`<profile>/permissions.tsv`. Device labels for `enumerateDevices()` are
-revealed only once a site has been granted a device, which is also what
-other browsers do.
-
-```
---forget-permissions forget every remembered answer, so sites ask again
---allow-media        grant every page without asking (a kiosk, a script)
---deny-media         deny every page
-media = ask|allow|deny     the same, in the config
-```
-
-Until 4.0.0 every request was granted silently. That was a shortcut, not a
-policy, and it is gone.
-
-## When the camera is not picked up
-
-browser-big probes the capture devices itself with GStreamer and logs what
-it found. If that count is non-zero but the page still says access was
-denied, the devices are fine — the web process could not reach them. It
-gets at them through the desktop portal, and the portal needs a working
-D-Bus.
-
-The usual cause is a missing machine id, which makes D-Bus unable to start
-at all:
-
-A machine id is a D-Bus requirement, not a systemd one. Either tool works,
-and neither needs an init system:
-
-```
-sudo dbus-uuidgen --ensure=/etc/machine-id       # any distro with dbus
-sudo ln -sf /etc/machine-id /var/lib/dbus/machine-id
-```
-
-Without dbus's tools at all, the file is just 32 hex characters:
-
-```
-head -c 16 /dev/urandom | od -An -tx1 | tr -d ' \n' | sudo tee /etc/machine-id
-```
+| `webkit-videorate-skip-to-first.patch` | WebKitGTK 2.52 / 2.54 | Own camera freezes or goes black, hundreds of frames per second, CPU pinned (WebKit PR 74373) |
+| `webkit-caps-normalize.patch` | WebKitGTK 2.54 | Camera modes listed as `640 x {480, 360}` are ignored. Many laptop cameras get no 4:3 mode, so 16:9 with black bars |
+| `gst-glupload-null-meta.patch` | GStreamer 1.28 (gst-plugins-base) | Web process crashes on a site's second camera request |
 
-Then a session bus. With no systemd user session, start one per run, or
-better, start the compositor inside one so every app shares it:
+Apply in the source tree, then rebuild and reinstall that package:
 
 ```
-dbus-run-session -- browser-big URL      # per run
-dbus-run-session -- sway                 # whole session
-```
+cd webkitgtk-2.54.0
+patch -p1 < /path/to/patches/webkit-videorate-skip-to-first.patch
+patch -p1 < /path/to/patches/webkit-caps-normalize.patch
 
-The portal itself is a separate package: `xdg-desktop-portal` plus a
-backend — `xdg-desktop-portal-wlr` for sway, or `-gtk`.
-
-browser-big checks the result rather than trusting the flag. A sandboxed
-web process is placed in its own mount namespace by `bwrap`, so it compares
-namespaces and says which it is:
-
-```
-sandbox: web process 312, parent bwrap, mount namespace differs from ours  ->  sandboxed
-sandbox: web process 370, parent browser-big, mount namespace same as ours  ->  NOT sandboxed
+cd gstreamer-1.28.x/subprojects/gst-plugins-base   # or the plain gst-plugins-base tarball
+patch -p1 < /path/to/patches/gst-glupload-null-meta.patch
 ```
 
-Portal warnings can keep appearing either way — WebKit asks the portal for
-settings and other things too, so they are not evidence of sandboxing.
-
-Or skip the portal entirely. `--no-sandbox` lets the web process open the
-capture devices directly, which is the pragmatic answer on a machine with
-no portal at all. It gives up the sandbox and prints a line saying so on
-every run.
-
-To rule the sandbox out instead:
-
-```
-WEBKIT_DISABLE_SANDBOX_THIS_IS_DANGEROUS=1 browser-big URL
-```
+Add `--dry-run` first to check. Without the GStreamer patch,
+`WEBKIT_GST_DISABLE_GL_SINK=1` avoids the crash at some CPU cost.
 
-`--media-trace` logs every `getUserMedia` call, the constraints it asked
-for and how it ended, **without changing any of them** — relaxing, caching,
-retrying and the audio fallback are all off. That is the first thing to
-reach for when a site claims access was denied while the devices look fine:
-it shows which call failed and what it wanted.
+Check that they work:
 
-If permission is granted and no device ever reports capturing, browser-big
-says so after a few seconds, with what to try next. That works on a plain
-run — it watches the capture state rather than needing the `--cam-fix`
-shim.
+- Videorate: the WebRTC sample `pc1` (below) encodes about 90 frames per
+  3 s report, not hundreds.
+- Caps: `--rtc-trace` shows a 4:3 camera track (`aspectRatio 1.333`) on a
+  site that asks for one.
 
-browser-big prints this advice itself the first time a getUserMedia call
-fails, and with `--cam-fix` it stops relaxing constraints once it sees the
-requested kind has no device at all — loosening a constraint cannot conjure
-a camera.
-
-**Finding the microphone.** `--prewarm` now separates real microphones
-from PulseAudio monitors — a monitor records what is being played, so
-`getUserMedia` cannot use it, and a log line listing only
-`Monitor of Dummy Output` means no microphone was found. What WebKit sees
-is exactly what this prints:
-
-```
-gst-device-monitor-1.0 Audio/Source
-arecord -l                    # does ALSA see a capture device?
-pactl info                    # is a Pulse server running, and did it get the card?
-```
-
-If ALSA has a capture device but GStreamer lists none, a half-working Pulse
-server is usually in the way.
-
-`--audio-alsa` ranks the pulse *elements* out, so playback and recording go
-through ALSA. That fixes browser audio on a machine whose Pulse server came
-up with a dummy sink.
-
-It does **not** change enumeration. `GST_PLUGIN_FEATURE_RANK` is honoured
-for elements but silently ignored for device providers — measured, not
-assumed — so which devices exist is still decided by the providers that are
-installed. A microphone therefore needs `alsadeviceprovider`:
-
-```
-gst-inspect-1.0 alsadeviceprovider     # part of gst-plugins-base's alsa plugin
-gst-device-monitor-1.0 Audio/Source    # what WebKit will see
-```
+## Video calls
 
-`--prewarm` reports whether that provider is present. `--gst-rank SPEC`
-sets the variable by hand.
+browser-big repairs these WebKit problems by default:
 
-To have it every run, put it in browser-big's config rather than typing the
-flag:
-
-```
-audio = alsa        # or pulse, or auto (the default)
-```
-
-It is deliberately **not** the built-in default. On a machine where
-PipeWire or PulseAudio is healthy, ranking `pulsesink` out makes WebKit
-open the card through `alsasink` directly, which without a dmix setup takes
-it exclusively and silences everything else. Whether that is right is a
-property of the machine, so it belongs in that machine's config.
-`--audio-pulse` undoes it for one run.
-
-**Seeing what the camera offers.** `--list-cameras` prints every format,
-size and frame rate each device supports, with the aspect ratio worked out
-for each, then exits. It runs before the window is created, so it works over
-ssh and from a script:
-
-```
-browser-big --list-cameras
-
-Integrated Camera: Integrated C   /dev/video0
-    media type   format       width   height      framerate              aspect
-    image/jpeg   -             1280 x 720         30/1                   16:9
-    video/x-raw  YUY2           640 x 480         { 30/1, 15/1 }         4:3
-    video/x-raw  YUY2          1280 x 720         10/1                   16:9
-```
-
-The listing ends with the exact flag to pin one of them:
-
-```
-browser-big URL --cam-force 640x480@30
-```
-
-That asks for 640x480 at 30fps whatever the page requests, and sets the
-aspect ratio to match. It implies `--cam-fix`.
-
-**Wrong shape.** `--cam-fix` used to delete the page's `aspectRatio` while
-relaxing, and drop width and height entirely at level 2 — so a site asking
-for 4:3 could be handed 16:9. It now keeps the aspect ratio as an ideal, and
-derives one from the requested width and height when it gives up on the
-resolution. To insist regardless of what the page asks:
+| Repair | Why |
+| --- | --- |
+| libnice ICE | librice finds no public address, so nothing connects past the LAN. `ice = rice` undoes it |
+| VP8 first | WebKit encodes the first codec of its own offer and ignores the answer. `video_codecs = native` undoes it |
+| Real SSRC and payload type | WebKit writes no `a=ssrc` lines. mediasoup sites get the values WebKit really sends |
+| `RTCRtpSendParameters.codecs` | WebKit requires it; some libraries omit it |
+| Camera without sound | A remote stream with silent audio is shown video-only, since WebKit's player waits for audio |
+| Permission state | Answered from remembered permissions, so sites see device labels |
+| Camera frame rate | WebKit only takes modes with the exact requested frame rate. A soft limit is dropped. `cam_size_fix = no` undoes it |
 
-```
-browser-big URL --cam-force 640x480@30      # 4:3
-```
+`--no-fix-webrtc` turns off the SSRC, parameters and silent-audio repairs.
+`--no-cam-fix` injects nothing.
 
-It implies `--cam-fix`, and sets width, height and `aspectRatio` as ideals
-so the request cannot become over-constrained.
+**Permissions.** A request asks at the top of the window: `Enter` allows,
+`Esc` denies, remembered per site. `media = ask|allow|deny` in the config.
 
-**`--cam-scale` is usually the answer.** WebKit does not scale a capture:
-asked for 320x240 it picks the nearest native camera mode — 848x480, say —
-and hands that over. Chrome scales. `--cam-scale` does the same, drawing
-the camera into a canvas of the requested size and capturing that, so the
-track really does report the size the page asked for:
+**Per machine:**
 
 ```
-browser-big URL --cam-scale
-→ cam-scale-mode  frame-callback  fps 15
-→ cam-scaled      from 1280x720  to 320x240  fps 15
+# ~/.config/browser-big/config
+audio = alsa                     # no sound server
+mic_match = ThinkPad             # mic whose label contains this text
+feature = OffscreenCanvas=off    # Zoom
+web_compat = yes                 # Zoom: screen.orientation
 ```
 
-The scaling runs on the page's own thread, so it draws only when a frame
-actually arrives (`requestVideoFrameCallback`) and the rate is capped at 15
-by default — a timer-driven 30fps redraw was enough to freeze a conference
-app that was already software-rendering. `--cam-scale-fps N` moves the cap.
-
-A bare `width: 320` in a page's constraints is only a *preference*, and
-WebKit may answer with a different size — 848x480 for a request of 320x240,
-in one real case. A site that publishes at the size it asked for then has a
-stream it cannot use. `--cam-exact 320x240@20` demands that size instead of
-preferring it, and fails outright if the camera cannot deliver it, which is
-at least an honest answer.
-
-**No microphone.** A site asking for camera *and* microphone fails outright
-on a machine with no audio input, even though the camera is fine. With
-`--cam-fix`, browser-big retries once for video alone rather than handing
-the site nothing. `--no-cam-drop-audio` turns that off, since the site did
-not ask for it.
+`mic_match` / `cam_match` pick a device by label. `mic_match` overrides the
+microphone a site asks for. `arecord -l` lists microphones.
 
-## When video or WebRTC does not work
-
-WebKit plays media and talks WebRTC through GStreamer, so a missing plugin
-looks like a broken site. browser-big checks at startup and says nothing
-unless something needed is absent:
+## When a call does not work
 
 ```
-gstreamer: these are missing, and pages will fail without them:
-  webrtcbin    WebRTC (gst-plugins-bad)
-  avdec_h264   H.264 video (gst-libav)
+browser-big URL --rtc-trace 2>&1 | tee /tmp/bb.log
 ```
 
-WebRTC needs more than one plugin, which is why "install gst-plugins-bad"
-often isn't enough:
-
-| Element | From | Needs |
-| --- | --- | --- |
-| `webrtcbin` | gst-plugins-bad | **libnice** at build time |
-| `x264enc` **or** `openh264enc` | gst-plugins-ugly / -bad | H.264 **encoding**, for publishing |
-| `nicesrc` / `nicesink` | libnice itself | built with GStreamer support |
-| `srtpenc` | gst-plugins-bad | libsrtp2 |
-| `dtlssrtpenc` | gst-plugins-bad | OpenSSL |
-| `rtpbin` | gst-plugins-good | — |
-
-Two more come from `gst-plugins-rs` — `audiornnoise` (noise suppression)
-and `rtpgccbwe` (RTP bandwidth estimation). Neither is required; WebKit
-complains about each at the moment it wants it, which reads like a fault,
-so browser-big lists them once up front as optional.
-
-`libgstwebrtc.so` links `libnice.so` directly, so if libnice is absent when
-gst-plugins-bad is configured, meson drops the `webrtc` feature and the
-build still succeeds — silently. Verify each with
-`gst-inspect-1.0 <element>`, or just start browser-big and read its report.
-
-## browser-big: fix and debug flags
-
-Nothing below is on unless asked for. A default run injects no JavaScript,
-probes no devices, starts no watchdogs and reloads nothing.
-
-**Look at what is happening**
-
-| Flag | |
-| --- | --- |
-| `--list-cameras` | every format, size, rate and aspect each camera offers, then exit |
-| `--prewarm` | what GStreamer can enumerate from here, cameras and microphones counted |
-| `--media-trace` | every `getUserMedia` call, the frame that made it, its constraints and outcome, nothing altered |
-| `--media-watchdog` | reports a `<video>` whose playback has stopped advancing, and tries to recover it |
-| `--media-debug` | the above plus capture state, process table and GStreamer environment |
-| `--gst-debug SPEC` | GStreamer's own logging, e.g. `v4l2*:6,webrtc*:5` |
-
-`Ctrl+Shift+T` lists every thread of the web process with its state (`R`
-running, `S` sleeping, `D` blocked in the kernel) and the kernel function
-it is waiting in. It reads `/proc` and does not ask the web process
-anything, so it works precisely when the page is frozen and the log has
-gone quiet — a thread in `D` in `snd_pcm_*` is the sound card, one in a
-pipe write is stderr, one spinning `R` is JavaScript or WebAssembly.
-
-`Ctrl+Shift+V` dumps the state of every `<video>` on the page — readyState,
-paused, currentTime, dimensions — which answers "is it stalled or is it
-empty" directly. `Ctrl+Shift+M` opens the built-in diagnostics page.
-
-**Change what is happening**
-
-| Flag | |
-| --- | --- |
-| `--cam-force WxH@FPS` | pin size, rate and aspect whatever the page asks |
-| `--cam-share` | hand a repeated request the stream already open, nothing else |
-| `--cam-fix` | relax tight constraints, retry looser, hold the stream |
-| `--fix-media` | `--cam-fix` plus prewarm, media watchdog and auto-reload |
-| `--audio-alsa` | play and record through ALSA rather than PulseAudio |
-| `--no-sandbox` | let the web process reach devices without the portal |
-| `--no-gpu`, `--no-dmabuf`, `--no-compositing`, `--no-jit` | rendering and JIT fallbacks |
-
-## browser-big's media workarounds are opt-in
-
-By default browser-big is plain WebKitGTK — no shim over `getUserMedia`, no
-device probe, no watchdogs, no reloads of its own. The fixes exist for sites
-that misbehave and are reached for deliberately:
-
-| Flag | What it does |
-| --- | --- |
-| `--fix-media` | all four below |
-| `--cam-fix` | relax tight `getUserMedia` constraints, retry looser, hold the stream |
-| `--prewarm` | probe capture devices at startup |
-| `--media-watchdog` | notice a stalled `<video>` and recover it |
-| `--auto-reload` | reload a page that never commits |
-| `--warm-cam` | open and release the camera before the first page |
-
-`--no-cam-relax` and `--no-cam-keepalive` trim `--cam-fix` when it is on.
-
-Only the camera flags (`--cam-fix`, `--cam-share`, `--fix-media`,
-`cam_share = yes`) change the camera. `--rtc-trace`, `--web-compat`,
-`--no-mic` and the offer fixes use the same injected script but pass the
-page's tracks through untouched. The log's `shim` line says `repair:true`
-when a camera repair is on.
-
-Before 4.14.0 they did not: any of those flags also cloned and held the
-capture track. On WebKitGTK 2.52 the page then gets a clone that freezes
-after about a second.
-
-## Search keywords
-
-Four are built in, and `g` is the default:
-
-| Key | Searches |
+| Line | Means |
 | --- | --- |
-| `g` | Google |
-| `d` | DuckDuckGo |
-| `w` | Wikipedia |
-| `s` | the SDL3 wiki (through DuckDuckGo; the wiki has no search address) |
+| `sdp` | Each description, codecs as `payload-type name` |
+| `ssrc-real`, `ssrc-ws`, `pt-fixed` | The server got the real SSRC and payload type |
+| `rtc-stats` | Every 3 s: `out-*` sent, `far-*` server report, `in-*` received |
+| `video-state` | Each `<video>`: size, frames shown |
+| `video-audio-split` | A silent-audio stream shown video-only |
+| `codec-mismatch` | We encode something the far end did not agree to |
 
-`w tree` searches Wikipedia; a bare `tree` goes to the default. A line that
-looks like an address (a scheme, a path or a dotted host) is opened, not
-searched. A bare `s` with nothing after it is an address too.
+- `out-video.enc` climbing, no `far-video`: the server drops our stream.
+- `in-video.dec` climbing, `video-state.frames` at 0: decoded, never shown.
+- `in-video.fir` climbing, `keys` still: no keyframe arrives.
 
-`Ctrl+K` lists the keywords and marks the default. Type
-`k https://example.org/?q={}` to add one; `{}` is where the words go,
-percent encoded. Tick the box to make it the default; `Delete` or `Ctrl+X`
-drops the one under the cursor. `--paths` lists them all.
+**Without a server:**
+`browser-big https://webrtc.github.io/samples/src/content/peerconnection/pc1/ --rtc-trace`.
+If that works, WebKit's WebRTC is fine and the problem is site-specific.
 
-Keywords live in `searches.tsv` next to the profile. One there, or in a
-config file, replaces a built-in of the same name:
+**Camera:** `browser-big --list-cameras` shows every mode each camera
+offers. Check `ls -l /dev/video*` and membership in group `video`.
 
-```
-search_s = https://wiki.libsdl.org/SDL3/{}
-search_default = d
-```
-
-A deleted built-in comes back on the next start; replace it instead.
-
-## When a page renders blank or zero sized
-
-If the log says `web process crashed` or JavaScriptCore prints
-`received NeedDebuggerBreak trap`, the page never had a chance — the web
-process died, which is a WebKit or driver problem rather than anything the
-browser did. Narrow it in this order:
+**Crash:** attach gdb before starting the camera:
 
 ```
-browser-mini URL --no-jit        # JavaScriptCore's JIT
-browser-mini URL --no-gpu        # accelerated compositing
-WEBKIT_DISABLE_SANDBOX_THIS_IS_DANGEROUS=1 browser-mini URL   # the sandbox
+gdb -p $(pgrep -nx WebKitWebProcess) -batch \
+    -ex 'handle SIGUSR1 SIGUSR2 SIGPIPE SIG34 SIG35 nostop noprint pass' \
+    -ex continue -ex 'thread apply all bt 30' > /tmp/bt.txt 2>&1
 ```
 
-The first one that helps names the culprit. A mismatched WebKit and
-JavaScriptCore after a partial upgrade is the usual cause of a JIT trap.
+## browser-big options
 
-Two other causes were found in the loading bar and fixed in 2.3.3. It reported a
-negative minimum width, which makes GTK abandon the allocation pass and
-leaves the web view unallocated — a zero-sized viewport and a white window
-you can scroll forever. It also changed size on every progress tick, which
-re-allocated the overlay and so re-laid-out the page dozens of times a
-second: black and white flicker in time with the page's own activity. The
-bar is drawn with cairo now, at a constant size, so a change repaints and
-nothing more. `--no-load-bar` turns it off.
+| Option | |
+| --- | --- |
+| `--rtc-trace` | Every WebRTC step, stats, video elements |
+| `--media-trace` | Every `getUserMedia` call and result |
+| `--media-debug` | Verbose media, permission and capture logs |
+| `--list-cameras` | Camera modes, then exit |
+| `--gst-debug SPEC` | GStreamer logging, e.g. `webrtc*:5` |
+| `--gst-rank SPEC` | Append to `GST_PLUGIN_FEATURE_RANK` |
+| `--cam-share` | A repeated request gets the camera already open |
+| `--no-mic` | Camera only |
+| `--audio-alsa` | ALSA instead of PulseAudio |
+| `--no-hw-decode`, `--no-va` | Software decoding / no VA-API |
 
-Otherwise it is usually
+## When a page renders badly
 
-Usually a broken GL or compositing stack rather than the page. In devtools
-a `body` of `0px x 0px` means the viewport itself has no size, so nothing
-the page's CSS says can help. Try these in order, both browsers:
+Try one at a time; the first that helps names the layer:
 
 ```
-browser-mini URL --no-dmabuf
+browser-mini URL --no-jit          # JavaScript JIT
+browser-mini URL --no-dmabuf       # WebKit's dmabuf renderer
 browser-mini URL --no-compositing
 browser-mini URL --no-gpu
+browser-mini URL --gsk cairo       # GTK renderer (default gl)
 ```
 
-`--no-hw-decode` is the same idea for video. Any of them can go in a config
-file once you know which one it was.
-
-## Options worth knowing
-
-| Option | Effect |
-| --- | --- |
-| `--profile NAME` | Named profile, persists cookies |
-| `--private` | Ephemeral session, no history |
-| `--clear-data` | Wipe the profile before starting |
-| `--paths` | Print every directory and file written, then exit |
-| `--player CMD` | start in media mode (`F2`), videos go to CMD |
-| `--enable-middle-click-paste` | Let middle clicks reach the page (swallowed by default) |
-| `--app-id ID` | Wayland `app_id` / X11 `WM_CLASS`, for sway rules |
-| `--css FILE` | Inject a user stylesheet |
-| `--ua-chrome`, `--ua-firefox`, … | Preset user agents |
-
-## Where things appear
-
-One rule, so there is nothing to learn:
-
-| | |
-| --- | --- |
-| things you type into | top, centred — the popup and the key list |
-| things that just tell you something | top right — messages and downloads |
-| where you are going | bottom left — the address being opened |
-
-Corners touching an edge stay square. Panels you can't interact with fade
-out while the pointer is over them, so you can read what's underneath;
-interactive ones don't.
-
-## Look
-
-Every overlay — the popup, toast, downloads — is drawn from
-swov's palette and geometry, so the two programs read as one set. Defaults
-match swov value for value: `tile` panels at `radius 14` with a `border 3`
-outline, `text` on top, `hint` for headers, `hl` (the orange) for the caret,
-the download bar and, at a third of its weight, for selections, and `find_hl`
-(a pale blue) for the match `Ctrl+F` is sitting on, `urgent` for failures.
-
-Nothing is hardcoded. `ui_css_install()` builds the stylesheet from the
-theme, so changing `hl` in a config file moves every accent at once.
-
-Panels hug the window edge rather than curve away from it: only the corners
-facing into the page are rounded, and the border on the touching side is
-dropped. The toast and the download list sit flush in the top right with one
-rounded corner; the popup hangs off the top edge, centered, with two; the
-address label sits in the top left with one; the key list floats clear in
-the middle with four.
-
-Every overlay is placed by hand from the window's real size, so a narrow or
-short window shrinks them instead of clipping them. Tested down to 240x180.
-
-## Config
-
-Same format and the same key names as swov: `key = value`, `#` or `;`
-comments, colours as `RRGGBB` or `RRGGBBAA`.
-
-Files are read in this order, later wins, command line on top of both:
-
-```
-${XDG_CONFIG_HOME:-~/.config}/swov/config          shared palette
-${XDG_CONFIG_HOME:-~/.config}/browser-mini/config   our own settings
-```
-
-So the palette lives once in swov's file and drives all three programs.
-swov's file is full of keys for swov, so unknown keys there are counted and
-ignored quietly — one line, not forty. An unknown key in the browser's own
-file, or on the command line, is named, because there it means a typo.
-
-| Option | Effect |
-| --- | --- |
-| `-c PATH` | Read this file instead of the chain |
-| `-n` | Ignore the config files |
-| `-s KEY=VAL` | Set one key. `--KEY=VAL` and bare `KEY=VAL` also work |
-
-Every key is also a command line option:
-
-```
-browser-mini https://example.com --hl=ff8800 -s radius=6 ui_scale=1.2
-```
-
-**Look keys** (shared with swov): `bg tile tile_sel tile_hover card
-card_hover text subtext dim accent hl hltext hint urgent outline find_hl
-start_bg radius border pad gap win_gap ui_scale font font_mono label_px
-title_px hint_px`
-
-**Browser keys**: `title app_id zoom mod clip_cmd player image_viewer media_mode player_match download_dir profile
-private no_media page_title middle_click_paste css user_agent quiet`
-
-`font` is empty by default, meaning the GTK theme font. `font_mono` is
-browser-only and covers URLs and file names, where fixed width earns its
-place.
-
-## Notes
-
-- Profile directories keep the historic name `wkview`. Renaming it would
-  orphan existing cookie jars.
-- Middle-click paste is off by default: the press is claimed in the capture
-  phase, since WebKitGTK has no setting for it. `--enable-middle-click-paste`
-  puts it back.
-- Downloads appear the moment one starts, in aligned columns: name on the
-  left, percentage / size / ETA right aligned, the bar underneath.
+`--feature NAME[=on|off]` sets a WebKit runtime feature, `--list-features`
+lists them. `--gl-info` checks the graphics stack.
diff --git a/browser-big.c b/browser-big.c
index 90147cf..937e034 100644
--- a/browser-big.c
+++ b/browser-big.c
@@ -42,7 +42,7 @@ static gboolean g_capture_started;   /* something actually began capturing */
 static guint    g_capture_watch;
 
 /* camera / media behaviour */
-static gboolean g_cam_fix;                 /* install the getUserMedia shim */
+static gboolean g_cam_fix = TRUE;          /* install the shim (--no-cam-fix: nothing) */
 /*
  * The shim carries more than camera repairs: --rtc-trace, --web-compat,
  * --no-mic and the offer fixes all ride in it. Installing it must not
@@ -58,18 +58,17 @@ static int      g_cam_retries   = 3;
 static int      g_cam_max_w     = 1280;
 static int      g_cam_max_h     = 720;
 static int      g_cam_max_fps   = 30;
-static int      g_cam_force_w, g_cam_force_h, g_cam_force_fps;
-static gboolean g_cam_force_exact;
 static gboolean g_no_mic;                  /* --no-mic diagnostic */
 static gboolean g_web_compat;              /* fill in APIs WebKit lacks */
-static gboolean g_cam_scale;
-static int      g_cam_scale_fps = 15;      /* ceiling for the scaled output */               /* give the page the size it asked */
-static gboolean g_rtc_params_fix;          /* fill in the required codecs */
+static gboolean g_rtc_params_fix = TRUE;   /* fill in the required codecs */
 static gboolean g_rtc_trace;               /* report each WebRTC step */
-static gboolean g_sdp_ssrc_fix;            /* add the missing a=ssrc cname */
+static gboolean g_sdp_ssrc_fix = TRUE;     /* a=ssrc lines, real SSRC and pt */
+static gboolean g_silent_split = TRUE;     /* play video while its audio is silent */
+static gboolean g_fps_loose = TRUE;        /* cam_size_fix: let the size pick the mode */
 static char    *g_video_codecs;            /* preferred order, e.g. "VP8" */
-static int      g_cam_hold_ms = 3000;      /* how long a shared capture lingers */
+static gboolean g_codecs_set;              /* given explicitly; unset = VP8 */
 static char    *g_cam_match;               /* prefer this camera label */
+static char    *g_mic_match;               /* prefer this microphone label */
 static gboolean g_prewarm;                 /* probe capture devices at startup */
 static gboolean g_warm_cam;                /* open the camera before page one */
 static int      g_warm_timeout  = 10;
@@ -89,6 +88,8 @@ static gboolean g_first_load_done;
 static gboolean    g_no_webrtc, g_no_mediastream;
 static const char *g_gst_debug, *g_gst_dbgfile, *g_webkit_dbg;
 static char       *g_gst_rank;             /* GST_PLUGIN_FEATURE_RANK, accumulated */
+static gboolean    g_audio_alsa;           /* audio = alsa / --audio-alsa */
+static gboolean    g_ice_libnice = TRUE;   /* ice = rice / --ice-rice to undo */
 
 static void
 gst_rank_add (const char *spec)
@@ -123,6 +124,8 @@ typedef struct {
 static void load_diag_page (WebKitWebView *view);
 static gboolean g_list_cameras;
 static void cam_list (void);
+static void set_video_codecs (const char *v);
+static void set_fix_webrtc (gboolean on);
 
 /* Wall-clock stamp, so the UI process, the web process and GStreamer logs
  * can be lined up against each other. */
@@ -155,6 +158,53 @@ mlog (const char *fmt, ...)
     g_free (msg);
 }
 
+/*
+ * GStreamer 1.28 starts the device providers asynchronously:
+ * gst_device_monitor_start() returns before any provider has probed, and
+ * GST_MESSAGE_DEVICE_MONITOR_STARTED is posted on the monitor's bus once
+ * the initial device list exists. Asking for the devices before that
+ * message yields an empty or partial list. WebKit waits for it, so we do
+ * too - with a deadline, since we are a diagnostic and must not hang.
+ */
+#if !GST_CHECK_VERSION (1, 28, 0)
+#define GST_MESSAGE_DEVICE_MONITOR_STARTED ((GstMessageType) (GST_MESSAGE_EXTENDED + 9))
+#endif
+
+static gboolean
+device_monitor_wait_started (GstDeviceMonitor *mon, guint timeout_ms)
+{
+    guint maj = 0, min = 0, mic = 0, nano = 0;
+    gst_version (&maj, &min, &mic, &nano);
+    if (maj < 1 || (maj == 1 && min < 28))
+        return TRUE;                   /* older: providers started synchronously */
+
+    GstBus  *bus      = gst_device_monitor_get_bus (mon);
+    gint64   deadline = g_get_monotonic_time () + (gint64) timeout_ms * 1000;
+    gboolean started  = FALSE;
+
+    for (;;) {
+        gint64 left = deadline - g_get_monotonic_time ();
+        if (left <= 0)
+            break;
+        GstMessage *m = gst_bus_timed_pop_filtered (bus, (GstClockTime) left * 1000,
+                                                    GST_MESSAGE_EXTENDED);
+        if (!m)
+            break;
+        if (GST_MESSAGE_TYPE (m) == GST_MESSAGE_DEVICE_MONITOR_STARTED) {
+            const GstStructure *st = gst_message_get_structure (m);
+            gboolean ok = TRUE;
+            if (st)
+                gst_structure_get_boolean (st, "success", &ok);
+            started = ok;
+            gst_message_unref (m);
+            break;
+        }
+        gst_message_unref (m);
+    }
+    gst_object_unref (bus);
+    return started;
+}
+
 static gboolean
 is_diag_request (const char *s)
 {
@@ -197,13 +247,16 @@ parse_cam_max (const char *spec)
  *  2. retries with progressively looser constraints instead of handing the
  *     page a NotReadableError it will usually turn into a permanent failure.
  *
- *  3. keeps the acquired tracks and hands the page *clones*. The page can
- *     stop its clone (leave a call, switch view) without closing the device,
- *     so the next getUserMedia() resolves in milliseconds instead of seconds.
+ *  3. keeps the acquired tracks and hands a repeated request the same live
+ *     track, counting the hand-outs so stop() only closes the device when
+ *     the last user lets go (--cam-share).
  *
  *  4. watches <video>/<audio> for playback that has stopped advancing and
  *     tries play() -> seek -> load() -> reload before giving up.
  *
+ *  5. carries the WebRTC repairs (SDP, SSRC, payload type, codec order,
+ *     silent-audio split) and the --rtc-trace instrumentation.
+ *
  * Configuration arrives as window.__bbCfg, built in main().
  */
 static const char *SHIM_JS =
@@ -262,8 +315,8 @@ static const char *SHIM_JS =
 "if(!C.camfix||!md||!md.getUserMedia){post({ev:'shim',camfix:false});return;}"
 "var origGUM=md.getUserMedia.bind(md);"
 "var origEnum=md.enumerateDevices?md.enumerateDevices.bind(md):null;"
-"var cachedV=null,cachedA=null,pinned=null,pinTried=false;"
-"var served=[],lastServe=0,lastDevices='';"
+"var cachedV=null,cachedA=null,pinned=null,pinnedA=null,pinTried=false;"
+"var lastDevices='';"
 "function now(){return (window.performance&&performance.now)?performance.now():Date.now();}"
 "function safe(o){try{return JSON.parse(JSON.stringify(o));}catch(e){return String(o);}}"
 "function num(v){if(v==null)return null;if(typeof v==='number')return v;"
@@ -298,9 +351,37 @@ static const char *SHIM_JS =
 "  if(level>=2&&o.audio&&typeof o.audio==='object')o.audio=true;"
 "  return o;"
 "}"
-/* --cam-force: the size and shape are ours, whatever the page asked. */
 /* --no-mic: the page gets no microphone, whatever it asked. A diagnostic:
  * if a freeze disappears, the audio path is the cause. */
+/*
+ * WebKit only considers camera modes whose frame rate list contains the
+ * requested rate exactly (RealtimeVideoCaptureSource::presetSupportsFrameRate).
+ * A page asking frameRate {max: 20} therefore skips a 320x240 mode that
+ * only runs at 30 and lands on the one 16:9 mode that lists 20 -
+ * letterboxed instead of the 4:3 it asked for. Chrome takes the native mode
+ * and drops frames. A soft limit (ideal or max) is left out here so the
+ * size decides; exact and min are kept. (The 4:3 modes also need the WebKit
+ * caps-normalize patch, see HANDOVER.md.)
+ */
+"function looseFps(c){"
+"  if(!C.fpsLoose||!c||!c.video||typeof c.video!=='object'||c.video.frameRate==null)return c;"
+"  var f=c.video.frameRate;"
+"  if(typeof f==='object'&&(f.exact!=null||f.min!=null))return c;"
+"  var o={},nv={},k;"
+"  for(k in c)o[k]=c[k];"
+"  for(k in c.video)if(k!=='frameRate')nv[k]=c.video[k];"
+"  o.video=nv;"
+"  post({ev:'gum-fps-loosened',was:f});"
+"  return o;"
+"}"
+"if(C.fpsLoose&&window.MediaStreamTrack&&MediaStreamTrack.prototype.applyConstraints){(function(){"
+"  var oAC=MediaStreamTrack.prototype.applyConstraints;"
+"  MediaStreamTrack.prototype.applyConstraints=function(c){"
+"    if(this.kind==='video'&&c&&typeof c==='object'&&c.frameRate!=null)"
+"      c=looseFps({video:c}).video;"
+"    return oAC.call(this,c);"
+"  };"
+"})();}"
 "function withoutMic(c){"
 "  if(!C.noMic||!c||!c.audio)return c;"
 "  var o={},k;for(k in c)o[k]=c[k];"
@@ -309,38 +390,43 @@ static const char *SHIM_JS =
 "  post({ev:'gum-nomic'});"
 "  return o;"
 "}"
-"function withForce(c){"
-"  if(!C.forceW||!c||!c.video)return c;"
-"  var o={},k;for(k in c)o[k]=c[k];"
-"  var v=(o.video===true||typeof o.video!=='object')?{}:o.video;"
-"  var nv={};for(k in v)nv[k]=v[k];"
-"  var k2=C.forceExact?'exact':'ideal',w={},h={},ar={},fr={};"
-"  w[k2]=C.forceW;h[k2]=C.forceH;ar[k2]=C.forceW/C.forceH;"
-"  nv.width=w;nv.height=h;nv.aspectRatio=ar;"
-"  if(C.forceFps){fr[k2]=C.forceFps;nv.frameRate=fr;}"
-"  o.video=nv;return o;"
+"function pinKind(o,kind,id){"
+"  if(!id||!o[kind])return;"
+"  var v=(o[kind]===true||typeof o[kind]!=='object')?{}:o[kind];"
+"  var nv={},k;for(k in v)nv[k]=v[k];"
+"  if(kind==='audio'&&C.micMatch)nv.deviceId={exact:id};"
+"  else if(!nv.deviceId)nv.deviceId={ideal:id};"
+"  o[kind]=nv;"
 "}"
 "function withPin(c){"
-"  if(!pinned||!c||!c.video)return c;"
+"  if((!pinned&&!pinnedA)||!c)return c;"
 "  var o={},k;for(k in c)o[k]=c[k];"
-"  var v=(o.video===true||typeof o.video!=='object')?{}:o.video;"
-"  var nv={};for(k in v)nv[k]=v[k];"
-"  if(!nv.deviceId)nv.deviceId={ideal:pinned};"
-"  o.video=nv;return o;"
+"  pinKind(o,'video',pinned);pinKind(o,'audio',pinnedA);"
+"  return o;"
 "}"
 "function pinDevice(){"
-"  if(pinTried||!C.match||!origEnum)return Promise.resolve(null);"
-"  pinTried=true;"
+"  if(pinTried||(!C.match&&!C.micMatch)||!origEnum)return Promise.resolve(null);"
 "  return origEnum().then(function(l){"
-"    var m=C.match.toLowerCase();"
-"    for(var i=0;i<l.length;i++)"
-"      if(l[i].kind==='videoinput'&&(l[i].label||'').toLowerCase().indexOf(m)>=0){pinned=l[i].deviceId;break;}"
-"    post({ev:'cam-pin',match:C.match,found:!!pinned});return pinned;"
+/* Before the first grant in a document the labels are empty, so there is
+ * nothing to match yet: try again once a stream has been handed out. */
+"    if(!l.some(function(d){return d.label;}))return null;"
+"    pinTried=true;"
+"    function find(kind,m){m=m.toLowerCase();"
+"      for(var i=0;i<l.length;i++)"
+"        if(l[i].kind===kind&&(l[i].label||'').toLowerCase().indexOf(m)>=0)return l[i].deviceId;"
+"      return null;}"
+"    if(C.match){pinned=find('videoinput',C.match);"
+"      post({ev:'cam-pin',match:C.match,found:!!pinned});}"
+"    if(C.micMatch){pinnedA=find('audioinput',C.micMatch);"
+"      post({ev:'mic-pin',match:C.micMatch,found:!!pinnedA,"
+"            have:l.filter(function(d){return d.kind==='audioinput';}).map(function(d){return d.label;})});}"
+"    return pinned;"
 "  }).catch(function(){return null;});"
 "}"
 "function dumpTrack(t){"
 "  var s={};try{s=t.getSettings?t.getSettings():{};}catch(e){}"
-"  post({ev:'track',kind:t.kind,label:t.label,readyState:t.readyState,settings:s});"
+"  post({ev:'track',kind:t.kind,id:t.id,label:t.label,"
+"        readyState:t.readyState,muted:!!t.muted,settings:s});"
 "}"
 "function wants(c,k){return !!(c&&c[k]);}"
 "function deviceOk(track,c){"
@@ -349,138 +435,54 @@ static const char *SHIM_JS =
 "  if(typeof want!=='string')return true;"
 "  try{return track.getSettings().deviceId===want;}catch(e){return true;}"
 "}"
+/*
+ * Handing the page the same track it already has, rather than a clone.
+ * A clone is an independent track over one capture in the specification;
+ * on WebKitGTK 2.52 it goes black about a second after it is handed over
+ * and the encoder never sees a frame. The track itself does not.
+ *
+ * The one thing a clone bought was an independent lifetime, so the page
+ * dropping its first stream did not close the capture. That is kept by
+ * counting the hand-outs and swallowing stop() until the last one.
+ */
+"var shareRefs=0,realStop=null;"
+"function shareTrack(t){"
+"  if(!t)return t;"
+"  if(!realStop){"
+"    realStop=t.stop.bind(t);"
+"    t.stop=function(){"
+"      shareRefs--;"
+"      if(shareRefs>0){post({ev:'cam-share-hold',refs:shareRefs});return;}"
+"      post({ev:'cam-share-stop'});realStop();"
+"    };"
+"  }"
+"  shareRefs++;"
+"  return t;"
+"}"
 "function serve(c){"
 "  if(!C.cache)return null;"
 "  var out=new MediaStream(),ok=true;"
 "  if(wants(c,'video')){"
 "    if(cachedV&&cachedV.readyState==='live'&&deviceOk(cachedV,c)){"
-"      var v=cachedV.clone();"
-/* honour what the page asked for unless relaxing was requested */
-"      if(typeof c.video==='object')try{"
-"        v.applyConstraints(C.relax?relaxVideo(c.video,1):c.video).catch(function(){});"
-"      }catch(e){}"
-"      out.addTrack(v);"
+"      out.addTrack(shareTrack(cachedV));"
 "    }else ok=false;"
 "  }"
 "  if(wants(c,'audio')){"
-"    if(cachedA&&cachedA.readyState==='live')out.addTrack(cachedA.clone());else ok=false;"
+"    if(cachedA&&cachedA.readyState==='live')"
+"      out.addTrack(cachedA);"
+"    else ok=false;"
 "  }"
 "  return (ok&&out.getTracks().length)?note(out):null;"
 "}"
-/* Remember the clones handed to the page, so we can tell when they have
- * all finished and the held source is no longer doing anybody any good. */
-"function note(st){"
-"  lastServe=now();"
-"  st.getTracks().forEach(function(t){served.push(t);});"
-"  return st;"
-"}"
-"function anyLive(){"
-"  served=served.filter(function(t){return t.readyState==='live';});"
-"  return served.length>0;"
-"}"
+"function note(st){return st;}"
 "function keep(st){"
 "  if(!C.cache)return st;"
 "  var v=st.getVideoTracks()[0],a=st.getAudioTracks()[0];"
 "  if(v)cachedV=v;if(a)cachedA=a;"
-"  var out=new MediaStream();"
-"  if(v)out.addTrack(v.clone());"
-"  if(a)out.addTrack(a.clone());"
-"  return out.getTracks().length?note(out):st;"
+"  if(v)shareTrack(v);"
+"  return note(st);"
 "}"
 /* Which requested kind the browser has no device for, if any. */
-/*
- * WebKit does not scale a capture: asked for 320x240 it picks the nearest
- * native camera mode and hands that over instead, so a site that needs
- * the size it asked for gets a stream it rejects. Chrome scales. This does
- * the same, by drawing the camera into a canvas of the requested size and
- * capturing that.
- */
-"function scaleStream(st,c){"
-"  if(!C.scale||!st){return Promise.resolve(st);}"
-"  var wv=(c&&typeof c.video==='object')?c.video:null;"
-"  var wantW=wv?num(wv.width):null,wantH=wv?num(wv.height):null,"
-"      wantF=wv?num(wv.frameRate):null;"
-"  var vt=st.getVideoTracks()[0];"
-"  if(!wantW||!wantH||!vt){"
-"    post({ev:'cam-scale-skip',why:!vt?'no video track':'no size asked'});"
-"    return Promise.resolve(st);}"
-"  var s={};try{s=vt.getSettings?vt.getSettings():{};}catch(e){}"
-"  if(s.width===wantW&&s.height===wantH){"
-"    post({ev:'cam-scale-skip',why:'already the right size'});"
-"    return Promise.resolve(st);}"
-"  try{"
-"    var v=document.createElement('video');"
-"    v.muted=true;v.defaultMuted=true;v.autoplay=true;v.playsInline=true;"
-"    v.setAttribute('playsinline','');"
-/* It has to be in the document, and not display:none, or some engines
- * never produce frames to draw from. Parked off-screen instead. */
-"    v.style.cssText='position:fixed;left:-10000px;top:0;width:4px;height:4px;"
-"opacity:0;pointer-events:none';"
-"    (document.body||document.documentElement).appendChild(v);"
-"    v.srcObject=new MediaStream([vt]);"
-"    var cv=document.createElement('canvas');"
-"    cv.width=wantW;cv.height=wantH;"
-"    var ctx=cv.getContext('2d');"
-"    if(!cv.captureStream){"
-"      post({ev:'cam-scale-error',why:'no captureStream'});"
-"      return Promise.resolve(st);}"
-"    var fps=Math.min(wantF||C.scaleFps||15,C.scaleFps||15);"
-/* play() is fired and not awaited: if it never settles the page's own
- * getUserMedia would hang forever, which is worse than a late first frame. */
-"    try{var p=v.play();if(p&&p.catch)p.catch(function(){});}catch(e){}"
-"    return new Promise(function(resolve){"
-"      var done=false;"
-"      function go(){"
-"        if(done)return;done=true;"
-/*
- * Drawing on a timer burns CPU whether or not a new frame arrived, and
- * this runs on the page's own main thread - enough to freeze a heavy
- * conference app that is already software-rendering. requestVideoFrameCallback
- * fires once per actual frame, which is both cheaper and in step.
- */
-"        var stopped=false;"
-"        function draw(){"
-"          try{if(v.videoWidth)ctx.drawImage(v,0,0,cv.width,cv.height);}catch(e){}"
-"        }"
-"        var timer=null;"
-"        if(v.requestVideoFrameCallback){"
-"          var last=0,minGap=1000/fps;"
-"          (function loop(now){"
-"            if(stopped)return;"
-"            if(!last||now-last>=minGap-2){last=now||0;draw();}"
-"            try{v.requestVideoFrameCallback(loop);}catch(e){stopped=true;}"
-"          })(0);"
-"          post({ev:'cam-scale-mode',mode:'frame-callback',fps:fps});"
-"        }else{"
-"          timer=setInterval(draw,Math.max(33,Math.round(1000/fps)));"
-"          post({ev:'cam-scale-mode',mode:'timer',fps:fps});"
-"        }"
-"        var out,nt;"
-"        try{out=cv.captureStream(fps);nt=out.getVideoTracks()[0];}catch(e){}"
-"        if(!nt){clearInterval(timer);"
-"          post({ev:'cam-scale-error',why:'captureStream gave no track'});"
-"          resolve(st);return;}"
-"        var oStop=nt.stop.bind(nt);"
-"        nt.stop=function(){stopped=true;if(timer)clearInterval(timer);"
-"          try{vt.stop();}catch(e){}"
-"          try{v.pause();v.srcObject=null;}catch(e){}"
-"          try{v.remove();}catch(e){}"
-"          oStop();};"
-"        var ms=new MediaStream();"
-"        ms.addTrack(nt);"
-"        st.getAudioTracks().forEach(function(a){ms.addTrack(a);});"
-"        ms.__bbScale={v:v,cv:cv,src:vt,timer:timer};"
-"        post({ev:'cam-scaled',from:{w:s.width||0,h:s.height||0},"
-"              to:{w:cv.width,h:cv.height},fps:fps});"
-"        resolve(ms);"
-"      }"
-"      v.addEventListener('loadedmetadata',go);"
-"      v.addEventListener('playing',go);"
-"      setTimeout(go,400);"        /* never leave the page waiting */
-"    });"
-"  }catch(e){post({ev:'cam-scale-error',why:String((e&&e.message)||e)});"
-"    return Promise.resolve(st);}"
-"}"
 "function countKinds(c){"
 "  if(!origEnum)return Promise.resolve(null);"
 "  return origEnum().then(function(l){"
@@ -499,23 +501,47 @@ static const char *SHIM_JS =
 "  return n==='NotReadableError'||n==='AbortError'||n==='OverconstrainedError'||"
 "         n==='TimeoutError'||n==='NotFoundError'||n==='TypeError'||!n;"
 "}"
+"function fixMic(st,c){"
+"  if(!C.micMatch||!wants(c,'audio'))return Promise.resolve(st);"
+"  var asked=c.audio&&typeof c.audio==='object'&&c.audio.deviceId;"
+"  var at=st.getAudioTracks()[0];"
+"  if(asked||!at)return Promise.resolve(st);"
+"  return pinDevice().then(function(){"
+"    var have='';try{have=at.getSettings().deviceId;}catch(e){}"
+"    if(!pinnedA||have===pinnedA)return st;"
+"    return origGUM({audio:{deviceId:{exact:pinnedA}}}).then(function(s2){"
+"      var nt=s2.getAudioTracks()[0];"
+"      if(!nt)return st;"
+"      st.removeTrack(at);try{at.stop();}catch(e){}"
+"      st.addTrack(nt);"
+"      post({ev:'mic-swapped',from:at.label,to:nt.label});"
+"      return st;"
+"    },function(e){post({ev:'mic-swap-failed',name:e&&e.name});return st;});"
+"  });"
+"}"
 "md.getUserMedia=function(c){"
 "  var t0=now();"
 "  post({ev:'gum-call',frame:location.href.slice(0,80),constraints:safe(c)});"
 "  var hit=serve(c);"
-"  if(hit){post({ev:'gum-cache-hit'});return scaleStream(hit,c);}"
+"  if(hit){"
+"    var ht=hit.getVideoTracks()[0];"
+"    post({ev:'gum-cache-hit',track:ht?ht.id:null,"
+"          same:!!(ht&&cachedV&&ht===cachedV)});"
+"    return Promise.resolve(hit);}"
 "  return pinDevice().then(function(){"
 "    var start=C.relax?1:0,levels=[],l;"
 "    for(l=start;l<=3&&levels.length<=C.retries;l++)levels.push(l);"
 "    if(!levels.length)levels=[start];"
 "    var i=0,audioDropped=false;"
 "    function attempt(){"
-"      var cc=withoutMic(withForce(withPin(relax(c,levels[i]))));"
+"      var cc=looseFps(withoutMic(withPin(relax(c,levels[i]))));"
 "      post({ev:'gum-try',level:levels[i],constraints:safe(cc)});"
 "      return origGUM(cc).then(function(st){"
 "        post({ev:'gum-ok',ms:Math.round(now()-t0),level:levels[i]});"
-"        st.getTracks().forEach(dumpTrack);"
-"        return scaleStream(keep(st),c);"
+"        return fixMic(st,c).then(function(st){"
+"          st.getTracks().forEach(dumpTrack);"
+"          return keep(st);"
+"        });"
 "      }).catch(function(err){"
 "        post({ev:'gum-error',name:err&&err.name,message:err&&err.message,"
 "              constraint:err&&err.constraint,level:levels[i],ms:Math.round(now()-t0)});"
@@ -556,25 +582,16 @@ static const char *SHIM_JS =
 "    return l;});};"
 "window.__bbWarm=function(){"
 "  return md.getUserMedia({video:true}).then(function(s){"
-"    s.getTracks().forEach(function(t){t.stop();});"   /* stops the clone only */
+"    s.getTracks().forEach(function(t){t.stop();});"   /* drops our share only */
 "    post({ev:'warm-ok'});return true;"
 "  }).catch(function(e){post({ev:'warm-error',name:e&&e.name,message:e&&e.message});return false;});"
 "};"
 "window.__bbRelease=function(){"
+"  shareRefs=0;"
+"  if(realStop)try{realStop();}catch(e){}"
 "  [cachedV,cachedA].forEach(function(t){if(t)try{t.stop();}catch(e){}});"
 "  cachedV=cachedA=null;post({ev:'released'});return true;"
 "};"
-/*
- * Holding the capture open is the whole point of sharing, but holding it
- * after the page has stopped every clone leaves the camera light on with
- * nothing watching. So the hold expires once no handed-out track is live.
- */
-"if(C.cache&&C.holdMs>0)setInterval(function(){"
-"  if(!cachedV&&!cachedA)return;"
-"  if(anyLive()){lastServe=now();return;}"
-"  if(now()-lastServe<C.holdMs)return;"
-"  window.__bbRelease();post({ev:'cam-hold-expired'});"
-"},1000);"
 /*
  * WebKitGTK's WebRTC is GStreamer-based and its offers differ from the
  * ones a site tested against Chrome. Two of those differences stop a
@@ -605,34 +622,85 @@ static const char *SHIM_JS =
 "  if(changed)post({ev:'sdp-ssrc-added'});"
 "  return parts.join('');"
 "}"
+/*
+ * What was actually negotiated, from the SDP itself: per media section
+ * its direction, any bandwidth ceiling the far end asked for, and the
+ * codecs in the order they were offered or accepted. When a stream is
+ * sent and the far end shows nothing, this says whether the two sides
+ * even agreed on a codec, and whether a limit was requested that we are
+ * ignoring.
+ */
+"function sdpSummary(sdp){"
+"  if(!sdp)return null;"
+"  var out=[],cur=null;"
+"  sdp.split(/\\r?\\n/).forEach(function(l){"
+"    var m=/^m=(\\w+)\\s+\\d+\\s+\\S+\\s+(.*)$/.exec(l);"
+"    if(m){cur={kind:m[1],pts:m[2].split(/\\s+/),dir:'',bw:'',names:{}};out.push(cur);return;}"
+"    if(!cur)return;"
+"    if(/^a=(sendrecv|sendonly|recvonly|inactive)/.test(l))cur.dir=l.slice(2);"
+"    else if(/^b=/.test(l))cur.bw=l.slice(2);"
+"    var r=/^a=rtpmap:(\\d+)\\s+([^/]+)/.exec(l);"
+"    if(r)cur.names[r[1]]=r[2];"
+"  });"
+"  return out.map(function(s){"
+"    return {kind:s.kind,dir:s.dir,bw:s.bw,"
+"            codecs:s.pts.map(function(p){return p+' '+(s.names[p]||'?');})};"
+"  });"
+"}"
+"function firstVideo(sdp){"
+"  var s=sdpSummary(sdp)||[],i,j;"
+"  for(i=0;i<s.length;i++){if(s[i].kind!=='video')continue;"
+"    for(j=0;j<s[i].codecs.length;j++){"
+"      var n=s[i].codecs[j].split(' ')[1]||'';"
+"      if(!/^(rtx|red|ulpfec|flexfec-03|\\?)$/i.test(n))return n.toUpperCase();}}"
+"  return null;"
+"}"
+"function postSdp(which,sdp){"
+"  try{var sum=sdpSummary(sdp);if(sum)post({ev:'sdp',which:which,m:sum});}catch(e){}"
+"}"
 "function codecPrefs(pc){"
 "  if(!C.codecs||!C.codecs.length)return;"
 "  try{"
 "    var caps=window.RTCRtpSender&&RTCRtpSender.getCapabilities?"
 "             RTCRtpSender.getCapabilities('video'):null;"
 "    if(!caps||!caps.codecs)return;"
-"    var want=C.codecs,groups=[],rest=[],i,j;"
-"    for(i=0;i<want.length;i++)groups.push([]);"
+"    var want=C.codecs.map(function(w){return w.toLowerCase().replace(/^video\\//,'');});"
+"    var front=[],rest=[];"
 "    caps.codecs.forEach(function(c){"
-"      var m=(c.mimeType||'').toLowerCase(),hit=-1;"
-"      for(j=0;j<want.length;j++){"
-"        var w=want[j].toLowerCase();"
-"        if(m===w||m==='video/'+w)hit=j;"
-"      }"
-"      if(hit>=0)groups[hit].push(c);else rest.push(c);"
-"    });"
-"    var flat=[];"
-"    groups.forEach(function(g){g.forEach(function(c){flat.push(c);});});"
-"    rest.forEach(function(c){flat.push(c);});"
-"    if(!flat.length)return;"
+"      var m=(c.mimeType||'').toLowerCase().replace(/^video\\//,'');"
+"      (want.indexOf(m)>=0?front:rest).push(c);});"
+"    front.sort(function(a,b){"
+"      return want.indexOf(a.mimeType.toLowerCase().replace(/^video\\//,''))-"
+"             want.indexOf(b.mimeType.toLowerCase().replace(/^video\\//,''));});"
+"    var flat=front.concat(rest);"
+"    if(!front.length)return;"
 "    pc.getTransceivers().forEach(function(t){"
-"      if(t.setCodecPreferences&&t.sender&&t.sender.track&&"
-"         t.sender.track.kind==='video')"
+"      if(t.setCodecPreferences&&t.sender&&t.sender.track&&t.sender.track.kind==='video')"
 "        try{t.setCodecPreferences(flat);}catch(e){}"
 "    });"
-"    post({ev:'codec-pref',order:flat.slice(0,4).map(function(c){return c.mimeType;})});"
 "  }catch(e){}"
 "}"
+"function sdpPreferCodecs(sdp,want){"
+"  if(!sdp||!want||!want.length)return sdp;"
+"  var parts=sdp.split(/(?=\\r?\\nm=)/),changed=false,order=null;"
+"  for(var i=0;i<parts.length;i++){"
+"    var sec=parts[i],m=/(^|\\n)(m=video [^\\r\\n]*? )([0-9 ]+)(\\r?\\n|$)/.exec(sec);"
+"    if(!m||/a=(recvonly|inactive)/.test(sec))continue;"
+"    var pts=m[3].trim().split(/\\s+/),names={},re=/a=rtpmap:(\\d+) ([^\\/\\r\\n]+)/g,r;"
+"    while((r=re.exec(sec)))names[r[1]]=r[2].toLowerCase();"
+"    var front=[];"
+"    want.forEach(function(w){w=w.toLowerCase().replace(/^video\\//,'');"
+"      pts.forEach(function(pt){if(names[pt]===w&&front.indexOf(pt)<0)front.push(pt);});});"
+"    if(!front.length)continue;"
+"    var rest=pts.filter(function(pt){return front.indexOf(pt)<0;});"
+"    var np=front.concat(rest).join(' ');"
+"    if(np===pts.join(' '))continue;"
+"    parts[i]=sec.replace(m[0],m[1]+m[2]+np+m[4]);"
+"    changed=true;order=front.map(function(pt){return pt+' '+names[pt];});"
+"  }"
+"  if(changed)post({ev:'codec-pref',order:order});"
+"  return parts.join('');"
+"}"
 /*
  * Where does a publish actually stop? The offer rewriting only helps if an
  * offer is ever made, so every step before it is reported too: the
@@ -699,13 +767,115 @@ static const char *SHIM_JS =
 "if(C.rtcTrace){(function(){"
 "  var O=window.RTCPeerConnection;"
 "  if(!O){post({ev:'rtc-missing'});return;}"
+"  var pcs=[];"
+/*
+ * Whether anything is actually going out. A connected ICE pair proves a
+ * path exists, not that the encoder ever produced a frame - and those two
+ * failures look identical from the outside: the other end shows a
+ * spinner. framesEncoded stays 0 when the camera track delivers nothing,
+ * bytesSent grows when it does.
+ */
+"  var vlast=new WeakMap(),vids=[];"
+"  setInterval(function(){"
+"    var l=document.querySelectorAll('video');"
+"    for(var i=0;i<l.length;i++){var v=l[i];"
+"      if(!v.srcObject)continue;"
+"      if(vids.indexOf(v)<0)vids.push(v);"
+"      var q=null;try{q=v.getVideoPlaybackQuality?v.getVideoPlaybackQuality():null;}catch(e){}"
+"      var r=v.getBoundingClientRect(),st=getComputedStyle(v),vt=[];"
+"      try{vt=v.srcObject.getVideoTracks();}catch(e){}"
+"      var o={ev:'video-state',v:vids.indexOf(v),id:v.id||v.className||'',"
+"        ready:v.readyState,paused:v.paused,w:v.videoWidth,h:v.videoHeight,"
+"        ct:Math.round(v.currentTime||0),frames:q?q.totalVideoFrames:-1,"
+"        dropped:q?q.droppedVideoFrames:-1,"
+"        box:Math.round(r.width)+'x'+Math.round(r.height),"
+"        shown:st.display!=='none'&&st.visibility!=='hidden'&&+st.opacity>0,"
+"        track:vt.length?(vt[0].id.slice(0,8)+' '+vt[0].readyState+(vt[0].muted?'/muted':'')):'none'};"
+"      var k=JSON.stringify(o);"
+"      if(vlast.get(v)===k)continue;"
+"      vlast.set(v,k);post(o);"
+"    }"
+"  },3000);"
+"  try{var SD=Object.getOwnPropertyDescriptor(HTMLMediaElement.prototype,'srcObject');"
+"    if(SD&&SD.set)Object.defineProperty(HTMLMediaElement.prototype,'srcObject',{configurable:true,"
+"      get:SD.get,set:function(v){"
+"        var vt=[];try{vt=v&&v.getVideoTracks?v.getVideoTracks():[];}catch(e){}"
+"        if(this.tagName==='VIDEO')post({ev:'video-attach',id:this.id||this.className||'',"
+"          track:vt.length?vt[0].id.slice(0,8):(v?'no-video':'null')});"
+"        return SD.set.call(this,v);}});}catch(e){}"
+"  try{var HP=HTMLMediaElement.prototype,oPlay=HP.play;"
+"    HP.play=function(){var v=this,p=oPlay.apply(v,arguments);"
+"      if(p&&p.catch)p.catch(function(e){"
+"        post({ev:'play-rejected',name:e&&e.name,message:String((e&&e.message)||e),"
+"              srcObject:!!v.srcObject,muted:v.muted});});"
+"      return p;};}catch(e){}"
+"  var last={};"
+"  setInterval(function(){"
+"    pcs.forEach(function(pc,i){"
+"      if(!pc.getStats||pc.signalingState==='closed')return;"
+"      pc.getStats().then(function(r){"
+"        if(!r||!r.forEach)return;"
+"        var o={ev:'rtc-stats',pc:i},any=false,codecs={};"
+/* The codec stats have to be collected first: an outbound-rtp only
+ * carries a codecId pointing at one of them. What is actually being
+ * encoded is the whole question when the far end shows nothing. */
+"        r.forEach(function(s){"
+"          if(s.type==='codec')codecs[s.id]={mime:s.mimeType,pt:s.payloadType};"
+"        });"
+"        r.forEach(function(s){"
+"          var k=s.kind||s.mediaType||'?';"
+"          if(s.type==='outbound-rtp'){any=true;"
+"            var c=codecs[s.codecId]||{};"
+"            o['out-'+k]={ssrc:s.ssrc||0,bytes:s.bytesSent||0,enc:s.framesEncoded||0,"
+"                         sent:s.framesSent||0,pkts:s.packetsSent||0,"
+"                         w:s.frameWidth||0,h:s.frameHeight||0,"
+"                         codec:c.mime||null,pt:c.pt,"
+"                         pli:s.pliCount||0,nack:s.nackCount||0,fir:s.firCount||0,"
+"                         keys:s.keyFramesEncoded||0,"
+"                         limited:s.qualityLimitationReason||null,"
+"                         target:s.targetBitrate||0};}"
+"          else if(s.type==='inbound-rtp'){any=true;"
+"            o['in-'+k]={ssrc:s.ssrc||0,bytes:s.bytesReceived||0,dec:s.framesDecoded||0,"
+"                        keys:s.keyFramesDecoded||0,w:s.frameWidth||0,h:s.frameHeight||0,"
+"                        pkts:s.packetsReceived||0,lost:s.packetsLost||0,"
+"                        disc:s.packetsDiscarded||0,"
+"                        pli:s.pliCount||0,fir:s.firCount||0,nack:s.nackCount||0};}"
+/* The far end's own receiver report, sent back over RTCP. Its presence
+ * proves our packets are arriving there; lost and rtt say in what state. */
+"          else if(s.type==='remote-inbound-rtp'){any=true;"
+"            o['far-'+k]={lost:s.packetsLost,frac:s.fractionLost,"
+"                         jitter:s.jitter,rtt:s.roundTripTime};}"
+"          else if(s.type==='candidate-pair'&&(s.nominated||s.state==='succeeded')){"
+"            o.pair={state:s.state,sent:s.bytesSent||0,recv:s.bytesReceived||0};}"
+"        });"
+"        try{pc.getSenders().forEach(function(sn){"
+"          if(!sn.track)return;any=true;"
+"          var g={};try{g=sn.track.getSettings?sn.track.getSettings():{};}catch(e){}"
+"          o['track-'+sn.track.kind]={state:sn.track.readyState,muted:!!sn.track.muted,"
+"                                     w:g.width||0,h:g.height||0};"
+"        });}catch(e){}"
+"        try{pc.getReceivers().forEach(function(rv){"
+"          if(!rv.track)return;any=true;"
+"          o['rtrack-'+rv.track.kind]={id:rv.track.id.slice(0,8),state:rv.track.readyState,muted:!!rv.track.muted};"
+"        });}catch(e){}"
+"        if(!any)return;"
+"        var key=JSON.stringify(o);"
+"        if(key===last[i])return;"          /* nothing moved, do not repeat */
+"        last[i]=key;post(o);"
+"      }).catch(function(e){"
+"        if(!pc.__bbStatsErr){pc.__bbStatsErr=1;"
+"          post({ev:'rtc-stats-error',pc:i,message:String((e&&e.message)||e)});}"
+"      });"
+"    });"
+"  },3000);"
 "  function W(){"
 "    var pc=new O(arguments[0],arguments[1]);"
-"    post({ev:'rtc-new'});"
+"    pcs.push(pc);var id=pcs.length-1;"
+"    post({ev:'rtc-new',pc:id});"
 "    ['icegatheringstatechange','iceconnectionstatechange',"
 "     'connectionstatechange','signalingstatechange'].forEach(function(e){"
 "      try{pc.addEventListener(e,function(){"
-"        post({ev:'rtc-state',on:e,ice:pc.iceConnectionState,"
+"        post({ev:'rtc-state',pc:id,on:e,ice:pc.iceConnectionState,"
 "              conn:pc.connectionState,sig:pc.signalingState});});}catch(x){}"
 "    });"
 "    return pc;"
@@ -715,17 +885,60 @@ static const char *SHIM_JS =
 "  window.RTCPeerConnection=W;"
 "  if(window.webkitRTCPeerConnection)window.webkitRTCPeerConnection=W;"
 "  ['addTrack','addTransceiver','addStream','setRemoteDescription',"
-"   'createOffer','createAnswer','setLocalDescription'].forEach(function(m){"
+"   'createOffer','createAnswer','setLocalDescription','addIceCandidate',"
+"   'setConfiguration','getStats','close'].forEach(function(m){"
 "    var f=O.prototype[m];"
 "    if(!f)return;"
 "    O.prototype[m]=function(){"
 "      var a0=arguments[0];"
 "      var d=(m==='setRemoteDescription'&&a0&&a0.type)?a0.type:"
 "            (a0&&a0.kind)?a0.kind:undefined;"
-"      post({ev:'rtc-call',fn:m,arg:d});"
-"      return f.apply(this,arguments);"
+"      if(m!=='getStats')post({ev:'rtc-call',fn:m,arg:d});"
+"      var r;"
+"      try{r=f.apply(this,arguments);}"
+"      catch(e){post({ev:'rtc-error',fn:m,when:'threw',"
+"                     name:e&&e.name,message:String((e&&e.message)||e)});throw e;}"
+/* A handler here does not take the rejection away from the page: it
+ * still gets the promise it was given, with its own handlers. */
+"      if(r&&r.then)r.then(undefined,function(e){"
+"        post({ev:'rtc-error',fn:m,when:'rejected',"
+"              name:e&&e.name,message:String((e&&e.message)||e)});});"
+"      return r;"
 "    };"
 "  });"
+/*
+ * Where a publisher sets its bitrate. WebKit requires
+ * RTCRtpSendParameters.codecs and Firefox does not, so a library on its
+ * Firefox path throws here - after the offer is made and before the
+ * local description is set, which looks like the site simply giving up.
+ * Traced whether or not --rtc-params-fix is repairing it.
+ */
+"  var SND=window.RTCRtpSender;"
+"  if(SND&&SND.prototype&&SND.prototype.setParameters){"
+"    var oSP2=SND.prototype.setParameters;"
+"    SND.prototype.setParameters=function(p){"
+"      post({ev:'rtc-call',fn:'setParameters',"
+"            arg:(p&&p.encodings&&p.encodings.length)?('encodings:'+p.encodings.length):undefined,"
+"            keys:p?Object.keys(p):[]});"
+"      var r;"
+"      try{r=oSP2.apply(this,arguments);}"
+"      catch(e){post({ev:'rtc-error',fn:'setParameters',when:'threw',"
+"                     name:e&&e.name,message:String((e&&e.message)||e)});throw e;}"
+"      if(r&&r.then)r.then(undefined,function(e){"
+"        post({ev:'rtc-error',fn:'setParameters',when:'rejected',"
+"              name:e&&e.name,message:String((e&&e.message)||e)});});"
+"      return r;"
+"    };"
+"  }"
+/* And what the page itself fails on, which is otherwise invisible. */
+"  try{"
+"    window.addEventListener('error',function(e){"
+"      post({ev:'page-error',message:String((e&&e.message)||e),"
+"            src:((e&&e.filename)||'').slice(-60),line:(e&&e.lineno)||0});});"
+"    window.addEventListener('unhandledrejection',function(e){"
+"      var r=e&&e.reason;"
+"      post({ev:'page-reject',name:r&&r.name,message:String((r&&r.message)||r)});});"
+"  }catch(e){}"
 /*
  * A publisher library decides up front whether it supports this browser,
  * from the user agent and a handful of API checks. When it decides not to,
@@ -763,17 +976,151 @@ static const char *SHIM_JS =
 "  };"
 "  post({ev:'params-fix-on'});"
 "})();}"
-"if(C.ssrcFix||(C.codecs&&C.codecs.length)){(function(){"
+"if(C.ssrcFix||C.rtcTrace||(C.codecs&&C.codecs.length)){(function(){"
 "  var P=window.RTCPeerConnection;"
 "  if(!P){post({ev:'rtc-missing'});return;}"
 "  var cname=rtcCname();"
 "  var oCO=P.prototype.createOffer,oCA=P.prototype.createAnswer,"
 "      oSLD=P.prototype.setLocalDescription;"
 "  function patch(d){"
-"    if(!C.ssrcFix||!d||!d.sdp)return d;"
-"    var s=sdpAddSsrc(d.sdp,cname);"
+"    if(!d||!d.sdp)return d;"
+"    var s=d.sdp;"
+"    if(C.codecs&&C.codecs.length)s=sdpPreferCodecs(s,C.codecs);"
+"    if(C.ssrcFix)s=sdpAddSsrc(s,cname);"
 "    return s===d.sdp?d:{type:d.type,sdp:s};"
 "  }"
+"  function fakesIn(sdp,map){"
+"    var want=[],vals={},k;"
+"    for(k in map)vals[map[k]]=1;"
+"    (sdp||'').split(/(?=\\r?\\nm=)/).forEach(function(sec){"
+"      var m=new RegExp('a=ssrc:(\\\\d+) cname:'+cname).exec(sec),"
+"          mid=/a=mid:(\\S+)/.exec(sec),kd=/m=(audio|video)/.exec(sec);"
+"      if(m&&kd&&!map[m[1]]&&!vals[m[1]])"
+"        want.push({fake:m[1],mid:mid?mid[1]:null,kind:kd[1]});"
+"    });"
+"    return want;"
+"  }"
+"  function hookLD(pc){"
+"    if(pc.__bbLD)return;pc.__bbLD=true;"
+"    ['localDescription','currentLocalDescription','pendingLocalDescription'].forEach(function(n){"
+"      var d=Object.getOwnPropertyDescriptor(P.prototype,n);"
+"      if(!d||!d.get)return;"
+"      try{Object.defineProperty(pc,n,{configurable:true,get:function(){"
+"        var v=d.get.call(pc);if(!v||!v.sdp)return v;"
+"        var x=v.sdp,k,map=pc.__bbMap||{};"
+"        for(k in map)x=x.split('a=ssrc:'+k+' ').join('a=ssrc:'+map[k]+' ');"
+"        return x===v.sdp?v:new RTCSessionDescription({type:v.type,sdp:x});"
+"      }});}catch(e){}"
+"    });"
+"  }"
+"  var SS={map:{},pending:{},info:{}};"
+"  function ssrcRe(f){return new RegExp('(^|[^0-9])'+f+'(?![0-9])','g');}"
+"  function realSsrc(pc){"
+"    var map=pc.__bbMap||(pc.__bbMap={});"
+"    var want=fakesIn(pc.localDescription&&pc.localDescription.sdp,map);"
+"    hookLD(pc);"
+"    if(!want.length)return;"
+"    want.forEach(function(w){SS.pending[w.fake]=1;});"
+"    var t0=Date.now();"
+"    function look(){"
+"      var trs=[];try{trs=pc.getTransceivers();}catch(e){}"
+"      Promise.all(want.map(function(w){"
+"        if(map[w.fake])return null;"
+"        var tr=trs.filter(function(t){return w.mid!==null&&t.mid===w.mid;})[0];"
+"        var q;try{q=(tr&&tr.sender&&tr.sender.getStats)?tr.sender.getStats():pc.getStats();}"
+"        catch(e){q=pc.getStats();}"
+"        return Promise.resolve(q).then(function(r){"
+"          var hit=null,cid=null,cod={};"
+"          r.forEach(function(st){if(st.type==='codec')cod[st.id]=st;});"
+"          r.forEach(function(st){"
+"            if(st.type!=='outbound-rtp'||!st.ssrc||hit)return;"
+"            if(st.mid&&w.mid&&st.mid!==w.mid)return;"
+"            var sk=st.kind||st.mediaType;"
+"            if(sk&&sk!==w.kind)return;"
+"            hit=st.ssrc;cid=st.codecId;"
+"          });"
+"          if(hit){var cc=cod[cid]||{};"
+"            SS.info[String(hit)]={pt:cc.payloadType,mime:cc.mimeType||null};}"
+"          if(hit){map[w.fake]=String(hit);SS.map[w.fake]=String(hit);delete SS.pending[w.fake];"
+"            post({ev:'ssrc-real',kind:w.kind,mid:w.mid,fake:+w.fake,real:+hit,ms:Date.now()-t0});}"
+"        },function(){});"
+"      })).then(function(){"
+"        var left=want.filter(function(w){return !map[w.fake];});"
+"        if(!left.length)return;"
+"        if(Date.now()-t0<15000){setTimeout(look,150);return;}"
+"        left.forEach(function(w){delete SS.pending[w.fake];"
+"          post({ev:'ssrc-real-missing',kind:w.kind,mid:w.mid,fake:+w.fake});});"
+"      });"
+"    }"
+"    look();"
+"  }"
+"  function ssrcRewrite(d){"
+"    if(typeof d!=='string')return {d:d,n:0};"
+"    var n=0,f;"
+"    for(f in SS.map){var re=ssrcRe(f);"
+"      if(re.test(d)){n++;d=d.replace(ssrcRe(f),'$1'+SS.map[f]);}}"
+"    return {d:d,n:n};"
+"  }"
+"  function ptFix(d){"
+"    if(typeof d!=='string')return {d:d,n:0};"
+"    var i=d.indexOf('['),j=d.indexOf('{'),k=i<0?j:(j<0?i:Math.min(i,j));"
+"    if(k<0)return {d:d,n:0};"
+"    var body;try{body=JSON.parse(d.slice(k));}catch(e){return {d:d,n:0};}"
+"    var n=0;"
+"    (function walk(o,depth){"
+"      if(!o||typeof o!=='object'||depth>12)return;"
+"      if(Array.isArray(o.codecs)&&Array.isArray(o.encodings)){"
+"        var info=null;"
+"        o.encodings.forEach(function(e){if(e&&SS.info[String(e.ssrc)])info=SS.info[String(e.ssrc)];});"
+"        var media=o.codecs.filter(function(c){"
+"          return c&&c.mimeType&&!/\\/(rtx|red|ulpfec|flexfec)/i.test(c.mimeType);});"
+"        var c=media[0];"
+"        if(info&&c&&info.pt!=null){"
+"          if(info.mime&&c.mimeType.toLowerCase()!==info.mime.toLowerCase())"
+"            post({ev:'codec-mismatch',sending:info.mime,agreed:c.mimeType});"
+"          else if(c.payloadType!==info.pt){"
+"            var old=c.payloadType;c.payloadType=info.pt;"
+"            o.codecs.forEach(function(x){"
+"              if(x&&x.parameters&&x.parameters.apt===old)x.parameters.apt=info.pt;});"
+"            n++;post({ev:'pt-fixed',mime:c.mimeType,from:old,to:info.pt});"
+"          }"
+"        }"
+"      }"
+"      for(var key in o)if(Object.prototype.hasOwnProperty.call(o,key))walk(o[key],depth+1);"
+"    })(body,0);"
+"    return n?{d:d.slice(0,k)+JSON.stringify(body),n:n}:{d:d,n:0};"
+"  }"
+"  function ssrcPending(d){"
+"    if(typeof d!=='string')return false;"
+"    for(var f in SS.pending)if(ssrcRe(f).test(d))return true;"
+"    return false;"
+"  }"
+"  if(C.ssrcFix&&window.WebSocket){(function(){"
+"    var WS=window.WebSocket.prototype,oSend=WS.send;"
+"    function flush(ws,t0,timedOut){"
+"      var q=ws.__bbQ||[],n=0;ws.__bbQ=null;"
+"      q.forEach(function(d){var r=ssrcRewrite(d);n+=r.n;r=ptFix(r.d);try{oSend.call(ws,r.d);}catch(e){}});"
+"      post({ev:'ssrc-ws',held:q.length,rewritten:n,ms:Date.now()-t0,timeout:!!timedOut});"
+"    }"
+"    WS.send=function(d){"
+"      var ws=this;"
+"      if(ws.__bbQ){ws.__bbQ.push(d);return;}"
+"      if(ssrcPending(d)){"
+"        ws.__bbQ=[d];var t0=Date.now();"
+"        (function wait(){"
+"          if(!ws.__bbQ)return;"
+"          var still=ws.__bbQ.some(ssrcPending);"
+"          if(!still)flush(ws,t0,false);"
+"          else if(Date.now()-t0>8000)flush(ws,t0,true);"
+"          else setTimeout(wait,100);"
+"        })();"
+"        return;"
+"      }"
+"      var r=ssrcRewrite(d);"
+"      if(r.n){post({ev:'ssrc-ws',held:0,rewritten:r.n,ms:0,timeout:false});r=ptFix(r.d);}"
+"      return oSend.call(ws,r.d);"
+"    };"
+"  })();}"
 "  P.prototype.createOffer=function(){"
 "    var pc=this,a=arguments;"
 "    codecPrefs(pc);"
@@ -793,16 +1140,62 @@ static const char *SHIM_JS =
 "  P.prototype.setLocalDescription=function(d){"
 "    var pc=this;"
 "    return Promise.resolve(oSLD.call(pc,patch(d))).then(function(r){"
+"      var sdp=(pc.localDescription&&pc.localDescription.sdp)||'';"
 "      if(C.ssrcFix)try{"
-"        var sdp=(pc.localDescription&&pc.localDescription.sdp)||'';"
 "        var n=(sdp.match(/a=ssrc:\\d+ cname:/g)||[]).length;"
 "        post({ev:'sdp-ssrc-verify',cnameLines:n,kept:n>0});"
 "      }catch(e){}"
+"      postSdp('local-'+((pc.localDescription&&pc.localDescription.type)||'?'),sdp);"
+"      if(C.ssrcFix)try{realSsrc(pc);}catch(e){}"
+"      return r;"
+"    });"
+"  };"
+"  var oSRD=P.prototype.setRemoteDescription;"
+"  P.prototype.setRemoteDescription=function(d){"
+"    var pc=this;"
+"    return Promise.resolve(oSRD.apply(pc,arguments)).then(function(r){"
+"      postSdp('remote-'+((d&&d.type)||'?'),(d&&d.sdp)||"
+"              ((pc.remoteDescription&&pc.remoteDescription.sdp)||''));"
+"      try{if(d&&d.type==='answer'){"
+"        var mine=firstVideo(pc.localDescription&&pc.localDescription.sdp),"
+"            theirs=firstVideo(d.sdp);"
+"        if(mine&&theirs&&mine!==theirs)"
+"          post({ev:'codec-mismatch',sending:mine,agreed:theirs});"
+"      }}catch(e){}"
 "      return r;"
 "    });"
 "  };"
 "  post({ev:'rtc-wrapped',ssrcFix:!!C.ssrcFix,codecs:C.codecs||[]});"
 "})();}"
+"if(C.silentSplit){(function(){"
+"  var D=Object.getOwnPropertyDescriptor(HTMLMediaElement.prototype,'srcObject');"
+"  if(!D||!D.set||!D.get||!window.MediaStream)return;"
+"  Object.defineProperty(HTMLMediaElement.prototype,'srcObject',{configurable:true,"
+"    get:function(){return this.__bbSplit?this.__bbOrig:D.get.call(this);},"
+"    set:function(v){this.__bbSplit=false;this.__bbOrig=v;this.__bbAt=Date.now();"
+"      return D.set.call(this,v);}});"
+"  function rejoin(el,why){"
+"    if(!el.__bbSplit)return;"
+"    el.__bbSplit=false;D.set.call(el,el.__bbOrig);"
+"    try{el.play().catch(function(){});}catch(e){}"
+"    post({ev:'video-audio-rejoin',id:el.id||'',why:why});"
+"  }"
+"  setInterval(function(){"
+"    var l=document.querySelectorAll('video');"
+"    for(var i=0;i<l.length;i++){var el=l[i],st=el.__bbOrig;"
+"      if(el.__bbSplit||!st||!st.getAudioTracks||el.readyState>0)continue;"
+"      if(Date.now()-(el.__bbAt||0)<2500)continue;"
+"      var vt=st.getVideoTracks(),at=st.getAudioTracks();"
+"      if(!vt.length||!at.length||vt[0].muted||vt[0].readyState!=='live')continue;"
+"      if(!at.every(function(t){return t.muted;}))continue;"
+"      el.__bbSplit=true;"
+"      D.set.call(el,new MediaStream(vt));"
+"      try{el.play().catch(function(){});}catch(e){}"
+"      post({ev:'video-audio-split',id:el.id||'',audio:at.length});"
+"      at.forEach(function(t){t.addEventListener('unmute',function(){rejoin(el,'audio-unmuted');},{once:true});});"
+"    }"
+"  },1000);"
+"})();}"
 "window.__bbHeld=function(){"
 "  return !!(cachedV&&cachedV.readyState==='live')||!!(cachedA&&cachedA.readyState==='live');"
 "};"
@@ -1048,7 +1441,8 @@ dump_gstreamer_env (void)
     dump_env ("GST_DEBUG");
     dump_env ("GST_DEBUG_FILE");
     dump_env ("WEBKIT_DEBUG");
-    dump_env ("WEBKIT_GST_ENABLE_HW_DECODERS");
+    dump_env ("GST_PLUGIN_FEATURE_RANK");
+    dump_env ("WEBKIT_GST_DISABLE_WEBRTC_NETWORK_SANDBOX");
     dump_env ("WEBKIT_DISABLE_DMABUF_RENDERER");
     dump_env ("WEBKIT_DISABLE_COMPOSITING_MODE");
 
@@ -1121,6 +1515,8 @@ capture_prewarm (gpointer u)
         gst_object_unref (mon);
         return G_SOURCE_REMOVE;
     }
+    if (!device_monitor_wait_started (mon, 5000))
+        mlog ("prewarm: device providers did not finish starting in 5 s, list may be short");
 
     GList *devs = gst_device_monitor_get_devices (mon);
     int n = 0;
@@ -1359,6 +1755,24 @@ on_web_process_terminated (WebKitWebView *view,
 
 /* ------------------------------------------------- script message routing */
 
+/* "sending":"H264" -> a new "H264" (short codec names, no escapes) */
+static char *
+json_str (const char *json, const char *key)
+{
+    char       *needle = g_strdup_printf ("\"%s\":\"", key);
+    const char *p      = json ? strstr (json, needle) : NULL;
+    char       *v      = NULL;
+
+    if (p) {
+        p += strlen (needle);
+        const char *e = strchr (p, '"');
+        if (e && e - p < 64)
+            v = g_strndup (p, e - p);
+    }
+    g_free (needle);
+    return v;
+}
+
 static gboolean
 ev_is (const char *json, const char *ev)
 {
@@ -1389,14 +1803,23 @@ on_script_message (WebKitUserContentManager *ucm, JSCValue *value, gpointer u)
         ev_is (s, "gum-ok")     || ev_is (s, "gum-error") ||
         ev_is (s, "gum-cache-hit") || ev_is (s, "gum-drop-audio") ||
         ev_is (s, "sdp-ssrc-added") || ev_is (s, "codec-pref") ||
+        ev_is (s, "codec-mismatch") || ev_is (s, "ssrc-real") ||
+        ev_is (s, "ssrc-real-missing") || ev_is (s, "ssrc-ws") ||
+        ev_is (s, "rtc-stats-error") || ev_is (s, "video-state") ||
+        ev_is (s, "play-rejected") || ev_is (s, "pt-fixed") ||
+        ev_is (s, "mic-pin") || ev_is (s, "cam-pin") ||
+        ev_is (s, "video-attach") || ev_is (s, "mic-swapped") ||
+        ev_is (s, "mic-swap-failed") || ev_is (s, "video-audio-split") ||
+        ev_is (s, "video-audio-rejoin") || ev_is (s, "gum-fps-loosened") ||
         ev_is (s, "rtc-wrapped")   || ev_is (s, "rtc-missing") ||
         ev_is (s, "rtc-new")       || ev_is (s, "rtc-call") ||
+        ev_is (s, "rtc-stats")     || ev_is (s, "rtc-error") ||
+        ev_is (s, "sdp")           ||
+        ev_is (s, "page-error")    || ev_is (s, "page-reject") ||
         ev_is (s, "rtc-state")     || ev_is (s, "rtc-trace-on") ||
         ev_is (s, "rtc-caps")      || ev_is (s, "send-params-fixed") ||
         ev_is (s, "params-fix-on") || ev_is (s, "sdp-ssrc-verify") ||
-        ev_is (s, "cam-scaled")    || ev_is (s, "cam-scale-error") ||
-        ev_is (s, "cam-scale-skip")|| ev_is (s, "compat-orientation") ||
-        ev_is (s, "cam-scale-mode")|| ev_is (s, "gum-nomic") ||
+        ev_is (s, "compat-orientation") || ev_is (s, "gum-nomic") ||
         ev_is (s, "compat-error")  ||
         ev_is (s, "media-stall")|| ev_is (s, "media-reload") ||
         ev_is (s, "media-dump") || ev_is (s, "warm-ok") ||
@@ -1410,6 +1833,40 @@ on_script_message (WebKitUserContentManager *ucm, JSCValue *value, gpointer u)
      * process could not reach them, which on a desktop means the portal,
      * and the portal needs a working D-Bus.
      */
+    /*
+     * WebKit picks the video encoder when the local description is set:
+     * the first codec of its own offer that it can encode
+     * (linkOutgoingSources -> configurePacketizers). The answer comes
+     * later and is not consulted again - codecPreferencesChanged refuses
+     * once the pipeline runs. So an answer that settles on another codec
+     * gets packets it cannot decode: the far end shows a spinner while
+     * our side reports megabytes sent.
+     */
+    if (ev_is (s, "codec-mismatch")) {
+        char *mine = json_str (s, "sending"), *theirs = json_str (s, "agreed");
+        mlog ("webrtc: the far end agreed to %s, but WebKit encodes %s - the",
+              theirs ? theirs : "?", mine ? mine : "?");
+        mlog ("webrtc: first codec of its own offer - and does not switch.");
+        mlog ("webrtc: The far end cannot decode that. --video-codecs %s",
+              theirs ? theirs : "VP8");
+        mlog ("webrtc: puts %s first in the offer, so both sides match.",
+              theirs ? theirs : "VP8");
+        g_free (mine); g_free (theirs);
+    }
+
+    /*
+     * WebKit writes no a=ssrc lines, so --sdp-ssrc-fix adds one for sites
+     * that read the SSRC out of the SDP (mediasoup). A made-up number
+     * would be announced to the server while WebKit sends another, and
+     * the server drops every packet. So the made-up one is swapped for
+     * the one WebKit really uses, read from its own stats.
+     */
+    if (ev_is (s, "ssrc-real-missing")) {
+        mlog ("webrtc: could not learn the real SSRC within 15s; the site was");
+        mlog ("webrtc: told a made-up one, and a server that trusts it (mediasoup)");
+        mlog ("webrtc: will drop this stream.");
+    }
+
     if (ev_is (s, "gum-drop-audio"))
         mlog ("camera: no microphone here, retrying for video alone");
 
@@ -1426,11 +1883,11 @@ on_script_message (WebKitUserContentManager *ucm, JSCValue *value, gpointer u)
         else
             mlog ("camera: --prewarm will say whether the devices are visible here");
 
-        mlog ("camera: the web process reaches devices through the desktop portal;");
-        mlog ("camera: with no portal, --no-sandbox lets it open them directly.");
-        mlog ("camera: a portal needs D-Bus, which needs a machine id:");
-        mlog ("camera:   dbus-uuidgen --ensure=/etc/machine-id   (no systemd needed)");
-        mlog ("camera:   dbus-run-session -- browser-big URL");
+        mlog ("camera: WebKit lists cameras with GStreamer's device monitor, so");
+        mlog ("camera: v4l2deviceprovider (gst-plugins-good) must be installed and");
+        mlog ("camera: /dev/video* readable by this user (group video).");
+        mlog ("camera: only a PipeWire >= 0.3.64 provider makes it ask the camera");
+        mlog ("camera: portal instead; a bubblewrap sandbox needs --no-sandbox.");
     }
 
     if (ev_is (s, "warm-page-done")) {
@@ -1515,11 +1972,15 @@ static char *
 build_shim (void)
 {
     char *match = js_quote (g_cam_match);
+    char *micmatch = js_quote (g_mic_match);
 
     /* ["VP8","VP9"] from a comma separated list */
     GString *cj = g_string_new ("[");
-    if (g_video_codecs) {
-        char **v = g_strsplit (g_video_codecs, ",", -1);
+    /* VP8 first by default: WebKit keeps encoding the first codec of its
+     * own offer, and VP8 is what SFUs answer. "native" leaves the order. */
+    const char *vc = g_codecs_set ? g_video_codecs : "VP8";
+    if (vc) {
+        char **v = g_strsplit (vc, ",", -1);
         for (int i = 0; v[i]; i++) {
             char *t = g_strstrip (g_strdup (v[i]));
             if (*t)
@@ -1532,33 +1993,32 @@ build_shim (void)
     char *codecs_js = g_string_free (cj, FALSE);
     char *cfg   = g_strdup_printf (
         "window.__bbCfg={camfix:%s,relax:%s,cache:%s,dropAudio:%s,retries:%d,"
-        "maxW:%d,maxH:%d,maxFps:%d,forceW:%d,forceH:%d,forceFps:%d,holdMs:%d,"
-        "forceExact:%s,ssrcFix:%s,codecs:%s,rtcTrace:%s,paramsFix:%s,scale:%s,"
-        "compat:%s,scaleFps:%d,noMic:%s,"
-        "match:%s,watchdog:%s,stall:%d,debug:%s};",
+        "maxW:%d,maxH:%d,maxFps:%d,"
+        "ssrcFix:%s,codecs:%s,rtcTrace:%s,paramsFix:%s,silentSplit:%s,"
+        "compat:%s,noMic:%s,fpsLoose:%s,"
+        "match:%s,micMatch:%s,watchdog:%s,stall:%d,debug:%s};",
         g_cam_fix        ? "true" : "false",
         (g_cam_repair && g_cam_relax)      ? "true" : "false",
         (g_cam_repair && g_cam_keepalive)  ? "true" : "false",
         (g_cam_repair && g_cam_drop_audio) ? "true" : "false",
         g_cam_repair ? g_cam_retries : 0,
         g_cam_max_w, g_cam_max_h, g_cam_max_fps,
-        g_cam_force_w, g_cam_force_h, g_cam_force_fps, g_cam_hold_ms,
-        g_cam_force_exact ? "true" : "false",
         g_sdp_ssrc_fix    ? "true" : "false",
         codecs_js,
         g_rtc_trace       ? "true" : "false",
         g_rtc_params_fix  ? "true" : "false",
-        g_cam_scale       ? "true" : "false",
+        g_silent_split    ? "true" : "false",
         g_web_compat      ? "true" : "false",
-        g_cam_scale_fps,
         g_no_mic          ? "true" : "false",
-        match,
+        g_fps_loose       ? "true" : "false",
+        match, micmatch,
         g_media_watchdog ? "true" : "false",
         g_stall_timeout,
         g_shim_debug     ? "true" : "false");
 
     char *js = g_strconcat (cfg, SHIM_JS, NULL);
     g_free (match);
+    g_free (micmatch);
     g_free (codecs_js);
     g_free (cfg);
     return js;
@@ -1652,11 +2112,11 @@ capture_check (gpointer u)
 
     mlog ("capture: permission was granted but nothing started capturing.");
     mlog ("capture: run --list-cameras to see the devices this build can reach;");
-    mlog ("capture: an empty list means the web process cannot get at them -");
-    mlog ("capture:   --no-sandbox      opens them without the desktop portal");
-    mlog ("capture:   --prewarm         says what GStreamer can see from here");
-    mlog ("capture: a camera but no microphone is usually a sound server with");
-    mlog ("capture: no card; audio = alsa in the config bypasses it.");
+    mlog ("capture: an empty list means GStreamer cannot see them from here -");
+    mlog ("capture:   --prewarm         lists cameras and microphones as WebKit sees them");
+    mlog ("capture:   --gst-debug webkitcapture*:5   WebKit's own capture log");
+    mlog ("capture: a camera but no microphone means no alsa/pulse device provider");
+    mlog ("capture: found a capture device (gst-device-monitor-1.0 Audio/Source).");
     return G_SOURCE_REMOVE;
 }
 
@@ -1755,10 +2215,8 @@ big_usage_options (GString *s)
 {
     g_string_append_printf (s,
 "camera and video:\n"
-"  Nothing in this section is on unless you ask for it. By default the\n"
-"  browser is plain WebKitGTK: no shim over getUserMedia, no device probe,\n"
-"  no watchdogs, no reloads of its own. Reach for these when a site\n"
-"  misbehaves, not before.\n"
+"  Off unless asked for: no device probe, no watchdogs, no reloads of\n"
+"  its own, and the page's camera constraints pass through untouched.\n"
 "\n"
 "  --fix-media         --cam-fix, --prewarm, --media-watchdog and\n"
 "                      --auto-reload together\n"
@@ -1791,33 +2249,22 @@ big_usage_options (GString *s)
 "  --no-cam-drop-audio with --cam-fix, fail instead of handing a site\n"
 "                      video only when the machine has no microphone\n"
 "  --cam-max WxH@FPS   cap relaxed constraints (default: %dx%d@%d)\n"
+"  --no-cam-size-fix   pass a page's soft frameRate limit on to WebKit. By\n"
+"                      default it is dropped: WebKit only picks camera\n"
+"                      modes that list that exact rate, so {max: 20} turns\n"
+"                      a 4:3 request into a letterboxed 16:9 mode\n"
+"                      (cam_size_fix = no)\n"
 "  --no-mic            give pages the camera but never the microphone. A\n"
 "                      diagnostic: if a freeze goes away, the audio path\n"
 "                      is the cause (the audio track here reports\n"
 "                      sampleRate 0, which is not a valid rate)\n"
-"  --web-compat        supply APIs WebKitGTK does not implement, currently\n"
-"                      screen.orientation. Zoom's media code reads\n"
-"                      screen.orientation.type and throws without it\n"
-"  --cam-scale         deliver the size the page asked for by scaling the\n"
-"                      camera through a canvas. WebKit picks the nearest\n"
-"                      native mode instead of scaling, so a site that\n"
-"                      needs 320x240 is handed 848x480 and refuses it.\n"
-"                      Scaling happens on the page's own thread, so the\n"
-"                      rate is capped (default 15)\n"
-"  --cam-scale-fps N   raise or lower that cap\n"
-"  --cam-exact WxH@FPS demand this size exactly, whatever the page asked.\n"
-"                      A bare width in a page's constraints is only a\n"
-"                      preference and WebKit may answer with another size;\n"
-"                      this refuses anything else. Fails outright if the\n"
-"                      camera cannot do it, which is the honest answer\n"
-"  --cam-force WxH@FPS ask for this size and aspect ratio whatever the page\n"
-"                      requested, e.g. 640x480@30 for 4:3. Implies\n"
-"                      --cam-fix. Use when a site negotiates a shape the\n"
-"                      camera answers badly\n"
+"  --web-compat        turn on WebKit's ScreenOrientationAPI feature, which\n"
+"                      is built but off on GTK, and if a page still sees no\n"
+"                      screen.orientation, supply a static one. Zoom's media\n"
+"                      code reads screen.orientation.type and throws without it\n"
 "  --cam-match TEXT    prefer the camera whose label contains TEXT\n"
-"  --cam-hold SEC      how long a shared capture is held after the page\n"
-"                      has stopped using it (default: 3). The camera light\n"
-"                      stays on for this long; 0 releases at once\n"
+"  --mic-match TEXT    prefer the microphone whose label contains TEXT, for\n"
+"                      pages that just take the first one (mic_match)\n"
 "  --cam-retries N     retries with looser constraints (default: %d)\n"
 "\n"
 "video:\n"
@@ -1828,11 +2275,16 @@ big_usage_options (GString *s)
 "                      default: %d)\n"
 "  --max-reloads N     auto-reloads per URL (default: %d)\n"
 
+"  --ice-rice          use WebKit's own librice ICE agent instead of the\n"
+"                      default libnice one. librice finds no public address\n"
+"                      here (a DNS/IPv6 FIXME in its STUN code), so calls\n"
+"                      over the internet fail. ice = rice in the config\n"
 "  --no-webrtc         disable WebRTC only\n"
 "  --no-mediastream    disable MediaStream / getUserMedia only\n"
 "\n"
-"webrtc:\n"
-"  --fix-webrtc        --sdp-ssrc-fix and --rtc-params-fix together\n"
+"webrtc (the repairs below are on by default):\n"
+"  --no-fix-webrtc     turn off --sdp-ssrc-fix, --rtc-params-fix and the\n"
+"                      silent-audio split at once (fix_webrtc = no)\n"
 "  --rtc-params-fix    supply RTCRtpSendParameters.codecs when a library\n"
 "                      omits it. WebKit requires it and Firefox does not,\n"
 "                      so a library on its Firefox path throws\n"
@@ -1841,12 +2293,12 @@ big_usage_options (GString *s)
 "                      made, tracks attached, offer created, answer set -\n"
 "                      so the last one logged is the step that failed.\n"
 "                      Included in --media-trace\n"
-"  --sdp-ssrc-fix      add the a=ssrc cname lines WebKit leaves out, which\n"
-"                      a site parsing the offer needs - without them it\n"
-"                      throws \"CNAME value not found\" and never connects\n"
-"  --video-codecs LIST preferred codec order for sending, e.g. VP8 or\n"
-"                      VP8,VP9. Use it when WebKit offers something this\n"
-"                      machine has no encoder for\n"
+"  --sdp-ssrc-fix      add the a=ssrc lines WebKit leaves out, then tell the\n"
+"                      server the SSRC and payload type WebKit really sends\n"
+"                      (mediasoup drops the stream otherwise)\n"
+"  --video-codecs LIST sending codec order (default: VP8). WebKit keeps\n"
+"                      encoding the first codec of its own offer, whatever\n"
+"                      the far end answers. native keeps WebKit's order\n"
 "\n"
 "diagnostics:\n"
 "  --media-trace       log every getUserMedia call, the constraints it\n"
@@ -1857,11 +2309,11 @@ big_usage_options (GString *s)
 "                      (a missing GStreamer plugin is always reported,\n"
 "                      since pages fail in confusing ways without one)\n"
 "  --audio-pulse       undo audio=alsa from a config file for this run\n"
-"  --audio-alsa        play and record through ALSA rather than PulseAudio,\n"
-"                      by ranking the pulse elements out. Fixes browser\n"
-"                      audio when the Pulse server has a dummy sink.\n"
-"                      Enumeration is a separate matter: device provider\n"
-"                      ranks cannot be overridden, so a microphone still\n"
+"  --audio-alsa        play through ALSA rather than PulseAudio, by\n"
+"                      ranking pulsesink out. Fixes browser audio when\n"
+"                      the Pulse server has a dummy sink. Microphones are\n"
+"                      a separate matter: GStreamer ignores ranks for\n"
+"                      device providers, so a microphone still\n"
 "                      needs alsadeviceprovider installed - check with\n"
 "                      gst-inspect-1.0 alsadeviceprovider\n"
 "                      Put audio = alsa in a config file to have this\n"
@@ -1936,7 +2388,8 @@ big_parse_arg (int argc, char **argv, int *i)
 
     if (!strcmp (a, "--warm-timeout")) {
         NEXT ("--warm-timeout");
-        g_warm_timeout = MAX (1, atoi (argv[++(*i)]));
+        const char *v = argv[++(*i)];
+        g_warm_timeout = MAX (1, atoi (v));
         return TRUE;
     }
     if (!strcmp (a, "--no-mic")) {
@@ -1949,53 +2402,7 @@ big_parse_arg (int argc, char **argv, int *i)
         g_web_compat = TRUE;
         g_cam_fix    = TRUE;           /* it rides in the same shim */
         g_cam_relax  = FALSE;
-        return TRUE;
-    }
-    if (!strcmp (a, "--cam-scale-fps")) {
-        NEXT ("--cam-scale-fps");
-        g_cam_scale_fps = CLAMP (atoi (argv[++(*i)]), 1, 60);
-        g_cam_scale = g_cam_fix = TRUE;
-        g_cam_relax = FALSE;
-        return TRUE;
-    }
-    if (!strcmp (a, "--cam-scale")) {
-        g_cam_scale = TRUE;
-        g_cam_fix   = TRUE;
-        g_cam_relax = FALSE;           /* the page's size is the point */
-        return TRUE;
-    }
-    if (!strcmp (a, "--cam-exact")) {
-        /*
-         * exact, not ideal. A bare width/height in a page's constraints is
-         * only a preference and WebKit is free to answer with another
-         * size - which it does, so a site that publishes at the size it
-         * asked for gets a stream it cannot use.
-         */
-        NEXT ("--cam-exact");
-        int sw = g_cam_max_w, sh = g_cam_max_h, sf = g_cam_max_fps;
-        if (!parse_cam_max (argv[++(*i)])) {
-            g_printerr ("%s: bad --cam-exact %s (want WxH@FPS)\n", argv[0], argv[*i]);
-            exit (1);
-        }
-        g_cam_force_w = g_cam_max_w;  g_cam_force_h = g_cam_max_h;
-        g_cam_force_fps = g_cam_max_fps;
-        g_cam_max_w = sw; g_cam_max_h = sh; g_cam_max_fps = sf;
-        g_cam_force_exact = TRUE;
-        g_cam_fix = TRUE;
-        g_cam_relax = FALSE;           /* relaxing would undo the point */
-        return TRUE;
-    }
-    if (!strcmp (a, "--cam-force")) {
-        NEXT ("--cam-force");
-        int sw = g_cam_max_w, sh = g_cam_max_h, sf = g_cam_max_fps;
-        if (!parse_cam_max (argv[++(*i)])) {
-            g_printerr ("%s: bad --cam-force %s (want WxH@FPS)\n", argv[0], argv[*i]);
-            exit (1);
-        }
-        g_cam_force_w = g_cam_max_w;  g_cam_force_h = g_cam_max_h;
-        g_cam_force_fps = g_cam_max_fps;
-        g_cam_max_w = sw; g_cam_max_h = sh; g_cam_max_fps = sf;
-        g_cam_fix = TRUE;              /* it is applied by the shim */
+        feature_request ("ScreenOrientationAPI");   /* WebKit has it, off on GTK */
         return TRUE;
     }
     if (!strcmp (a, "--cam-max")) {
@@ -2010,16 +2417,20 @@ big_parse_arg (int argc, char **argv, int *i)
         NEXT ("--cam-match");
         g_free (g_cam_match);
         g_cam_match = g_strdup (argv[++(*i)]);
+        g_cam_fix   = TRUE;
         return TRUE;
     }
-    if (!strcmp (a, "--cam-hold")) {
-        NEXT ("--cam-hold");
-        g_cam_hold_ms = MAX (0, atoi (argv[++(*i)])) * 1000;
+    if (!strcmp (a, "--mic-match")) {
+        NEXT ("--mic-match");
+        g_free (g_mic_match);
+        g_mic_match = g_strdup (argv[++(*i)]);
+        g_cam_fix   = TRUE;            /* it rides in the same shim */
         return TRUE;
     }
     if (!strcmp (a, "--cam-retries")) {
         NEXT ("--cam-retries");
-        g_cam_retries = CLAMP (atoi (argv[++(*i)]), 0, 3);
+        const char *v = argv[++(*i)];
+        g_cam_retries = CLAMP (atoi (v), 0, 3);
         return TRUE;
     }
 
@@ -2031,17 +2442,20 @@ big_parse_arg (int argc, char **argv, int *i)
 
     if (!strcmp (a, "--stall-timeout")) {
         NEXT ("--stall-timeout");
-        g_stall_timeout = MAX (2, atoi (argv[++(*i)]));
+        const char *v = argv[++(*i)];
+        g_stall_timeout = MAX (2, atoi (v));
         return TRUE;
     }
     if (!strcmp (a, "--load-timeout")) {
         NEXT ("--load-timeout");
-        g_load_timeout = MAX (0, atoi (argv[++(*i)]));
+        const char *v = argv[++(*i)];
+        g_load_timeout = MAX (0, atoi (v));
         return TRUE;
     }
     if (!strcmp (a, "--max-reloads")) {
         NEXT ("--max-reloads");
-        g_max_reloads = MAX (0, atoi (argv[++(*i)]));
+        const char *v = argv[++(*i)];
+        g_max_reloads = MAX (0, atoi (v));
         return TRUE;
     }
 
@@ -2052,11 +2466,14 @@ big_parse_arg (int argc, char **argv, int *i)
         g_cam_relax      = FALSE;
         return TRUE;
     }
-    if (!strcmp (a, "--fix-webrtc")) {     /* both offer repairs at once */
-        g_rtc_params_fix = TRUE;
-        g_sdp_ssrc_fix   = TRUE;
-        g_cam_fix        = TRUE;
-        g_cam_relax      = FALSE;
+    if (!strcmp (a, "--fix-webrtc")) {     /* the default; kept for old scripts */
+        set_fix_webrtc (TRUE);
+        g_cam_fix = TRUE;
+        return TRUE;
+    }
+    if (!strcmp (a, "--no-fix-webrtc"))  { set_fix_webrtc (FALSE); return TRUE; }
+    if (!strcmp (a, "--no-cam-size-fix") || !strcmp (a, "--no-framerate-fix")) {
+        g_fps_loose = FALSE;
         return TRUE;
     }
     if (!strcmp (a, "--rtc-trace")) {
@@ -2074,8 +2491,7 @@ big_parse_arg (int argc, char **argv, int *i)
     }
     if (!strcmp (a, "--video-codecs")) {
         NEXT ("--video-codecs");
-        g_free (g_video_codecs);
-        g_video_codecs = g_strdup (argv[++(*i)]);
+        set_video_codecs (argv[++(*i)]);
         g_cam_fix   = TRUE;
         g_cam_relax = FALSE;
         return TRUE;
@@ -2103,21 +2519,11 @@ big_parse_arg (int argc, char **argv, int *i)
         return TRUE;
     }
 
-    if (!strcmp (a, "--audio-pulse")) { g_clear_pointer (&g_gst_rank, g_free); return TRUE; }
+    if (!strcmp (a, "--audio-pulse")) { g_audio_alsa = FALSE; return TRUE; }
+    if (!strcmp (a, "--ice-libnice")) { g_ice_libnice = TRUE;  return TRUE; }
+    if (!strcmp (a, "--ice-rice"))    { g_ice_libnice = FALSE; return TRUE; }
     if (!strcmp (a, "--audio-alsa")) {
-        /*
-         * Rank PulseAudio's elements out so autoaudiosink/autoaudiosrc
-         * pick ALSA. This fixes playback on a machine whose Pulse server
-         * came up with a dummy sink.
-         *
-         * It does NOT change which devices are enumerated:
-         * GST_PLUGIN_FEATURE_RANK is honoured for elements but ignored for
-         * device providers, so the microphone still depends on
-         * alsadeviceprovider being installed. Checked, not assumed.
-         */
-        gst_rank_add ("pulsesink:NONE,pulsesrc:NONE,"
-                      "alsasink:PRIMARY,alsasrc:PRIMARY,"
-                      "pulsedeviceprovider:NONE,alsadeviceprovider:PRIMARY");
+        g_audio_alsa = TRUE;
         return TRUE;
     }
     if (!strcmp (a, "--no-va")) {
@@ -2183,6 +2589,26 @@ big_parse_arg (int argc, char **argv, int *i)
  * setting for a machine with no working sound server, and that is a
  * property of the machine, so it belongs in that machine's config.
  */
+/* --video-codecs / video_codecs: a list, or native for WebKit's own order */
+static void
+set_video_codecs (const char *v)
+{
+    g_free (g_video_codecs);
+    g_video_codecs = NULL;
+    g_codecs_set   = TRUE;
+    if (v && *v && g_ascii_strcasecmp (v, "native") && g_ascii_strcasecmp (v, "none"))
+        g_video_codecs = g_strdup (v);
+}
+
+/* fix_webrtc = no / --no-fix-webrtc: every call repair off at once */
+static void
+set_fix_webrtc (gboolean on)
+{
+    g_sdp_ssrc_fix   = on;
+    g_rtc_params_fix = on;
+    g_silent_split   = on;
+}
+
 /* the core keeps its own truthy(); this is browser-big's copy */
 static gboolean
 truthy_value (const char *v)
@@ -2197,17 +2623,50 @@ big_cfg_set (const char *key, const char *value)
     /*
      * cam_share = yes
      *
-     * WebKitGTK does not multiplex one camera across two getUserMedia
-     * calls the way Chrome and Firefox do, so a page that asks twice gets
-     * a second track that never delivers a frame. Sharing hands the
-     * repeated request a clone of the live track over the single open,
-     * which is what the other engines do internally.
+     * A page that opens the same camera twice gets the live track a
+     * second time instead of a second capture, with stop() counted, which
+     * is what the other engines do internally.
      */
     if (!g_ascii_strcasecmp (key, "web_compat")) {
         if (truthy_value (value)) {
             g_web_compat = TRUE;
             g_cam_fix    = TRUE;
             g_cam_relax  = FALSE;
+            feature_request ("ScreenOrientationAPI");
+        }
+        return TRUE;
+    }
+
+    /* the sending codec order: VP8 first matches what most SFUs answer */
+    if (!g_ascii_strcasecmp (key, "video_codecs")) {
+        set_video_codecs (value);
+        g_cam_fix = TRUE;              /* it rides in the same shim */
+        g_cam_relax = FALSE;
+        return TRUE;
+    }
+
+    if (!g_ascii_strcasecmp (key, "cam_match") || !g_ascii_strcasecmp (key, "mic_match")) {
+        char **dst = g_ascii_tolower (key[0]) == 'c' ? &g_cam_match : &g_mic_match;
+        g_free (*dst);
+        *dst = g_strdup (value);
+        g_cam_fix = TRUE;
+        return TRUE;
+    }
+
+    if (!g_ascii_strcasecmp (key, "sdp_ssrc_fix")) {
+        if (truthy_value (value)) {
+            g_sdp_ssrc_fix = TRUE;
+            g_cam_fix      = TRUE;     /* it rides in the same shim */
+            g_cam_relax    = FALSE;
+        }
+        return TRUE;
+    }
+
+    if (!g_ascii_strcasecmp (key, "rtc_params_fix")) {
+        if (truthy_value (value)) {
+            g_rtc_params_fix = TRUE;
+            g_cam_fix        = TRUE;
+            g_cam_relax      = FALSE;
         }
         return TRUE;
     }
@@ -2223,16 +2682,33 @@ big_cfg_set (const char *key, const char *value)
         return TRUE;
     }
 
+    if (!g_ascii_strcasecmp (key, "cam_size_fix") || !g_ascii_strcasecmp (key, "framerate_fix")) {
+        g_fps_loose = truthy_value (value);
+        return TRUE;
+    }
+
+    if (!g_ascii_strcasecmp (key, "fix_webrtc")) {
+        set_fix_webrtc (truthy_value (value));
+        return TRUE;
+    }
+
+    if (!g_ascii_strcasecmp (key, "ice")) {
+        if (!g_ascii_strcasecmp (value, "libnice"))      g_ice_libnice = TRUE;
+        else if (!g_ascii_strcasecmp (value, "rice") ||
+                 !g_ascii_strcasecmp (value, "librice") ||
+                 !g_ascii_strcasecmp (value, "auto"))   g_ice_libnice = FALSE;
+        else g_printerr ("config: ice must be rice or libnice\n");
+        return TRUE;
+    }
+
     if (g_ascii_strcasecmp (key, "audio") != 0)
         return FALSE;
 
     if (!g_ascii_strcasecmp (value, "alsa")) {
-        gst_rank_add ("pulsesink:NONE,pulsesrc:NONE,"
-                      "alsasink:PRIMARY,alsasrc:PRIMARY,"
-                      "pulsedeviceprovider:NONE,alsadeviceprovider:PRIMARY");
+        g_audio_alsa = TRUE;
     } else if (!g_ascii_strcasecmp (value, "pulse") ||
                !g_ascii_strcasecmp (value, "auto")) {
-        g_clear_pointer (&g_gst_rank, g_free);
+        g_audio_alsa = FALSE;
     } else {
         g_printerr ("config: audio must be alsa, pulse or auto\n");
     }
@@ -2257,9 +2733,31 @@ big_pre_gtk (void)
         g_setenv ("GST_DEBUG", s, TRUE);
         g_free (s);
     }
-    if (g_gst_rank) {
-        g_setenv ("GST_PLUGIN_FEATURE_RANK", g_gst_rank, TRUE);
-        mlog ("gstreamer: GST_PLUGIN_FEATURE_RANK=%s", g_gst_rank);
+    /*
+     * audio = alsa. WebKit plays through autoaudiosink, which takes the
+     * highest ranked sink, so ranking pulsesink out is all it takes.
+     * Capture is different: WebKit opens the GstDevice the device monitor
+     * found (gst_device_create_element), so the pulsesrc/alsasrc ranks do
+     * not matter there, and GStreamer applies GST_PLUGIN_FEATURE_RANK to
+     * element factories only - device provider entries are ignored
+     * (gstpluginfeature.c, GStreamer 1.28).
+     */
+    if (g_audio_alsa)
+        gst_rank_add ("pulsesink:NONE");
+    if (g_gst_rank)
+        gst_rank_env_add (g_gst_rank);    /* append: core and user may have set it */
+    if (g_getenv ("GST_PLUGIN_FEATURE_RANK"))
+        mlog ("gstreamer: GST_PLUGIN_FEATURE_RANK=%s", g_getenv ("GST_PLUGIN_FEATURE_RANK"));
+    /*
+     * Which ICE agent WebKit hands webrtcbin (GStreamerMediaEndpoint.cpp,
+     * 2.52+): by default its own librice agent, whose sockets, STUN, TURN
+     * and DNS lookups live in the network process. With this variable set
+     * WebKit creates a plain webrtcbin, which uses libnice inside the web
+     * process - the older, well-trodden path. Both need libgstnice.
+     */
+    if (g_ice_libnice) {
+        g_setenv ("WEBKIT_GST_DISABLE_WEBRTC_NETWORK_SANDBOX", "1", TRUE);
+        mlog ("webrtc: ICE through libnice in the web process (ice = rice to undo)");
     }
     if (g_gst_dbgfile)    g_setenv ("GST_DEBUG_FILE", g_gst_dbgfile, TRUE);
     if (g_webkit_dbg)     g_setenv ("WEBKIT_DEBUG", g_webkit_dbg, TRUE);
@@ -2270,93 +2768,127 @@ big_pre_gtk (void)
  * plugin looks like a broken site rather than a missing package. Silent
  * when everything needed is present.
  */
+static gboolean
+have_element (const char *name)
+{
+    GstElementFactory *f = gst_element_factory_find (name);
+    if (f) gst_object_unref (f);
+    return f != NULL;
+}
+
+static gboolean
+have_any_element (const char *const *names)
+{
+    for (int i = 0; names[i]; i++)
+        if (have_element (names[i]))
+            return TRUE;
+    return FALSE;
+}
+
+static gboolean
+have_provider (const char *name)
+{
+    GstDeviceProviderFactory *f = gst_device_provider_factory_find (name);
+    if (f) gst_object_unref (f);
+    return f != NULL;
+}
+
 static void
 codec_check (void)
 {
     /*
-     * webrtcbin alone is not enough: a call also needs ICE from libnice,
-     * SRTP, DTLS and the RTP manager. Naming each one separately turns
-     * "WebRTC does not work" into a shopping list.
+     * What the sources demand, not a guess:
+     *
+     * webrtcbin (gst-plugins-bad 1.28, gstwebrtcbin.c) refuses to leave
+     * NULL unless nicesrc/nicesink and dtlsenc/dtlsdec are in the
+     * registry - whichever ICE agent it was given, so WebKit's librice
+     * agent does not lift the libnice requirement - and creates no data
+     * channel without sctpenc/sctpdec. rtpbin is looked up by name in
+     * WebKit's GStreamerMediaEndpoint.cpp.
+     *
+     * Capture (GStreamerVideoCapturer.cpp): source -> decodebin3 ->
+     * videoconvert -> videoscale -> videorate -> capsfilter. Cameras come
+     * from the device monitor, i.e. v4l2deviceprovider, unless a PipeWire
+     * provider and a camera portal exist; microphones from alsa/pulse
+     * device providers.
      */
     static const struct { const char *element; const char *what; } need[] = {
-        { "webrtcbin",   "WebRTC             gst-plugins-bad, built with libnice" },
+        { "webrtcbin",   "WebRTC             gst-plugins-bad (webrtc plugin)" },
+        { "nicesrc",     "ICE                libnice's GStreamer plugin (libgstnice)" },
+        { "nicesink",    "ICE                libnice's GStreamer plugin (libgstnice)" },
+        { "dtlsenc",     "DTLS               gst-plugins-bad, needs OpenSSL" },
+        { "dtlsdec",     "DTLS               gst-plugins-bad, needs OpenSSL" },
+        { "dtlssrtpenc", "DTLS-SRTP          gst-plugins-bad" },
+        { "dtlssrtpdec", "DTLS-SRTP          gst-plugins-bad" },
         { "srtpenc",     "SRTP               gst-plugins-bad, needs libsrtp2" },
-        { "dtlssrtpenc", "DTLS               gst-plugins-bad, needs OpenSSL" },
-        { "rtpbin",      "RTP                gst-plugins-good" },
-        { "vp8dec",      "VP8 video          gst-plugins-good, needs libvpx" },
+        { "srtpdec",     "SRTP               gst-plugins-bad, needs libsrtp2" },
+        { "sctpenc",     "data channels      gst-plugins-bad (sctp plugin)" },
+        { "sctpdec",     "data channels      gst-plugins-bad (sctp plugin)" },
+        { "rtpbin",      "RTP                gst-plugins-good (rtpmanager)" },
+        { "rtpfunnel",   "RTP                gst-plugins-good (rtpmanager)" },
+        { "rtpopuspay",  "Opus over RTP      gst-plugins-good (rtp)" },
+        { "rtpopusdepay","Opus over RTP      gst-plugins-good (rtp)" },
+        { "rtpvp8pay",   "VP8 over RTP       gst-plugins-good (rtp)" },
+        { "rtpvp8depay", "VP8 over RTP       gst-plugins-good (rtp)" },
+        { "rtph264pay",  "H.264 over RTP     gst-plugins-good (rtp)" },
+        { "rtph264depay","H.264 over RTP     gst-plugins-good (rtp)" },
+        { "opusenc",     "Opus audio         gst-plugins-base, needs libopus" },
         { "opusdec",     "Opus audio         gst-plugins-base, needs libopus" },
-        { "avdec_h264",  "H.264 video        gst-libav" },
-        { "avdec_aac",   "AAC audio          gst-libav" },
+        { "vp8enc",      "VP8 video          gst-plugins-good, needs libvpx" },
+        { "vp8dec",      "VP8 video          gst-plugins-good, needs libvpx" },
+        { "audioconvert","audio              gst-plugins-base" },
+        { "audioresample","audio             gst-plugins-base" },
+        { "audiomixer",  "audio              gst-plugins-base" },
+        { "videoconvert","capture            gst-plugins-base" },
+        { "videoscale",  "capture            gst-plugins-base" },
+        { "videorate",   "capture            gst-plugins-base" },
+        { "decodebin3",  "capture            gst-plugins-base (playback)" },
+        { "autoaudiosink","playback          gst-plugins-good (autodetect)" },
     };
     GString *missing = g_string_new (NULL);
 
-    for (gsize i = 0; i < G_N_ELEMENTS (need); i++) {
-        GstElementFactory *f = gst_element_factory_find (need[i].element);
-        if (f) {
-            gst_object_unref (f);
-            continue;
-        }
-        g_string_append_printf (missing, "  %-12s %s\n", need[i].element, need[i].what);
-    }
-
-    /* Publishing to most platforms needs H.264 out, not just in. Either
-     * encoder serves, so neither belongs in the list above. */
-    GstElementFactory *x264 = gst_element_factory_find ("x264enc");
-    GstElementFactory *oh   = gst_element_factory_find ("openh264enc");
-
-    if (!x264 && !oh)
-        g_string_append (missing,
-                         "  x264enc      H.264 encoding     gst-plugins-ugly,"
-                         " or openh264enc from gst-plugins-bad\n");
-    if (x264) gst_object_unref (x264);
-    if (oh)   gst_object_unref (oh);
-
-    /*
-     * webrtcbin existing is not the same as it working. Without an ICE
-     * agent it gathers no candidates and createAnswer() never settles -
-     * a call that hangs with no error at all. Worth building one once.
-     */
-    GstElement *wrb = gst_element_factory_make ("webrtcbin", NULL);
-    if (!wrb) {
-        g_string_append (missing, "  webrtcbin    could not be created at all\n");
-    } else {
-        GObject *agent = NULL;
-        g_object_get (wrb, "ice-agent", &agent, NULL);
-        if (!agent)
-            g_string_append (missing,
-                             "  ICE agent    webrtcbin has none: no candidates will be\n"
-                             "               gathered and createAnswer() never returns.\n"
-                             "               libnice with GStreamer support is what provides it\n");
-        else
-            g_object_unref (agent);
-        gst_object_unref (wrb);
-    }
+    for (gsize i = 0; i < G_N_ELEMENTS (need); i++)
+        if (!have_element (need[i].element))
+            g_string_append_printf (missing, "  %-13s %s\n", need[i].element, need[i].what);
+
+    /* One of each will do. The H.264 encoders are the ones WebKit's own
+     * webkitvideoencoder knows how to drive (VideoEncoderPrivateGStreamer.cpp). */
+    static const char *const h264enc[] = { "x264enc", "openh264enc", "vah264enc",
+                                           "vah264lpenc", "omxh264enc", NULL };
+    static const char *const h264dec[] = { "avdec_h264", "vah264dec", "openh264dec",
+                                           "v4l2slh264dec", "v4l2h264dec", NULL };
+    static const char *const jpegdec[] = { "jpegdec", "avdec_mjpeg", "vajpegdec", NULL };
+    static const char *const aacdec[]  = { "avdec_aac", "faad", "fdkaacdec", NULL };
+
+    if (!have_any_element (h264enc))
+        g_string_append (missing, "  H.264 enc     x264enc (gst-plugins-ugly), openh264enc (-bad)"
+                                  " or vah264enc (-bad, VA-API)\n");
+    if (!have_any_element (h264dec))
+        g_string_append (missing, "  H.264 dec     avdec_h264 (gst-libav) or vah264dec (-bad, VA-API)\n");
+    if (!have_any_element (aacdec))
+        g_string_append (missing, "  AAC dec       avdec_aac (gst-libav) - MP4 sites, not WebRTC\n");
+
+    if (!have_provider ("v4l2deviceprovider"))
+        g_string_append (missing, "  v4l2deviceprovider   cameras: gst-plugins-good (video4linux2)\n");
+    if (!have_provider ("alsadeviceprovider") && !have_provider ("pulsedeviceprovider") &&
+        !have_provider ("pipewiredeviceprovider"))
+        g_string_append (missing, "  alsadeviceprovider   microphones: gst-plugins-base (alsa)\n");
 
     if (missing->len) {
         mlog ("gstreamer: these are missing, and pages will fail without them:");
         g_printerr ("%s", missing->str);
     }
 
-    /*
-     * Not fatal, but WebKit complains about each one at the moment it
-     * needs it, which reads like a fault. Listed once, up front, marked
-     * for what they are.
-     */
-    static const struct { const char *element; const char *what; } nice_to_have[] = {
-        { "audiornnoise", "noise suppression   gst-plugins-rs" },
-        { "rtpgccbwe",    "RTP bandwidth est.  gst-plugins-rs" },
-    };
+    /* Not fatal, but WebKit complains about each at the moment it wants it. */
     GString *optional = g_string_new (NULL);
-
-    for (gsize i = 0; i < G_N_ELEMENTS (nice_to_have); i++) {
-        GstElementFactory *f = gst_element_factory_find (nice_to_have[i].element);
-        if (f) {
-            gst_object_unref (f);
-            continue;
-        }
-        g_string_append_printf (optional, "  %-12s %s\n",
-                                nice_to_have[i].element, nice_to_have[i].what);
-    }
+    if (!have_element ("audiornnoise"))
+        g_string_append (optional, "  audiornnoise  noise suppression   gst-plugins-rs\n");
+    if (!have_element ("rtpgccbwe"))
+        g_string_append (optional, "  rtpgccbwe     bandwidth estimation gst-plugins-rs\n");
+    if (!have_any_element (jpegdec))
+        g_string_append (optional, "  jpegdec       MJPEG cameras       gst-plugins-good (jpeg)\n");
+    if (!have_element ("vp9enc") || !have_element ("vp9dec"))
+        g_string_append (optional, "  vp9enc/dec    VP9 video           gst-plugins-good, libvpx\n");
 
     if (optional->len) {
         mlog ("gstreamer: these are optional - pages work without them:");
@@ -2432,6 +2964,8 @@ cam_list (void)
         gst_object_unref (mon);
         return;
     }
+    if (!device_monitor_wait_started (mon, 5000))
+        g_printerr ("cameras: device providers did not finish starting in 5 s\n");
 
     GList *devs = gst_device_monitor_get_devices (mon);
 
@@ -2457,10 +2991,6 @@ cam_list (void)
         g_free (name);
     }
 
-    if (devs)
-        g_print ("to ask for one of these whatever the page requests:\n"
-                 "    browser-big URL --cam-force 640x480@30\n");
-
     g_list_free_full (devs, gst_object_unref);
     gst_device_monitor_stop (mon);
     gst_object_unref (mon);
@@ -2625,6 +3155,7 @@ big_cleanup (void)
 {
     g_free (g_shim_js);
     g_free (g_cam_match);
+    g_free (g_mic_match);
 }
 
 /* ----------------------------------------------------------------- main */
diff --git a/browser_core.c b/browser_core.c
index 906dde1..4d65b91 100644
--- a/browser_core.c
+++ b/browser_core.c
@@ -108,6 +108,7 @@ static char       *g_data_dir;     /* profile data dir, NULL when private */
 static char       *g_profile;      /* NULL -> "default" */
 static gboolean    g_clear_data;
 static char       *g_user_agent;
+static gboolean    g_allow_popups; /* --allow-popups: window.open without a click */
 static gboolean    g_follow_page_title = TRUE; /* see --title / --page-title */
 static char       *g_clip_cmd;     /* --clip-cmd, NULL -> wl-copy  */
 static gboolean    g_no_middle_paste = TRUE;  /* --enable-middle-click-paste */
@@ -142,6 +143,12 @@ static int         g_real_stderr = -1;
 static int         g_noise_fd    = -1;   /* read end, for the final drain */
 static GHashTable *g_noise;          /* line -> occurrences */
 static gboolean    g_no_sandbox;
+
+/* --lock-page / --lock-site: this window shows one address, or one site,
+ * and nothing else. */
+typedef enum { LOCK_OFF, LOCK_PAGE, LOCK_SITE } LockMode;
+static LockMode    g_lock_mode;
+static char       *g_lock_uri;       /* the address as it was opened */
 static gboolean    g_want_page_title;
 static char       *g_css_owned;    /* when --css came from the config */
 
@@ -191,6 +198,7 @@ static gboolean   on_decide_policy (WebKitWebView *view, WebKitPolicyDecision *d
                                     WebKitPolicyDecisionType type, gpointer u);
 static gboolean   on_permission    (WebKitWebView *view, WebKitPermissionRequest *req,
                                     gpointer u);
+static Win       *win_for_view     (WebKitWebView *view);
 static GtkWidget *on_create        (WebKitWebView *view, WebKitNavigationAction *act,
                                     gpointer u);
 static void       on_ready_to_show (WebKitWebView *view, gpointer u);
@@ -259,20 +267,31 @@ elide (const char *s, int max_chars)
 
 /* ------------------------------------------------------------- helpers */
 
+/*
+ * GST_PLUGIN_FEATURE_RANK is one variable that several options feed, and
+ * the user may have set it too. Append, never replace: GStreamer applies
+ * the entries in order, so a later one for the same element wins.
+ */
 void
-settings_set_bool_if_exists (WebKitSettings *s, const char *prop, gboolean value)
+gst_rank_env_add (const char *spec)
 {
-    if (g_object_class_find_property (G_OBJECT_GET_CLASS (s), prop))
-        g_object_set (G_OBJECT (s), prop, value, NULL);
-    else
-        LOG ("note: WebKitSettings property not supported: %s\n", prop);
+    const char *had = g_getenv ("GST_PLUGIN_FEATURE_RANK");
+    char       *v   = (had && *had) ? g_strconcat (had, ",", spec, NULL) : g_strdup (spec);
+
+    g_setenv ("GST_PLUGIN_FEATURE_RANK", v, TRUE);
+    g_free (v);
 }
 
+/* A front-end asking for a WebKit runtime feature, same as --feature. */
 void
-object_set_string_if_exists (GObject *o, const char *prop, const char *value)
+feature_request (const char *spec)
 {
-    if (g_object_class_find_property (G_OBJECT_GET_CLASS (o), prop))
-        g_object_set (o, prop, value, NULL);
+    if (!g_features)
+        g_features = g_ptr_array_new_with_free_func (g_free);
+    for (guint i = 0; i < g_features->len; i++)
+        if (!g_strcmp0 (g_ptr_array_index (g_features, i), spec))
+            return;
+    g_ptr_array_add (g_features, g_strdup (spec));
 }
 
 /* "example.com" -> "https://example.com", "./page.html" -> "file:///...". */
@@ -671,9 +690,49 @@ dl_dir_for_uri (const char *uri)
     return (g_download_dir && *g_download_dir) ? g_download_dir : ".";
 }
 
-/* Which rule is answering for this address right now. */
+/*
+ * --lock-page / --lock-site. The address bar, a link, a redirect, a
+ * script and a popup all end up in decide-policy, so one check there
+ * covers every way to leave the page. A fragment is the same page.
+ */
+static gboolean
+nav_allowed (const char *uri)
+{
+    if (g_lock_mode == LOCK_OFF || !g_lock_uri || !uri || !*uri)
+        return TRUE;
+
+    if (g_lock_mode == LOCK_SITE) {
+        char *a = uri_host (uri);
+        char *b = uri_host (g_lock_uri);
+
+        /* A file:// address has no host and so has no site either. Rather
+         * than letting every local file through, or none, the lock falls
+         * back to the single page. */
+        if (a && b && *a && *b) {
+            gboolean ok = !g_ascii_strcasecmp (a, b);
+            g_free (a);
+            g_free (b);
+            return ok;
+        }
+        g_free (a);
+        g_free (b);
+    }
+
+    gsize n = strcspn (uri, "#");
+    gsize m = strcspn (g_lock_uri, "#");
+    return n == m && !strncmp (uri, g_lock_uri, n);
+}
+
+/*
+ * Which scope the popup opens on. Not the same as which rule answers for
+ * the address right now.
+ * A page with a rule of its own offers to change that rule. A page that
+ * is only covered by its site's rule offers a rule of its own instead:
+ * somebody setting a directory while looking at a subpage means that
+ * subpage, and the narrower rule wins without disturbing the site's.
+ */
 static DlScope
-dl_scope_for_uri (const char *uri)
+dl_scope_default_for_uri (const char *uri)
 {
     if (uri && g_hash_table_contains (g_dlrules, uri))
         return DL_SCOPE_PAGE;
@@ -682,7 +741,7 @@ dl_scope_for_uri (const char *uri)
     gboolean site = host && g_hash_table_contains (g_dlrules, host);
     g_free (host);
 
-    return site ? DL_SCOPE_SITE : DL_SCOPE_ALL;
+    return site ? DL_SCOPE_PAGE : DL_SCOPE_ALL;
 }
 
 /* Downloads are often shared with other users or a daemon, so the target
@@ -808,7 +867,6 @@ dl_dir_set (const char *uri, const char *dir, DlScope scope)
 
     g_free (g_download_dir);
     g_download_dir = g_strdup (dir);
-    object_set_string_if_exists (G_OBJECT (g_session), "downloads-directory", g_download_dir);
 }
 
 /* ------------------------------------------------------- search keywords */
@@ -2020,10 +2078,18 @@ cfg_set (const char *k, const char *v)
     if (key_is (k, "download_dir"))  { set_str (&g_download_dir, v); return TRUE; }
     if (key_is (k, "profile"))       { set_str (&g_profile, v); return TRUE; }
     if (key_is (k, "user_agent"))    { set_str (&g_user_agent, v); return TRUE; }
+    if (key_is (k, "popups"))        { g_allow_popups = !g_ascii_strcasecmp (v, "allow") || truthy (v); return TRUE; }
     if (key_is (k, "css"))           { set_str (&g_css_owned, v); g_css_path = g_css_owned; return TRUE; }
     if (key_is (k, "zoom"))          { g_zoom = CLAMP (g_ascii_strtod (v, NULL), ZOOM_MIN, ZOOM_MAX); return TRUE; }
     if (key_is (k, "private"))       { g_private = truthy (v); return TRUE; }
     if (key_is (k, "no_media"))      { g_deny_media = truthy (v); return TRUE; }
+    if (key_is (k, "lock")) {
+        if (!g_ascii_strcasecmp (v, "page"))      g_lock_mode = LOCK_PAGE;
+        else if (!g_ascii_strcasecmp (v, "site")) g_lock_mode = LOCK_SITE;
+        else if (!truthy (v))                     g_lock_mode = LOCK_OFF;
+        else g_printerr ("config: lock must be page, site or no\n");
+        return TRUE;
+    }
     if (key_is (k, "quiet"))         { g_quiet = truthy (v); return TRUE; }
     if (key_is (k, "page_title"))    { g_want_page_title = truthy (v); return TRUE; }
     if (key_is (k, "select_all"))    { g_fix_select_all = truthy (v); return TRUE; }
@@ -2287,7 +2353,8 @@ usage (const char *argv0, gboolean to_stdout)
 "  --no-gpu            hardware acceleration policy NEVER\n"
 "  --no-dmabuf         WEBKIT_DISABLE_DMABUF_RENDERER=1\n"
 "  --no-compositing    WEBKIT_DISABLE_COMPOSITING_MODE=1\n"
-"  --no-hw-decode      WEBKIT_GST_ENABLE_HW_DECODERS=0\n"
+"  --no-hw-decode      rank the hardware video decoders out\n"
+"                      (GST_PLUGIN_FEATURE_RANK), so software decodes\n"
 "                      the four to reach for when a machine comes back up\n"
 "                      and pages render blank or zero sized\n"
 "  --enable-middle-click-paste\n"
@@ -2332,6 +2399,9 @@ usage (const char *argv0, gboolean to_stdout)
 "                      what is on disk now, then exit. Follows --profile\n"
 "                      and --private wherever they sit on the line\n"
 "  --user-agent UA     set an explicit user agent string\n"
+"  --allow-popups      let pages open windows without a click (Zoom,\n"
+"                      meeting and login flows that open a tab after a\n"
+"                      server round trip). popups = allow in a config file\n"
 "  --ua-chrome | --ua-win-chrome | --ua-win-edge | --ua-firefox | --ua-safari\n"
 "\n"
 "history:\n"
@@ -2363,8 +2433,9 @@ usage (const char *argv0, gboolean to_stdout)
 "           font font_mono label_px title_px hint_px\n"
 "  ours:    title app_id zoom mod clip_cmd download_dir profile private\n"
 "           no_media page_title middle_click_paste select_all load_bar\n"
+"           lock (page, site or no: stay on the address given)\n"
 "           css\n"
-"           user_agent quiet\n"
+"           user_agent quiet popups (allow: see --allow-popups)\n"
 "           always_overwrite  search_default (which keyword needs no prefix)\n"
 "           gsk (GTK's renderer: gl, cairo, vulkan, ngl)\n"
 "           feature = Name[=on|off] (a WebKit runtime feature; repeatable)\n"
@@ -2399,6 +2470,13 @@ usage (const char *argv0, gboolean to_stdout)
 "                      sampled every 5s and, when it spins at 90%%+ cpu for\n"
 "                      15s, its running and blocked threads are printed -\n"
 "                      the thing to read when a page freezes\n"
+"  --lock-page         show the address given and nothing else: a link, a\n"
+"                      redirect, a script or the address bar leading\n"
+"                      anywhere else is refused, and no second window is\n"
+"                      opened. A fragment counts as the same page\n"
+"  --lock-site         the same, but anything on that host is allowed.\n"
+"                      Use it when a login or a payment step leaves the\n"
+"                      page. lock = page|site in a config file\n"
 "  --no-console        do not print the page's own console output, while\n"
 "                      keeping ours. A page that logs heavily makes the\n"
 "                      web process wait on every message\n"
@@ -2674,6 +2752,10 @@ ui_css_install (void)
         "}",
         c_text, ui_font, t->label_px * s);
 
+    /* an error toast: the same panel, the failure colour, and wrapped */
+    g_string_append_printf (css,
+        "label.br-toast.br-toast-error { color: %s; }", c_urgent);
+
     /* URLs and file names: fixed width, so they line up and elide sanely */
     g_string_append_printf (css,
         "entry.br-omni-entry, entry.br-omni-entry > text,"
@@ -3230,6 +3312,21 @@ on_decide_policy (WebKitWebView            *view,
             webkit_policy_decision_ignore (decision);
             return TRUE;
         }
+
+        WebKitURIRequest *req = act ? webkit_navigation_action_get_request (act) : NULL;
+        const char       *to  = req ? webkit_uri_request_get_uri (req) : NULL;
+
+        if (!nav_allowed (to)) {
+            Win  *w   = win_for_view (view);
+            char *msg = g_strdup_printf ("locked to %s",
+                                         g_lock_mode == LOCK_SITE ? "this site" : "this page");
+            LOG ("lock: refused %s\n", to ? to : "(no address)");
+            if (w)
+                toast_show (w, msg, 3);
+            g_free (msg);
+            webkit_policy_decision_ignore (decision);
+            return TRUE;
+        }
         return FALSE;
     }
 
@@ -3268,8 +3365,10 @@ static const char *
 perm_type_name (WebKitPermissionRequest *req)
 {
     if (WEBKIT_IS_USER_MEDIA_PERMISSION_REQUEST (req))          return "user-media";
-    if (WEBKIT_IS_DEVICE_INFO_PERMISSION_REQUEST (req))         return "device-info";
+    /* 2.54 only declares it when WebKit was built with pointer lock */
+#ifdef WEBKIT_TYPE_POINTER_LOCK_PERMISSION_REQUEST
     if (WEBKIT_IS_POINTER_LOCK_PERMISSION_REQUEST (req))        return "pointer-lock";
+#endif
     if (WEBKIT_IS_GEOLOCATION_PERMISSION_REQUEST (req))         return "geolocation";
     if (WEBKIT_IS_NOTIFICATION_PERMISSION_REQUEST (req))        return "notification";
     if (WEBKIT_IS_CLIPBOARD_PERMISSION_REQUEST (req))           return "clipboard";
@@ -3346,7 +3445,9 @@ media_spawn (Win *w, const char *cmd, const char *arg, const char *shown)
     int     argc = 0;
 
     if (!g_shell_parse_argv (cmd, &argc, &args, &err)) {
-        g_printerr ("media: cannot parse \"%s\": %s\n", cmd, err->message);
+        char *msg = g_strdup_printf ("media: cannot parse \"%s\": %s", cmd, err->message);
+        toast_error (w, msg);
+        g_free (msg);
         g_clear_error (&err);
         return FALSE;
     }
@@ -3361,13 +3462,14 @@ media_spawn (Win *w, const char *cmd, const char *arg, const char *shown)
                                  G_SPAWN_STDOUT_TO_DEV_NULL | G_SPAWN_STDERR_TO_DEV_NULL,
                                  NULL, NULL, NULL, &err);
     char *msg = ok ? g_strdup_printf ("%s  %s", args[0], shown)
-                   : g_strdup_printf ("%s: %s", args[0], err->message);
-    if (ok)
+                   : g_strdup_printf ("media: cannot start %s: %s", args[0], err->message);
+    if (ok) {
         LOG ("media: %s %s\n", cmd, arg);
-    else
-        g_printerr ("media: %s: %s\n", cmd, err->message);
-    if (w)
-        toast_show (w, msg, ok ? 2 : URL_TOAST_SECONDS);
+        if (w)
+            toast_show (w, msg, 2);
+    } else {
+        toast_error (w, msg);
+    }
 
     g_free (msg);
     g_clear_error (&err);
@@ -3437,12 +3539,9 @@ on_image_fetched (GObject *src, GAsyncResult *res, gpointer u)
         char *why = err ? g_strdup (err->message)
                   : g_strdup_printf ("HTTP %u, %" G_GSIZE_FORMAT " bytes", code,
                                      body ? g_bytes_get_size (body) : 0);
-        g_printerr ("media: image %s: %s\n", f->uri, why);
-        if (w) {
-            char *t = g_strdup_printf ("image not fetched: %s", why);
-            toast_show (w, t, URL_TOAST_SECONDS);
-            g_free (t);
-        }
+        char *t = g_strdup_printf ("media: image not fetched: %s\n%s", why, f->uri);
+        toast_error (w, t);
+        g_free (t);
         g_free (why);
     } else {
         char *dir  = media_tmp_dir ();
@@ -3453,12 +3552,17 @@ on_image_fetched (GObject *src, GAsyncResult *res, gpointer u)
             const guint8 *p = g_bytes_get_data (body, &n);
             gboolean wrote = write (fd, p, n) == (ssize_t) n;
             close (fd);
-            if (wrote)
+            if (wrote) {
                 media_spawn (w, g_image_viewer, tmpl, f->uri);
-            else
-                g_printerr ("media: cannot write %s\n", tmpl);
+            } else {
+                char *t = g_strdup_printf ("media: cannot write %s", tmpl);
+                toast_error (w, t);
+                g_free (t);
+            }
         } else {
-            g_printerr ("media: cannot create a file in %s\n", dir);
+            char *t = g_strdup_printf ("media: cannot create a file in %s", dir);
+            toast_error (w, t);
+            g_free (t);
         }
         g_free (tmpl);
         g_free (dir);
@@ -3754,29 +3858,17 @@ on_permission (WebKitWebView *view, WebKitPermissionRequest *req, gpointer u)
         return TRUE;
     }
 
-    /*
-     * Device labels for enumerateDevices(). A browser reveals them once
-     * the site has been granted a device, and not before; the same rule
-     * applies here, read from what was remembered.
-     */
-    if (WEBKIT_IS_DEVICE_INFO_PERMISSION_REQUEST (req)) {
-        const char *had   = perm_remembered (host);
-        gboolean    allow = g_media_policy == MEDIA_ALLOW ||
-                            (g_media_policy == MEDIA_ASK && had && !strcmp (had, "allow"));
-        LOG ("perm: %s -> %s\n", type, allow ? "allow" : "deny");
-        if (allow)
-            webkit_permission_request_allow (req);
-        else
-            webkit_permission_request_deny (req);
-        g_free (host);
-        return TRUE;
-    }
+    /* Device labels for enumerateDevices() are not decided here: WebKit
+     * 2.54 no longer emits WebKitDeviceInfoPermissionRequest and asks
+     * query-permission-state instead, see on_query_permission(). */
 
+#ifdef WEBKIT_TYPE_POINTER_LOCK_PERMISSION_REQUEST
     if (WEBKIT_IS_POINTER_LOCK_PERMISSION_REQUEST (req)) {
         webkit_permission_request_allow (req);
         g_free (host);
         return TRUE;
     }
+#endif
 
     LOG ("perm: %s -> deny\n", type);
     webkit_permission_request_deny (req);
@@ -3784,6 +3876,45 @@ on_permission (WebKitWebView *view, WebKitPermissionRequest *req, gpointer u)
     return TRUE;
 }
 
+/*
+ * navigator.permissions.query({name:'camera'|'microphone'}), and WebKit's
+ * own question before enumerateDevices() and getUserMedia(): is this site
+ * already allowed? Unanswered, WebKit's documented default is "prompt",
+ * so a remembered site would never be told it is allowed and device
+ * labels would stay hidden. Answered from the same per-site memory as the
+ * prompt, keyed by the top-level origin's host.
+ */
+static gboolean
+on_query_permission (WebKitWebView *view, WebKitPermissionStateQuery *q, gpointer u)
+{
+    (void) view; (void) u;
+    const char *name = webkit_permission_state_query_get_name (q);
+
+    if (g_strcmp0 (name, "camera") != 0 && g_strcmp0 (name, "microphone") != 0)
+        return FALSE;                          /* WebKit answers "prompt" */
+
+    WebKitSecurityOrigin *o    = webkit_permission_state_query_get_security_origin (q);
+    const char           *host = o ? webkit_security_origin_get_host (o) : NULL;
+    WebKitPermissionState st;
+
+    if (g_media_policy == MEDIA_ALLOW)
+        st = WEBKIT_PERMISSION_STATE_GRANTED;
+    else if (g_media_policy == MEDIA_DENY)
+        st = WEBKIT_PERMISSION_STATE_DENIED;
+    else {
+        const char *had = perm_remembered (host);
+        if (!had)
+            return FALSE;
+        st = !strcmp (had, "allow") ? WEBKIT_PERMISSION_STATE_GRANTED
+                                    : WEBKIT_PERMISSION_STATE_DENIED;
+    }
+
+    LOG ("perm: query %s for %s -> %s\n", name, host ? host : "?",
+         st == WEBKIT_PERMISSION_STATE_GRANTED ? "granted" : "denied");
+    webkit_permission_state_query_finish (q, st);
+    return TRUE;
+}
+
 /* ------------------------------------------------------------ inspector */
 
 static void
@@ -4656,11 +4787,12 @@ omni_setup_scope (Win *w, OmniMode mode, const char *uri)
     GtkStringList *list = gtk_string_list_new (items);
 
     gtk_drop_down_set_model (GTK_DROP_DOWN (w->omniscope), G_LIST_MODEL (list));
-    gtk_drop_down_set_selected (GTK_DROP_DOWN (w->omniscope), dl_scope_for_uri (uri));
+    DlScope now = dl_scope_default_for_uri (uri);
+
+    gtk_drop_down_set_selected (GTK_DROP_DOWN (w->omniscope), now);
     gtk_widget_set_visible (w->omniscope, TRUE);
 
     /* the same select decides how far this reaches */
-    DlScope now = dl_scope_for_uri (uri);
     char   *key = now == DL_SCOPE_PAGE ? g_strdup (uri)
                 : now == DL_SCOPE_SITE ? uri_host (uri)
                                        : NULL;
@@ -4689,6 +4821,17 @@ omni_show (Win *w, OmniMode mode)
     w->omni_needle = g_strdup ("");
     w->omni_mode   = mode;
 
+    /* The download-directory clash question hides the input and turns the
+     * hint into its question. Whatever way it was left - answered, Esc, a
+     * click outside - the next popup starts from the ordinary layout, and
+     * an unanswered question is dropped rather than answered later. */
+    w->dl_conflict = FALSE;
+    g_clear_pointer (&w->dl_pending_dir, g_free);
+    gtk_widget_set_visible (w->omnientry, TRUE);
+    gtk_label_set_wrap (GTK_LABEL (w->omnihint), FALSE);
+    gtk_widget_set_hexpand (w->omnihint, FALSE);
+    gtk_widget_set_halign (w->omnihint, GTK_ALIGN_END);
+
     w->omni_setting = TRUE;
     gtk_editable_set_text (GTK_EDITABLE (w->omnientry),
                            mode == OMNI_URL   ? (uri ? uri : "")
@@ -4984,7 +5127,10 @@ omni_apply_dldir (Win *w)
                   : g_strdup_printf ("%u rules already go there.  Enter drops them,  Esc keeps all.", users->len);
 
         gtk_label_set_text (GTK_LABEL (w->omnihint), ask);
-        gtk_widget_set_halign (w->omnihint, GTK_ALIGN_START);
+        gtk_label_set_wrap (GTK_LABEL (w->omnihint), TRUE);
+        gtk_label_set_xalign (GTK_LABEL (w->omnihint), 0.0);
+        gtk_widget_set_hexpand (w->omnihint, TRUE);
+        gtk_widget_set_halign (w->omnihint, GTK_ALIGN_FILL);
         gtk_widget_set_visible (w->omnihint, TRUE);
         gtk_widget_set_visible (w->omnientry, FALSE);
         gtk_widget_set_visible (w->omniscope, FALSE);
@@ -5111,6 +5257,8 @@ toast_single_line (Win *w)
     GtkLabel *l = GTK_LABEL (w->toast);
 
     gtk_widget_remove_css_class (w->toast, "br-toast-url");
+    gtk_widget_remove_css_class (w->toast, "br-toast-error");
+    w->error_until_us = 0;
     gtk_label_set_wrap (l, FALSE);
     gtk_label_set_lines (l, -1);
     gtk_label_set_ellipsize (l, PANGO_ELLIPSIZE_MIDDLE);
@@ -5128,6 +5276,41 @@ toast_show (Win *w, const char *text, guint seconds)
         g_source_remove (w->toast_id);
     w->toast_id = g_timeout_add_seconds (seconds, toast_timeout, w);
 }
+
+/*
+ * Something the user asked for did not happen. A message that is cut in
+ * the middle, gone in four seconds or faded under the pointer is no
+ * message at all, so this one is wrapped whole, in the failure colour,
+ * stays up for ERROR_TOAST_SECONDS, does not fade, and always goes to
+ * stderr as well - -q or not.
+ */
+#define ERROR_TOAST_SECONDS 10
+
+void
+toast_error (Win *w, const char *text)
+{
+    g_printerr ("error: %s\n", text);
+    if (!w)
+        return;
+
+    GtkLabel *l = GTK_LABEL (w->toast);
+
+    toast_single_line (w);
+    gtk_widget_add_css_class (w->toast, "br-toast-error");
+    gtk_label_set_ellipsize (l, PANGO_ELLIPSIZE_NONE);
+    gtk_label_set_wrap (l, TRUE);
+    gtk_label_set_wrap_mode (l, PANGO_WRAP_WORD_CHAR);
+    gtk_label_set_max_width_chars (l, 60);
+    gtk_label_set_xalign (l, 0.0);
+    gtk_label_set_text (l, text);
+    gtk_widget_set_opacity (w->topright, 1.0);
+    gtk_widget_set_visible (w->toast, TRUE);
+    w->error_until_us = g_get_monotonic_time () + (gint64) ERROR_TOAST_SECONDS * G_USEC_PER_SEC;
+
+    if (w->toast_id)
+        g_source_remove (w->toast_id);
+    w->toast_id = g_timeout_add_seconds (ERROR_TOAST_SECONDS, toast_timeout, w);
+}
 /*
  * <mod>+P: the whole address. It is wrapped at any character, in the mono
  * font, and as wide as the window allows, so nothing is cut out. Only a
@@ -5534,7 +5717,9 @@ overlay_hover_update (Win *w, double x, double y)
                        g_get_monotonic_time () - w->dl_reveal_us
                        < (gint64) DL_REVEAL_MS * 1000;
 
-    fade_if_under (w, w->topright, x, y, keep_dl);
+    gboolean keep_err = w->error_until_us && g_get_monotonic_time () < w->error_until_us;
+
+    fade_if_under (w, w->topright, x, y, keep_dl || keep_err);
     fade_if_under (w, w->urltoast, x, y, FALSE);
 }
 
@@ -6172,8 +6357,16 @@ static void
 media_mode_toggle (Win *w)
 {
     if ((!g_player || !*g_player) && (!g_image_viewer || !*g_image_viewer)) {
-        toast_show (w, "media mode needs player = mpv and/or image_viewer = imv "
-                       "in the config", URL_TOAST_SECONDS);
+        char *cfg = g_build_filename (g_get_user_config_dir (), g_app->default_app_id,
+                                      "config", NULL);
+        char *msg = g_strdup_printf ("F2 media mode: no player or image viewer is set.\n"
+                                     "Add to %s:\n"
+                                     "    player = mpv\n"
+                                     "    image_viewer = imv\n"
+                                     "or start with --player mpv", cfg);
+        toast_error (w, msg);
+        g_free (msg);
+        g_free (cfg);
         return;
     }
 
@@ -7045,6 +7238,7 @@ view_wire (WebKitWebView *view)
      * session, not on the web view - it is wired up once in browser_main(). */
     g_signal_connect (view, "decide-policy",      G_CALLBACK (on_decide_policy), NULL);
     g_signal_connect (view, "permission-request", G_CALLBACK (on_permission), NULL);
+    g_signal_connect (view, "query-permission-state", G_CALLBACK (on_query_permission), NULL);
     g_signal_connect (view, "create",             G_CALLBACK (on_create), NULL);
     g_signal_connect (view, "load-changed",       G_CALLBACK (on_load_core), NULL);
     g_signal_connect (view, "load-failed",        G_CALLBACK (on_load_failed_core), NULL);
@@ -7095,6 +7289,12 @@ on_create (WebKitWebView *view, WebKitNavigationAction *act, gpointer u)
 {
     (void) act; (void) u;
 
+    /* A second window would be a way around the lock, so there is none. */
+    if (g_lock_mode != LOCK_OFF) {
+        LOG ("lock: refused a new window\n");
+        return NULL;
+    }
+
     /* popups (SSO, OAuth, "open in new window" players) share the session */
     WebKitWebView *nv = view_new (view);
     g_signal_connect (nv, "ready-to-show", G_CALLBACK (on_ready_to_show), NULL);
@@ -7158,13 +7358,6 @@ setup_settings (void)
     /* best effort at behaving like a mainstream browser */
     webkit_settings_set_enable_site_specific_quirks (g_settings, TRUE);
 
-    /* Cloudflare/Turnstile sometimes correlates missing GPU features with bots */
-    settings_set_bool_if_exists (g_settings, "enable-webgl", TRUE);
-    settings_set_bool_if_exists (g_settings, "enable-accelerated-2d-canvas", TRUE);
-
-    /* lets a player pick a codec the build actually has, instead of
-     * negotiating one it cannot decode and then stalling */
-    settings_set_bool_if_exists (g_settings, "enable-media-capabilities", TRUE);
 
     if (g_user_agent && *g_user_agent) {
         webkit_settings_set_user_agent (g_settings, g_user_agent);
@@ -7176,6 +7369,12 @@ setup_settings (void)
      * --no-devtools */
     webkit_settings_set_enable_developer_extras                 (g_settings, !g_no_devtools);
     webkit_settings_set_enable_media_stream                     (g_settings, TRUE);
+    /* Off, WebKit returns null from a window.open that no click started
+     * - and a click stops counting once the page has waited on a fetch.
+     * Zoom opens its meeting tab exactly that way. */
+    webkit_settings_set_javascript_can_open_windows_automatically (g_settings, g_allow_popups);
+    if (g_allow_popups)
+        LOG ("popups: pages may open windows without a click\n");
     webkit_settings_set_enable_webrtc                           (g_settings, TRUE);
     webkit_settings_set_enable_mediasource                      (g_settings, TRUE);
     webkit_settings_set_enable_encrypted_media                  (g_settings, TRUE);
@@ -7488,7 +7687,8 @@ browser_main (int argc, char **argv, const BrowserApp *app)
             cfg_set ("player_match", argv[++i]);
         } else if (!strcmp (a, "--zoom")) {
             NEED_ARG ("--zoom");
-            g_zoom = CLAMP (g_ascii_strtod (argv[++i], NULL), ZOOM_MIN, ZOOM_MAX);
+            const char *zv = argv[++i];
+            g_zoom = CLAMP (g_ascii_strtod (zv, NULL), ZOOM_MIN, ZOOM_MAX);
         } else if (!strcmp (a, "--css")) {
             NEED_ARG ("--css");
             g_css_path = argv[++i];
@@ -7529,6 +7729,10 @@ browser_main (int argc, char **argv, const BrowserApp *app)
             g_gsk_renderer = argv[++i];
         } else if (!strcmp (a, "--no-proc-watch")) {
             g_proc_watch = FALSE;
+        } else if (!strcmp (a, "--lock-page")) {
+            g_lock_mode = LOCK_PAGE;
+        } else if (!strcmp (a, "--lock-site")) {
+            g_lock_mode = LOCK_SITE;
         } else if (!strcmp (a, "--no-console")) {
             g_no_console = TRUE;
         } else if (!strcmp (a, "--all-messages")) {
@@ -7559,6 +7763,8 @@ browser_main (int argc, char **argv, const BrowserApp *app)
             g_profile = g_strdup (argv[++i]);
         } else if (!strcmp (a, "--clear-data")) {
             g_clear_data = TRUE;
+        } else if (!strcmp (a, "--allow-popups")) {
+            g_allow_popups = TRUE;
         } else if (!strcmp (a, "--user-agent")) {
             NEED_ARG ("--user-agent");
             g_free (g_user_agent);
@@ -7599,6 +7805,13 @@ browser_main (int argc, char **argv, const BrowserApp *app)
     }
 #undef NEED_ARG
 
+    /* Locking needs something to lock to, and saying so before the
+     * window is built is cheaper than after it. */
+    if (g_lock_mode != LOCK_OFF && !url_arg) {
+        g_printerr ("%s: --lock-page/--lock-site needs an address to stay on\n", argv[0]);
+        return 1;
+    }
+
     /* The page <title> drives the window title by default. An explicit
      * --title pins it instead, unless --page-title is also given - then
      * --title is just the fallback for pages with no title of their own. */
@@ -7681,7 +7894,17 @@ browser_main (int argc, char **argv, const BrowserApp *app)
     }
     if (g_no_dmabuf)      g_setenv ("WEBKIT_DISABLE_DMABUF_RENDERER", "1", TRUE);
     if (g_no_compositing) g_setenv ("WEBKIT_DISABLE_COMPOSITING_MODE", "1", TRUE);
-    if (g_no_hw_decode)   g_setenv ("WEBKIT_GST_ENABLE_HW_DECODERS", "0", TRUE);
+    /* WebKitGTK 2.54 has no switch of its own for this (the old
+     * WEBKIT_GST_ENABLE_HW_DECODERS is gone). GStreamer's documented way
+     * is the feature rank: a decoder at NONE is never autoplugged. */
+    if (g_no_hw_decode)
+        gst_rank_env_add ("vah264dec:NONE,vah265dec:NONE,vavp8dec:NONE,vavp9dec:NONE,"
+                          "vaav1dec:NONE,vampeg2dec:NONE,vajpegdec:NONE,"
+                          "vaapih264dec:NONE,vaapih265dec:NONE,vaapivp8dec:NONE,"
+                          "vaapivp9dec:NONE,vaapiav1dec:NONE,vaapidecodebin:NONE,"
+                          "v4l2slh264dec:NONE,v4l2slh265dec:NONE,v4l2slvp8dec:NONE,"
+                          "v4l2slvp9dec:NONE,v4l2slav1dec:NONE,v4l2h264dec:NONE,"
+                          "v4l2h265dec:NONE,v4l2vp8dec:NONE,v4l2vp9dec:NONE");
 
     if (app->pre_gtk)
         app->pre_gtk ();
@@ -7709,7 +7932,6 @@ browser_main (int argc, char **argv, const BrowserApp *app)
     setup_session ();
     g_signal_connect (g_session, "download-started",
                       G_CALLBACK (on_session_download_started), NULL);
-    object_set_string_if_exists (G_OBJECT (g_session), "downloads-directory", g_download_dir);
 
     history_setup (g_data_dir);
     dlrules_setup (g_data_dir);      /* app wide; the profile is only migrated from */
@@ -7750,16 +7972,31 @@ browser_main (int argc, char **argv, const BrowserApp *app)
         gtk_window_present (GTK_WINDOW (win));
     } else {
         /* the front-end may claim the address, e.g. browser-big's "diag" */
+        char *url = normalize_uri (url_arg);
+
+        /* The lock is measured against the address as given, before the
+         * front-end gets a chance to serve something of its own. */
+        if (g_lock_mode != LOCK_OFF) {
+            if (!url) {
+                usage_short (argv[0]);
+                noise_drain ();
+                return 1;
+            }
+            g_lock_uri = g_strdup (url);
+            LOG ("lock: %s only (%s)\n",
+                 g_lock_mode == LOCK_SITE ? "this site" : "this page", g_lock_uri);
+        }
+
         if (!(app->load_uri && app->load_uri (view, url_arg))) {
-            char *url = normalize_uri (url_arg);
             if (!url) {
                 usage_short (argv[0]);
+                noise_drain ();
                 return 1;
             }
             LOG ("load: %s\n", url);
             webkit_web_view_load_uri (view, url);
-            g_free (url);
         }
+        g_free (url);
         gtk_window_present (GTK_WINDOW (win));
     }
 
@@ -7781,6 +8018,7 @@ browser_main (int argc, char **argv, const BrowserApp *app)
     g_free (g_profile);
     g_free (g_user_agent);
     g_free (g_css_owned);
+    g_free (g_lock_uri);
     g_free (g_theme.font);
     g_free (g_theme.font_mono);
     return 0;
diff --git a/browser_core.h b/browser_core.h
index 93299b4..8c9cc5d 100644
--- a/browser_core.h
+++ b/browser_core.h
@@ -61,6 +61,7 @@ typedef struct {
     guint          dl_history_id;
     gboolean       primary;
     gint64         dl_reveal_us;  /* a download just appeared; do not fade   */
+    gint64         error_until_us; /* an error toast is up; do not fade      */
 
     /* stored-history walk, see history.c section in browser_core.c */
     gboolean       hist_walk;    /* past the end of the session list       */
@@ -104,7 +105,10 @@ typedef struct {
     void     (*usage_options) (GString *out);   /* extra option sections  */
     void     (*usage_keys)    (GString *out);   /* extra key bindings     */
 
-    /* Return TRUE if argv[*i] was consumed; advance *i over its value. */
+    /* Return TRUE if argv[*i] was consumed; advance *i over its value.
+     * Read that value into a local first: MAX/MIN/CLAMP are macros that
+     * evaluate their arguments more than once, so MAX (0, atoi (argv[++(*i)]))
+     * advances *i twice and swallows the next option's value. */
     gboolean (*parse_arg)     (int argc, char **argv, int *i);
 
     void     (*pre_gtk)       (void);           /* env, before gtk_init() */
@@ -168,10 +172,11 @@ extern const char     *g_mod_name;
 
 Win  *win_of    (WebKitWebView *view);
 void  toast_show (Win *w, const char *text, guint seconds);
+void  toast_error (Win *w, const char *text);   /* red, wrapped, 10 s, also stderr */
 void  css_reload (void);
 void  view_eval  (WebKitWebView *view, const char *js);
 char *normalize_uri (const char *in);
-void  settings_set_bool_if_exists (WebKitSettings *s, const char *prop, gboolean value);
-void  object_set_string_if_exists (GObject *o, const char *prop, const char *value);
+void  gst_rank_env_add (const char *spec);   /* append to GST_PLUGIN_FEATURE_RANK */
+void  feature_request  (const char *spec);   /* --feature NAME[=on|off], from a front-end */
 
 #endif /* BROWSER_CORE_H */
diff --git a/gst-glupload-null-meta.patch b/gst-glupload-null-meta.patch
new file mode 100644
index 0000000..e44d8e1
--- /dev/null
+++ b/gst-glupload-null-meta.patch
@@ -0,0 +1,15 @@
+--- a/gst-libs/gst/gl/gstglupload.c
++++ b/gst-libs/gst/gl/gstglupload.c
+@@ -1695,8 +1695,10 @@
+      * matches the size we use to import the dmabuf. @outcaps will remains
+      * display resolution as expected.
+      */
+-    out_info->width = meta->width;
+-    out_info->height = meta->height;
++    if (meta) {
++      out_info->width = meta->width;
++      out_info->height = meta->height;
++    }
+ 
+     /*
+      * When we zero-copy tiles, we need to propagate the strides, which contains
diff --git a/webkit-caps-normalize.patch b/webkit-caps-normalize.patch
new file mode 100644
index 0000000..826ccba
--- /dev/null
+++ b/webkit-caps-normalize.patch
@@ -0,0 +1,16 @@
+--- a/Source/WebCore/platform/mediastream/gstreamer/GStreamerVideoCaptureSource.cpp
++++ b/Source/WebCore/platform/mediastream/gstreamer/GStreamerVideoCaptureSource.cpp
+@@ -298,7 +298,13 @@
+ void GStreamerVideoCaptureSource::generatePresets()
+ {
+     Vector<VideoPreset> presets;
++    // A V4L2 device may list several sizes in one structure, for instance
++    // width=640, height={ 480, 360 }. Split them into one structure each,
++    // or every size listed that way is skipped below as not discrete - on
++    // a typical laptop camera that is every 4:3 mode.
+     auto caps = m_capturer->caps();
++    if (caps)
++        caps = adoptGRef(gst_caps_normalize(gst_caps_copy(caps.get())));
+     for (unsigned i = 0; i < gst_caps_get_size(caps.get()); i++) {
+         GstStructure* str = gst_caps_get_structure(caps.get(), i);
+ 
diff --git a/webkit-videorate-skip-to-first.patch b/webkit-videorate-skip-to-first.patch
new file mode 100644
index 0000000..89ecc6b
--- /dev/null
+++ b/webkit-videorate-skip-to-first.patch
@@ -0,0 +1,13 @@
+--- a/Source/WebCore/platform/mediastream/gstreamer/GStreamerVideoCapturer.cpp
++++ b/Source/WebCore/platform/mediastream/gstreamer/GStreamerVideoCapturer.cpp
+@@ -135,6 +135,10 @@
+ 
+     auto* bin = gst_bin_new(nullptr);
+     auto* videorate = makeGStreamerElement("videorate"_s, "videorate"_s);
++    // The capture pipeline runs with base time 0, so buffer PTS are absolute
++    // CLOCK_MONOTONIC values. Without skip-to-first, videorate fills the gap
++    // from segment start with uptime x fps duplicate frames (WebKit PR 74373).
++    g_object_set(videorate, "skip-to-first", TRUE, nullptr);
+ 
+     // The workaround below doesn't seem necessary anymore in GStreamer 1.28 and beyond.
+     // Fixed by: https://gitlab.freedesktop.org/gstreamer/gstreamer/-/commit/6f623af4d745efaacd0c8639b99536def4a65c78