Browser git · main
Minimal browser in C (mini and big variant)
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.gzWebcam stream is working (patch webkit and gst...)
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