Browser git · main
Minimal browser in C (mini and big variant)
C 95.8% Markdown 3.9%git clone https://git.christianimmanuel.de/web-tools/Browser.gitwget https://git.christianimmanuel.de/web-tools/Browser/archive/Browser.tar.gzRename browser And fix ctr+a not marking And stuff
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 */