Nimbin[12]?Web & Tools / Browser / browser-big.c

Browser git · main

Minimal browser in C (mini and big variant)

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

C 95.8% Markdown 3.9%
git clone https://git.christianimmanuel.de/web-tools/Browser.gitwget https://git.christianimmanuel.de/web-tools/Browser/archive/Browser.tar.gz
browser-big.c 126.3 KB · 3191 lines raw
/*
 * browser-big - WebKitGTK 6.0 (GTK4) page viewer for camera and streaming
 *
 * The window, URL bar, downloads, profiles, history and keys all come
 * from browser_core.c. What is left here is the part that makes camera
 * capture and video playback actually come up on the first try:
 *
 *   - a getUserMedia() shim that relaxes over-tight constraints, retries
 *     with progressively looser ones, and keeps the capture stream alive
 *     so the second and third call return instantly
 *   - a startup warm-up of the GStreamer/V4L2 capture stack, so the
 *     page's own getUserMedia() does not pay for device probing
 *   - a media watchdog that notices a stalled <video> and recovers it,
 *     and a load watchdog that reloads a page that never commits
 *   - a built-in diagnostics page, reachable as "diag"
 *
 * Run with -h for the full option and key list.
 */

#include "browser_core.h"

#include <gst/gst.h>

#include <dirent.h>
#include <errno.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/types.h>

/* watchdogs */
#define RELOAD_MIN_GAP_SECONDS 5  /* never auto-reload faster than this */

/* -------------------------------------------------------------- globals */

static gboolean g_media_debug;
static gboolean g_shim_debug;      /* the shim reports calls; not the rest */
static int      g_prewarm_video;   /* cameras GStreamer found for us */
static int      g_prewarm_audio;   /* real microphones, monitors excluded */
static gboolean g_gum_hint_done;
static gboolean g_capture_started;   /* something actually began capturing */
static guint    g_capture_watch;

/* camera / media behaviour */
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
 * change how the camera behaves, so the repairs below - relaxing,
 * retrying, dropping audio, and above all cloning and holding the capture
 * track - only apply when one of the camera flags asked for them.
 */
static gboolean g_cam_repair;              /* --cam-fix, --cam-share, ... */
static gboolean g_cam_relax     = TRUE;    /* loosen the page's constraints */
static gboolean g_cam_keepalive = TRUE;    /* reuse and hold the capture stream */
static gboolean g_cam_drop_audio = TRUE;   /* video-only rather than nothing */
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 gboolean g_no_mic;                  /* --no-mic diagnostic */
static gboolean g_web_compat;              /* fill in APIs WebKit lacks */
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 = 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 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;

static gboolean g_media_watchdog;
static int      g_stall_timeout  = 6;      /* seconds of frozen playback */
static gboolean g_auto_reload;
static int      g_load_timeout   = 25;     /* seconds to commit a load, 0 = off */
static int      g_max_reloads    = 3;

static char    *g_shim_js;                 /* built once at startup */
static char    *g_pending_url;             /* held back while warming up */
static guint    g_warm_timeout_id;
static gboolean g_first_load_done;

/* rendering / debugging switches that only touch the environment */
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)
{
    if (!g_gst_rank) {
        g_gst_rank = g_strdup (spec);
        return;
    }
    char *joined = g_strconcat (g_gst_rank, ",", spec, NULL);
    g_free (g_gst_rank);
    g_gst_rank = joined;
}

/* every VA-API element GStreamer offers, so none of them gets picked */
#define VA_RANK_OFF \
    "vah264enc:NONE,vah264lpenc:NONE,vah265enc:NONE,vah265lpenc:NONE," \
    "vavp9enc:NONE,vaav1enc:NONE,vah264dec:NONE,vah265dec:NONE," \
    "vavp8dec:NONE,vavp9dec:NONE,vaav1dec:NONE,vajpegdec:NONE," \
    "vapostproc:NONE,vadeinterlace:NONE,vacompositor:NONE"
static int         g_gst_level = -1;

/* Per-window watchdog bookkeeping, hung off Win.ext. */
typedef struct {
    guint   load_wd_id;      /* "load did not commit" timer      */
    char   *reload_uri;      /* URI the reload budget belongs to */
    int     reloads;         /* auto-reloads spent on that URI   */
    gint64  last_reload_us;
} BigWin;

#define EXT(w) ((BigWin *) (w)->ext)

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. */
static void
tstamp (char *buf, gsize n)
{
    GDateTime *dt  = g_date_time_new_now_local ();
    char      *hms = g_date_time_format (dt, "%H:%M:%S");

    g_snprintf (buf, n, "%s.%03d", hms, g_date_time_get_microsecond (dt) / 1000);
    g_free (hms);
    g_date_time_unref (dt);
}

static void G_GNUC_PRINTF (1, 2)
mlog (const char *fmt, ...)
{
    if (g_quiet)
        return;

    char ts[32];
    tstamp (ts, sizeof ts);

    va_list ap;
    va_start (ap, fmt);
    char *msg = g_strdup_vprintf (fmt, ap);
    va_end (ap);

    g_printerr ("[%s] %s\n", ts, msg);
    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)
{
    return !g_strcmp0 (s, "diag") || !g_strcmp0 (s, "about:diag");
}

/* "1280x720@30", "640x480", "@15" - every part optional. */
static gboolean
parse_cam_max (const char *spec)
{
    int w = 0, h = 0, fps = 0;

    if (sscanf (spec, "%dx%d@%d", &w, &h, &fps) >= 2) {
        g_cam_max_w = w;
        g_cam_max_h = h;
        if (fps > 0)
            g_cam_max_fps = fps;
        return TRUE;
    }
    if (sscanf (spec, "@%d", &fps) == 1 && fps > 0) {
        g_cam_max_fps = fps;
        return TRUE;
    }
    return FALSE;
}

/* ================================================================== */
/*                    injected JavaScript                             */
/* ================================================================== */

/*
 * The shim runs at document-start in every frame. It does four things:
 *
 *  1. relaxes the constraints a page asks for. Exact 1280x720@30 forces the
 *     V4L2 source into one specific mode; if the camera only offers it as
 *     MJPEG the pipeline has to bring up a decoder before the promise can
 *     resolve, which is a large part of the "my cam is too slow" delay.
 *     Turning exact into ideal lets the source pick a mode it already has.
 *
 *  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 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 =
"(function(){"
"if(window.__bbShim)return; window.__bbShim=true;"
"var C=window.__bbCfg||{};"
"function post(o){try{o.t=Date.now();"
"  var h=window.webkit&&webkit.messageHandlers&&webkit.messageHandlers.bbEvent;"
"  if(h)h.postMessage(JSON.stringify(o));else if(C.debug)console.log('[bb]',JSON.stringify(o));"
"}catch(e){}}"
"window.__bbPost=post;"

/* ---- media watchdog ---- */
"function watchdog(){"
"  var seen=new WeakMap();"
"  function attach(v){"
"    ['error','stalled','abort','emptied','suspend'].forEach(function(n){"
"      v.addEventListener(n,function(){"
"        var code=(v.error&&v.error.code)||0;"
"        post({ev:'media-event',name:n,code:code,readyState:v.readyState,networkState:v.networkState});"
"        if(n==='error'&&(code===3||code===4))post({ev:'media-reload',reason:'media-error-'+code});"
"      });});"
"    if(C.debug)['waiting','playing','canplay','loadeddata'].forEach(function(n){"
"      v.addEventListener(n,function(){post({ev:'media-event',name:n,readyState:v.readyState});});});"
"  }"
"  function recover(v,s){"
"    post({ev:'media-stall',tries:s.tries,readyState:v.readyState,networkState:v.networkState,"
"          w:v.videoWidth||0,src:(v.currentSrc||'').slice(0,140)});"
"    try{"
"      if(s.tries===1){if(v.play)v.play().catch(function(){});return;}"
"      if(s.tries===2){"
"        if(v.seekable&&v.seekable.length){"
"          try{v.currentTime=Math.max(0,v.seekable.end(v.seekable.length-1)-0.5);}catch(e){}}"
"        if(v.play)v.play().catch(function(){});return;}"
"      if(s.tries===3&&v.currentSrc&&v.currentSrc.indexOf('blob:')!==0){"
"        v.load();if(v.play)v.play().catch(function(){});return;}"
"    }catch(e){}"
"    if(s.tries>=4){post({ev:'media-reload',reason:'stalled'});s.tries=0;}"
"  }"
"  function check(){"
"    var l=document.querySelectorAll('video,audio'),now=Date.now();"
"    for(var i=0;i<l.length;i++){var v=l[i],s=seen.get(v);"
"      if(!s){s={ct:-1,since:now,tries:0};seen.set(v,s);attach(v);}"
"      if(v.paused||v.ended||!v.currentSrc&&!v.srcObject){s.ct=v.currentTime;s.since=now;continue;}"
"      if(v.currentTime!==s.ct){s.ct=v.currentTime;s.since=now;s.tries=0;continue;}"
"      if(now-s.since<C.stall*1000)continue;"
"      s.since=now;s.tries++;recover(v,s);"
"    }"
"  }"
"  setInterval(check,1000);"
"}"
"if(C.watchdog)watchdog();"

/* ---- getUserMedia ---- */
"var md=navigator.mediaDevices;"
"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,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;"
"  return v.exact!=null?v.exact:v.ideal!=null?v.ideal:v.max!=null?v.max:v.min!=null?v.min:null;}"
"function relaxVideo(v,level){"
"  if(v===true||v==null)return true;"
"  if(typeof v!=='object')return v;"
"  var o={},k;for(k in v)o[k]=v[k];"
"  if(level>=1){"
"    if(o.aspectRatio!=null){var ar=num(o.aspectRatio);"
"      if(ar==null)delete o.aspectRatio;else o.aspectRatio={ideal:ar};}"
"    ['width','height','frameRate'].forEach(function(k){"
"      if(o[k]==null)return;var n=num(o[k]);if(n==null){delete o[k];return;}o[k]={ideal:n};});"
"    if(o.width&&C.maxW&&o.width.ideal>C.maxW)o.width={ideal:C.maxW};"
"    if(o.height&&C.maxH&&o.height.ideal>C.maxH)o.height={ideal:C.maxH};"
"    if(o.frameRate&&C.maxFps&&o.frameRate.ideal>C.maxFps)o.frameRate={ideal:C.maxFps};"
"    if(o.deviceId&&o.deviceId.exact)o.deviceId={ideal:o.deviceId.exact};"
"    if(o.facingMode&&o.facingMode.exact)o.facingMode={ideal:o.facingMode.exact};"
"  }"
/* Giving up on the resolution should not give up on the shape: the
 * aspect ratio the page asked for is carried over as an ideal. */
"  if(level>=2){"
"    if(o.aspectRatio==null&&o.width&&o.height&&o.width.ideal&&o.height.ideal)"
"      o.aspectRatio={ideal:o.width.ideal/o.height.ideal};"
"    delete o.width;delete o.height;delete o.frameRate;delete o.facingMode;}"
"  if(level>=3)return true;"
"  return Object.keys(o).length?o:true;"
"}"
"function relax(c,level){"
"  if(!c)return c;var o={},k;for(k in c)o[k]=c[k];"
"  if(o.video)o.video=relaxVideo(o.video,level);"
"  if(level>=2&&o.audio&&typeof o.audio==='object')o.audio=true;"
"  return o;"
"}"
/* --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];"
"  o.audio=false;"
"  if(!o.video){post({ev:'gum-nomic-refused'});return c;}"
"  post({ev:'gum-nomic'});"
"  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&&!pinnedA)||!c)return c;"
"  var o={},k;for(k in c)o[k]=c[k];"
"  pinKind(o,'video',pinned);pinKind(o,'audio',pinnedA);"
"  return o;"
"}"
"function pinDevice(){"
"  if(pinTried||(!C.match&&!C.micMatch)||!origEnum)return Promise.resolve(null);"
"  return origEnum().then(function(l){"
/* 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,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){"
"  if(!track||!c||typeof c.video!=='object'||!c.video.deviceId)return true;"
"  var want=c.video.deviceId.exact||c.video.deviceId;"
"  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)){"
"      out.addTrack(shareTrack(cachedV));"
"    }else ok=false;"
"  }"
"  if(wants(c,'audio')){"
"    if(cachedA&&cachedA.readyState==='live')"
"      out.addTrack(cachedA);"
"    else ok=false;"
"  }"
"  return (ok&&out.getTracks().length)?note(out):null;"
"}"
"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;"
"  if(v)shareTrack(v);"
"  return note(st);"
"}"
/* Which requested kind the browser has no device for, if any. */
"function countKinds(c){"
"  if(!origEnum)return Promise.resolve(null);"
"  return origEnum().then(function(l){"
"    var v=0,a=0,i;"
"    for(i=0;i<l.length;i++){"
"      if(l[i].kind==='videoinput')v++;"
"      else if(l[i].kind==='audioinput')a++;"
"    }"
"    if(wants(c,'video')&&!v)return 'video';"
"    if(wants(c,'audio')&&!a)return 'audio';"
"    return null;"
"  }).catch(function(){return null;});"
"}"
"function retryable(e){"
"  var n=e&&e.name;"
"  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){"
"    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=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]});"
"        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)});"
"        i++;"
/* No device at all is not an over-tight constraint: loosening it three
 * times cannot help, it just costs the page 700ms and floods the log. */
"        return countKinds(c).then(function(missing){"
/* No microphone, but a camera: a site asking for both gets video rather
 * than nothing. It did not ask for that, so it is a choice, not a
 * default - see --no-cam-drop-audio. */
"          if(missing==='audio'&&C.dropAudio&&wants(c,'video')&&!audioDropped){"
"            audioDropped=true;"
"            post({ev:'gum-drop-audio'});"
"            var vo={},k;for(k in c)if(k!=='audio')vo[k]=c[k];"
"            c=vo;i=0;"
"            return attempt();"
"          }"
"          if(missing){"
"            post({ev:'gum-nodevices',kind:missing});"
"            throw err;"
"          }"
"          if(i<levels.length&&retryable(err))"
"            return new Promise(function(r){setTimeout(r,250);}).then(attempt);"
"          throw err;"
"        });"
"      });"
"    }"
"    return attempt();"
"  });"
"};"
"if(origEnum)md.enumerateDevices=function(){"
"  return origEnum().then(function(l){"
"    if(C.debug){"
"      var lst=l.map(function(d){return {kind:d.kind,label:d.label};});"
"      var key=JSON.stringify(lst);"
"      if(key!==lastDevices){lastDevices=key;post({ev:'devices',list:lst});}"
"    }"
"    return l;});};"
"window.__bbWarm=function(){"
"  return md.getUserMedia({video:true}).then(function(s){"
"    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;"
"};"
/*
 * WebKitGTK's WebRTC is GStreamer-based and its offers differ from the
 * ones a site tested against Chrome. Two of those differences stop a
 * publish dead, and both are repaired here rather than in the site:
 *
 *   - no a=ssrc:<n> cname:<v> lines, so a parser looking for the CNAME
 *     throws "CNAME value not found" and never reaches the connection
 *   - a codec order offering something this machine has no encoder for
 */
"function rtcCname(){"
"  return 'bb' + Math.floor(Math.random()*1e9).toString(36);"
"}"
"function sdpAddSsrc(sdp,cname){"
"  if(!sdp)return sdp;"
"  var parts=sdp.split(/(?=[\\r\\n]m=)/),i,changed=false;"
"  for(i=0;i<parts.length;i++){"
"    var s=parts[i];"
"    if(!/[\\r\\n]m=(audio|video)/.test(s)&&!/^m=(audio|video)/.test(s))continue;"
"    if(/a=recvonly/.test(s))continue;"
"    if(/a=ssrc:\\d+ cname:/.test(s))continue;"
"    var ssrc=Math.floor(Math.random()*4294967294)+1;"
"    var add='a=ssrc:'+ssrc+' cname:'+cname+'\\r\\n';"
"    var ms=/a=msid:(\\S+)\\s+(\\S+)/.exec(s);"
"    if(ms)add+='a=ssrc:'+ssrc+' msid:'+ms[1]+' '+ms[2]+'\\r\\n';"
"    parts[i]=s.replace(/[\\r\\n]*$/,'\\r\\n')+add;"
"    changed=true;"
"  }"
"  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.map(function(w){return w.toLowerCase().replace(/^video\\//,'');});"
"    var front=[],rest=[];"
"    caps.codecs.forEach(function(c){"
"      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')"
"        try{t.setCodecPreferences(flat);}catch(e){}"
"    });"
"  }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
 * connection being constructed, tracks being attached, the remote answer
 * arriving. The last event seen is the step that failed.
 */
/*
 * screen.orientation is absent in WebKitGTK, and a media library reading
 * screen.orientation.type throws before it ever gets going. Supplying a
 * plausible read-only value is enough.
 */
"if(C.compat&&window.screen&&!window.screen.orientation){(function(){"
"  try{"
"    var angle=0,type='landscape-primary';"
"    var o={get angle(){return angle;},get type(){return type;},"
"           onchange:null,"
"           addEventListener:function(){},removeEventListener:function(){},"
"           dispatchEvent:function(){return false;},"
"           lock:function(){return Promise.reject(new Error('not supported'));},"
"           unlock:function(){}};"
"    try{Object.defineProperty(window.screen,'orientation',"
"        {value:o,configurable:true,enumerable:true});}"
"    catch(e){window.screen.orientation=o;}"
"    post({ev:'compat-orientation',type:type});"
"  }catch(e){post({ev:'compat-error',why:String((e&&e.message)||e)});}"
"})();}"
/* Reported whether or not WebRTC exists: its absence is the point. */
"if(C.rtcTrace){"
"  post({ev:'rtc-caps',"
"    ua:navigator.userAgent,"
"    pc:!!window.RTCPeerConnection,"
"    addTransceiver:!!((window.RTCPeerConnection&&window.RTCPeerConnection.prototype)&&(window.RTCPeerConnection&&window.RTCPeerConnection.prototype).addTransceiver),"
"    senderCaps:!!(window.RTCRtpSender&&RTCRtpSender.getCapabilities),"
"    receiverCaps:!!(window.RTCRtpReceiver&&RTCRtpReceiver.getCapabilities),"
"    dataChannel:!!((window.RTCPeerConnection&&window.RTCPeerConnection.prototype)&&(window.RTCPeerConnection&&window.RTCPeerConnection.prototype).createDataChannel),"
"    getStats:!!((window.RTCPeerConnection&&window.RTCPeerConnection.prototype)&&(window.RTCPeerConnection&&window.RTCPeerConnection.prototype).getStats),"
"    mediaRecorder:!!window.MediaRecorder,"
"    insertableStreams:!!(window.RTCRtpSender&&RTCRtpSender.prototype&&"
"                         RTCRtpSender.prototype.createEncodedStreams),"
/*
 * A conferencing web client needs more than WebRTC: it processes frames in
 * WebAssembly, often in a worker, and wants WebGL for rendering and
 * SharedArrayBuffer for zero-copy handoff. Any of these missing sends it
 * down a slow path or stops it dead, and the failure shows up as a freeze
 * rather than as an error.
 */
"    webgl:(function(){try{var c=document.createElement('canvas');"
"      return !!(c.getContext('webgl')||c.getContext('experimental-webgl'));}"
"      catch(e){return false;}})(),"
"    webgl2:(function(){try{return !!document.createElement('canvas')"
"      .getContext('webgl2');}catch(e){return false;}})(),"
"    offscreenCanvas:typeof OffscreenCanvas!=='undefined',"
"    offscreenWebgl:(function(){try{return typeof OffscreenCanvas!=='undefined'&&"
"      !!(new OffscreenCanvas(2,2).getContext('webgl'));}catch(e){return false;}})(),"
"    wasm:typeof WebAssembly!=='undefined',"
"    sharedArrayBuffer:typeof SharedArrayBuffer!=='undefined',"
"    crossOriginIsolated:(typeof crossOriginIsolated!=='undefined')?crossOriginIsolated:null,"
"    webcodecs:typeof VideoEncoder!=='undefined',"
"    worker:typeof Worker!=='undefined',"
"    rvfc:!!(document.createElement('video').requestVideoFrameCallback),"
"    vcodecs:(function(){try{return (RTCRtpSender.getCapabilities('video')"
"      .codecs||[]).map(function(c){return c.mimeType;});}catch(e){return null;}})()"
"  });""}"
"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]);"
"    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',pc:id,on:e,ice:pc.iceConnectionState,"
"              conn:pc.connectionState,sig:pc.signalingState});});}catch(x){}"
"    });"
"    return pc;"
"  }"
"  W.prototype=O.prototype;"
"  ['generateCertificate'].forEach(function(m){if(O[m])W[m]=O[m].bind(O);});"
"  window.RTCPeerConnection=W;"
"  if(window.webkitRTCPeerConnection)window.webkitRTCPeerConnection=W;"
"  ['addTrack','addTransceiver','addStream','setRemoteDescription',"
"   '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;"
"      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,
 * it never builds a connection and there is nothing further to trace - so
 * report what it would have looked at.
 */

"  post({ev:'rtc-trace-on'});"
"})();}"
/*
 * WebKit insists RTCRtpSendParameters.codecs be present; Firefox lets it
 * be omitted, so a library taking its Firefox path calls setParameters
 * without it and WebKit throws
 *
 *   TypeError: Member RTCRtpSendParameters.codecs is required
 *
 * Filling it in from the sender's current parameters is what the other
 * engines effectively do.
 */
"if(C.paramsFix&&window.RTCRtpSender&&RTCRtpSender.prototype&&"
"   RTCRtpSender.prototype.setParameters){(function(){"
"  var oSP=RTCRtpSender.prototype.setParameters;"
"  RTCRtpSender.prototype.setParameters=function(p){"
"    try{"
"      if(p&&typeof p==='object'&&!p.codecs){"
"        var cur=null;"
"        try{cur=this.getParameters?this.getParameters():null;}catch(e){}"
"        var q={},k;for(k in p)q[k]=p[k];"
"        q.codecs=(cur&&cur.codecs)?cur.codecs:[];"
"        post({ev:'send-params-fixed',had:Object.keys(p),codecs:q.codecs.length});"
"        p=q;"
"      }"
"    }catch(e){}"
"    return oSP.call(this,p);"
"  };"
"  post({ev:'params-fix-on'});"
"})();}"
"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(!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);"
"    return oCO.apply(pc,a).then(patch);"
"  };"
"  P.prototype.createAnswer=function(){"
"    var pc=this,a=arguments;"
"    codecPrefs(pc);"
"    return oCA.apply(pc,a).then(patch);"
"  };"
/*
 * Injecting the lines is not the same as them surviving: WebKit reparses
 * and re-serialises the description, and a library reads them back from
 * pc.localDescription. So check afterwards and say which it was - if they
 * are stripped, this approach cannot work and it is better to know.
 */
"  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 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');"
"};"
"post({ev:'shim',camfix:true,repair:!!(C.cache||C.relax||C.retries),url:location.href});"
"})();";

/* Dumps the state of every media element; used by <mod>+Shift+V. */
static const char *MEDIA_DUMP_JS =
"(function(){var o=[],l=document.querySelectorAll('video,audio');"
"for(var i=0;i<l.length;i++){var v=l[i];o.push({i:i,tag:v.tagName,readyState:v.readyState,"
"networkState:v.networkState,paused:v.paused,muted:v.muted,"
"currentTime:+(v.currentTime||0).toFixed(2),"
"duration:isFinite(v.duration)?+v.duration.toFixed(2):null,"
"w:v.videoWidth||0,h:v.videoHeight||0,err:(v.error&&v.error.code)||0,"
"src:(v.currentSrc||(v.srcObject?'[srcObject]':'')).slice(0,140)});}"
"if(window.__bbPost)window.__bbPost({ev:'media-dump',held:!!(window.__bbHeld&&__bbHeld()),elements:o});"
"else console.log('[bb] media-dump',JSON.stringify(o));})();";

/* Loaded before the real page when --warm-cam is on. Its only job is to make
 * the capture stack pay its startup cost here instead of inside the site. */
static const char *WARM_HTML =
"<!doctype html><meta charset='utf-8'><title>warming camera</title>"
"<style>body{font:14px system-ui,sans-serif;background:#111;color:#ccc;margin:24px}</style>"
"<p>warming up the camera…</p>"
"<script>"
"function done(r){try{webkit.messageHandlers.bbEvent.postMessage("
"  JSON.stringify({ev:'warm-page-done',result:r}));}catch(e){}}"
"if(navigator.mediaDevices&&navigator.mediaDevices.getUserMedia){"
"  navigator.mediaDevices.getUserMedia({video:true}).then(function(s){"
"    s.getTracks().forEach(function(t){t.stop();});done('ok');"
"  }).catch(function(e){done('error:'+e.name);});"
"}else done('unsupported');"
"</script>";

/* Built-in getUserMedia diagnostics, loaded with an https base URI so it
 * counts as a secure context. */
static const char *DIAG_HTML =
"<!doctype html><meta charset='utf-8'>"
"<title>browser-big media diagnostics</title>"
"<style>"
"body{font:14px/1.4 system-ui,sans-serif;margin:16px;background:#111;color:#eee}"
"button{font:inherit;margin:2px;padding:6px 10px}"
"video{width:640px;max-width:100%;background:#000;display:block;margin:8px 0}"
"pre{white-space:pre-wrap;background:#000;color:#8f8;padding:8px;max-height:40vh;overflow:auto}"
"h1{font-size:16px}"
"</style>"
"<h1>browser-big media diagnostics</h1>"
"<div>"
"<button id='enum'>enumerateDevices</button>"
"<button id='v'>video: true</button>"
"<button id='vhd'>video 1280x720@30 (ideal)</button>"
"<button id='vexact'>video 1280x720@30 (exact)</button>"
"<button id='ar'>video aspectRatio 16:9 (exact)</button>"
"<button id='a'>audio: true</button>"
"<button id='warm'>warm</button>"
"<button id='rel'>release</button>"
"<button id='stop'>stop</button>"
"<button id='clr'>clear log</button>"
"</div>"
"<video id='vid' autoplay playsinline muted></video>"
"<pre id='log'></pre>"
"<script>"
"var L=document.getElementById('log');"
"var V=document.getElementById('vid');"
"var cur=null,fpsTimer=null;"
"function log(){var a=[].slice.call(arguments).map(function(x){"
"  return (typeof x==='string')?x:JSON.stringify(x);}).join(' ');"
"  L.textContent+=(new Date().toISOString().substr(11,12))+' '+a+'\\n';"
"  L.scrollTop=L.scrollHeight;}"
"function stop(){if(cur){cur.getTracks().forEach(function(t){t.stop();});cur=null;}"
"  if(fpsTimer){clearInterval(fpsTimer);fpsTimer=null;}V.srcObject=null;log('stopped');}"
"function dumpTrack(t){var s={},c={};"
"  try{s=t.getSettings?t.getSettings():{};}catch(e){}"
"  try{c=t.getCapabilities?t.getCapabilities():{};}catch(e){}"
"  log('track',t.kind,'label='+JSON.stringify(t.label),'readyState='+t.readyState);"
"  log('  settings',s);log('  capabilities',c);"
"  t.onended=function(){log('track ended',t.kind);};"
"  t.onmute=function(){log('track mute',t.kind);};"
"  t.onunmute=function(){log('track unmute',t.kind);};}"
"function watchFps(){"
"  if(!('requestVideoFrameCallback' in HTMLVideoElement.prototype)){"
"    log('note: requestVideoFrameCallback unavailable, cannot measure FPS');return;}"
"  var n=0,last=performance.now();"
"  function cb(){n++;V.requestVideoFrameCallback(cb);}V.requestVideoFrameCallback(cb);"
"  fpsTimer=setInterval(function(){var t=performance.now();"
"    log('measured FPS ~'+(n*1000/(t-last)).toFixed(1));n=0;last=t;},2000);}"
"function go(c){stop();var t0=performance.now();log('getUserMedia',c);"
"  navigator.mediaDevices.getUserMedia(c).then(function(st){"
"    cur=st;V.srcObject=st;"
"    log('OK in '+Math.round(performance.now()-t0)+'ms, tracks='+st.getTracks().length);"
"    st.getTracks().forEach(dumpTrack);if(c.video)watchFps();"
"  }).catch(function(e){log('ERROR',e.name,e.message||'',e.constraint?('constraint='+e.constraint):'',"
"    'after '+Math.round(performance.now()-t0)+'ms');});}"
"document.getElementById('enum').onclick=function(){"
"  navigator.mediaDevices.enumerateDevices().then(function(ds){"
"    log('devices:');ds.forEach(function(d){log('  ',d.kind,JSON.stringify(d.label));});"
"    try{log('supportedConstraints',navigator.mediaDevices.getSupportedConstraints());}catch(e){}});};"
"document.getElementById('v').onclick=function(){go({video:true});};"
"document.getElementById('vhd').onclick=function(){go({video:{width:{ideal:1280},height:{ideal:720},frameRate:{ideal:30}}});};"
"document.getElementById('vexact').onclick=function(){go({video:{width:{exact:1280},height:{exact:720},frameRate:{exact:30}}});};"
"document.getElementById('ar').onclick=function(){go({video:{aspectRatio:{exact:1.7777777778}}});};"
"document.getElementById('a').onclick=function(){go({audio:true});};"
"document.getElementById('warm').onclick=function(){"
"  if(window.__bbWarm)__bbWarm().then(function(r){log('warm',r);});else log('shim disabled');};"
"document.getElementById('rel').onclick=function(){"
"  if(window.__bbRelease){__bbRelease();log('released held tracks');}else log('shim disabled');};"
"document.getElementById('stop').onclick=stop;"
"document.getElementById('clr').onclick=function(){L.textContent='';};"
"V.addEventListener('error',function(){log('video element error');});"
"log('ready. origin='+location.origin+' secureContext='+window.isSecureContext"
"    +' shim='+(window.__bbShim?'on':'off'));"
"</script>";

/* ------------------------------------------------- process inventory */

/* WebKitGTK 6.0 exposes no UI-process API for the auxiliary process PIDs, so
 * read them from /proc: descendant PIDs of ours whose comm starts with
 * "WebKit" (comm is truncated to 15 chars, e.g. "WebKitGPUProces"). */
static gboolean
proc_ppid_comm (pid_t pid, pid_t *ppid, char *comm, gsize commlen)
{
    char *p    = g_strdup_printf ("/proc/%d/status", (int) pid);
    char *data = NULL;
    gboolean ok = g_file_get_contents (p, &data, NULL, NULL);
    g_free (p);
    if (!ok)
        return FALSE;

    *ppid = 0;
    comm[0] = '\0';
    for (char *line = data; line && *line; ) {
        char *nl = strchr (line, '\n');
        if (nl) *nl = '\0';
        if (g_str_has_prefix (line, "Name:"))
            g_strlcpy (comm, g_strchomp (line + 5 + strspn (line + 5, " \t")), commlen);
        else if (g_str_has_prefix (line, "PPid:"))
            *ppid = (pid_t) atoi (line + 5);
        if (!nl) break;
        line = nl + 1;
    }
    g_free (data);
    return TRUE;
}

static gboolean
is_descendant (GHashTable *parent, pid_t pid, pid_t ancestor)
{
    for (int guard = 0; pid > 1 && guard < 128; guard++) {
        gpointer pp = g_hash_table_lookup (parent, GINT_TO_POINTER ((int) pid));
        if (!pp)
            return FALSE;
        pid_t par = (pid_t) GPOINTER_TO_INT (pp);
        if (par == ancestor)
            return TRUE;
        pid = par;
    }
    return FALSE;
}

static void
dump_child_processes (const char *why)
{
    DIR *d = opendir ("/proc");
    if (!d) {
        mlog ("proc: cannot open /proc (%s)", g_strerror (errno));
        return;
    }

    GHashTable *parent = g_hash_table_new (g_direct_hash, g_direct_equal);
    GHashTable *comm   = g_hash_table_new_full (g_direct_hash, g_direct_equal,
                                                NULL, g_free);
    struct dirent *e;
    while ((e = readdir (d))) {
        char *end;
        long pid = strtol (e->d_name, &end, 10);
        if (*end != '\0' || pid <= 0)
            continue;
        pid_t ppid;
        char  cm[64];
        if (!proc_ppid_comm ((pid_t) pid, &ppid, cm, sizeof cm))
            continue;
        g_hash_table_insert (parent, GINT_TO_POINTER ((int) pid),
                             GINT_TO_POINTER ((int) ppid));
        g_hash_table_insert (comm, GINT_TO_POINTER ((int) pid), g_strdup (cm));
    }
    closedir (d);

    pid_t self = getpid ();
    GHashTableIter it;
    gpointer k, v;
    int found = 0;
    g_hash_table_iter_init (&it, comm);
    while (g_hash_table_iter_next (&it, &k, &v)) {
        pid_t pid = (pid_t) GPOINTER_TO_INT (k);
        const char *cm = v;
        if (!g_str_has_prefix (cm, "WebKit"))
            continue;
        if (pid == self || !is_descendant (parent, pid, self))
            continue;
        pid_t ppid = (pid_t) GPOINTER_TO_INT (
            g_hash_table_lookup (parent, GINT_TO_POINTER ((int) pid)));
        mlog ("proc: %-16s pid=%d ppid=%d", cm, (int) pid, (int) ppid);
        found++;
    }
    if (!found)
        mlog ("proc: no WebKit auxiliary processes found yet (%s)", why);

    g_hash_table_destroy (parent);
    g_hash_table_destroy (comm);
}

/* --------------------------------------------------- capture diagnostics */

static void
dump_env (const char *name)
{
    const char *v = g_getenv (name);
    mlog ("env: %-28s %s", name, v ? v : "(unset)");
}

static void
print_versions (void)
{
    char *gstv = gst_version_string ();
    mlog ("browser-big starting (pid %d)", (int) getpid ());
    mlog ("versions: GTK %u.%u.%u | GLib %u.%u.%u | WebKitGTK %u.%u.%u | %s",
          gtk_get_major_version (), gtk_get_minor_version (), gtk_get_micro_version (),
          glib_major_version, glib_minor_version, glib_micro_version,
          webkit_get_major_version (), webkit_get_minor_version (),
          webkit_get_micro_version (),
          gstv ? gstv : "GStreamer ?");
    g_free (gstv);
}

static void
dump_gstreamer_env (void)
{
    mlog ("---- GStreamer environment ----");
    dump_env ("GST_PLUGIN_PATH");
    dump_env ("GST_PLUGIN_SYSTEM_PATH_1_0");
    dump_env ("GST_PLUGIN_SCANNER");
    dump_env ("GST_REGISTRY_1_0");
    dump_env ("GST_DEBUG");
    dump_env ("GST_DEBUG_FILE");
    dump_env ("WEBKIT_DEBUG");
    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");

    /*
     * Whether GStreamer can report anything at all. Built with
     * -Dgst-debug=disabled these are compile-time constants, GST_DEBUG is
     * ignored, GST_DEBUG_FILE is never even created - and every attempt
     * to diagnose a media problem returns nothing, which looks like the
     * problem being silent rather than the tooling being absent.
     */
    /*
     * Two different things, easily confused: whether the debug system was
     * compiled in at all (a macro in gstconfig.h), and whether a level is
     * currently set (gst_debug_is_active(), which is false whenever
     * GST_DEBUG is unset, even in a build that supports it).
     */
#ifdef GST_DISABLE_GST_DEBUG
    mlog ("gst-debug: COMPILED OUT of this GStreamer - GST_DEBUG can never\n"
          "gst-debug: report anything. Rebuild gstreamer core with its\n"
          "gst-debug: gst_debug option on.");
#else
    mlog ("gst-debug: supported; currently %s (threshold %d)",
          gst_debug_is_active () ? "ON" : "off, set GST_DEBUG to use it",
          (int) gst_debug_get_default_threshold ());
#endif

    mlog ("---- capture / WebRTC element check ----");
    static const char *els[] = {
        "v4l2src", "pipewiresrc", "videoconvert", "videoscale", "videorate",
        "capsfilter", "jpegdec", "vah264dec",
        "vp8enc", "vp8dec", "vp9enc", "vp9dec",
        "openh264enc", "openh264dec", "x264enc", "avdec_h264", "avdec_aac",
        "webrtcbin", "rtpvp8pay", "opusenc", "opusdec",
        "matroskademux", "qtdemux", "hlsdemux", "dashdemux", NULL
    };
    for (int i = 0; els[i]; i++) {
        GstElementFactory *f = gst_element_factory_find (els[i]);
        mlog ("gst-element: %-16s %s", els[i], f ? "present" : "MISSING");
        if (f) gst_object_unref (f);
    }
    mlog ("---------------------------------------");
}

/*
 * Startup warm-up, step one: enumerate the capture devices ourselves.
 *
 * This builds the GStreamer plugin registry, walks udev and asks every
 * /dev/video* node for its caps - exactly the work WebKit would otherwise do
 * inside the page's first getUserMedia() call, where a site's own timeout is
 * already running. Doing it here moves that cost off the critical path and,
 * as a bonus, prints which nodes are real cameras and which are the metadata
 * nodes UVC devices expose next to them.
 *
 * Runs in an idle callback so the window is up first.
 */
static gboolean
capture_prewarm (gpointer u)
{
    (void) u;

    gint64 t0 = g_get_monotonic_time ();
    GstDeviceMonitor *mon = gst_device_monitor_new ();

    /* NULL caps: a camera that only offers MJPEG must not be filtered out */
    gst_device_monitor_add_filter (mon, "Video/Source", NULL);
    gst_device_monitor_add_filter (mon, "Audio/Source", NULL);

    if (!gst_device_monitor_start (mon)) {
        mlog ("prewarm: device monitor failed to start");
        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;
    g_prewarm_video = 0;
    g_prewarm_audio = 0;
    for (GList *l = devs; l; l = l->next, n++) {
        GstDevice *dev   = l->data;
        char      *name  = gst_device_get_display_name (dev);
        char      *klass = gst_device_get_device_class (dev);
        GstCaps   *caps  = gst_device_get_caps (dev);

        mlog ("prewarm: %-12s %-32s %u caps", klass ? klass : "?",
              name ? name : "?", caps ? gst_caps_get_size (caps) : 0);

        if (klass && strstr (klass, "Video"))
            g_prewarm_video++;

        /* A PulseAudio monitor records what is being played, not what a
         * microphone hears - getUserMedia cannot use it. */
        if (klass && strstr (klass, "Audio") && name) {
            if (strstr (name, "Monitor of"))
                mlog ("prewarm:   ^ a monitor, not a microphone");
            else
                g_prewarm_audio++;
        }

        if (caps && g_media_debug) {
            char *s = gst_caps_to_string (caps);
            mlog ("prewarm:   %s", s);
            g_free (s);
        }
        if (caps) gst_caps_unref (caps);
        g_free (name);
        g_free (klass);
    }
    g_list_free_full (devs, gst_object_unref);

    gst_device_monitor_stop (mon);
    gst_object_unref (mon);

    mlog ("prewarm: %d capture device(s) probed in %d ms  (%d camera, %d microphone)",
          n, (int) ((g_get_monotonic_time () - t0) / 1000),
          g_prewarm_video, g_prewarm_audio);

    if (g_prewarm_audio == 0) {
        GstDeviceProviderFactory *alsa =
            gst_device_provider_factory_find ("alsadeviceprovider");

        mlog ("prewarm: no microphone here.");
        mlog ("prewarm: alsadeviceprovider is %s",
              alsa ? "installed" : "MISSING - only PulseAudio can enumerate,"
                                   " and a dummy sink means no devices");
        if (alsa)
            gst_object_unref (alsa);
    }
    return G_SOURCE_REMOVE;
}

/* ------------------------------------------------- capture state signals */

static const char *
capture_state_name (WebKitMediaCaptureState s)
{
    switch (s) {
        case WEBKIT_MEDIA_CAPTURE_STATE_NONE:   return "none";
        case WEBKIT_MEDIA_CAPTURE_STATE_ACTIVE: return "active";
        case WEBKIT_MEDIA_CAPTURE_STATE_MUTED:  return "muted";
        default:                                return "?";
    }
}

static void
on_capture_notify (GObject *obj, GParamSpec *ps, gpointer u)
{
    (void) u;
    WebKitWebView *view = WEBKIT_WEB_VIEW (obj);
    const char *name = g_param_spec_get_name (ps);
    WebKitMediaCaptureState st = WEBKIT_MEDIA_CAPTURE_STATE_NONE;

    if (!g_strcmp0 (name, "camera-capture-state"))
        st = webkit_web_view_get_camera_capture_state (view);
    else if (!g_strcmp0 (name, "microphone-capture-state"))
        st = webkit_web_view_get_microphone_capture_state (view);
    else if (!g_strcmp0 (name, "display-capture-state"))
        st = webkit_web_view_get_display_capture_state (view);

    mlog ("capture: %s -> %s", name, capture_state_name (st));

    if (st != WEBKIT_MEDIA_CAPTURE_STATE_NONE)
        g_capture_started = TRUE;
    if (g_media_debug)
        dump_child_processes ("capture change");
}

/* ------------------------------------------------------------ watchdogs */

/* Spend one auto-reload from this window's budget, if there is one left. */
static gboolean
reload_budget_take (Win *w, const char *why)
{
    if (!g_auto_reload || !w)
        return FALSE;

    const char *uri = webkit_web_view_get_uri (w->view);
    if (g_strcmp0 (uri, EXT (w)->reload_uri) != 0) {
        g_free (EXT (w)->reload_uri);
        EXT (w)->reload_uri = g_strdup (uri);
        EXT (w)->reloads    = 0;
    }

    gint64 nowus = g_get_monotonic_time ();
    if (nowus - EXT (w)->last_reload_us < (gint64) RELOAD_MIN_GAP_SECONDS * G_USEC_PER_SEC)
        return FALSE;

    if (EXT (w)->reloads >= g_max_reloads) {
        mlog ("watchdog: %s, but the reload budget for this page is spent", why);
        return FALSE;
    }

    EXT (w)->reloads++;
    EXT (w)->last_reload_us = nowus;
    return TRUE;
}

static void
auto_reload (Win *w, const char *why)
{
    if (!reload_budget_take (w, why))
        return;

    mlog ("watchdog: %s -> reload %d/%d", why, EXT (w)->reloads, g_max_reloads);
    toast_show (w, "reloading (watchdog)", 2);
    webkit_web_view_reload (w->view);
}

static gboolean
load_watchdog_fire (gpointer u)
{
    Win *w = u;
    EXT (w)->load_wd_id = 0;

    if (!webkit_web_view_is_loading (w->view))
        return G_SOURCE_REMOVE;

    auto_reload (w, "page did not finish loading in time");
    return G_SOURCE_REMOVE;
}

static void
load_watchdog_arm (Win *w)
{
    if (EXT (w)->load_wd_id) {
        g_source_remove (EXT (w)->load_wd_id);
        EXT (w)->load_wd_id = 0;
    }
    if (g_load_timeout > 0 && g_auto_reload)
        EXT (w)->load_wd_id = g_timeout_add_seconds (g_load_timeout, load_watchdog_fire, w);
}

static void
load_watchdog_disarm (Win *w)
{
    if (EXT (w)->load_wd_id) {
        g_source_remove (EXT (w)->load_wd_id);
        EXT (w)->load_wd_id = 0;
    }
}

static void
on_load_changed (WebKitWebView *view, WebKitLoadEvent ev, gpointer u)
{
    (void) u;
    Win *w = win_of (view);

    const char *n = "?";
    switch (ev) {
        case WEBKIT_LOAD_STARTED:    n = "started";    break;
        case WEBKIT_LOAD_REDIRECTED: n = "redirected"; break;
        case WEBKIT_LOAD_COMMITTED:  n = "committed";  break;
        case WEBKIT_LOAD_FINISHED:   n = "finished";   break;
    }

    if (g_media_debug) {
        const char *uri = webkit_web_view_get_uri (view);
        mlog ("load: %-10s %s", n, uri ? uri : "");
    }

    if (!w)
        return;

    if (ev == WEBKIT_LOAD_STARTED)
        load_watchdog_arm (w);
    else if (ev == WEBKIT_LOAD_FINISHED)
        load_watchdog_disarm (w);
}

static gboolean
on_load_failed (WebKitWebView *view, WebKitLoadEvent ev,
                gchar *uri, GError *error, gpointer u)
{
    (void) ev; (void) u;

    /* a user-cancelled load is not a failure worth reloading */
    if (error && error->domain == WEBKIT_NETWORK_ERROR &&
        error->code == WEBKIT_NETWORK_ERROR_CANCELLED)
        return FALSE;

    mlog ("load: FAILED %s: %s", uri ? uri : "",
          error ? error->message : "(no message)");

    Win *w = win_of (view);
    if (w) {
        load_watchdog_disarm (w);
        auto_reload (w, "load failed");
    }
    return FALSE;   /* let WebKit show its default error page */
}

static void
on_web_process_terminated (WebKitWebView *view,
                           WebKitWebProcessTerminationReason reason, gpointer u)
{
    (void) u;
    const char *r = "unknown";
    switch (reason) {
        case WEBKIT_WEB_PROCESS_CRASHED:                r = "crashed"; break;
        case WEBKIT_WEB_PROCESS_EXCEEDED_MEMORY_LIMIT:  r = "exceeded-memory-limit"; break;
        case WEBKIT_WEB_PROCESS_TERMINATED_BY_API:      r = "terminated-by-api"; break;
    }
    mlog ("WEB PROCESS TERMINATED: %s", r);

    Win *w = win_of (view);
    if (w && reason != WEBKIT_WEB_PROCESS_TERMINATED_BY_API)
        auto_reload (w, "web process died");
}

/* ------------------------------------------------- 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)
{
    char    *needle = g_strdup_printf ("\"ev\":\"%s\"", ev);
    gboolean hit    = json && strstr (json, needle) != NULL;
    g_free (needle);
    return hit;
}

static void
warm_finish (const char *why);

static void
on_script_message (WebKitUserContentManager *ucm, JSCValue *value, gpointer u)
{
    (void) u;

    char *s = jsc_value_to_string (value);
    if (!s)
        return;

    WebKitWebView *view = g_object_get_data (G_OBJECT (ucm), "view");
    Win           *w    = view ? win_of (view) : NULL;

    /* Everything is interesting under --media-debug; without it only the
     * events that say something went wrong or slow. */
    if (g_media_debug || g_shim_debug ||
        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, "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") ||
        ev_is (s, "warm-error") || ev_is (s, "cam-pin"))
        mlog ("js: %s", s);

    /*
     * We probe the capture devices in this process with GStreamer, and
     * the page asks for them in the web process. When we found cameras
     * and the page found none, the devices are not the problem: the web
     * 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");

    if (ev_is (s, "gum-nodevices") && !g_gum_hint_done) {
        g_gum_hint_done = TRUE;
        mlog ("camera: the page asked for a device the web process cannot see");
    }

    if ((ev_is (s, "gum-error") || ev_is (s, "gum-nodevices")) && !g_gum_hint_done) {
        g_gum_hint_done = TRUE;
        if (g_prewarm_video > 0)
            mlog ("camera: %d camera(s) are present here, but the page was given none",
                  g_prewarm_video);
        else
            mlog ("camera: --prewarm will say whether the devices are visible here");

        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")) {
        warm_finish ("camera warm-up finished");
    } else if (ev_is (s, "media-reload")) {
        if (w)
            auto_reload (w, "video stalled");
    } else if (ev_is (s, "media-stall") && w) {
        toast_show (w, "video stalled - recovering", 2);
    }

    g_free (s);
}

/* ------------------------------------------------------------ diag page */

static void
load_diag_page (WebKitWebView *view)
{
    mlog ("loading built-in media diagnostics page");
    webkit_web_view_load_html (view, DIAG_HTML, "https://bigbrowser.diag/");
}

/* ------------------------------------------------------------ warm-up */

/* Called when the warm-up page reports back, or when it takes too long. */
static void
warm_finish (const char *why)
{
    if (!g_pending_url)
        return;

    if (g_warm_timeout_id) {
        g_source_remove (g_warm_timeout_id);
        g_warm_timeout_id = 0;
    }

    char *url = g_pending_url;
    g_pending_url = NULL;

    mlog ("warm: %s -> loading %s", why, url);

    if (g_wins->len > 0) {
        Win *w = g_ptr_array_index (g_wins, 0);
        if (is_diag_request (url))
            load_diag_page (w->view);
        else
            webkit_web_view_load_uri (w->view, url);
    }
    g_free (url);
}

static gboolean
warm_timeout (gpointer u)
{
    (void) u;
    g_warm_timeout_id = 0;
    warm_finish ("warm-up timed out");
    return G_SOURCE_REMOVE;
}

/* JSON string literal for the shim config. */
static char *
js_quote (const char *s)
{
    if (!s)
        return g_strdup ("null");

    GString *out = g_string_new ("\"");
    for (const char *p = s; *p; p++) {
        if (*p == '"' || *p == '\\')
            g_string_append_c (out, '\\');
        if ((unsigned char) *p < 0x20)
            continue;
        g_string_append_c (out, *p);
    }
    g_string_append_c (out, '"');
    return g_string_free (out, FALSE);
}

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 ("[");
    /* 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)
                g_string_append_printf (cj, "%s\"%s\"", cj->len > 1 ? "," : "", t);
            g_free (t);
        }
        g_strfreev (v);
    }
    g_string_append_c (cj, ']');
    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,"
        "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_sdp_ssrc_fix    ? "true" : "false",
        codecs_js,
        g_rtc_trace       ? "true" : "false",
        g_rtc_params_fix  ? "true" : "false",
        g_silent_split    ? "true" : "false",
        g_web_compat      ? "true" : "false",
        g_no_mic          ? "true" : "false",
        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;
}

/*
 * "Sandbox disabled" is only a claim about an environment variable. This
 * checks the result: a sandboxed web process is put in its own mount
 * namespace by bwrap, so comparing namespaces answers the question for
 * real, and a bwrap in the ancestry confirms it.
 */
static gboolean
sandbox_report (gpointer u)
{
    (void) u;

    char  *ours = g_file_read_link ("/proc/self/ns/mnt", NULL);
    GDir  *d    = g_dir_open ("/proc", 0, NULL);
    pid_t  self = getpid ();

    /* Only our own web process counts. Without this it would happily
     * report on another browser's, which is worse than saying nothing. */
    GHashTable *parent = g_hash_table_new (g_direct_hash, g_direct_equal);

    if (!d) {
        g_free (ours);
        return G_SOURCE_REMOVE;
    }

    const char *name;
    while ((name = g_dir_read_name (d))) {
        if (!g_ascii_isdigit (name[0]))
            continue;

        pid_t pid = (pid_t) atoi (name);
        pid_t ppid = 0;
        char  comm[64];

        if (!proc_ppid_comm (pid, &ppid, comm, sizeof comm))
            continue;

        g_hash_table_insert (parent, GINT_TO_POINTER (pid), GINT_TO_POINTER (ppid));

        /* /proc/<pid>/status truncates Name: to 15 characters, so
         * "WebKitWebProcess" never appears in full. */
        if (!strstr (comm, "WebKitWebProc"))
            continue;
        if (ppid != self && !is_descendant (parent, pid, self))
            continue;                  /* somebody else's browser */

        char *theirs = NULL;
        char *link   = g_strdup_printf ("/proc/%d/ns/mnt", (int) pid);
        theirs = g_file_read_link (link, NULL);
        g_free (link);

        char  pcomm[64] = "";
        pid_t pppid = 0;
        proc_ppid_comm (ppid, &pppid, pcomm, sizeof pcomm);

        gboolean same = ours && theirs && !g_strcmp0 (ours, theirs);

        mlog ("sandbox: web process %d, parent %s, mount namespace %s ours"
              "  ->  %s",
              (int) pid, *pcomm ? pcomm : "?", same ? "same as" : "differs from",
              same ? "NOT sandboxed" : "sandboxed");

        g_free (theirs);
        break;
    }

    g_dir_close (d);
    g_hash_table_destroy (parent);
    g_free (ours);
    return G_SOURCE_REMOVE;
}

/*
 * A page can be allowed a camera and still get nothing, and without the
 * shim there is no gum-error to tell us. So when permission is granted,
 * wait a moment: if no device ever reports capturing, the request failed
 * somewhere below the page and that is worth saying plainly.
 */
static gboolean
capture_check (gpointer u)
{
    (void) u;
    g_capture_watch = 0;

    if (g_capture_started)
        return G_SOURCE_REMOVE;

    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 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;
}

static void
big_media_asked (gboolean audio, gboolean video)
{
    (void) audio; (void) video;

    if (g_capture_watch)
        g_source_remove (g_capture_watch);

    g_capture_started = FALSE;
    g_capture_watch   = g_timeout_add_seconds (5, capture_check, NULL);
}

/*
 * Where is the web process? When a page freezes and the log shows nothing,
 * the answer is in what its threads are doing: a thread in D state is
 * blocked in the kernel (a device write, a pipe), and wchan names the
 * function it is waiting in. Needs no gdb and no privileges.
 */
static void
threads_dump (void)
{
    GDir       *d    = g_dir_open ("/proc", 0, NULL);
    pid_t       self = getpid ();
    GHashTable *parent = g_hash_table_new (g_direct_hash, g_direct_equal);
    const char *name;
    guint       shown = 0;

    if (!d)
        return;

    /* first pass: parent map, so only our own web processes count */
    GPtrArray *webs = g_ptr_array_new ();
    while ((name = g_dir_read_name (d))) {
        if (!g_ascii_isdigit (name[0])) continue;
        pid_t pid = atoi (name), ppid = 0; char comm[64];
        if (!proc_ppid_comm (pid, &ppid, comm, sizeof comm)) continue;
        g_hash_table_insert (parent, GINT_TO_POINTER (pid), GINT_TO_POINTER (ppid));
        if (strstr (comm, "WebKitWebProc") || strstr (comm, "WebKitGPUProc"))
            g_ptr_array_add (webs, GINT_TO_POINTER (pid));
    }
    g_dir_close (d);

    for (guint i = 0; i < webs->len; i++) {
        pid_t pid = GPOINTER_TO_INT (g_ptr_array_index (webs, i));
        if (!is_descendant (parent, pid, self))
            continue;

        char *tdir = g_strdup_printf ("/proc/%d/task", (int) pid);
        GDir *td   = g_dir_open (tdir, 0, NULL);
        mlog ("threads: process %d", (int) pid);
        mlog ("threads:   %-6s %-2s %-18s %s", "tid", "st", "name", "waiting in");

        const char *tn;
        while (td && (tn = g_dir_read_name (td))) {
            char *p, *comm = NULL, *wchan = NULL, *stat = NULL;
            char  state = '?';

            p = g_strdup_printf ("%s/%s/comm", tdir, tn);
            g_file_get_contents (p, &comm, NULL, NULL); g_free (p);
            p = g_strdup_printf ("%s/%s/wchan", tdir, tn);
            g_file_get_contents (p, &wchan, NULL, NULL); g_free (p);
            p = g_strdup_printf ("%s/%s/stat", tdir, tn);
            if (g_file_get_contents (p, &stat, NULL, NULL)) {
                char *rp = strrchr (stat, ')');      /* comm may hold spaces */
                if (rp && rp[1] == ' ' && rp[2]) state = rp[2];
            }
            g_free (p);

            if (comm)  g_strchomp (comm);
            if (wchan) g_strchomp (wchan);

            /* R running, S sleeping, D blocked in the kernel */
            mlog ("threads:   %-6s %-2c %-18s %s", tn, state,
                  comm ? comm : "?", (wchan && *wchan && strcmp (wchan, "0")) ? wchan : "-");
            shown++;
            g_free (comm); g_free (wchan); g_free (stat);
        }
        if (td) g_dir_close (td);
        g_free (tdir);
    }

    if (!shown)
        mlog ("threads: no web process found under this browser");

    g_ptr_array_free (webs, TRUE);
    g_hash_table_destroy (parent);
}

/* ---------------------------------------------------------------- hooks */

static void
big_usage_options (GString *s)
{
    g_string_append_printf (s,
"camera and video:\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"
"  --cam-share         hand a repeated request the stream already open,\n"
"                      and change nothing else. For a page that asks for\n"
"                      the same camera twice: the second open yields a\n"
"                      track that never delivers a frame. cam_share = yes\n"
"                      in a config file makes it permanent\n"
"  --cam-fix           relax over-tight getUserMedia constraints, retry\n"
"                      with looser ones, and hold the capture stream so a\n"
"                      second call is instant\n""                      Only these camera flags change the camera. The\n"
"                      tracing and compat flags share the same script\n"
"                      but hand the page its tracks untouched; the log's\n"
"                      shim line says repair:true when anything is on\n"
"  --list-cameras      print every format, size and frame rate each camera\n"
"                      offers, with the aspect ratio of each, then exit.\n"
"                      Use it to check a site is asking for a shape the\n"
"                      camera actually has\n"
"  --prewarm           probe the capture devices at startup, so the page's\n"
"                      own getUserMedia does not pay for it\n"
"  --media-watchdog    notice a stalled <video> and try to recover it\n"
"  --auto-reload       reload a page that never commits\n"
"  --warm-cam          open and release the camera before the first page\n"
"                      loads, so the page's getUserMedia() is not the one\n"
"                      paying for device probing (the LED blinks once)\n"
"  --warm-timeout SEC  give up warming after SEC seconds (default: %d)\n"
"  --no-cam-relax      with --cam-fix, pass the page's constraints through\n"
"                      unchanged\n"
"  --no-cam-keepalive  with --cam-fix, do not hold on to the capture stream\n"
"  --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        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"
"  --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"

"  --stall-timeout SEC frozen playback counts as stalled after SEC (default: %d)\n"

"  --load-timeout SEC  reload if a page does not commit in SEC (0 = off,\n"
"                      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 (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"
"                      \"Member RTCRtpSendParameters.codecs is required\"\n"
"  --rtc-trace         report every WebRTC step - the connection being\n"
"                      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 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"
"                      asked for and how it ended, without changing any of\n"
"                      them. The first thing to reach for when a site says\n"
"                      access was denied and the devices look fine\n"
"  --media-debug       verbose media, permission and capture logging\n"
"                      (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 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"
"                      every run, on a machine with no sound server\n"
"  --no-va             rank every VA-API element out: no hardware video\n"
"                      encoding or decoding. With --no-dmabuf and\n"
"                      --no-hw-decode this is an all-software media path,\n"
"                      which is the test for a GPU driver problem\n"
"  --gst-rank SPEC     add to GST_PLUGIN_FEATURE_RANK yourself, e.g.\n"
"                      'pulsedeviceprovider:NONE'\n"
"  --gst-debug SPEC    set GST_DEBUG (e.g. 'v4l2*:6,webrtc*:5')\n"
"  --gst-debug-level N set GST_DEBUG to a global level\n"
"  --gst-debug-file P  set GST_DEBUG_FILE\n"
"  --webkit-debug SPEC set WEBKIT_DEBUG (e.g. 'Media' or 'all')\n"
"\n",
        g_warm_timeout, g_cam_max_w, g_cam_max_h, g_cam_max_fps, g_cam_retries,
        g_stall_timeout, g_load_timeout, g_max_reloads);
}

static void
big_usage_keys (GString *s)
{
    g_string_append (s,
"  <mod>+Shift+M       open the built-in media diagnostics page\n"
"  <mod>+Shift+C       warm the camera now (before you press \"join\")\n"
"  <mod>+Shift+X       release the held capture stream\n"
"  <mod>+Shift+V       dump the state of every <video> to stderr\n");
}

/* Returns TRUE when argv[*i] belonged to us. */
static gboolean
big_parse_arg (int argc, char **argv, int *i)
{
    const char *a = argv[*i];

#define NEXT(opt) \
    do { if (*i + 1 >= argc) { \
             g_printerr ("%s: %s needs an argument\n", argv[0], opt); \
             exit (1); \
         } } while (0)

    /* ---- camera ---- */
    if (!strcmp (a, "--list-cameras"))        { g_list_cameras = TRUE; return TRUE; }
    if (!strcmp (a, "--warm-cam"))            { g_warm_cam = TRUE; return TRUE; }
    if (!strcmp (a, "--cam-fix"))             { g_cam_fix = g_cam_repair = TRUE; return TRUE; }
    if (!strcmp (a, "--cam-share")) {
        /*
         * Hand a repeated request the stream that is already open, and
         * nothing else - no relaxing, no retries. A camera cannot be
         * opened twice: the second open returns a live track that never
         * produces a frame, and the page waits for it forever.
         */
        g_cam_fix       = TRUE;
        g_cam_repair    = TRUE;
        g_cam_relax     = FALSE;
        g_cam_keepalive = TRUE;
        g_cam_retries   = 0;
        return TRUE;
    }
    if (!strcmp (a, "--prewarm"))             { g_prewarm = TRUE; return TRUE; }
    if (!strcmp (a, "--media-watchdog"))      { g_media_watchdog = TRUE; return TRUE; }
    if (!strcmp (a, "--auto-reload"))         { g_auto_reload = TRUE; return TRUE; }
    if (!strcmp (a, "--fix-media")) {         /* everything at once */
        g_cam_fix = g_cam_repair = g_prewarm = g_media_watchdog = g_auto_reload = TRUE;
        return TRUE;
    }
    if (!strcmp (a, "--no-prewarm"))          { g_prewarm = FALSE; return TRUE; }
    if (!strcmp (a, "--no-cam-fix"))          { g_cam_fix = FALSE; return TRUE; }
    if (!strcmp (a, "--no-cam-relax"))        { g_cam_relax = FALSE; return TRUE; }
    if (!strcmp (a, "--no-cam-keepalive"))    { g_cam_keepalive = FALSE; return TRUE; }
    if (!strcmp (a, "--no-cam-drop-audio"))  { g_cam_drop_audio = FALSE; return TRUE; }

    if (!strcmp (a, "--warm-timeout")) {
        NEXT ("--warm-timeout");
        const char *v = argv[++(*i)];
        g_warm_timeout = MAX (1, atoi (v));
        return TRUE;
    }
    if (!strcmp (a, "--no-mic")) {
        g_no_mic    = TRUE;
        g_cam_fix   = TRUE;
        g_cam_relax = FALSE;
        return TRUE;
    }
    if (!strcmp (a, "--web-compat")) {
        g_web_compat = TRUE;
        g_cam_fix    = TRUE;           /* it rides in the same shim */
        g_cam_relax  = FALSE;
        feature_request ("ScreenOrientationAPI");   /* WebKit has it, off on GTK */
        return TRUE;
    }
    if (!strcmp (a, "--cam-max")) {
        NEXT ("--cam-max");
        if (!parse_cam_max (argv[++(*i)])) {
            g_printerr ("%s: bad --cam-max %s (want WxH@FPS)\n", argv[0], argv[*i]);
            exit (1);
        }
        return TRUE;
    }
    if (!strcmp (a, "--cam-match")) {
        NEXT ("--cam-match");
        g_free (g_cam_match);
        g_cam_match = g_strdup (argv[++(*i)]);
        g_cam_fix   = TRUE;
        return TRUE;
    }
    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");
        const char *v = argv[++(*i)];
        g_cam_retries = CLAMP (atoi (v), 0, 3);
        return TRUE;
    }

    /* ---- video / rendering ---- */
    if (!strcmp (a, "--no-media-watchdog"))   { g_media_watchdog = FALSE; return TRUE; }
    if (!strcmp (a, "--no-auto-reload"))      { g_auto_reload = FALSE; return TRUE; }
    if (!strcmp (a, "--no-webrtc"))           { g_no_webrtc = TRUE; return TRUE; }
    if (!strcmp (a, "--no-mediastream"))      { g_no_mediastream = TRUE; return TRUE; }

    if (!strcmp (a, "--stall-timeout")) {
        NEXT ("--stall-timeout");
        const char *v = argv[++(*i)];
        g_stall_timeout = MAX (2, atoi (v));
        return TRUE;
    }
    if (!strcmp (a, "--load-timeout")) {
        NEXT ("--load-timeout");
        const char *v = argv[++(*i)];
        g_load_timeout = MAX (0, atoi (v));
        return TRUE;
    }
    if (!strcmp (a, "--max-reloads")) {
        NEXT ("--max-reloads");
        const char *v = argv[++(*i)];
        g_max_reloads = MAX (0, atoi (v));
        return TRUE;
    }

    /* ---- diagnostics ---- */
    if (!strcmp (a, "--rtc-params-fix")) {
        g_rtc_params_fix = TRUE;
        g_cam_fix        = TRUE;
        g_cam_relax      = FALSE;
        return TRUE;
    }
    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")) {
        g_rtc_trace = TRUE;
        g_cam_fix   = TRUE;            /* it rides in the same shim */
        g_cam_relax = FALSE;
        g_shim_debug = TRUE;
        return TRUE;
    }
    if (!strcmp (a, "--sdp-ssrc-fix")) {
        g_sdp_ssrc_fix = TRUE;
        g_cam_fix      = TRUE;         /* it rides in the same shim */
        g_cam_relax    = FALSE;
        return TRUE;
    }
    if (!strcmp (a, "--video-codecs")) {
        NEXT ("--video-codecs");
        set_video_codecs (argv[++(*i)]);
        g_cam_fix   = TRUE;
        g_cam_relax = FALSE;
        return TRUE;
    }
    if (!strcmp (a, "--media-debug")) {
        g_media_debug = g_shim_debug = TRUE;
        return TRUE;
    }

    if (!strcmp (a, "--media-trace")) {
        /*
         * Watch without touching: the shim is installed purely to log
         * every getUserMedia call and its result. Relaxing, caching,
         * retrying and the audio fallback are all off, so the page gets
         * exactly the behaviour it would have had without us - which is
         * the point of a trace.
         */
        g_cam_fix        = TRUE;
        g_cam_relax      = FALSE;
        g_cam_keepalive  = FALSE;
        g_cam_drop_audio = FALSE;
        g_cam_retries    = 0;
        g_shim_debug     = TRUE;
        g_rtc_trace      = TRUE;       /* a trace should cover WebRTC too */
        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")) {
        g_audio_alsa = TRUE;
        return TRUE;
    }
    if (!strcmp (a, "--no-va")) {
        gst_rank_add (VA_RANK_OFF);
        return TRUE;
    }
    if (!strcmp (a, "--gst-rank")) {
        NEXT ("--gst-rank");
        gst_rank_add (argv[++(*i)]);
        return TRUE;
    }

    if (!strcmp (a, "--gst-debug")) {
        NEXT ("--gst-debug");
        g_gst_debug = argv[++(*i)];
        return TRUE;
    }
    if (g_str_has_prefix (a, "--gst-debug=")) {
        g_gst_debug = a + strlen ("--gst-debug=");
        return TRUE;
    }
    if (!strcmp (a, "--gst-debug-level")) {
        NEXT ("--gst-debug-level");
        g_gst_level = atoi (argv[++(*i)]);
        return TRUE;
    }
    if (g_str_has_prefix (a, "--gst-debug-level=")) {
        g_gst_level = atoi (a + strlen ("--gst-debug-level="));
        return TRUE;
    }
    if (!strcmp (a, "--gst-debug-file")) {
        NEXT ("--gst-debug-file");
        g_gst_dbgfile = argv[++(*i)];
        return TRUE;
    }
    if (g_str_has_prefix (a, "--gst-debug-file=")) {
        g_gst_dbgfile = a + strlen ("--gst-debug-file=");
        return TRUE;
    }
    if (!strcmp (a, "--webkit-debug")) {
        NEXT ("--webkit-debug");
        g_webkit_dbg = argv[++(*i)];
        return TRUE;
    }
    if (g_str_has_prefix (a, "--webkit-debug=")) {
        g_webkit_dbg = a + strlen ("--webkit-debug=");
        return TRUE;
    }

#undef NEXT
    return FALSE;
}

/* Environment has to be set before any process is spawned, so the web and
 * GPU processes - where GStreamer actually runs - inherit it. */
/*
 * audio = alsa | pulse
 *
 * Not defaulted to alsa in the binary on purpose: 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. It is the right
 * 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)
{
    return !(!g_ascii_strcasecmp (v, "0")  || !g_ascii_strcasecmp (v, "no") ||
             !g_ascii_strcasecmp (v, "off")|| !g_ascii_strcasecmp (v, "false"));
}

static gboolean
big_cfg_set (const char *key, const char *value)
{
    /*
     * cam_share = yes
     *
     * 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;
    }

    if (!g_ascii_strcasecmp (key, "cam_share")) {
        if (truthy_value (value)) {
            g_cam_fix       = TRUE;
            g_cam_repair    = TRUE;
            g_cam_relax     = FALSE;
            g_cam_keepalive = TRUE;
            g_cam_retries   = 0;
        }
        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")) {
        g_audio_alsa = TRUE;
    } else if (!g_ascii_strcasecmp (value, "pulse") ||
               !g_ascii_strcasecmp (value, "auto")) {
        g_audio_alsa = FALSE;
    } else {
        g_printerr ("config: audio must be alsa, pulse or auto\n");
    }
    return TRUE;
}

static void
big_pre_gtk (void)
{
    /* Before gtk_init, so asking what the camera can do does not need a
     * display - this has to work over ssh and from a script. */
    if (g_list_cameras) {
        gst_init (NULL, NULL);
        cam_list ();
        exit (0);
    }

    if (g_gst_debug) {
        g_setenv ("GST_DEBUG", g_gst_debug, TRUE);
    } else if (g_gst_level >= 0) {
        char *s = g_strdup_printf ("%d", g_gst_level);
        g_setenv ("GST_DEBUG", s, TRUE);
        g_free (s);
    }
    /*
     * 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);
}

/*
 * WebKit plays media and talks WebRTC through GStreamer, so a missing
 * 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)
{
    /*
     * 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 (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" },
        { "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" },
        { "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++)
        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 at the moment it wants it. */
    GString *optional = g_string_new (NULL);
    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:");
        g_printerr ("%s", optional->str);
    }

    g_string_free (optional, TRUE);
    g_string_free (missing, TRUE);
}

/* 640x480 -> "4:3". Only meaningful when both are fixed numbers. */
static char *
aspect_of (int w, int h)
{
    if (w <= 0 || h <= 0)
        return g_strdup ("");

    int a = w, b = h, t;
    while (b) { t = b; b = a % b; a = t; }      /* gcd */

    return g_strdup_printf ("%d:%d", w / a, h / a);
}

static void
cam_list_caps (GstCaps *caps)
{
    guint n = caps ? gst_caps_get_size (caps) : 0;

    for (guint i = 0; i < n; i++) {
        GstStructure *s = gst_caps_get_structure (caps, i);
        const GValue *vw = gst_structure_get_value (s, "width");
        const GValue *vh = gst_structure_get_value (s, "height");
        const GValue *vf = gst_structure_get_value (s, "framerate");
        const char   *fmt = gst_structure_get_string (s, "format");

        char *w  = vw ? gst_value_serialize (vw) : g_strdup ("?");
        char *h  = vh ? gst_value_serialize (vh) : g_strdup ("?");
        char *fr = vf ? gst_value_serialize (vf) : g_strdup ("?");

        /* the serializer annotates list members, which reads as noise */
        if (strstr (fr, "(fraction)")) {
            char **bits = g_strsplit (fr, "(fraction)", -1);
            char  *clean = g_strjoinv ("", bits);
            g_strfreev (bits);
            g_free (fr);
            fr = clean;
        }

        int iw = 0, ih = 0;
        gst_structure_get_int (s, "width", &iw);
        gst_structure_get_int (s, "height", &ih);
        char *ar = aspect_of (iw, ih);

        g_print ("    %-12s %-6s %11s x %-11s %-22s %s\n",
                 gst_structure_get_name (s), fmt ? fmt : "-", w, h, fr, ar);

        g_free (ar); g_free (fr); g_free (h); g_free (w);
    }
}

/* --list-cameras: print and stop, so it can be read without a window. */
static void
cam_list (void)
{
    GstDeviceMonitor *mon = gst_device_monitor_new ();
    GstCaps          *any = gst_caps_new_any ();

    gst_device_monitor_add_filter (mon, "Video/Source", any);
    gst_caps_unref (any);

    if (!gst_device_monitor_start (mon)) {
        g_printerr ("cameras: the device monitor would not start\n");
        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);

    if (!devs)
        g_print ("no cameras found\n");

    for (GList *l = devs; l; l = l->next) {
        GstDevice    *dev  = l->data;
        char         *name = gst_device_get_display_name (dev);
        GstStructure *props = gst_device_get_properties (dev);
        const char   *path  = props ? gst_structure_get_string (props, "device.path") : NULL;
        GstCaps      *caps  = gst_device_get_caps (dev);

        g_print ("%s%s%s\n", name ? name : "?",
                 path ? "   " : "", path ? path : "");
        g_print ("    %-12s %-6s %11s   %-11s %-22s %s\n",
                 "media type", "format", "width", "height", "framerate", "aspect");
        cam_list_caps (caps);
        g_print ("\n");

        if (caps)  gst_caps_unref (caps);
        if (props) gst_structure_free (props);
        g_free (name);
    }

    g_list_free_full (devs, gst_object_unref);
    gst_device_monitor_stop (mon);
    gst_object_unref (mon);
}

static void
big_startup (void)
{
    gst_init (NULL, NULL);
    codec_check ();

    print_versions ();
    if (g_media_debug)
        dump_gstreamer_env ();

    if (g_cam_fix || g_media_watchdog)
        g_shim_js = build_shim ();

    /* runs once the main loop starts, i.e. after the window is up */
    if (g_prewarm)
        g_idle_add (capture_prewarm, NULL);

    /* once the web process exists, say plainly whether it is sandboxed */
    g_timeout_add_seconds (3, sandbox_report, NULL);
}

static void
big_settings_ready (WebKitSettings *s)
{
    if (g_no_mediastream) {
        webkit_settings_set_enable_media_stream (s, FALSE);
        LOG ("media: MediaStream disabled\n");
    }
    if (g_no_webrtc) {
        webkit_settings_set_enable_webrtc (s, FALSE);
        LOG ("media: WebRTC disabled\n");
    }
}

static void
big_ucm_ready (WebKitUserContentManager *ucm)
{
    /* connect before registering, to avoid a race (per WebKit docs) */
    g_signal_connect (ucm, "script-message-received::bbEvent",
                      G_CALLBACK (on_script_message), NULL);
    webkit_user_content_manager_register_script_message_handler (ucm, "bbEvent", NULL);

    if (g_shim_js) {
        WebKitUserScript *script =
            webkit_user_script_new (g_shim_js,
                                    WEBKIT_USER_CONTENT_INJECT_ALL_FRAMES,
                                    WEBKIT_USER_SCRIPT_INJECT_AT_DOCUMENT_START,
                                    NULL, NULL);
        webkit_user_content_manager_add_script (ucm, script);
        webkit_user_script_unref (script);
    }
}

static void
big_view_ready (WebKitWebView *view)
{
    g_signal_connect (view, "load-changed", G_CALLBACK (on_load_changed), NULL);
    g_signal_connect (view, "load-failed",  G_CALLBACK (on_load_failed), NULL);
    g_signal_connect (view, "web-process-terminated",
                      G_CALLBACK (on_web_process_terminated), NULL);

    g_signal_connect (view, "notify::camera-capture-state",
                      G_CALLBACK (on_capture_notify), NULL);
    g_signal_connect (view, "notify::microphone-capture-state",
                      G_CALLBACK (on_capture_notify), NULL);
    g_signal_connect (view, "notify::display-capture-state",
                      G_CALLBACK (on_capture_notify), NULL);
}

static void
big_win_ready (Win *w)
{
    w->ext = g_new0 (BigWin, 1);
}

static void
big_win_gone (Win *w)
{
    BigWin *e = EXT (w);
    if (!e)
        return;

    if (e->load_wd_id)
        g_source_remove (e->load_wd_id);
    g_free (e->reload_uri);
    g_free (e);
    w->ext = NULL;
}

static gboolean
big_key (Win *w, guint key, gboolean shift)
{
    if (!shift)
        return FALSE;

    switch (key) {
    case GDK_KEY_m:
        load_diag_page (w->view);
        return TRUE;
    case GDK_KEY_c:
        toast_show (w, "warming camera", 2);
        view_eval (w->view, "window.__bbWarm&&window.__bbWarm();");
        return TRUE;
    case GDK_KEY_x:
        toast_show (w, "releasing camera", 2);
        view_eval (w->view, "window.__bbRelease&&window.__bbRelease();");
        return TRUE;
    case GDK_KEY_v:
        toast_show (w, "media state -> stderr", 2);
        view_eval (w->view, MEDIA_DUMP_JS);
        return TRUE;
    case GDK_KEY_t:
        /* Deliberately not view_eval: a frozen web process cannot answer,
         * and this reads /proc, which does not need it to. */
        toast_show (w, "web process threads -> stderr", 2);
        threads_dump ();
        return TRUE;
    default:
        return FALSE;
    }
}

/* "diag" is ours, and --warm-cam holds the first address back until the
 * camera has been opened once. */
static gboolean
big_load_uri (WebKitWebView *view, const char *raw)
{
    gboolean first = !g_first_load_done;
    g_first_load_done = TRUE;

    if (first && g_warm_cam && !g_deny_media) {
        g_pending_url = is_diag_request (raw) ? g_strdup ("diag") : normalize_uri (raw);
        g_warm_timeout_id = g_timeout_add_seconds (g_warm_timeout, warm_timeout, NULL);
        mlog ("warm: opening the camera before %s", g_pending_url);
        webkit_web_view_load_html (view, WARM_HTML, "https://bigbrowser.warm/");
        return TRUE;
    }

    if (is_diag_request (raw)) {
        load_diag_page (view);
        return TRUE;
    }

    return FALSE;
}

/* our own pages are served from load_html, they are not links */
static gboolean
big_history_skip (const char *uri)
{
    return g_str_has_prefix (uri, "https://bigbrowser.diag/") ||
           g_str_has_prefix (uri, "https://bigbrowser.warm/");
}

static void
big_cleanup (void)
{
    g_free (g_shim_js);
    g_free (g_cam_match);
    g_free (g_mic_match);
}

/* ----------------------------------------------------------------- main */

static const BrowserApp browser_big = {
    .tagline        = "WebKitGTK 6.0 / GTK4 page viewer with camera and streaming fixes",
    .default_title  = "Browser Big",
    .default_app_id = "browser-big",
    .usage_arg      = "URL|PATH|diag",

    .usage_options  = big_usage_options,
    .usage_keys     = big_usage_keys,
    .parse_arg      = big_parse_arg,
    .cfg_set        = big_cfg_set,
    .media_asked    = big_media_asked,
    .pre_gtk        = big_pre_gtk,
    .startup        = big_startup,
    .settings_ready = big_settings_ready,
    .ucm_ready      = big_ucm_ready,
    .view_ready     = big_view_ready,
    .win_ready      = big_win_ready,
    .win_gone       = big_win_gone,
    .key            = big_key,
    .load_uri       = big_load_uri,
    .history_skip   = big_history_skip,
    .cleanup        = big_cleanup,
};

int
main (int argc, char **argv)
{
    return browser_main (argc, argv, &browser_big);
}