Nimbin2git.christianimmanuel.de / Tools / Browser / browser-big.c

Browser git · main

git clone https://git.christianimmanuel.de/tools/Browser.gitwget https://git.christianimmanuel.de/tools/Browser/archive/Browser.tar.gz
browser-big.c 47.9 KB · 1357 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;

/* camera / media behaviour */
static gboolean g_cam_fix       = TRUE;    /* install the getUserMedia shim */
static gboolean g_cam_relax     = TRUE;    /* loosen the page's constraints */
static gboolean g_cam_keepalive = TRUE;    /* reuse and hold the capture stream */
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 char    *g_cam_match;               /* prefer this camera label */
static gboolean g_prewarm       = TRUE;    /* 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 = TRUE;
static int      g_stall_timeout  = 6;      /* seconds of frozen playback */
static gboolean g_auto_reload    = TRUE;
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_gpu, g_no_webrtc, g_no_mediastream;
static gboolean    g_no_hw_decode, g_no_dmabuf, g_no_compositing;
static const char *g_gst_debug, *g_gst_dbgfile, *g_webkit_dbg;
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);

/* 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);
}

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 the page *clones*. The page can
 *     stop its clone (leave a call, switch view) without closing the device,
 *     so the next getUserMedia() resolves in milliseconds instead of seconds.
 *
 *  4. watches <video>/<audio> for playback that has stopped advancing and
 *     tries play() -> seek -> load() -> reload before giving up.
 *
 * 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,pinTried=false;"
"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)delete o.aspectRatio;"
"    ['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};"
"  }"
"  if(level>=2){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;"
"}"
"function withPin(c){"
"  if(!pinned||!c||!c.video)return c;"
"  var o={},k;for(k in c)o[k]=c[k];"
"  var v=(o.video===true||typeof o.video!=='object')?{}:o.video;"
"  var nv={};for(k in v)nv[k]=v[k];"
"  if(!nv.deviceId)nv.deviceId={ideal:pinned};"
"  o.video=nv;return o;"
"}"
"function pinDevice(){"
"  if(pinTried||!C.match||!origEnum)return Promise.resolve(null);"
"  pinTried=true;"
"  return origEnum().then(function(l){"
"    var m=C.match.toLowerCase();"
"    for(var i=0;i<l.length;i++)"
"      if(l[i].kind==='videoinput'&&(l[i].label||'').toLowerCase().indexOf(m)>=0){pinned=l[i].deviceId;break;}"
"    post({ev:'cam-pin',match:C.match,found:!!pinned});return pinned;"
"  }).catch(function(){return null;});"
"}"
"function dumpTrack(t){"
"  var s={};try{s=t.getSettings?t.getSettings():{};}catch(e){}"
"  post({ev:'track',kind:t.kind,label:t.label,readyState:t.readyState,settings:s});"
"}"
"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;}"
"}"
"function serve(c){"
"  if(!C.cache)return null;"
"  var out=new MediaStream(),ok=true;"
"  if(wants(c,'video')){"
"    if(cachedV&&cachedV.readyState==='live'&&deviceOk(cachedV,c)){"
"      var v=cachedV.clone();"
"      if(typeof c.video==='object')try{v.applyConstraints(relaxVideo(c.video,1)).catch(function(){});}catch(e){}"
"      out.addTrack(v);"
"    }else ok=false;"
"  }"
"  if(wants(c,'audio')){"
"    if(cachedA&&cachedA.readyState==='live')out.addTrack(cachedA.clone());else ok=false;"
"  }"
"  return (ok&&out.getTracks().length)?out:null;"
"}"
"function keep(st){"
"  if(!C.cache)return st;"
"  var v=st.getVideoTracks()[0],a=st.getAudioTracks()[0];"
"  if(v)cachedV=v;if(a)cachedA=a;"
"  var out=new MediaStream();"
"  if(v)out.addTrack(v.clone());"
"  if(a)out.addTrack(a.clone());"
"  return out.getTracks().length?out:st;"
"}"
"function retryable(e){"
"  var n=e&&e.name;"
"  return n==='NotReadableError'||n==='AbortError'||n==='OverconstrainedError'||"
"         n==='TimeoutError'||n==='NotFoundError'||n==='TypeError'||!n;"
"}"
"md.getUserMedia=function(c){"
"  var t0=now();"
"  post({ev:'gum-call',constraints:safe(c)});"
"  var hit=serve(c);"
"  if(hit){post({ev:'gum-cache-hit'});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;"
"    function attempt(){"
"      var cc=withPin(relax(c,levels[i]));"
"      post({ev:'gum-try',level:levels[i],constraints:safe(cc)});"
"      return origGUM(cc).then(function(st){"
"        post({ev:'gum-ok',ms:Math.round(now()-t0),level:levels[i]});"
"        st.getTracks().forEach(dumpTrack);"
"        return 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++;"
"        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)post({ev:'devices',list:l.map(function(d){return {kind:d.kind,label:d.label};})});"
"    return l;});};"
"window.__bbWarm=function(){"
"  return md.getUserMedia({video:true}).then(function(s){"
"    s.getTracks().forEach(function(t){t.stop();});"   /* stops the clone 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(){"
"  [cachedV,cachedA].forEach(function(t){if(t)try{t.stop();}catch(e){}});"
"  cachedV=cachedA=null;post({ev:'released'});return true;"
"};"
"window.__bbHeld=function(){"
"  return !!(cachedV&&cachedV.readyState==='live')||!!(cachedA&&cachedA.readyState==='live');"
"};"
"post({ev:'shim',camfix:true,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>bigbrowser 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>bigbrowser 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 ("bigbrowser 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 ("WEBKIT_GST_ENABLE_HW_DECODERS");
    dump_env ("WEBKIT_DISABLE_DMABUF_RENDERER");
    dump_env ("WEBKIT_DISABLE_COMPOSITING_MODE");

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

    GList *devs = gst_device_monitor_get_devices (mon);
    int n = 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 (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", n,
          (int) ((g_get_monotonic_time () - t0) / 1000));
    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 (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 */

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 ||
        ev_is (s, "gum-ok")     || ev_is (s, "gum-error") ||
        ev_is (s, "gum-cache-hit") ||
        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);

    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 *cfg   = g_strdup_printf (
        "window.__bbCfg={camfix:%s,relax:%s,cache:%s,retries:%d,"
        "maxW:%d,maxH:%d,maxFps:%d,match:%s,watchdog:%s,stall:%d,debug:%s};",
        g_cam_fix        ? "true" : "false",
        g_cam_relax      ? "true" : "false",
        g_cam_keepalive  ? "true" : "false",
        g_cam_retries,
        g_cam_max_w, g_cam_max_h, g_cam_max_fps,
        match,
        g_media_watchdog ? "true" : "false",
        g_stall_timeout,
        g_media_debug    ? "true" : "false");

    char *js = g_strconcat (cfg, SHIM_JS, NULL);
    g_free (match);
    g_free (cfg);
    return js;
}

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

static void
big_usage_options (GString *s)
{
    g_string_append_printf (s,
"camera:\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-prewarm        skip the GStreamer capture-device probe at startup\n"
"  --no-cam-fix        do not touch getUserMedia() at all\n"
"  --no-cam-relax      pass the page's constraints through unchanged\n"
"  --no-cam-keepalive  do not reuse or hold on to the capture stream\n"
"  --cam-max WxH@FPS   cap relaxed constraints (default: %dx%d@%d)\n"
"  --cam-match TEXT    prefer the camera whose label contains TEXT\n"
"  --cam-retries N     retries with looser constraints (default: %d)\n"
"\n"
"video:\n"
"  --no-media-watchdog do not watch <video> elements for stalls\n"
"  --stall-timeout SEC frozen playback counts as stalled after SEC (default: %d)\n"
"  --no-auto-reload    never reload a page on our own\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"
"  --no-gpu            hardware acceleration policy NEVER\n"
"  --no-hw-decode      WEBKIT_GST_ENABLE_HW_DECODERS=0\n"
"  --no-dmabuf         WEBKIT_DISABLE_DMABUF_RENDERER=1\n"
"  --no-compositing    WEBKIT_DISABLE_COMPOSITING_MODE=1\n"
"  --no-webrtc         disable WebRTC only\n"
"  --no-mediastream    disable MediaStream / getUserMedia only\n"
"\n"
"diagnostics:\n"
"  --media-debug       verbose media, permission and capture logging\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, "--warm-cam"))            { g_warm_cam = 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, "--warm-timeout")) {
        NEXT ("--warm-timeout");
        g_warm_timeout = MAX (1, atoi (argv[++(*i)]));
        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)]);
        return TRUE;
    }
    if (!strcmp (a, "--cam-retries")) {
        NEXT ("--cam-retries");
        g_cam_retries = CLAMP (atoi (argv[++(*i)]), 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-gpu"))              { g_no_gpu = TRUE; return TRUE; }
    if (!strcmp (a, "--no-hw-decode"))        { g_no_hw_decode = TRUE; return TRUE; }
    if (!strcmp (a, "--no-dmabuf"))           { g_no_dmabuf = TRUE; return TRUE; }
    if (!strcmp (a, "--no-compositing"))      { g_no_compositing = TRUE; 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");
        g_stall_timeout = MAX (2, atoi (argv[++(*i)]));
        return TRUE;
    }
    if (!strcmp (a, "--load-timeout")) {
        NEXT ("--load-timeout");
        g_load_timeout = MAX (0, atoi (argv[++(*i)]));
        return TRUE;
    }
    if (!strcmp (a, "--max-reloads")) {
        NEXT ("--max-reloads");
        g_max_reloads = MAX (0, atoi (argv[++(*i)]));
        return TRUE;
    }

    /* ---- diagnostics ---- */
    if (!strcmp (a, "--media-debug"))         { g_media_debug = TRUE; 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. */
static void
big_pre_gtk (void)
{
    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);
    }
    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);
    if (g_no_hw_decode)   g_setenv ("WEBKIT_GST_ENABLE_HW_DECODERS", "0", TRUE);
    if (g_no_dmabuf)      g_setenv ("WEBKIT_DISABLE_DMABUF_RENDERER", "1", TRUE);
    if (g_no_compositing) g_setenv ("WEBKIT_DISABLE_COMPOSITING_MODE", "1", TRUE);
}

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

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

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");
    }
    if (g_no_gpu) {
        webkit_settings_set_hardware_acceleration_policy (
            s, WEBKIT_HARDWARE_ACCELERATION_POLICY_NEVER);
        LOG ("gpu: hardware acceleration policy = NEVER\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;
    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);
}

/* ----------------------------------------------------------------- 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,
    .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);
}