Rename browser And fix ctr+a not marking And stuff
Christian Immanuel · 2026-09-04 12:50 · 5cbe2d3b38c2a06f59d4f8efd2ef6a4e4ff5dc84
Makefile | 25 +-
README.md | 58 ++-
browser-big.c | 1357 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++
browser-mini.c | 22 +
browser_core.c | 823 ++++++++++++++++++++++++++--------
browser_core.h | 1 +
6 files changed, 2084 insertions(+), 202 deletions(-)
diff --git a/Makefile b/Makefile
index becfbf4..3e16300 100644
--- a/Makefile
+++ b/Makefile
@@ -2,8 +2,8 @@ CC ?= gcc
CFLAGS ?= -O2 -Wall -Wextra
PREFIX ?= /usr/local
-VERSION := 1.1.2
-SOURCES := browser_core.c browser_core.h minibrowser.c bigbrowser.c Makefile
+VERSION := 2.3.0
+SOURCES := browser_core.c browser_core.h browser-mini.c browser-big.c Makefile
BUILD_ID := $(shell cat $(SOURCES) 2>/dev/null | md5sum | cut -c1-7)
CORE_PKGS = gtk4 webkitgtk-6.0 libsoup-3.0
@@ -16,24 +16,27 @@ BIG_LIBS := $(shell pkg-config --libs $(BIG_PKGS))
VERFLAGS := -DBROWSER_VERSION='"$(VERSION)"' -DBROWSER_BUILD='"$(BUILD_ID)"'
-BINS = minibrowser bigbrowser
+BINS = browser-mini browser-big
all: $(BINS)
-minibrowser: minibrowser.c browser_core.c browser_core.h
- $(CC) $(CFLAGS) $(VERFLAGS) -o $@ minibrowser.c browser_core.c $(CORE_CFLAGS) $(CORE_LIBS)
+browser-mini: browser-mini.c browser_core.c browser_core.h
+ $(CC) $(CFLAGS) $(VERFLAGS) -o $@ browser-mini.c browser_core.c $(CORE_CFLAGS) $(CORE_LIBS)
-bigbrowser: bigbrowser.c browser_core.c browser_core.h
- $(CC) $(CFLAGS) $(VERFLAGS) -o $@ bigbrowser.c browser_core.c $(BIG_CFLAGS) $(BIG_LIBS)
+browser-big: browser-big.c browser_core.c browser_core.h
+ $(CC) $(CFLAGS) $(VERFLAGS) -o $@ browser-big.c browser_core.c $(BIG_CFLAGS) $(BIG_LIBS)
version:
@echo "$(VERSION) (build $(BUILD_ID))"
install: all
- install -Dm755 minibrowser $(DESTDIR)$(PREFIX)/bin/minibrowser
- install -Dm755 bigbrowser $(DESTDIR)$(PREFIX)/bin/bigbrowser
+ install -Dm755 browser-mini $(DESTDIR)$(PREFIX)/bin/browser-mini
+ install -Dm755 browser-big $(DESTDIR)$(PREFIX)/bin/browser-big
+
+uninstall:
+ rm -f $(DESTDIR)$(PREFIX)/bin/browser-mini $(DESTDIR)$(PREFIX)/bin/browser-big
clean:
- rm -f $(BINS)
+ rm -f $(BINS) minibrowser bigbrowser
-.PHONY: all install clean version
+.PHONY: all install uninstall clean version
diff --git a/README.md b/README.md
index 14452fb..e5fb5c1 100644
--- a/README.md
+++ b/README.md
@@ -1,4 +1,4 @@
-# minibrowser / bigbrowser
+# browser-mini / browser-big
Two WebKitGTK 6.0 (GTK4) page viewers sharing one core.
@@ -11,15 +11,15 @@ GTK 4.14 / WebKitGTK 2.52.3, and run headless under Xvfb.
| --- | --- |
| `browser_core.h` | Window state, the `BrowserApp` hook struct, shared helpers |
| `browser_core.c` | Window, popup, toast, zoom, downloads, profiles, keys, history |
-| `minibrowser.c` | 20 lines. Fills in four fields and calls `browser_main()` |
-| `bigbrowser.c` | Camera / GStreamer / watchdogs / diagnostics on top of the core |
+| `browser-mini.c` | 20 lines. Fills in four fields and calls `browser_main()` |
+| `browser-big.c` | Camera / GStreamer / watchdogs / diagnostics on top of the core |
-minibrowser adds nothing to the core. Every hook is optional.
+browser-mini adds nothing to the core. Every hook is optional.
## Version
```
-minibrowser --version # minibrowser 1.1.2 (build 060a193)
+browser-mini --version # browser-mini 2.3.0 (build bcff1fa)
make version
```
@@ -36,18 +36,21 @@ apt install build-essential pkg-config libgtk-4-dev libwebkitgtk-6.0-dev \
make
```
-`libgstreamer1.0-dev` is only needed for bigbrowser. `make install` puts both
+`libgstreamer1.0-dev` is only needed for browser-big. `make install` puts both
in `/usr/local/bin`.
## Usage
```
-minibrowser [URL] [options]
-bigbrowser [URL|PATH|diag] [options]
+browser-mini [URL] [options]
+browser-big [URL|PATH|diag] [options]
```
-With no address the window comes up on `about:blank` with the history popup
-open, so there is something to pick from rather than an empty window. A local
+With no address the window comes up on a start page: the program's name set
+large, a rule under it, the version and build id, and the three keys worth
+knowing. It is drawn from the same palette as the panels, so the browser
+looks like one thing from the first frame, and it is served without a base
+URI so it stays out of the history by itself. A local
file needs three slashes — `file:///tmp/index.html` — though a bare path
works too (`./index.html`, `/tmp/index.html`), and anything without a
scheme is tried as https.
@@ -62,7 +65,7 @@ Run either with `-h` for the full option and key list.
| `Ctrl+J` | Back |
| `Ctrl+Shift+J` | Forward |
| `Ctrl+H` | The same popup, opened on the history |
-| `Ctrl+F` | Find in page. `Enter` next, `Shift+Enter` previous, `Esc` out |
+| `Ctrl+F` | Find in page. `Enter`/`Down`/`Ctrl+N` next, `Shift+Enter`/`Up`/`Ctrl+Shift+N` previous, `Esc` out |
| `Ctrl+S` | Download directory: this page, this site, or everything |
| `Ctrl+K` | Add a search keyword |
| `F1`, `Ctrl+/` | The key list, on screen |
@@ -78,7 +81,7 @@ Run either with `-h` for the full option and key list.
Use `--mod alt|super|meta` to move the modifier off Ctrl. Nothing else is
intercepted, so copy/paste keeps working inside the page.
-bigbrowser adds `Ctrl+Shift+M` (diagnostics page), `Ctrl+Shift+C` (warm the
+browser-big adds `Ctrl+Shift+M` (diagnostics page), `Ctrl+Shift+C` (warm the
camera), `Ctrl+Shift+X` (release it), `Ctrl+Shift+V` (dump video state).
## History
@@ -99,8 +102,10 @@ Trimmed to the last 2000 lines at startup.
One popup does both jobs, because they are the same job: an input line with
the matching history under it.
-- `Ctrl+O` types an address. The list stays out of the way until `Tab` or
- `Down` asks for it — with nothing typed yet, that is the whole history.
+- `Ctrl+O` types an address. The history is listed straight away — eight
+ lines by default, `list_rows` in a config file — and narrows as you type,
+ so the two are one thing: pick a line, or press `Enter` and what you typed
+ is loaded.
- `Ctrl+H` opens the same popup on the history.
Every panel — address, history, find, download directory, key list — is the
@@ -127,6 +132,15 @@ brightens on hover. When the directory popup asks about a clash the same two
buttons become `Drop that rule` and `Keep both`, so both answers are on
screen rather than implied.
+While a page loads, its address appears top left and becomes the page title
+as soon as one arrives, with a thin accent line across the very top showing
+progress.
+
+`Ctrl+A` selects all — in a popup's input, and in the page. WebKitGTK maps
+that key to move-to-start-of-line, an Emacs habit that surprises anyone
+typing into a web text field, so it is turned into WebKit's own select-all
+command. `select_all = no` gives the key back to the page.
+
`Esc` closes whatever is on screen — popup, key list, download list or a
message — and reaches the page only when nothing of ours is up.
@@ -150,7 +164,9 @@ read the same rules, since which directory a site's files go in has nothing
to do with which cookie jar is in use. Rules written by an older build are
carried over from the profile on first run.
-The key is a whole address or a bare host. The scopes are independent and the
+The key is a whole address or a bare host. The `Ctrl+S` popup lists everything
+stored, so a directory can be picked with the arrows or the mouse instead
+of typed, and the rule under the cursor dropped with `Delete` or `Ctrl+X`. The scopes are independent and the
narrower one always wins: a rule on `a.de/b` keeps its own directory when
`a.de` gets one. Setting a rule only ever writes the key for the scope you
picked. A rule set on a page also covers
@@ -194,6 +210,11 @@ a file is going before it lands.
## Search keywords
+`Ctrl+K` opens a popup listing the keywords it knows. Add one by typing
+`g https://google.com/search?q={}`, pick an existing one with the arrows or
+the mouse to edit it, drop it with `Delete` or `Ctrl+X`.
+
+
A single word in front of the text in the address popup runs a search:
```
@@ -229,7 +250,8 @@ Every overlay — the popup, toast, downloads — is drawn from
swov's palette and geometry, so the two programs read as one set. Defaults
match swov value for value: `tile` panels at `radius 14` with a `border 3`
outline, `text` on top, `hint` for headers, `hl` (the orange) for the caret,
-the download bar and, at a third of its weight, for selections, `urgent` for failures.
+the download bar and, at a third of its weight, for selections, and `find_hl`
+(a pale blue) for the match `Ctrl+F` is sitting on, `urgent` for failures.
Nothing is hardcoded. `ui_css_install()` builds the stylesheet from the
theme, so changing `hl` in a config file moves every accent at once.
@@ -253,7 +275,7 @@ Files are read in this order, later wins, command line on top of both:
```
${XDG_CONFIG_HOME:-~/.config}/swov/config shared palette
-${XDG_CONFIG_HOME:-~/.config}/minibrowser/config our own settings
+${XDG_CONFIG_HOME:-~/.config}/browser-mini/config our own settings
```
So the palette lives once in swov's file and drives all three programs.
@@ -269,7 +291,7 @@ what makes one file safe to share.
Every key is also a command line option:
```
-minibrowser https://example.com --hl=ff8800 -s radius=6 ui_scale=1.2
+browser-mini https://example.com --hl=ff8800 -s radius=6 ui_scale=1.2
```
**Look keys** (shared with swov): `bg tile tile_sel tile_hover card
diff --git a/browser-big.c b/browser-big.c
new file mode 100644
index 0000000..56de3ae
--- /dev/null
+++ b/browser-big.c
@@ -0,0 +1,1357 @@
+/*
+ * 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);
+}
diff --git a/browser-mini.c b/browser-mini.c
new file mode 100644
index 0000000..7f00264
--- /dev/null
+++ b/browser-mini.c
@@ -0,0 +1,22 @@
+/*
+ * browser-mini - minimal WebKitGTK 6.0 (GTK4) page viewer
+ *
+ * All of the behaviour lives in browser_core.c. This file exists to say
+ * that minibrowser adds nothing to it. Run with -h for the option and
+ * key list.
+ */
+
+#include "browser_core.h"
+
+static const BrowserApp browser_mini = {
+ .tagline = "minimal WebKitGTK 6.0 / GTK4 page viewer",
+ .default_title = "Browser Mini",
+ .default_app_id = "browser-mini",
+ .usage_arg = "URL",
+};
+
+int
+main (int argc, char **argv)
+{
+ return browser_main (argc, argv, &browser_mini);
+}
diff --git a/browser_core.c b/browser_core.c
index 93b4b19..fffe7a0 100644
--- a/browser_core.c
+++ b/browser_core.c
@@ -64,6 +64,7 @@ static const BrowserApp *g_app;
static GPtrArray *g_ucms; /* WebKitUserContentManager*, unowned */
static const char *g_css_path;
static char *g_css_data;
+static gboolean g_find_css_on; /* the find highlight sheet is in */
static int g_windows;
static char *g_download_dir;
@@ -74,6 +75,7 @@ static char *g_user_agent;
static gboolean g_follow_page_title = TRUE; /* see --title / --page-title */
static char *g_clip_cmd; /* --clip-cmd, NULL -> wl-copy */
static gboolean g_no_middle_paste = TRUE; /* --enable-middle-click-paste */
+static gboolean g_fix_select_all = TRUE; /* see on_key */
static gboolean g_want_page_title;
static char *g_css_owned; /* when --css came from the config */
@@ -133,6 +135,8 @@ static void downloads_reveal (void);
static void downloads_tick_start (void);
static void dl_panel_rebuild (Win *w);
static void css_apply_one (WebKitUserContentManager *ucm);
+static void css_apply_all (void);
+static void find_highlight (gboolean on);
static void history_note (Win *w);
static gboolean parse_mod (const char *name);
@@ -910,8 +914,10 @@ typedef struct { double r, g, b, a; } Color;
typedef struct {
/* palette, swov names */
- Color bg, tile, tile_sel, tile_hover, card, card_hover;
+ Color bg, tile, tile_sel, tile_hover, mini_bg, card, card_hover, card_focus;
Color text, subtext, dim, accent, hl, hltext, hint, urgent, outline;
+ Color find_hl; /* the match <mod>+F is sitting on */
+ Color current, match, shadow; /* carried for interchange */
/* geometry */
double radius, border, pad, gap, win_gap, ui_scale;
@@ -921,6 +927,7 @@ typedef struct {
/* how wide a popup wants to be, before the window has its say */
int popup_w;
+ int list_rows; /* how many lines a popup list shows */
char *font; /* "" -> whatever GTK is themed with */
char *font_mono; /* URLs and file names read better fixed width */
} Theme;
@@ -991,7 +998,9 @@ theme_defaults (void)
t->tile = rgba_hex (0x1e2733f2);
t->tile_sel = rgba_hex (0x26313ff2);
t->tile_hover = rgba_hex (0x2b3644f2);
+ t->mini_bg = rgba_hex (0x11171f9e);
t->card = rgba_hex (0x33404ff7);
+ t->card_focus = rgba_hex (0x3b4a5bf7);
t->card_hover = rgba_hex (0x46566af7);
t->hl = rgba_hex (0xcb9b00ff);
t->text = rgba_hex (0xe8e8e8ff);
@@ -1002,6 +1011,10 @@ theme_defaults (void)
t->hint = rgba_hex (0xa7b5c4ff);
t->urgent = rgba_hex (0xe0533cff);
t->outline = rgba_hex (0x0a0e1499);
+ t->find_hl = rgba_hex (0x9fd6f5ff); /* pale blue, not the orange */
+ t->current = rgba_hex (0x4fb3a5ff);
+ t->match = rgba_hex (0xb58ae0ff);
+ t->shadow = rgba_hex (0x00000073);
t->radius = 14.0;
t->border = 3.0;
@@ -1010,6 +1023,7 @@ theme_defaults (void)
t->win_gap = 5.0;
t->ui_scale = 1.0;
+ t->list_rows = 8;
t->popup_w = 550; /* about 50 characters of the mono font */
t->ws_px = 26;
t->label_px = 16;
@@ -1049,11 +1063,19 @@ cfg_set (const char *k, const char *v)
/* ---- palette, shared with swov ---- */
if (key_is (k, "bg")) return parse_color (v, &t->bg);
- if (key_is (k, "tile") || key_is (k, "panel"))
+ if (key_is (k, "tile") || key_is (k, "panel") || key_is (k, "ring"))
return parse_color (v, &t->tile);
- if (key_is (k, "tile_sel")) return parse_color (v, &t->tile_sel);
+ if (key_is (k, "tile_sel") || key_is (k, "ring2"))
+ return parse_color (v, &t->tile_sel);
if (key_is (k, "tile_hover")) return parse_color (v, &t->tile_hover);
if (key_is (k, "card")) return parse_color (v, &t->card);
+ if (key_is (k, "card_focus")) return parse_color (v, &t->card_focus);
+ if (key_is (k, "mini_bg") || key_is (k, "center"))
+ return parse_color (v, &t->mini_bg);
+ if (key_is (k, "current")) return parse_color (v, &t->current);
+ if (key_is (k, "match")) return parse_color (v, &t->match);
+ if (key_is (k, "shadow_color") || key_is (k, "shadow"))
+ return parse_color (v, &t->shadow);
if (key_is (k, "card_hover") || key_is (k, "hover"))
return parse_color (v, &t->card_hover);
if (key_is (k, "text")) return parse_color (v, &t->text);
@@ -1065,6 +1087,8 @@ cfg_set (const char *k, const char *v)
if (key_is (k, "hint")) return parse_color (v, &t->hint);
if (key_is (k, "urgent")) return parse_color (v, &t->urgent);
if (key_is (k, "outline")) return parse_color (v, &t->outline);
+ if (key_is (k, "find_hl") || key_is (k, "find"))
+ return parse_color (v, &t->find_hl);
/* ---- geometry, shared with swov ---- */
if (key_is (k, "radius") || key_is (k, "corner")) { t->radius = g_ascii_strtod (v, NULL); return TRUE; }
@@ -1080,6 +1104,7 @@ cfg_set (const char *k, const char *v)
/* ---- text, shared with swov ---- */
if (key_is (k, "popup_w") || key_is (k, "popup_width")) { t->popup_w = atoi (v); return TRUE; }
+ if (key_is (k, "list_rows") || key_is (k, "rows")) { t->list_rows = atoi (v); return TRUE; }
if (key_is (k, "ws_px") || key_is (k, "workspace_px")) { t->ws_px = atoi (v); return TRUE; }
if (key_is (k, "label_px") || key_is (k, "app_px")) { t->label_px = atoi (v); return TRUE; }
if (key_is (k, "title_px")) { t->title_px = atoi (v); return TRUE; }
@@ -1100,6 +1125,7 @@ cfg_set (const char *k, const char *v)
if (key_is (k, "no_media")) { g_deny_media = truthy (v); return TRUE; }
if (key_is (k, "quiet")) { g_quiet = truthy (v); return TRUE; }
if (key_is (k, "page_title")) { g_want_page_title = truthy (v); return TRUE; }
+ if (key_is (k, "select_all")) { g_fix_select_all = truthy (v); return TRUE; }
if (key_is (k, "middle_click_paste")) { g_no_middle_paste = !truthy (v); return TRUE; }
if (key_is (k, "always_overwrite")) {
g_always_overwrite = truthy (v);
@@ -1239,9 +1265,11 @@ usage (const char *argv0, gboolean to_stdout)
if (hpath)
hblock = g_strdup_printf (
- " Every address that came up is appended to\n"
+ " Every address that came up is kept in\n"
" %s\n"
- " as a tab separated line: timestamp, URL, page title.\n", hpath);
+ " as a tab separated line: timestamp, URL, page title. One line per\n"
+" address: visiting a page again moves it to the end instead of adding\n"
+" a second copy.\n", hpath);
else
hblock = g_strdup (" Off, because --private was given.\n");
const char *arg = g_app->usage_arg ? g_app->usage_arg : "URL";
@@ -1249,7 +1277,10 @@ usage (const char *argv0, gboolean to_stdout)
g_string_append_printf (s, "%s - %s\n\n", argv0, g_app->tagline);
g_string_append_printf (s, "usage: %s [%s] [options]\n"
"\n"
-" With no address the window comes up blank with the history open.\n"
+" With no address the window comes up on a start page carrying the\n"
+" program's name, version and the three keys worth knowing. It is drawn\n"
+" from the same palette as the panels.\n"
+"\n"
" A local file needs three slashes: file:///tmp/index.html. A bare path\n"
" works too, ./index.html or /tmp/index.html, and anything without a\n"
" scheme is tried as https.\n"
@@ -1306,8 +1337,8 @@ usage (const char *argv0, gboolean to_stdout)
" in ~/.local/share/wkview/download-dirs.tsv, which\n"
" belongs to you rather than to a profile: every\n"
" profile and both browsers share it. Search keywords\n"
-" are per profile, in searches.tsv. A directory that does not exist is\n"
-" created group writable (0770)\n"
+" are per profile, in searches.tsv. A directory that\n"
+" does not exist is created group writable (0770)\n"
" --profile NAME named profile, persists cookies (default: \"default\")\n"
" --clear-data wipe the profile's data and cache before starting\n"
" --private ephemeral session, no stored history, ignores\n"
@@ -1334,12 +1365,14 @@ usage (const char *argv0, gboolean to_stdout)
" ignored with a note on stderr.\n"
"\n"
" look: popup_width (how wide the panels want to be, %d)\n"
+" list_rows (lines a popup list shows, %d)\n"
" bg tile tile_sel tile_hover card card_hover text subtext dim\n"
-" accent hl hltext hint urgent outline\n"
+" accent hl hltext hint urgent outline find_hl\n"
" radius border pad gap win_gap ui_scale\n"
" font font_mono label_px title_px hint_px\n"
" ours: title app_id zoom mod clip_cmd download_dir profile private\n"
-" no_media page_title middle_click_paste css user_agent quiet\n"
+" no_media page_title middle_click_paste select_all css\n"
+" user_agent quiet\n"
" always_overwrite\n"
" search_KEY (e.g. search_s = https://google.com/search?q={})\n"
"\n"
@@ -1349,16 +1382,23 @@ usage (const char *argv0, gboolean to_stdout)
" -V, --version version and build id, to tell two builds apart\n"
"\n"
"key bindings (<mod> = %s):\n"
-" <mod>+O or <mod>+L type an address. Tab or Down brings up the matching\n"
-" history, and with nothing typed yet, all of it.\n"
+" <mod>+O or <mod>+L type an address. The history is listed straight\n"
+" away and narrows as you type, so the two are one\n"
+" thing: pick a line, or press Enter and what you\n"
+" typed is loaded\n"
" A search keyword in front runs a search instead:\n"
" \"s tree\" with s = https://google.com/search?q={}\n"
" <mod>+H the same popup, opened on the history\n"
-" <mod>+F find in the page. Enter for the next hit,\n"
-" Shift+Enter for the previous, Esc to stop\n"
+" <mod>+F find in the page. Enter, Down or <mod>+N for the\n"
+" next hit, Shift+Enter, Up or <mod>+Shift+N for the\n"
+" previous, Esc to stop. The hit you are on is shown\n"
+" in find_hl, a pale blue by default\n"
" <mod>+S download directory. The select decides how far it\n"
" reaches: this address only, everything on the site,\n"
-" or everything. The narrower rule wins, and a rule\n"
+" or everything. The popup lists what is already\n"
+" stored: arrows or the mouse pick one, Delete or\n"
+" <mod>+X drops the rule under the cursor.\n"
+" The narrower rule wins, and a rule\n"
" set on a page also covers the files it hands out.\n"
" If something else already points at that directory\n"
" you are asked: Enter drops the older rule, Esc lets\n"
@@ -1367,8 +1407,11 @@ usage (const char *argv0, gboolean to_stdout)
" ask for, with no question. \"everything\" lasts for\n"
" this run only; put download_dir in a config file to\n"
" keep it\n"
-" <mod>+K add a search keyword, as \"g URL\" with {} where the\n"
-" words go\n"
+" <mod>+K search keywords. The popup lists the ones it knows;\n"
+" add one as \"g URL\" with {} where the words go, and\n"
+" drop the one under the cursor with Delete or\n"
+" <mod>+X. Then \"g tree\" in the address popup\n"
+" searches for tree\n"
" F1 or <mod>+/ the key list, on screen\n"
" <mod>+J back: one page back, then on into the stored history\n"
" <mod>+Shift+J forward, the same way round\n"
@@ -1377,8 +1420,8 @@ usage (const char *argv0, gboolean to_stdout)
" keys named on them, so Enter is never the only way in.\n"
"\n"
" In the popup: Tab and Down walk forward through the matches, Shift+Tab\n"
-" and Up back, the wheel scrolls, a click opens, Delete removes the entry\n"
-" from the history. Whatever is selected is written into the input, so\n"
+" and Up back, the wheel scrolls, a click opens, Delete or <mod>+X removes\n"
+" the entry. Whatever is selected is written into the input, so\n"
" Enter always loads what you can read. Esc, <mod>+H or a click outside\n"
" closes it.\n"
"\n"
@@ -1394,7 +1437,7 @@ usage (const char *argv0, gboolean to_stdout)
" <mod>+minus zoom out\n"
" <mod>+0 reset zoom to 100%%\n"
" <mod>+Y copy the current URL, and show what was copied\n",
- hblock, HIST_KEEP, cfg_shared, cfg_own, g_theme.popup_w,
+ hblock, HIST_KEEP, cfg_shared, cfg_own, g_theme.popup_w, g_theme.list_rows,
g_mod_name, DL_HISTORY_SECONDS, URL_TOAST_SECONDS);
if (g_app->usage_keys)
@@ -1406,8 +1449,10 @@ usage (const char *argv0, gboolean to_stdout)
" message - and reaches the page only when nothing of ours is up.\n"
"\n"
" Only the combinations listed above are intercepted; everything else\n"
-" (<mod>+A, <mod>+C, <mod>+V, ...) goes straight to the page, so select-all\n"
-" and copy/paste keep working inside input fields. While the URL bar is\n"
+" (<mod>+C, <mod>+V, ...) goes straight to the page, so copy and paste\n"
+" keep working inside input fields. <mod>+A is turned into WebKit's\n"
+" select-all, because WebKit itself reads it as move-to-start-of-line;\n"
+" set select_all=no if a page needs the key for itself. While the URL bar is\n"
" open every key except Esc belongs to it, so <mod>+A selects its text.\n"
"%s",
g_mod == GDK_CONTROL_MASK
@@ -1438,10 +1483,43 @@ usage_short (const char *argv0)
/* User stylesheet injected into the pages (--css). One content manager
* per view, so the sheet has to be applied to each of them. */
+/*
+ * WebKit shows the match it is sitting on as the document selection, so
+ * colouring ::selection is what colours the current hit. The sheet only
+ * goes in while <mod>+F is open, so ordinary selections keep the page's
+ * own colours the rest of the time.
+ */
+static void
+css_apply_find (WebKitUserContentManager *ucm)
+{
+ if (!g_find_css_on)
+ return;
+
+ char *bg = css_rgba (g_theme.find_hl);
+ char *fg = css_rgba (g_theme.hltext);
+ char *css = g_strdup_printf ("::selection{background-color:%s;color:%s}"
+ "::-moz-selection{background-color:%s;color:%s}",
+ bg, fg, bg, fg);
+
+ WebKitUserStyleSheet *ss =
+ webkit_user_style_sheet_new (css,
+ WEBKIT_USER_CONTENT_INJECT_ALL_FRAMES,
+ WEBKIT_USER_STYLE_LEVEL_USER,
+ NULL, NULL);
+ webkit_user_content_manager_add_style_sheet (ucm, ss);
+ webkit_user_style_sheet_unref (ss);
+
+ g_free (css);
+ g_free (fg);
+ g_free (bg);
+}
+
static void
css_apply_one (WebKitUserContentManager *ucm)
{
webkit_user_content_manager_remove_all_style_sheets (ucm);
+ css_apply_find (ucm);
+
if (!g_css_data)
return;
@@ -1454,6 +1532,13 @@ css_apply_one (WebKitUserContentManager *ucm)
webkit_user_style_sheet_unref (ss);
}
+static void
+css_apply_all (void)
+{
+ for (guint i = 0; i < g_ucms->len; i++)
+ css_apply_one (g_ptr_array_index (g_ucms, i));
+}
+
void
css_reload (void)
{
@@ -1470,8 +1555,7 @@ css_reload (void)
}
}
- for (guint i = 0; i < g_ucms->len; i++)
- css_apply_one (g_ptr_array_index (g_ucms, i));
+ css_apply_all ();
}
/*
@@ -1489,6 +1573,8 @@ ui_css_install (void)
char *c_sel = css_rgba (t->tile_sel);
char *c_hover = css_rgba (t->tile_hover);
char *c_card = css_rgba (t->card);
+ char *c_mini = css_rgba (t->mini_bg);
+ char *c_current = css_rgba (t->current);
char *c_text = css_rgba (t->text);
char *c_subtext = css_rgba (t->subtext);
char *c_dim = css_rgba (t->dim);
@@ -1561,6 +1647,7 @@ ui_css_install (void)
"label.br-pick-sub { color: %s; %s font-size: %.0fpx; }"
"label.br-dl-name { color: %s; %s font-size: %.0fpx; }"
"label.br-dl-status { color: %s; %s font-size: %.0fpx; }"
+ "label.br-dl-live { color: %s; %s font-size: %.0fpx; }"
"label.br-dl-ask { color: %s; %s font-size: %.0fpx; }"
"label.br-dl-failed { color: %s; %s font-size: %.0fpx; }"
"label.br-dl-dir { color: %s; %s font-size: %.0fpx; }"
@@ -1588,6 +1675,7 @@ ui_css_install (void)
c_subtext, mono_font, t->title_px * s,
c_text, mono_font, t->label_px * s,
c_subtext, mono_font, t->title_px * s,
+ c_current, mono_font, t->title_px * s,
c_hl, mono_font, t->title_px * s,
c_urgent, mono_font, t->title_px * s,
c_dim, mono_font, t->title_px * s,
@@ -1641,9 +1729,22 @@ ui_css_install (void)
" background-color: %s; border: none;"
" border-radius: %.0fpx; min-height: %.0fpx;"
"}",
- c_card, t->radius / 3.0, 6.0 * s,
+ c_mini, t->radius / 3.0, 6.0 * s,
c_hl, t->radius / 3.0, 6.0 * s);
+ /* ---- the loading hairline, across the very top ---- */
+ g_string_append_printf (css,
+ "progressbar.br-load, progressbar.br-load > trough {"
+ " background-color: transparent; background-image: none;"
+ " border: none; border-radius: 0; min-height: %.0fpx;"
+ " padding: 0; margin: 0;"
+ "}"
+ "progressbar.br-load > trough > progress {"
+ " background-color: %s; border: none; border-radius: 0;"
+ " min-height: %.0fpx;"
+ "}",
+ 3.0 * s, c_hl, 3.0 * s);
+
/* ---- picker rows ---- */
g_string_append_printf (css,
"scrolledwindow.br-pick-scroll,"
@@ -1680,6 +1781,7 @@ ui_css_install (void)
g_free (c_dim); g_free (c_hl); g_free (c_hint);
g_free (c_urgent); g_free (c_outline); g_free (c_hltext);
g_free (c_hl_soft); g_free (c_hl_dim);
+ g_free (c_mini); g_free (c_current);
}
/* ----------------------------------------------------------- clipboard */
@@ -2273,108 +2375,54 @@ history_load (void)
if (n > HIST_KEEP)
first = n - HIST_KEEP;
- GString *kept = g_string_new (NULL);
-
- for (guint i = first; i < n; i++) {
- char **f = g_strsplit (lines[i], "\t", 3);
- if (g_strv_length (f) >= 2 && history_uri_ok (f[1])) {
- hist_add (f[1], g_strv_length (f) >= 3 ? f[2] : "");
- g_string_append (kept, lines[i]);
- g_string_append_c (kept, '\n');
+ /*
+ * One line per address, keeping the newest visit. Files written before
+ * this rule existed are full of repeats, so the cleanup happens here
+ * rather than only on the way in - the file is tidied on first read.
+ */
+ GHashTable *seen = g_hash_table_new (g_str_hash, g_str_equal);
+ GPtrArray *keep = g_ptr_array_new (); /* line indices, newest first */
+
+ for (guint i = n; i > first; i--) {
+ char **f = g_strsplit (lines[i - 1], "\t", 3);
+
+ if (g_strv_length (f) >= 2 && history_uri_ok (f[1]) &&
+ !g_hash_table_contains (seen, f[1])) {
+ g_hash_table_add (seen, g_strdup (f[1]));
+ g_ptr_array_add (keep, GUINT_TO_POINTER (i - 1));
}
g_strfreev (f);
}
- if (first > 0)
- g_file_set_contents (g_hist_path, kept->str, -1, NULL);
-
- g_string_free (kept, TRUE);
- g_strfreev (lines);
- g_free (data);
-
- LOG ("history: %u entries from %s\n", g_hist->len, g_hist_path);
-}
-
-static void
-history_append (const char *uri, const char *title)
-{
- if (!g_hist_path)
- return;
-
- /* reloads and in-page jumps should not pile up */
- if (g_hist->len && !g_strcmp0 (hist_at (g_hist->len - 1)->uri, uri))
- return;
-
- char *t = history_clean (title);
- hist_add (uri, t);
-
- GDateTime *now = g_date_time_new_now_local ();
- char *when = g_date_time_format_iso8601 (now);
- FILE *f = g_fopen (g_hist_path, "a");
-
- if (f) {
- fprintf (f, "%s\t%s\t%s\n", when, uri, t);
- fclose (f);
- } else {
- g_printerr ("history: cannot write %s: %s\n", g_hist_path, g_strerror (errno));
- }
-
- g_free (t);
- g_free (when);
- g_date_time_unref (now);
-}
+ GString *kept = g_string_new (NULL);
+ guint dropped = 0;
-static void
-history_setup (const char *data_dir)
-{
- g_hist = g_ptr_array_new_with_free_func (hist_entry_free);
+ for (guint i = keep->len; i > 0; i--) { /* back into file order */
+ guint idx = GPOINTER_TO_UINT (g_ptr_array_index (keep, i - 1));
+ char **f = g_strsplit (lines[idx], "\t", 3);
- if (g_private || !data_dir) {
- LOG ("history: off\n");
- return;
+ hist_add (f[1], g_strv_length (f) >= 3 ? f[2] : "");
+ g_string_append (kept, lines[idx]);
+ g_string_append_c (kept, '\n');
+ g_strfreev (f);
}
- g_hist_path = g_build_filename (data_dir, HIST_FILE, NULL);
- history_load ();
-}
-
-/* The <title> is often still empty when the load finishes, so the record
- * is written a moment later. It also debounces redirect chains. */
-static gboolean
-history_write (gpointer u)
-{
- Win *w = u;
- w->hist_id = 0;
-
- const char *uri = webkit_web_view_get_uri (w->view);
- if (!history_uri_ok (uri))
- return G_SOURCE_REMOVE;
- if (g_app->history_skip && g_app->history_skip (uri))
- return G_SOURCE_REMOVE; /* a front-end's internal page */
+ dropped = (n - first) - keep->len;
- history_append (uri, webkit_web_view_get_title (w->view));
- w->hist_pos = (int) g_hist->len - 1;
- return G_SOURCE_REMOVE;
-}
+ if (first > 0 || dropped > 0)
+ g_file_set_contents (g_hist_path, kept->str, -1, NULL);
-/* Called when a load settles. Records the page unless we navigated there
- * ourselves, in which case the cursor is already where it belongs. */
-static void
-history_note (Win *w)
-{
- if (w->hist_loading) {
- w->hist_loading = FALSE;
- return;
- }
+ if (dropped)
+ LOG ("history: dropped %u duplicate lines\n", dropped);
- w->hist_walk = FALSE; /* the user went somewhere new */
+ g_ptr_array_free (keep, TRUE);
+ g_hash_table_destroy (seen);
- if (w->load_failed)
- return;
+ g_string_free (kept, TRUE);
+ g_strfreev (lines);
+ g_free (data);
- if (w->hist_id)
- g_source_remove (w->hist_id);
- w->hist_id = g_timeout_add (HIST_SETTLE_MS, history_write, w);
+ LOG ("history: %u entries from %s\n", g_hist->len, g_hist_path);
}
/*
@@ -2486,6 +2534,36 @@ urltoast_show (Win *w, const char *uri)
w->urltoast_id = g_timeout_add_seconds (URL_TOAST_SECONDS, urltoast_timeout, w);
}
+/* The title only turns up part way through the load, so the label starts
+ * as the address and is upgraded in place when the name arrives. */
+static void
+on_title_toast (GObject *obj, GParamSpec *ps, gpointer u)
+{
+ (void) ps; (void) u;
+ WebKitWebView *view = WEBKIT_WEB_VIEW (obj);
+ Win *w = win_of (view);
+ const char *t = webkit_web_view_get_title (view);
+
+ if (w && t && *t && gtk_widget_get_visible (w->urltoast))
+ gtk_label_set_text (GTK_LABEL (w->urltoast), t);
+}
+
+static void
+on_load_progress (GObject *obj, GParamSpec *ps, gpointer u)
+{
+ (void) ps; (void) u;
+ WebKitWebView *view = WEBKIT_WEB_VIEW (obj);
+ Win *w = win_of (view);
+
+ if (!w)
+ return;
+
+ double p = webkit_web_view_get_estimated_load_progress (view);
+
+ gtk_progress_bar_set_fraction (GTK_PROGRESS_BAR (w->loadbar), p);
+ gtk_widget_set_visible (w->loadbar, p > 0.0 && p < 1.0);
+}
+
static void
on_load_core (WebKitWebView *view, WebKitLoadEvent ev, gpointer u)
{
@@ -2500,6 +2578,7 @@ on_load_core (WebKitWebView *view, WebKitLoadEvent ev, gpointer u)
} else if (ev == WEBKIT_LOAD_COMMITTED) {
urltoast_show (w, webkit_web_view_get_uri (view)); /* after redirects */
} else if (ev == WEBKIT_LOAD_FINISHED) {
+ gtk_widget_set_visible (w->loadbar, FALSE);
history_note (w);
}
}
@@ -2545,6 +2624,124 @@ omni_matches (const HistEntry *e, const char *needle)
return hit;
}
+/* One row shape for both lists: a strong line and a quiet one under it. */
+static GtkWidget *
+omni_row_new (const char *main_text, const char *sub_text)
+{
+ GtkWidget *row = gtk_list_box_row_new ();
+ GtkWidget *box = gtk_box_new (GTK_ORIENTATION_VERTICAL, 0);
+
+ GtkWidget *main_l = gtk_label_new (main_text);
+ gtk_widget_add_css_class (main_l, "br-pick-main");
+ gtk_label_set_xalign (GTK_LABEL (main_l), 0.0);
+ gtk_label_set_ellipsize (GTK_LABEL (main_l), PANGO_ELLIPSIZE_MIDDLE);
+ gtk_box_append (GTK_BOX (box), main_l);
+
+ if (sub_text && *sub_text) {
+ GtkWidget *sub = gtk_label_new (sub_text);
+ gtk_widget_add_css_class (sub, "br-pick-sub");
+ gtk_label_set_xalign (GTK_LABEL (sub), 0.0);
+ gtk_label_set_ellipsize (GTK_LABEL (sub), PANGO_ELLIPSIZE_MIDDLE);
+ gtk_box_append (GTK_BOX (box), sub);
+ }
+
+ gtk_list_box_row_set_child (GTK_LIST_BOX_ROW (row), box);
+ return row;
+}
+
+static void
+omni_empty_row (Win *w, const char *text)
+{
+ GtkWidget *row = gtk_list_box_row_new ();
+ GtkWidget *l = gtk_label_new (text);
+
+ gtk_widget_add_css_class (l, "br-pick-sub");
+ gtk_label_set_xalign (GTK_LABEL (l), 0.0);
+ gtk_list_box_row_set_child (GTK_LIST_BOX_ROW (row), l);
+ gtk_list_box_row_set_selectable (GTK_LIST_BOX_ROW (row), FALSE);
+ gtk_list_box_append (GTK_LIST_BOX (w->omnilist), row);
+}
+
+/* The directory popup lists what is already stored, so a directory can be
+ * picked instead of typed, and dropped with Delete. */
+static void
+omni_rebuild_rules (Win *w, const char *needle)
+{
+ GList *keys = g_hash_table_get_keys (g_dlrules);
+ guint shown = 0;
+
+ keys = g_list_sort (keys, (GCompareFunc) g_strcmp0);
+
+ for (GList *l = keys; l; l = l->next) {
+ const char *key = l->data;
+ const char *dir = g_hash_table_lookup (g_dlrules, key);
+
+ if (needle && *needle) {
+ char *k = g_utf8_casefold (key, -1);
+ char *d = g_utf8_casefold (dir, -1);
+ gboolean hit = strstr (k, needle) || strstr (d, needle);
+ g_free (k);
+ g_free (d);
+ if (!hit)
+ continue;
+ }
+
+ gboolean always = dl_always_for_key (key);
+ char *sub = g_strdup_printf ("%s%s", key, always ? " (always replaces)" : "");
+ GtkWidget *row = omni_row_new (dir, sub);
+
+ g_object_set_data_full (G_OBJECT (row), "rule", g_strdup (key), g_free);
+ gtk_list_box_append (GTK_LIST_BOX (w->omnilist), row);
+ g_free (sub);
+ shown++;
+ }
+
+ g_list_free (keys);
+
+ if (shown == 0)
+ omni_empty_row (w, g_hash_table_size (g_dlrules)
+ ? "no match" : "no directories stored yet");
+}
+
+/* <mod>+K lists the keywords it already knows, the same way the directory
+ * popup lists its rules. */
+static void
+omni_rebuild_searches (Win *w, const char *needle)
+{
+ GList *keys = g_searches ? g_hash_table_get_keys (g_searches) : NULL;
+ guint shown = 0;
+
+ keys = g_list_sort (keys, (GCompareFunc) g_strcmp0);
+
+ for (GList *l = keys; l; l = l->next) {
+ const char *key = l->data;
+ const char *url = g_hash_table_lookup (g_searches, key);
+
+ if (needle && *needle) {
+ char *k = g_utf8_casefold (key, -1);
+ char *u = g_utf8_casefold (url, -1);
+ gboolean hit = strstr (k, needle) || strstr (u, needle);
+ g_free (k);
+ g_free (u);
+ if (!hit)
+ continue;
+ }
+
+ char *main_text = g_strdup_printf ("%s %s", key, url);
+ GtkWidget *row = omni_row_new (main_text, NULL);
+
+ g_object_set_data_full (G_OBJECT (row), "search", g_strdup (key), g_free);
+ gtk_list_box_append (GTK_LIST_BOX (w->omnilist), row);
+ g_free (main_text);
+ shown++;
+ }
+
+ g_list_free (keys);
+
+ if (shown == 0)
+ omni_empty_row (w, "no keywords yet - type one as: g https://host/?q={}");
+}
+
static void
omni_rebuild (Win *w)
{
@@ -2555,44 +2752,33 @@ omni_rebuild (Win *w)
char *needle = g_utf8_casefold (w->omni_needle ? w->omni_needle : "", -1);
guint shown = 0;
+ if (w->omni_mode == OMNI_DLDIR) {
+ omni_rebuild_rules (w, needle);
+ g_free (needle);
+ return;
+ }
+
+ if (w->omni_mode == OMNI_SEARCH) {
+ omni_rebuild_searches (w, needle);
+ g_free (needle);
+ return;
+ }
+
/* newest first: what you want in a history list */
for (guint i = g_hist->len; i > 0 && shown < PICK_ROWS; i--) {
HistEntry *e = hist_at (i - 1);
if (!omni_matches (e, needle))
continue;
- GtkWidget *row = gtk_list_box_row_new ();
- GtkWidget *box = gtk_box_new (GTK_ORIENTATION_VERTICAL, 0);
-
- GtkWidget *main_l = gtk_label_new (*e->title ? e->title : e->uri);
- gtk_widget_add_css_class (main_l, "br-pick-main");
- gtk_label_set_xalign (GTK_LABEL (main_l), 0.0);
- gtk_label_set_ellipsize (GTK_LABEL (main_l), PANGO_ELLIPSIZE_END);
- gtk_box_append (GTK_BOX (box), main_l);
-
- if (*e->title) {
- GtkWidget *sub = gtk_label_new (e->uri);
- gtk_widget_add_css_class (sub, "br-pick-sub");
- gtk_label_set_xalign (GTK_LABEL (sub), 0.0);
- gtk_label_set_ellipsize (GTK_LABEL (sub), PANGO_ELLIPSIZE_MIDDLE);
- gtk_box_append (GTK_BOX (box), sub);
- }
-
- gtk_list_box_row_set_child (GTK_LIST_BOX_ROW (row), box);
+ GtkWidget *row = omni_row_new (*e->title ? e->title : e->uri,
+ *e->title ? e->uri : NULL);
g_object_set_data (G_OBJECT (row), "pos", GINT_TO_POINTER ((int) i - 1));
gtk_list_box_append (GTK_LIST_BOX (w->omnilist), row);
shown++;
}
- if (shown == 0) {
- GtkWidget *row = gtk_list_box_row_new ();
- GtkWidget *l = gtk_label_new (g_hist->len ? "no match" : "no history yet");
- gtk_widget_add_css_class (l, "br-pick-sub");
- gtk_label_set_xalign (GTK_LABEL (l), 0.0);
- gtk_list_box_row_set_child (GTK_LIST_BOX_ROW (row), l);
- gtk_list_box_row_set_selectable (GTK_LIST_BOX_ROW (row), FALSE);
- gtk_list_box_append (GTK_LIST_BOX (w->omnilist), row);
- }
+ if (shown == 0)
+ omni_empty_row (w, g_hist->len ? "no match" : "no history yet");
g_free (needle);
}
@@ -2620,6 +2806,100 @@ omni_scroll_to (Win *w, GtkListBoxRow *row)
gtk_adjustment_set_value (adj, bot - page);
}
+static void
+history_append (const char *uri, const char *title)
+{
+ if (!g_hist_path)
+ return;
+
+ /* One line per address. Visiting a page again moves it to the end
+ * rather than adding a second copy, so the list stays a set and the
+ * newest visit is the one you scroll to first. */
+ int seen = -1;
+ for (guint i = 0; i < g_hist->len; i++)
+ if (!g_strcmp0 (hist_at (i)->uri, uri)) {
+ seen = (int) i;
+ break;
+ }
+
+ if (seen >= 0 && seen == (int) g_hist->len - 1)
+ return; /* already the newest, nothing to do */
+
+ if (seen >= 0)
+ history_delete (seen); /* drops it from the file too */
+
+ char *t = history_clean (title);
+ hist_add (uri, t);
+
+ GDateTime *now = g_date_time_new_now_local ();
+ char *when = g_date_time_format_iso8601 (now);
+ FILE *f = g_fopen (g_hist_path, "a");
+
+ if (f) {
+ fprintf (f, "%s\t%s\t%s\n", when, uri, t);
+ fclose (f);
+ } else {
+ g_printerr ("history: cannot write %s: %s\n", g_hist_path, g_strerror (errno));
+ }
+
+ g_free (t);
+ g_free (when);
+ g_date_time_unref (now);
+}
+
+static void
+history_setup (const char *data_dir)
+{
+ g_hist = g_ptr_array_new_with_free_func (hist_entry_free);
+
+ if (g_private || !data_dir) {
+ LOG ("history: off\n");
+ return;
+ }
+
+ g_hist_path = g_build_filename (data_dir, HIST_FILE, NULL);
+ history_load ();
+}
+
+/* The <title> is often still empty when the load finishes, so the record
+ * is written a moment later. It also debounces redirect chains. */
+static gboolean
+history_write (gpointer u)
+{
+ Win *w = u;
+ w->hist_id = 0;
+
+ const char *uri = webkit_web_view_get_uri (w->view);
+ if (!history_uri_ok (uri))
+ return G_SOURCE_REMOVE;
+ if (g_app->history_skip && g_app->history_skip (uri))
+ return G_SOURCE_REMOVE; /* a front-end's internal page */
+
+ history_append (uri, webkit_web_view_get_title (w->view));
+ w->hist_pos = (int) g_hist->len - 1;
+ return G_SOURCE_REMOVE;
+}
+
+/* Called when a load settles. Records the page unless we navigated there
+ * ourselves, in which case the cursor is already where it belongs. */
+static void
+history_note (Win *w)
+{
+ if (w->hist_loading) {
+ w->hist_loading = FALSE;
+ return;
+ }
+
+ w->hist_walk = FALSE; /* the user went somewhere new */
+
+ if (w->load_failed)
+ return;
+
+ if (w->hist_id)
+ g_source_remove (w->hist_id);
+ w->hist_id = g_timeout_add (HIST_SETTLE_MS, history_write, w);
+}
+
/* Delete in the popup: drop the selected row and keep the place. */
static void
omni_delete_selected (Win *w)
@@ -2630,8 +2910,22 @@ omni_delete_selected (Win *w)
if (!row || !gtk_list_box_row_get_selectable (row))
return;
- int idx = gtk_list_box_row_get_index (row);
- history_delete (GPOINTER_TO_INT (g_object_get_data (G_OBJECT (row), "pos")));
+ int idx = gtk_list_box_row_get_index (row);
+ const char *rule = g_object_get_data (G_OBJECT (row), "rule");
+
+ const char *search = g_object_get_data (G_OBJECT (row), "search");
+
+ if (search) {
+ LOG ("search: dropped %s\n", search);
+ g_hash_table_remove (g_searches, search);
+ searches_save ();
+ } else if (rule) {
+ LOG ("download: dropped rule %s\n", rule);
+ dl_dir_forget (rule);
+ dlrules_save ();
+ } else {
+ history_delete (GPOINTER_TO_INT (g_object_get_data (G_OBJECT (row), "pos")));
+ }
omni_rebuild (w);
@@ -2652,30 +2946,68 @@ omni_reflect (Win *w, GtkListBoxRow *row)
if (!row || !gtk_list_box_row_get_selectable (row))
return;
- int pos = GPOINTER_TO_INT (g_object_get_data (G_OBJECT (row), "pos"));
- if (pos < 0 || pos >= (int) g_hist->len)
- return;
+ const char *text;
+ char *owned = NULL;
+ const char *search = g_object_get_data (G_OBJECT (row), "search");
+ const char *rule = g_object_get_data (G_OBJECT (row), "rule");
+
+ if (search) {
+ const char *url = g_hash_table_lookup (g_searches, search);
+ if (!url)
+ return;
+ owned = g_strdup_printf ("%s %s", search, url);
+ text = owned;
+ } else if (rule) {
+ text = g_hash_table_lookup (g_dlrules, rule); /* its directory */
+ if (!text)
+ return;
+ } else {
+ int pos = GPOINTER_TO_INT (g_object_get_data (G_OBJECT (row), "pos"));
+ if (pos < 0 || pos >= (int) g_hist->len)
+ return;
+ text = hist_at (pos)->uri;
+ }
w->omni_setting = TRUE;
- gtk_editable_set_text (GTK_EDITABLE (w->omnientry), hist_at (pos)->uri);
+ gtk_editable_set_text (GTK_EDITABLE (w->omnientry), text);
gtk_editable_set_position (GTK_EDITABLE (w->omnientry), -1);
w->omni_setting = FALSE;
+
+ g_free (owned);
}
-/* The list is worth as much of the window as it can decently have: a
- * fixed cap looked cramped on anything but a small screen. */
+/*
+ * A fixed number of lines, measured from a real row rather than guessed,
+ * so the box is the same height whether the history has four entries or
+ * four hundred. Min and max are set together: a list that grows and
+ * shrinks as you type is harder to read than one that stays put.
+ */
static void
omni_size_list (Win *w)
{
- int h = gtk_widget_get_height (w->win);
+ int rows = g_theme.list_rows > 0 ? g_theme.list_rows : 8;
+ int row_h = 0;
+
+ GtkListBoxRow *first = gtk_list_box_get_row_at_index (GTK_LIST_BOX (w->omnilist), 0);
+ if (first) {
+ int min_h, nat_h;
+ gtk_widget_measure (GTK_WIDGET (first), GTK_ORIENTATION_VERTICAL, -1,
+ &min_h, &nat_h, NULL, NULL);
+ row_h = nat_h;
+ }
+
+ if (row_h <= 0) /* nothing built yet, estimate */
+ row_h = (int) ((g_theme.label_px + g_theme.title_px) * g_theme.ui_scale
+ + g_theme.win_gap);
+
+ int want = rows * row_h;
+ int win_h = gtk_widget_get_height (w->win);
- if (h <= 0)
- h = 800; /* not mapped yet, assume the default */
+ if (win_h > 0)
+ want = MIN (want, (int) (win_h * 0.7));
- gtk_scrolled_window_set_min_content_height (GTK_SCROLLED_WINDOW (w->omniscroll),
- MIN (320, (int) (h * 0.35)));
- gtk_scrolled_window_set_max_content_height (GTK_SCROLLED_WINDOW (w->omniscroll),
- (int) (h * 0.68));
+ gtk_scrolled_window_set_min_content_height (GTK_SCROLLED_WINDOW (w->omniscroll), want);
+ gtk_scrolled_window_set_max_content_height (GTK_SCROLLED_WINDOW (w->omniscroll), want);
}
static void
@@ -2684,8 +3016,8 @@ omni_list_show (Win *w)
if (w->omni_list)
return;
- omni_size_list (w);
omni_rebuild (w);
+ omni_size_list (w); /* after the rows exist, so one can be measured */
gtk_widget_set_visible (w->omniscroll, TRUE);
w->omni_list = TRUE;
}
@@ -2723,8 +3055,10 @@ omni_hide (Win *w)
if (!gtk_widget_get_visible (w->omni))
return;
- if (w->omni_mode == OMNI_FIND)
+ if (w->omni_mode == OMNI_FIND) {
webkit_find_controller_search_finish (webkit_web_view_get_find_controller (w->view));
+ find_highlight (FALSE);
+ }
gtk_widget_set_visible (w->omni, FALSE);
gtk_widget_set_visible (w->omniscroll, FALSE);
@@ -2816,8 +3150,14 @@ omni_show (Win *w, OmniMode mode)
if (mode == OMNI_URL)
gtk_editable_select_region (GTK_EDITABLE (w->omnientry), 0, -1);
- else if (mode == OMNI_HISTORY)
- omni_list_show (w);
+
+ if (mode == OMNI_URL || mode == OMNI_HISTORY)
+ omni_list_show (w); /* the history is worth seeing unasked */
+
+ if (mode == OMNI_FIND)
+ find_highlight (TRUE);
+ else if (mode == OMNI_DLDIR || mode == OMNI_SEARCH)
+ omni_list_show (w); /* what is already stored */
}
static void
@@ -2870,6 +3210,17 @@ omni_go (Win *w)
WEBKIT_FIND_OPTIONS_WRAP_AROUND)
/* WebKit scrolls the hit into view for us. */
+/* The sheet is only worth injecting while the find bar is up. */
+static void
+find_highlight (gboolean on)
+{
+ if (g_find_css_on == on)
+ return;
+
+ g_find_css_on = on;
+ css_apply_all ();
+}
+
static void
find_run (Win *w)
{
@@ -3102,7 +3453,12 @@ on_omni_changed (GtkEditable *e, gpointer u)
return;
}
- if (w->omni_list)
+ /* Typing an address searches the history at the same time: the two
+ * were never really different jobs. Enter still loads what is typed,
+ * so a page that is not in the list is one keystroke away either way. */
+ if (!w->omni_list && w->omni_mode == OMNI_URL && *w->omni_needle)
+ omni_list_show (w);
+ else if (w->omni_list)
omni_rebuild (w);
}
@@ -3113,7 +3469,11 @@ on_omni_row_activated (GtkListBox *list, GtkListBoxRow *row, gpointer u)
Win *w = u;
omni_reflect (w, row);
- omni_go (w);
+
+ /* in the directory popup a click chooses the directory; Save still
+ * has to be pressed, since the scope has to be right too */
+ if (w->omni_mode != OMNI_DLDIR)
+ omni_go (w);
}
/* --------------------------------------------------------------- toast */
@@ -3240,6 +3600,7 @@ dl_row_new (Dl *d)
gtk_widget_add_css_class (status_l,
d->state == DL_FAILED ? "br-dl-failed"
: d->state == DL_ASK ? "br-dl-ask"
+ : d->state == DL_ACTIVE ? "br-dl-live"
: "br-dl-status");
gtk_label_set_xalign (GTK_LABEL (status_l), 1.0);
gtk_widget_set_halign (status_l, GTK_ALIGN_END);
@@ -3634,6 +3995,15 @@ on_key (GtkEventControllerKey *c, guint keyval, guint code,
}
if (gtk_widget_get_visible (w->omni)) {
+ /* Select-all, whatever GTK's key theme has to say about it: with
+ * the Emacs bindings <mod>+A is beginning-of-line, which is not
+ * what anyone expects from a text field. */
+ if (mod && !shift && key == GDK_KEY_a) {
+ gtk_editable_select_region (GTK_EDITABLE (w->omnientry), 0, -1);
+ return TRUE;
+ }
+
+
if ((mod && ((key == GDK_KEY_h && w->omni_mode == OMNI_HISTORY) ||
(key == GDK_KEY_f && w->omni_mode == OMNI_FIND) ||
(key == GDK_KEY_s && w->omni_mode == OMNI_DLDIR) ||
@@ -3642,20 +4012,33 @@ on_key (GtkEventControllerKey *c, guint keyval, guint code,
return TRUE;
}
- /* finding: Enter walks the hits, there is no list to steer */
+ /* finding: several ways to walk the hits, since the hands are
+ * already on the keyboard and everyone reaches for a different one */
if (w->omni_mode == OMNI_FIND) {
- if (keyval == GDK_KEY_Return || keyval == GDK_KEY_KP_Enter) {
+ if (keyval == GDK_KEY_Return || keyval == GDK_KEY_KP_Enter ||
+ (mod && key == GDK_KEY_n)) {
find_step (w, shift ? -1 : +1);
return TRUE;
}
+ if (keyval == GDK_KEY_Down || keyval == GDK_KEY_KP_Down) {
+ find_step (w, +1);
+ return TRUE;
+ }
+ if (keyval == GDK_KEY_Up || keyval == GDK_KEY_KP_Up) {
+ find_step (w, -1);
+ return TRUE;
+ }
return FALSE;
}
- /* settings modes: no list to steer, Enter applies */
- if (w->omni_mode == OMNI_DLDIR || w->omni_mode == OMNI_SEARCH)
+ /* the keyword popup has no list to steer */
+ if (w->omni_mode == OMNI_SEARCH)
return FALSE;
- if (keyval == GDK_KEY_Delete || keyval == GDK_KEY_KP_Delete) {
+ /* Delete is missing from plenty of keyboards, so <mod>+X does the
+ * same thing wherever an entry can be dropped. */
+ if (keyval == GDK_KEY_Delete || keyval == GDK_KEY_KP_Delete ||
+ (mod && key == GDK_KEY_x)) {
omni_delete_selected (w);
return TRUE;
}
@@ -3771,6 +4154,17 @@ on_key (GtkEventControllerKey *c, guint keyval, guint code,
clipboard_copy (w, webkit_web_view_get_uri (w->view));
return TRUE;
+ /* WebKitGTK maps <mod>+A to MoveToBeginningOfLine, an Emacs habit that
+ * surprises everyone typing into a web text field. The editing command
+ * is what the key is supposed to do, so run that instead. Turn it off
+ * with select_all=no for pages that bind <mod>+A themselves. */
+ case GDK_KEY_a:
+ if (shift || !g_fix_select_all)
+ break;
+ webkit_web_view_execute_editing_command (w->view,
+ WEBKIT_EDITING_COMMAND_SELECT_ALL);
+ return TRUE;
+
/* '+' usually needs Shift, and some layouts send '=' or the keypad key */
case GDK_KEY_plus:
case GDK_KEY_equal:
@@ -3822,11 +4216,13 @@ static const KeyRow g_keyrows[] = {
{ "Tab / Down", "next match" },
{ "Shift+Tab / Up", "previous match" },
{ "Enter", "open" },
- { "Delete", "remove the entry from the history" },
+ { "Delete / %s+X", "remove the entry under the cursor" },
{ "Esc", "close" },
{ NULL, "page" },
- { "%s+F", "find, Enter next, Shift+Enter previous" },
+ { "%s+F", "find in the page" },
+ { "Enter / Down / %s+N", "next match, while finding" },
+ { "Shift+Enter / Up", "previous match" },
{ "%s+G", "scroll to the top" },
{ "%s+Shift+G", "scroll to the bottom" },
{ "%s+plus / minus / 0", "zoom in, out, reset" },
@@ -4253,6 +4649,15 @@ window_new (WebKitWebView *view, gboolean primary)
gtk_label_set_max_width_chars (GTK_LABEL (w->urltoast), 70);
gtk_overlay_add_overlay (GTK_OVERLAY (overlay), w->urltoast);
+ /* a hairline across the very top, so a slow page still says something */
+ w->loadbar = gtk_progress_bar_new ();
+ gtk_widget_add_css_class (w->loadbar, "br-load");
+ gtk_widget_set_halign (w->loadbar, GTK_ALIGN_FILL);
+ gtk_widget_set_valign (w->loadbar, GTK_ALIGN_START);
+ gtk_widget_set_can_target (w->loadbar, FALSE);
+ gtk_widget_set_visible (w->loadbar, FALSE);
+ gtk_overlay_add_overlay (GTK_OVERLAY (overlay), w->loadbar);
+
/* Top right column: URL toast on top, downloads below. The whole
* column is click-through, so it never swallows a click on the page. */
w->topright = gtk_box_new (GTK_ORIENTATION_VERTICAL, (int) g_theme.gap);
@@ -4374,6 +4779,9 @@ view_wire (WebKitWebView *view)
g_signal_connect (view, "create", G_CALLBACK (on_create), NULL);
g_signal_connect (view, "load-changed", G_CALLBACK (on_load_core), NULL);
g_signal_connect (view, "load-failed", G_CALLBACK (on_load_failed_core), NULL);
+ g_signal_connect (view, "notify::title", G_CALLBACK (on_title_toast), NULL);
+ g_signal_connect (view, "notify::estimated-load-progress",
+ G_CALLBACK (on_load_progress), NULL);
WebKitFindController *fc = webkit_web_view_get_find_controller (view);
g_signal_connect (fc, "counted-matches", G_CALLBACK (on_found_count), NULL);
@@ -4499,6 +4907,75 @@ setup_settings (void)
g_app->settings_ready (g_settings);
}
+/* ------------------------------------------------------------ start page */
+
+/*
+ * With no address to open, the window would otherwise come up empty. This
+ * is drawn from the same palette as everything else, so the browser looks
+ * like one thing from the first frame. It is served from load_html with no
+ * base URI, which makes it about:blank as far as the rest of the code is
+ * concerned - so it stays out of the history by itself.
+ */
+static char *
+start_page_html (void)
+{
+ Theme *t = &g_theme;
+
+ char *c_bg = css_rgba (t->bg);
+ char *c_tile = css_rgba (t->tile);
+ char *c_text = css_rgba (t->text);
+ char *c_dim = css_rgba (t->dim);
+ char *c_subtext = css_rgba (t->subtext);
+ char *c_hl = css_rgba (t->hl);
+ char *c_glow = css_rgba_at (t->hl, 0.22);
+
+ /* "browser-mini" reads better as browser + mini */
+ const char *name = g_app->default_app_id;
+ const char *dash = strchr (name, '-');
+ char *head = dash ? g_strndup (name, (gsize) (dash - name)) : g_strdup (name);
+ const char *tail = dash ? dash + 1 : NULL;
+
+ char *html = g_strdup_printf (
+"<!doctype html><meta charset=\"utf-8\"><title>%s</title><style>"
+"html,body{height:100%%;margin:0}"
+"body{display:flex;align-items:center;justify-content:center;"
+"background:radial-gradient(circle at 50%% 38%%,%s 0%%,%s 72%%);"
+"font-family:%s,sans-serif;color:%s;"
+"-webkit-font-smoothing:antialiased}"
+".c{text-align:center;transform:translateY(-5vh)}"
+"h1{margin:0;font-size:11vmin;font-weight:200;letter-spacing:.18em;"
+"color:%s;text-shadow:0 0 60px %s}"
+"h1 b{color:%s;font-weight:600}"
+".r{width:9em;height:2px;margin:1.1em auto 1.4em;"
+"background:linear-gradient(90deg,transparent,%s,transparent)}"
+".v{font-family:%s,monospace;font-size:.85rem;color:%s;letter-spacing:.1em}"
+".k{margin-top:2.6em;font-family:%s,monospace;font-size:.95rem;color:%s}"
+".k b{color:%s;font-weight:600}"
+"</style>"
+"<div class=c><h1>%s%s<b>%s</b></h1><div class=r></div>"
+"<div class=v>%s · build %s</div>"
+"<div class=k><b>%s+O</b> address <b>%s+H</b> history"
+" <b>F1</b> keys</div></div>",
+ name,
+ c_tile, c_bg,
+ *t->font ? t->font : "system-ui", c_text,
+ c_text, c_glow,
+ c_hl,
+ c_hl,
+ t->font_mono, c_dim,
+ t->font_mono, c_subtext,
+ c_hl,
+ head, tail ? " " : "", tail ? tail : "",
+ BROWSER_VERSION, BROWSER_BUILD,
+ g_mod_name, g_mod_name);
+
+ g_free (head);
+ g_free (c_bg); g_free (c_tile); g_free (c_text);
+ g_free (c_dim); g_free (c_subtext);
+ g_free (c_hl); g_free (c_glow);
+ return html;
+}
+
/* ----------------------------------------------------------------- main */
int
@@ -4697,12 +5174,12 @@ browser_main (int argc, char **argv, const BrowserApp *app)
GMainLoop *loop = g_main_loop_new (NULL, FALSE);
g_object_set_data (G_OBJECT (win), "loop", loop);
- /* No address given: come up blank with the history open, so there is
- * something to pick from rather than an empty window. */
+ /* No address given: the start page, rather than an empty window. */
if (!url_arg) {
- webkit_web_view_load_uri (view, "about:blank");
+ char *html = start_page_html ();
+ webkit_web_view_load_html (view, html, NULL);
+ g_free (html);
gtk_window_present (GTK_WINDOW (win));
- omni_show (win_of (view), OMNI_HISTORY);
} else {
/* the front-end may claim the address, e.g. bigbrowser's "diag" */
if (!(app->load_uri && app->load_uri (view, url_arg))) {
diff --git a/browser_core.h b/browser_core.h
index 002ecb6..f955afc 100644
--- a/browser_core.h
+++ b/browser_core.h
@@ -45,6 +45,7 @@ typedef struct {
GtkWidget *omnisave;
GtkWidget *omnicancel;
GtkWidget *urltoast; /* the address, top left, on a new page */
+ GtkWidget *loadbar; /* a thin line across the top while loading */
guint urltoast_id;
GtkWidget *keys; /* key reference, hidden by default */
GtkWidget *topright; /* box holding the toast and the downloads */