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.gzinit
Makefile | 30 + README.md | 230 ++++ bigbrowser | Bin 0 -> 169560 bytes bigbrowser.c | 1357 ++++++++++++++++++ browser_core.c | 4195 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ browser_core.h | 143 ++ minibrowser | Bin 0 -> 127264 bytes minibrowser.c | 22 + 8 files changed, 5977 insertions(+) diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..8215f37 --- /dev/null +++ b/Makefile @@ -0,0 +1,30 @@ +CC ?= gcc +CFLAGS ?= -O2 -Wall -Wextra +PREFIX ?= /usr/local + +CORE_PKGS = gtk4 webkitgtk-6.0 libsoup-3.0 +BIG_PKGS = $(CORE_PKGS) gstreamer-1.0 + +CORE_CFLAGS := $(shell pkg-config --cflags $(CORE_PKGS)) +CORE_LIBS := $(shell pkg-config --libs $(CORE_PKGS)) +BIG_CFLAGS := $(shell pkg-config --cflags $(BIG_PKGS)) +BIG_LIBS := $(shell pkg-config --libs $(BIG_PKGS)) + +BINS = minibrowser bigbrowser + +all: $(BINS) + +minibrowser: minibrowser.c browser_core.c browser_core.h + $(CC) $(CFLAGS) -o $@ minibrowser.c browser_core.c $(CORE_CFLAGS) $(CORE_LIBS) + +bigbrowser: bigbrowser.c browser_core.c browser_core.h + $(CC) $(CFLAGS) -o $@ bigbrowser.c browser_core.c $(BIG_CFLAGS) $(BIG_LIBS) + +install: all + install -Dm755 minibrowser $(DESTDIR)$(PREFIX)/bin/minibrowser + install -Dm755 bigbrowser $(DESTDIR)$(PREFIX)/bin/bigbrowser + +clean: + rm -f $(BINS) + +.PHONY: all install clean diff --git a/README.md b/README.md new file mode 100644 index 0000000..711afa3 --- /dev/null +++ b/README.md @@ -0,0 +1,230 @@ +# minibrowser / bigbrowser + +Two WebKitGTK 6.0 (GTK4) page viewers sharing one core. + +**100% Vibecode but tested** — built warning-free with `-Wall -Wextra` against +GTK 4.14 / WebKitGTK 2.52.3, and run headless under Xvfb. + +## Layout + +| File | What it is | +| --- | --- | +| `browser_core.h` | Window state, the `BrowserApp` hook struct, shared helpers | +| `browser_core.c` | Window, popup, toast, zoom, downloads, profiles, keys, history | +| `minibrowser.c` | 20 lines. Fills in four fields and calls `browser_main()` | +| `bigbrowser.c` | Camera / GStreamer / watchdogs / diagnostics on top of the core | + +minibrowser adds nothing to the core. Every hook is optional. + +## Build + +Debian and derivatives: + +``` +apt install build-essential pkg-config libgtk-4-dev libwebkitgtk-6.0-dev \ + libsoup-3.0-dev libgstreamer1.0-dev +make +``` + +`libgstreamer1.0-dev` is only needed for bigbrowser. `make install` puts both +in `/usr/local/bin`. + +## Usage + +``` +minibrowser [URL] [options] +bigbrowser [URL|PATH|diag] [options] +``` + +With no address the window comes up blank with the history open. A local +file needs three slashes — `file:///tmp/index.html` — though a bare path +works too (`./index.html`, `/tmp/index.html`), and anything without a +scheme is tried as https. + +Run either with `-h` for the full option and key list. + +## Keys + +| Key | Action | +| --- | --- | +| `Ctrl+O`, `Ctrl+L` | Type an address | +| `Ctrl+J` | Back | +| `Ctrl+Shift+J` | Forward | +| `Ctrl+H` | The same popup, opened on the history | +| `Ctrl+F` | Find in page. `Enter` next, `Shift+Enter` previous, `Esc` out | +| `Ctrl+S` | Download directory: this page, this site, or everything | +| `Ctrl+K` | Add a search keyword | +| `F1`, `Ctrl+/` | The key list, on screen | +| `Ctrl+G` | Scroll to top | +| `Ctrl+Shift+G` | Scroll to bottom | +| `Ctrl+R`, `F5` | Reload | +| `Ctrl+D` | Recent downloads | +| `Ctrl+P` | Show current URL | +| `Ctrl+Y` | Copy URL, and show what was copied | +| `Ctrl+plus/minus/0` | Zoom | +| `F12` | Developer tools | + +Use `--mod alt|super|meta` to move the modifier off Ctrl. Nothing else is +intercepted, so copy/paste keeps working inside the page. + +bigbrowser adds `Ctrl+Shift+M` (diagnostics page), `Ctrl+Shift+C` (warm the +camera), `Ctrl+Shift+X` (release it), `Ctrl+Shift+V` (dump video state). + +## History + +Stored at: + +``` +~/.local/share/wkview/<profile>/history.tsv +``` + +One tab separated line per page: timestamp, URL, title. Only successful +loads of `http`, `https` and `file` are written, so every line is a link +that worked. Failed loads, error pages and internal pages are skipped. +Trimmed to the last 2000 lines at startup. + +`--private` turns it off. The file is plain text — grep it, edit it, delete it. + +One popup does both jobs, because they are the same job: an input line with +the matching history under it. + +- `Ctrl+O` types an address. The list stays out of the way until `Tab` or + `Down` asks for it — with nothing typed yet, that is the whole history. +- `Ctrl+H` opens the same popup on the history. + +`Tab` and `Down` walk forward through the matches, `Shift+Tab` and `Up` +back, the wheel scrolls, a click opens. Whatever is selected is written into +the input, so `Enter` always loads exactly what you can read. `Esc`, +`Ctrl+H` or a click outside closes it. + +`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. + +Back and forward walk WebKit's session list first, which keeps scroll +position and form state. Once that runs out they continue into the stored +history, so back still works on the first page after a restart. + +## Download directories + +Downloads land in `--download-dir` unless a rule says otherwise. `Ctrl+S` +sets one: type a directory, and the select decides how far it reaches — +this address only, everything on the site, or everything. The narrower rule +wins, so one page can go somewhere its site does not. + +``` +~/.local/share/wkview/<profile>/download-dirs.tsv key <TAB> directory +``` + +The key is a whole address or a bare host. A rule set on a page also covers +the files that page hands out, which usually live on another address — a +CDN, or just a different path — so a rule on a download page catches what +it serves. + +A file whose name is already taken is downloaded beside the old one and the +question is asked in the download panel itself — no second popup. `Enter` +puts it in the old one's place, `Esc` keeps both, and the transfer only +reads as done once the file is where it is going to stay. A directory that does not exist +is created group writable (`0770`). The download panel shows the +destination in small text on the right of its header, so it is clear where +a file is going before it lands. + +## Search keywords + +A single word in front of the text in the address popup runs a search: + +``` +s tree -> https://google.com/search?hl=en&q=tree +``` + +`Ctrl+K` adds one — type `s https://google.com/search?hl=en&q={}`. `{}` is +replaced with the rest of the line, percent encoded. Keywords live in +`searches.tsv` next to the profile, and a config file can set them too: + +``` +search_s = https://google.com/search?hl=en&q={} +``` + +A bare `s` with nothing after it is still treated as an address, so a site +whose name collides with a keyword still works. + +## Options worth knowing + +| Option | Effect | +| --- | --- | +| `--profile NAME` | Named profile, persists cookies | +| `--private` | Ephemeral session, no history | +| `--clear-data` | Wipe the profile before starting | +| `--enable-middle-click-paste` | Let middle clicks reach the page (swallowed by default) | +| `--app-id ID` | Wayland `app_id` / X11 `WM_CLASS`, for sway rules | +| `--css FILE` | Inject a user stylesheet | +| `--ua-chrome`, `--ua-firefox`, … | Preset user agents | + +## Look + +Every overlay — the popup, toast, downloads — is drawn from +swov's palette and geometry, so the two programs read as one set. Defaults +match swov value for value: `tile` panels at `radius 14` with a `border 3` +outline, `text` on top, `hint` for headers, `hl` (the orange) for the caret +and the download bar, `urgent` for failures. + +Nothing is hardcoded. `ui_css_install()` builds the stylesheet from the +theme, so changing `hl` in a config file moves every accent at once. + +Panels hug the window edge rather than curve away from it: only the corners +facing into the page are rounded, and the border on the touching side is +dropped. The toast and the download list sit flush in the top right with one +rounded corner; the popup hangs off the top edge, centered, with two; the +address label sits in the top left with one; the key list floats clear in +the middle with four. + +Every overlay is placed by hand from the window's real size, so a narrow or +short window shrinks them instead of clipping them. Tested down to 240x180. + +## Config + +Same format and the same key names as swov: `key = value`, `#` or `;` +comments, colours as `RRGGBB` or `RRGGBBAA`. + +Files are read in this order, later wins, command line on top of both: + +``` +${XDG_CONFIG_HOME:-~/.config}/swov/config shared palette +${XDG_CONFIG_HOME:-~/.config}/minibrowser/config our own settings +``` + +So the palette lives once in swov's file and drives all three programs. +Keys a program does not know are ignored with a note on stderr — that is +what makes one file safe to share. + +| Option | Effect | +| --- | --- | +| `-c PATH` | Read this file instead of the chain | +| `-n` | Ignore the config files | +| `-s KEY=VAL` | Set one key. `--KEY=VAL` and bare `KEY=VAL` also work | + +Every key is also a command line option: + +``` +minibrowser https://example.com --hl=ff8800 -s radius=6 ui_scale=1.2 +``` + +**Look keys** (shared with swov): `bg tile tile_sel tile_hover card +card_hover text subtext dim accent hl hltext hint urgent outline radius +border pad gap win_gap ui_scale font font_mono label_px title_px hint_px` + +**Browser keys**: `title app_id zoom mod clip_cmd download_dir profile +private no_media page_title middle_click_paste css user_agent quiet` + +`font` is empty by default, meaning the GTK theme font. `font_mono` is +browser-only and covers URLs and file names, where fixed width earns its +place. + +## Notes + +- Profile directories keep the historic name `wkview`. Renaming it would + orphan existing cookie jars. +- Middle-click paste is off by default: the press is claimed in the capture + phase, since WebKitGTK has no setting for it. `--enable-middle-click-paste` + puts it back. +- Downloads appear the moment one starts, in aligned columns: name on the + left, percentage / size / ETA right aligned, the bar underneath. diff --git a/bigbrowser b/bigbrowser new file mode 100755 index 0000000..8e496f9 Binary files /dev/null and b/bigbrowser differ diff --git a/bigbrowser.c b/bigbrowser.c new file mode 100644 index 0000000..d22d4cf --- /dev/null +++ b/bigbrowser.c @@ -0,0 +1,1357 @@ +/* + * bigbrowser - 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 bigbrowser = { + .tagline = "WebKitGTK 6.0 / GTK4 page viewer with camera and streaming fixes", + .default_title = "Bigbrowser", + .default_app_id = "bigbrowser", + .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, &bigbrowser); +} diff --git a/browser_core.c b/browser_core.c new file mode 100644 index 0000000..36c2c2b --- /dev/null +++ b/browser_core.c @@ -0,0 +1,4195 @@ +/* + * browser_core - the shared half of minibrowser and bigbrowser. + * + * See browser_core.h for the front-end interface. Nothing in here knows + * anything about cameras, GStreamer or diagnostics; that is bigbrowser's + * business and it hangs off the BrowserApp hooks. + */ + +#include "browser_core.h" + +#include <errno.h> +#include <signal.h> +#include <stdlib.h> +#include <string.h> +#include <unistd.h> + +#ifdef GDK_WINDOWING_WAYLAND +#include <gdk/wayland/gdkwayland.h> +#endif + +#define DEFAULT_CLIP_CMD "wl-copy" + +/* Directory name used under ~/.local/share and ~/.cache for profiles. + * Kept as "wkview" on purpose: renaming it would orphan existing + * cookie jars / logins. Move those dirs by hand if you change it. */ +#define PROFILE_DIR_NAME "wkview" + +/* download overlay */ +#define DL_LINGER_SECONDS 4 /* keep a finished download on screen */ +#define DL_TICK_MS 100 /* progress / ETA refresh rate */ +#define DL_ACTIVE_ROWS 4 /* max rows in the live overlay */ +#define DL_HISTORY_ROWS 8 /* max rows in the <mod>+D list */ +#define DL_KEEP 32 /* max downloads remembered */ +#define DL_NAME_CHARS 30 /* filenames are elided to this */ + +/* stored history */ +#define HIST_FILE "history.tsv" +#define DLRULES_FILE "download-dirs.tsv" +#define SEARCH_FILE "searches.tsv" +#define HIST_KEEP 2000 /* lines kept when the file is trimmed */ +#define PICK_ROWS 200 /* most matches the picker will build */ +#define HIST_SETTLE_MS 400 /* wait for the page <title> to arrive */ + +/* -------------------------------------------------------------- globals */ + +WebKitSettings *g_settings; +WebKitNetworkSession *g_session; + +gboolean g_quiet; +gboolean g_deny_media; +gboolean g_private; +double g_zoom = 1.0; +char *g_title; /* window title, see --title */ +char *g_app_id; /* wayland app_id / X11 WM_CLASS */ +GPtrArray *g_wins; /* Win*, every open window */ + +GdkModifierType g_mod = GDK_CONTROL_MASK; +const char *g_mod_name = "Ctrl"; + +static const BrowserApp *g_app; + +static GPtrArray *g_ucms; /* WebKitUserContentManager*, unowned */ +static const char *g_css_path; +static char *g_css_data; +static int g_windows; + +static char *g_download_dir; +static char *g_data_dir; /* profile data dir, NULL when private */ +static char *g_profile; /* NULL -> "default" */ +static gboolean g_clear_data; +static char *g_user_agent; +static gboolean g_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_want_page_title; +static char *g_css_owned; /* when --css came from the config */ + +/* stored history: every address that actually came up, oldest first */ +typedef struct { + char *uri; + char *title; +} HistEntry; + +static GPtrArray *g_hist; /* HistEntry* */ +static char *g_hist_path; /* NULL when history is off */ + +/* One tracked download. Lives in g_downloads; the WebKitDownload keeps a + * pointer to it under the "dl" data key. */ +typedef enum { DL_ACTIVE, DL_ASK, DL_DONE, DL_FAILED } DlState; + +typedef struct { + char *name; + char *dest; /* where it is actually being written */ + char *want; /* the name it wanted, when that existed already */ + gboolean overwrite; /* the user said yes; rename over it when done */ + gboolean ask; /* the name was taken, waiting for an answer */ + DlState state; + double progress; /* 0..1, 0 when the size is unknown */ + guint64 received; + guint64 total; + gint64 start_us; + gint64 end_us; + + /* smoothed transfer rate, so the ETA is not thrown off by the first + * burst out of the socket buffer */ + double rate; /* bytes/s, exponentially smoothed */ + gint64 rate_us; /* when the rate was last sampled */ + guint64 rate_bytes; /* bytes received at that sample */ +} Dl; + +static GPtrArray *g_downloads; /* Dl*, oldest first */ +static guint g_dl_tick; /* refresh timer, runs only when needed */ + +/* ---------------------------------------------------------- prototypes */ + +static GtkWidget *window_new (WebKitWebView *view, gboolean primary); +static gboolean on_key (GtkEventControllerKey *c, guint keyval, guint code, + GdkModifierType state, gpointer user_data); +static void on_close (GtkWindow *w, gpointer user_data); +static gboolean on_decide_policy (WebKitWebView *view, WebKitPolicyDecision *decision, + WebKitPolicyDecisionType type, gpointer u); +static gboolean on_permission (WebKitWebView *view, WebKitPermissionRequest *req, + gpointer u); +static GtkWidget *on_create (WebKitWebView *view, WebKitNavigationAction *act, + gpointer u); +static void on_ready_to_show (WebKitWebView *view, gpointer u); +static void on_session_download_started (WebKitNetworkSession *session, + WebKitDownload *download, gpointer u); +static void downloads_refresh (void); +static void downloads_tick_start (void); +static void dl_panel_rebuild (Win *w); +static void css_apply_one (WebKitUserContentManager *ucm); +static void history_note (Win *w); + +static gboolean parse_mod (const char *name); +static void omni_hide (Win *w); +static void dlrules_save (void); +static void omni_rebuild (Win *w); + +/* ------------------------------------------------------ small formatters */ + +static char * +format_size (guint64 bytes) +{ + if (bytes >= 1024ULL * 1024 * 1024) + return g_strdup_printf ("%.1f GB", bytes / (1024.0 * 1024 * 1024)); + if (bytes >= 1024ULL * 1024) + return g_strdup_printf ("%.1f MB", bytes / (1024.0 * 1024)); + if (bytes >= 1024) + return g_strdup_printf ("%.0f kB", bytes / 1024.0); + return g_strdup_printf ("%" G_GUINT64_FORMAT " B", bytes); +} + +static char * +format_seconds (double secs) +{ + if (secs < 1) + return g_strdup ("<1s"); + if (secs < 60) + return g_strdup_printf ("%.0fs", secs); + if (secs < 3600) + return g_strdup_printf ("%dm%02ds", (int) secs / 60, (int) secs % 60); + return g_strdup_printf ("%dh%02dm", (int) secs / 3600, ((int) secs % 3600) / 60); +} + +/* Middle-elide, so both the stem and the extension stay readable. */ +static char * +elide (const char *s, int max_chars) +{ + if (!s) + return g_strdup (""); + + glong len = g_utf8_strlen (s, -1); + if (len <= max_chars) + return g_strdup (s); + + int keep = max_chars - 1; + int left = keep / 2; + int right = keep - left; + + char *head = g_strndup (s, (gsize) (g_utf8_offset_to_pointer (s, left) - s)); + char *out = g_strconcat (head, "…", g_utf8_offset_to_pointer (s, len - right), NULL); + g_free (head); + return out; +} + +/* ------------------------------------------------------------- helpers */ + +void +settings_set_bool_if_exists (WebKitSettings *s, const char *prop, gboolean value) +{ + if (g_object_class_find_property (G_OBJECT_GET_CLASS (s), prop)) + g_object_set (G_OBJECT (s), prop, value, NULL); + else + LOG ("note: WebKitSettings property not supported: %s\n", prop); +} + +void +object_set_string_if_exists (GObject *o, const char *prop, const char *value) +{ + if (g_object_class_find_property (G_OBJECT_GET_CLASS (o), prop)) + g_object_set (o, prop, value, NULL); +} + +/* "example.com" -> "https://example.com", "./page.html" -> "file:///...". */ +char * +normalize_uri (const char *in) +{ + if (!in) + return NULL; + + char *s = g_strstrip (g_strdup (in)); + if (!*s) { + g_free (s); + return NULL; + } + + if (g_str_has_prefix (s, "file://") && !g_str_has_prefix (s, "file:///")) { + char *fixed = g_strconcat ("file:///", s + strlen ("file://"), NULL); + g_free (s); + return fixed; + } + + if (strstr (s, "://") || + g_str_has_prefix (s, "about:") || + g_str_has_prefix (s, "data:") || + g_str_has_prefix (s, "mailto:")) + return s; + + if (g_file_test (s, G_FILE_TEST_EXISTS)) { + char *abs = g_canonicalize_filename (s, NULL); + char *uri = g_filename_to_uri (abs, NULL, NULL); + g_free (abs); + if (uri) { + g_free (s); + return uri; + } + } + + char *https = g_strconcat ("https://", s, NULL); + g_free (s); + return https; +} + +static gboolean +parse_mod (const char *name) +{ + if (!g_ascii_strcasecmp (name, "ctrl") || !g_ascii_strcasecmp (name, "control")) { + g_mod = GDK_CONTROL_MASK; g_mod_name = "Ctrl"; return TRUE; + } + if (!g_ascii_strcasecmp (name, "alt") || !g_ascii_strcasecmp (name, "mod1")) { + g_mod = GDK_ALT_MASK; g_mod_name = "Alt"; return TRUE; + } + if (!g_ascii_strcasecmp (name, "super") || !g_ascii_strcasecmp (name, "win")) { + g_mod = GDK_SUPER_MASK; g_mod_name = "Super"; return TRUE; + } + if (!g_ascii_strcasecmp (name, "meta")) { + g_mod = GDK_META_MASK; g_mod_name = "Meta"; return TRUE; + } + return FALSE; +} + +/* ------------------------------------------------------- rm -rf helper */ + +static gboolean +rm_rf (const char *path) +{ + if (!path || !*path || !g_file_test (path, G_FILE_TEST_EXISTS)) + return TRUE; + + if (g_file_test (path, G_FILE_TEST_IS_DIR)) { + GError *err = NULL; + GDir *d = g_dir_open (path, 0, &err); + if (!d) { + g_printerr ("clear-data: cannot open dir %s: %s\n", path, err->message); + g_error_free (err); + return FALSE; + } + + const char *name; + while ((name = g_dir_read_name (d))) { + char *child = g_build_filename (path, name, NULL); + gboolean ok = rm_rf (child); + g_free (child); + if (!ok) { + g_dir_close (d); + return FALSE; + } + } + g_dir_close (d); + + if (g_rmdir (path) != 0) { + g_printerr ("clear-data: cannot remove dir %s\n", path); + return FALSE; + } + return TRUE; + } + + if (g_remove (path) != 0) { + g_printerr ("clear-data: cannot remove file %s\n", path); + return FALSE; + } + return TRUE; +} + +static void +profile_dirs (const char *profile, char **out_data_dir, char **out_cache_dir) +{ + if (!profile || !*profile) + profile = "default"; + + *out_data_dir = g_build_filename (g_get_user_data_dir (), PROFILE_DIR_NAME, profile, NULL); + *out_cache_dir = g_build_filename (g_get_user_cache_dir (), PROFILE_DIR_NAME, profile, NULL); +} + +/* ------------------------------------------------- per-site download dirs */ + +/* + * A download normally lands in --download-dir. Anything more specific is + * a rule, remembered in + * + * <profile data dir>/download-dirs.tsv key <TAB> directory + * + * where the key is either a whole address or a bare host. The narrower + * one wins, so a single page can go somewhere its site does not: + * + * https://host/a/b -> the rule for that exact address + * host -> the rule for the site + * neither -> --download-dir + * + * <mod>+S edits whichever of the three you pick. + */ + +typedef enum { DL_SCOPE_PAGE, DL_SCOPE_SITE, DL_SCOPE_ALL } DlScope; + +static GHashTable *g_dlrules; /* host -> dir, both owned */ + +static char * +uri_host (const char *uri) +{ + if (!uri) + return NULL; + + GUri *u = g_uri_parse (uri, G_URI_FLAGS_NONE, NULL); + if (!u) + return NULL; + + const char *h = g_uri_get_host (u); + char *out = (h && *h) ? g_strdup (h) : NULL; + + g_uri_unref (u); + return out; +} + +static void +dlrules_save (void) +{ + if (!g_dlrules || !g_data_dir) + return; + + GString *s = g_string_new (NULL); + GHashTableIter it; + gpointer k, v; + + g_hash_table_iter_init (&it, g_dlrules); + while (g_hash_table_iter_next (&it, &k, &v)) + g_string_append_printf (s, "%s\t%s\n", (char *) k, (char *) v); + + char *path = g_build_filename (g_data_dir, DLRULES_FILE, NULL); + if (!g_file_set_contents (path, s->str, -1, NULL)) + g_printerr ("download: cannot write %s\n", path); + + g_free (path); + g_string_free (s, TRUE); +} + +static void +dlrules_setup (const char *data_dir) +{ + g_dlrules = g_hash_table_new_full (g_str_hash, g_str_equal, g_free, g_free); + + if (!data_dir) + return; /* --private: rules stay in memory */ + + char *path = g_build_filename (data_dir, DLRULES_FILE, NULL); + char *data = NULL; + + if (g_file_get_contents (path, &data, NULL, NULL)) { + char **lines = g_strsplit (data, "\n", -1); + for (int i = 0; lines[i]; i++) { + if (!*lines[i]) + continue; + char **f = g_strsplit (lines[i], "\t", 2); + if (g_strv_length (f) == 2 && *f[0] && *f[1]) + g_hash_table_insert (g_dlrules, g_strdup (f[0]), g_strdup (f[1])); + g_strfreev (f); + } + g_strfreev (lines); + g_free (data); + LOG ("download: %u site rules from %s\n", + g_hash_table_size (g_dlrules), path); + } + + g_free (path); +} + +/* The directory a download from this address should land in: the most + * specific rule that matches, else the general one. */ +static const char * +dl_dir_for_uri (const char *uri) +{ + if (uri) { + const char *exact = g_hash_table_lookup (g_dlrules, uri); + if (exact && *exact) + return exact; + } + + char *host = uri_host (uri); + if (host) { + const char *dir = g_hash_table_lookup (g_dlrules, host); + g_free (host); + if (dir && *dir) + return dir; + } + + return (g_download_dir && *g_download_dir) ? g_download_dir : "."; +} + +/* Which rule is answering for this address right now. */ +static DlScope +dl_scope_for_uri (const char *uri) +{ + if (uri && g_hash_table_contains (g_dlrules, uri)) + return DL_SCOPE_PAGE; + + char *host = uri_host (uri); + gboolean site = host && g_hash_table_contains (g_dlrules, host); + g_free (host); + + return site ? DL_SCOPE_SITE : DL_SCOPE_ALL; +} + +/* Downloads are often shared with other users or a daemon, so the target + * is group writable. mkdir honours the umask, hence the explicit chmod. */ +static gboolean +dl_dir_make (const char *dir) +{ + if (g_mkdir_with_parents (dir, 0770) != 0) { + g_printerr ("download: cannot create dir %s: %s\n", dir, g_strerror (errno)); + return FALSE; + } + + if (g_chmod (dir, 0770) != 0) + LOG ("download: cannot set 0770 on %s: %s\n", dir, g_strerror (errno)); + + return TRUE; +} + +/* + * A rule set on a page has to apply to the files that page hands out, and + * those usually live on another address entirely - a CDN, or just a + * different path. So the page is asked about first, and only then the + * file's own address. Without this, a rule set with <mod>+S on a download + * page would never match anything it served. + */ +static const char * +dl_dir_for_download (WebKitDownload *download) +{ + WebKitWebView *view = webkit_download_get_web_view (download); + WebKitURIRequest *req = webkit_download_get_request (download); + + const char *page = view ? webkit_web_view_get_uri (view) : NULL; + const char *file = req ? webkit_uri_request_get_uri (req) : NULL; + const char *dir; + + if (page && (dir = g_hash_table_lookup (g_dlrules, page)) && *dir) + return dir; + if (file && (dir = g_hash_table_lookup (g_dlrules, file)) && *dir) + return dir; + + char *host = uri_host (page); + if (host) { + dir = g_hash_table_lookup (g_dlrules, host); + g_free (host); + if (dir && *dir) + return dir; + } + + return dl_dir_for_uri (file); /* the file's host, then the general one */ +} + +/* scope is what the select in the <mod>+S popup decides. Setting a + * broader scope clears the narrower rules that would have shadowed it. */ +static void +dl_dir_set (const char *uri, const char *dir, DlScope scope) +{ + char *host = uri_host (uri); + + if (scope == DL_SCOPE_PAGE && uri) { + LOG ("download: %s -> %s\n", uri, dir); + g_hash_table_insert (g_dlrules, g_strdup (uri), g_strdup (dir)); + dlrules_save (); + g_free (host); + return; + } + + if (uri) + g_hash_table_remove (g_dlrules, uri); + + if (scope == DL_SCOPE_SITE && host) { + LOG ("download: %s -> %s\n", host, dir); + g_hash_table_insert (g_dlrules, host, g_strdup (dir)); /* takes host */ + dlrules_save (); + return; + } + + if (host) { + g_hash_table_remove (g_dlrules, host); + g_free (host); + } + dlrules_save (); + + g_free (g_download_dir); + g_download_dir = g_strdup (dir); + object_set_string_if_exists (G_OBJECT (g_session), "downloads-directory", g_download_dir); +} + +/* ------------------------------------------------------- search keywords */ + +/* + * A single letter in front of the text in the address popup runs a search: + * "s tree" with s = https://google.com/search?q={} loads the result page. + * {} is replaced with the rest of the line, percent encoded. + * + * <profile data dir>/searches.tsv letter <TAB> url + * + * <mod>+K adds one, and a config file can too: search_s = https://... + */ + +static GHashTable *g_searches; /* keyword -> url template */ + +static void +searches_save (void) +{ + if (!g_searches || !g_data_dir) + return; + + GString *s = g_string_new (NULL); + GHashTableIter it; + gpointer k, v; + + g_hash_table_iter_init (&it, g_searches); + while (g_hash_table_iter_next (&it, &k, &v)) + g_string_append_printf (s, "%s\t%s\n", (char *) k, (char *) v); + + char *path = g_build_filename (g_data_dir, SEARCH_FILE, NULL); + g_file_set_contents (path, s->str, -1, NULL); + + g_free (path); + g_string_free (s, TRUE); +} + +static void +searches_setup (const char *data_dir) +{ + if (!g_searches) + g_searches = g_hash_table_new_full (g_str_hash, g_str_equal, g_free, g_free); + + if (!data_dir) + return; + + char *path = g_build_filename (data_dir, SEARCH_FILE, NULL); + char *data = NULL; + + if (g_file_get_contents (path, &data, NULL, NULL)) { + char **lines = g_strsplit (data, "\n", -1); + for (int i = 0; lines[i]; i++) { + if (!*lines[i]) + continue; + char **f = g_strsplit (lines[i], "\t", 2); + if (g_strv_length (f) == 2 && *f[0] && *f[1]) + g_hash_table_insert (g_searches, g_strdup (f[0]), g_strdup (f[1])); + g_strfreev (f); + } + g_strfreev (lines); + g_free (data); + LOG ("search: %u keywords from %s\n", g_hash_table_size (g_searches), path); + } + + g_free (path); +} + +static void +search_set (const char *key, const char *url) +{ + if (!g_searches) + g_searches = g_hash_table_new_full (g_str_hash, g_str_equal, g_free, g_free); + + g_hash_table_insert (g_searches, g_strdup (key), g_strdup (url)); + searches_save (); + LOG ("search: %s -> %s\n", key, url); +} + +/* + * "s tree" -> the s template with tree in it. The keyword is one word and + * has to be followed by something, so a bare "s" is still a search for a + * site called s, and an address with a space in it is unaffected. + */ +static char * +search_expand (const char *line) +{ + if (!g_searches || !line) + return NULL; + + const char *sp = strchr (line, ' '); + if (!sp || sp == line) + return NULL; + + char *key = g_strndup (line, (gsize) (sp - line)); + const char *tmpl = g_hash_table_lookup (g_searches, key); + g_free (key); + + if (!tmpl) + return NULL; + + const char *query = sp + 1; + while (*query == ' ') + query++; + if (!*query) + return NULL; + + char *enc = g_uri_escape_string (query, NULL, TRUE); + char *out; + + if (strstr (tmpl, "{}")) { + char **parts = g_strsplit (tmpl, "{}", -1); + out = g_strjoinv (enc, parts); + g_strfreev (parts); + } else { + out = g_strconcat (tmpl, enc, NULL); /* template without a slot */ + } + + g_free (enc); + return out; +} + +/* --------------------------------------------------------------- config */ + +/* + * Same file format and the same key names as swov, so one file can drive + * both. `key = value` per line, `#` or `;` comments, a trailing comment + * needs a space in front of the `#`. Colours are RRGGBB or RRGGBBAA with + * an optional leading `#`. + * + * Loaded in this order, later wins: + * + * ${XDG_CONFIG_HOME:-~/.config}/swov/config the shared palette + * ${XDG_CONFIG_HOME:-~/.config}/<prgname>/config our own settings + * + * -c PATH replaces the chain, -n skips it, and every key is also a command + * line option: --pad=12, -s pad=12 or plain pad=12. + */ + +typedef struct { double r, g, b, a; } Color; + +typedef struct { + /* palette, swov names */ + Color bg, tile, tile_sel, tile_hover, card, card_hover; + Color text, subtext, dim, accent, hl, hltext, hint, urgent, outline; + + /* geometry */ + double radius, border, pad, gap, win_gap, ui_scale; + + /* text */ + int label_px, title_px, hint_px, ws_px; + char *font; /* "" -> whatever GTK is themed with */ + char *font_mono; /* URLs and file names read better fixed width */ +} Theme; + +static Theme g_theme; + +static Color +rgba_hex (guint32 v) +{ + Color c = { ((v >> 24) & 0xff) / 255.0, + ((v >> 16) & 0xff) / 255.0, + ((v >> 8) & 0xff) / 255.0, + ((v ) & 0xff) / 255.0 }; + return c; +} + +/* RRGGBB or RRGGBBAA, '#' optional - swov's parse_color, same rules. */ +static gboolean +parse_color (const char *s, Color *out) +{ + if (!s) + return FALSE; + while (*s == '#' || *s == ' ') + s++; + + size_t n = strlen (s); + if (n != 6 && n != 8) + return FALSE; + for (size_t i = 0; i < n; i++) + if (!g_ascii_isxdigit (s[i])) + return FALSE; + + guint64 v = g_ascii_strtoull (s, NULL, 16); + if (n == 6) + v = (v << 8) | 0xff; + + *out = rgba_hex ((guint32) v); + return TRUE; +} + +/* GTK wants rgba(), and the alpha matters: these panels sit over a page. */ +static char * +css_rgba (Color c) +{ + return g_strdup_printf ("rgba(%d,%d,%d,%.3f)", + (int) (c.r * 255 + 0.5), + (int) (c.g * 255 + 0.5), + (int) (c.b * 255 + 0.5), c.a); +} + +static void +theme_defaults (void) +{ + Theme *t = &g_theme; + + /* swov's palette, value for value */ + t->bg = rgba_hex (0x0d1117cc); + t->tile = rgba_hex (0x1e2733f2); + t->tile_sel = rgba_hex (0x26313ff2); + t->tile_hover = rgba_hex (0x2b3644f2); + t->card = rgba_hex (0x33404ff7); + t->card_hover = rgba_hex (0x46566af7); + t->hl = rgba_hex (0xcb9b00ff); + t->text = rgba_hex (0xe8e8e8ff); + t->subtext = rgba_hex (0xb3c0cdff); + t->dim = rgba_hex (0x5a6b7aff); + t->accent = rgba_hex (0x89afc4ff); + t->hltext = rgba_hex (0x141414ff); + t->hint = rgba_hex (0xa7b5c4ff); + t->urgent = rgba_hex (0xe0533cff); + t->outline = rgba_hex (0x0a0e1499); + + t->radius = 14.0; + t->border = 3.0; + t->pad = 10.0; + t->gap = 14.0; + t->win_gap = 5.0; + t->ui_scale = 1.0; + + t->ws_px = 26; + t->label_px = 16; + t->title_px = 13; + t->hint_px = 14; + + t->font = g_strdup (""); + t->font_mono = g_strdup ("monospace"); +} + +static gboolean +key_is (const char *k, const char *a) +{ + return g_ascii_strcasecmp (k, a) == 0; +} + +static gboolean +truthy (const char *v) +{ + return !(key_is (v, "0") || key_is (v, "no") || key_is (v, "off") || + key_is (v, "false")); +} + +static void +set_str (char **dst, const char *v) +{ + g_free (*dst); + *dst = g_strdup (v); +} + +/* Returns FALSE for keys we do not know, so the caller can decide whether + * that is worth a word on stderr. */ +static gboolean +cfg_set (const char *k, const char *v) +{ + Theme *t = &g_theme; + + /* ---- palette, shared with swov ---- */ + if (key_is (k, "bg")) return parse_color (v, &t->bg); + if (key_is (k, "tile") || key_is (k, "panel")) + return parse_color (v, &t->tile); + if (key_is (k, "tile_sel")) 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_hover") || key_is (k, "hover")) + return parse_color (v, &t->card_hover); + if (key_is (k, "text")) return parse_color (v, &t->text); + if (key_is (k, "subtext")) return parse_color (v, &t->subtext); + if (key_is (k, "dim")) return parse_color (v, &t->dim); + if (key_is (k, "accent")) return parse_color (v, &t->accent); + if (key_is (k, "hl")) return parse_color (v, &t->hl); + if (key_is (k, "hltext")) return parse_color (v, &t->hltext); + 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); + + /* ---- geometry, shared with swov ---- */ + if (key_is (k, "radius") || key_is (k, "corner")) { t->radius = g_ascii_strtod (v, NULL); return TRUE; } + if (key_is (k, "border")) { t->border = g_ascii_strtod (v, NULL); return TRUE; } + if (key_is (k, "pad")) { t->pad = g_ascii_strtod (v, NULL); return TRUE; } + if (key_is (k, "gap")) { t->gap = g_ascii_strtod (v, NULL); return TRUE; } + if (key_is (k, "win_gap") || key_is (k, "window_gap")) { t->win_gap = g_ascii_strtod (v, NULL); return TRUE; } + if (key_is (k, "ui_scale") || key_is (k, "font_scale") || key_is (k, "text_scale")) { + t->ui_scale = g_ascii_strtod (v, NULL); + if (t->ui_scale <= 0.1) t->ui_scale = 1.0; + return TRUE; + } + + /* ---- text, shared with swov ---- */ + 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; } + if (key_is (k, "hint_px") || key_is (k, "count_px")) { t->hint_px = atoi (v); return TRUE; } + if (key_is (k, "font")) { set_str (&t->font, v); return TRUE; } + if (key_is (k, "font_mono")) { set_str (&t->font_mono, v); return TRUE; } + + /* ---- ours ---- */ + if (key_is (k, "title")) { set_str (&g_title, v); return TRUE; } + if (key_is (k, "app_id")) { set_str (&g_app_id, v); return TRUE; } + if (key_is (k, "clip_cmd")) { set_str (&g_clip_cmd, v); return TRUE; } + if (key_is (k, "download_dir")) { set_str (&g_download_dir, v); return TRUE; } + if (key_is (k, "profile")) { set_str (&g_profile, v); return TRUE; } + if (key_is (k, "user_agent")) { set_str (&g_user_agent, v); return TRUE; } + if (key_is (k, "css")) { set_str (&g_css_owned, v); g_css_path = g_css_owned; return TRUE; } + if (key_is (k, "zoom")) { g_zoom = CLAMP (g_ascii_strtod (v, NULL), ZOOM_MIN, ZOOM_MAX); return TRUE; } + if (key_is (k, "private")) { g_private = truthy (v); return TRUE; } + if (key_is (k, "no_media")) { g_deny_media = truthy (v); return TRUE; } + if (key_is (k, "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, "middle_click_paste")) { g_no_middle_paste = !truthy (v); return TRUE; } + if (key_is (k, "mod")) return parse_mod (v); + + /* search_s = https://example.com/?q={} */ + if (g_ascii_strncasecmp (k, "search_", 7) == 0 && k[7]) { + search_set (k + 7, v); + return TRUE; + } + + if (g_app->cfg_set && g_app->cfg_set (k, v)) + return TRUE; + + return FALSE; +} + +static void +cfg_set_line (const char *pair) +{ + const char *eq = strchr (pair, '='); + if (!eq) + return; + + char *k = g_strstrip (g_strndup (pair, (gsize) (eq - pair))); + char *v = g_strstrip (g_strdup (eq + 1)); + + if (*k && !cfg_set (k, v)) + LOG ("config: unknown key '%s' (ignored)\n", k); + + g_free (k); + g_free (v); +} + +static gboolean +cfg_load_file (const char *path) +{ + char *data = NULL; + + if (!path || !g_file_get_contents (path, &data, NULL, NULL)) + return FALSE; + + char **lines = g_strsplit (data, "\n", -1); + + for (int i = 0; lines[i]; i++) { + char *s = g_strstrip (lines[i]); + if (!*s || *s == '#' || *s == ';') + continue; + + char *eq = strchr (s, '='); + if (!eq) + continue; + + /* a trailing comment needs a space in front of the hash, so that + * a '#' inside a value survives */ + char *hash = strchr (eq + 1, '#'); + if (hash && hash > eq + 1 && hash[-1] == ' ') + *hash = '\0'; + + cfg_set_line (s); + } + + g_strfreev (lines); + g_free (data); + LOG ("config: read %s\n", path); + return TRUE; +} + +static char * +cfg_path_for (const char *app) +{ + const char *xdg = g_getenv ("XDG_CONFIG_HOME"); + + if (xdg && *xdg) + return g_build_filename (xdg, app, "config", NULL); + return g_build_filename (g_get_home_dir (), ".config", app, "config", NULL); +} + +/* swov's palette first, then ours on top of it */ +static void +cfg_load_default_chain (const char *app) +{ + char *shared = cfg_path_for ("swov"); + char *own = cfg_path_for (app); + + cfg_load_file (shared); + cfg_load_file (own); + + g_free (shared); + g_free (own); +} + +/* "pad=12" is a setting, "https://x/?a=b" is not: a key is a bare word. */ +static gboolean +looks_like_setting (const char *a) +{ + const char *eq = strchr (a, '='); + if (!eq || eq == a) + return FALSE; + + for (const char *p = a; p < eq; p++) + if (!g_ascii_isalnum (*p) && *p != '_' && *p != '-') + return FALSE; + return TRUE; +} + +/* --------------------------------------------------------------- usage */ + +/* Where the stored history lives, for --help. Follows --profile, so pass + * -h last if you want the path for a specific profile. */ +static char * +history_path (void) +{ + if (g_private) + return NULL; + + char *data_dir = NULL, *cache_dir = NULL; + profile_dirs (g_profile, &data_dir, &cache_dir); + + char *path = g_build_filename (data_dir, HIST_FILE, NULL); + g_free (data_dir); + g_free (cache_dir); + return path; +} + +static void +usage (const char *argv0, gboolean to_stdout) +{ + GString *s = g_string_new (NULL); + char *hpath = history_path (); + char *hblock; + char *cfg_shared = cfg_path_for ("swov"); + char *cfg_own = cfg_path_for (g_app->default_app_id); + + if (hpath) + hblock = g_strdup_printf ( + " Every address that came up is appended to\n" + " %s\n" + " as a tab separated line: timestamp, URL, page title.\n", hpath); + else + hblock = g_strdup (" Off, because --private was given.\n"); + const char *arg = g_app->usage_arg ? g_app->usage_arg : "URL"; + + 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" +" 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" +"\n", argv0, arg); + + g_string_append_printf (s, +"window:\n" +" --title NAME pin the window title to NAME (default: follow the\n" +" page <title>, falling back to \"%s\")\n" +" --page-title keep following the page <title> even with --title,\n" +" which then only supplies the fallback\n" +" --app-id ID Wayland app_id / X11 WM_CLASS (default: \"%s\")\n" +" this is what sway matches in for_window [app_id=...]\n" +" --zoom N initial zoom level (default: 1.0)\n" +"\n" +"keys:\n" +" --mod KEY modifier for the shortcuts below:\n" +" ctrl | alt | super | meta (default: ctrl)\n" +" --clip-cmd CMD command used by %s+Y, gets the URL on stdin\n" +" (default: \"%s\"; \"internal\" uses the GTK clipboard)\n" +"\n" +"page:\n" +" --css FILE inject FILE as a user stylesheet\n" +" --devtools open the inspector once the first page commits\n" +" --no-media deny camera / microphone / screen-share requests\n" +" --enable-middle-click-paste\n" +" let middle-button clicks reach the page. They are\n" +" swallowed by default, so a stray click cannot paste\n" +" the primary selection into a form\n" +"\n" +" A file whose name is already taken is downloaded beside the old one and\n" +" the question is asked in the download panel itself: Enter puts it in\n" +" the old one's place, Esc keeps both. The transfer only reads as done\n" +" once the file is where it is going to stay.\n" +"\n" +" A download shows a progress bar in the top right corner with the file\n" +" name, percentage and estimated time left. Move the pointer over it and\n" +" it fades out so you can read the page underneath; it disappears on its\n" +" own %d seconds after the transfer ends.\n" +"\n", + g_app->default_title, g_app->default_app_id, g_mod_name, + DEFAULT_CLIP_CMD, DL_LINGER_SECONDS); + + if (g_app->usage_options) + g_app->usage_options (s); + + g_string_append_printf (s, +"session:\n" +" --download-dir DIR download target (default: XDG download dir)\n" +" single pages and whole sites can be sent elsewhere\n" +" with <mod>+S; those rules live next to the profile\n" +" in download-dirs.tsv, search keywords in\n" +" searches.tsv. A directory that does not exist is\n" +" 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" +" --profile/--clear-data\n" +" --user-agent UA set an explicit user agent string\n" +" --ua-chrome | --ua-win-chrome | --ua-win-edge | --ua-firefox | --ua-safari\n" +"\n" +"history:\n" +"%s" +" Only successful loads of http, https and file are recorded, so every\n" +" line is a link that worked. Failed loads, error pages and internal\n" +" pages are left out. The last %d lines are kept.\n" +"\n" +"config:\n" +" -c, --config PATH read this file instead of the default chain\n" +" -n, --no-config ignore the config files\n" +" -s, --set KEY=VAL set one key, as does --KEY=VAL or plain KEY=VAL\n" +"\n" +" Read in this order, later wins, then the command line on top:\n" +" %s\n" +" %s\n" +" Same format and the same key names as swov, so the palette can live in\n" +" swov's file and drive both. Keys either program does not know are\n" +" ignored with a note on stderr.\n" +"\n" +" look: bg tile tile_sel tile_hover card card_hover text subtext dim\n" +" accent hl hltext hint urgent outline\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" +" search_KEY (e.g. search_s = https://google.com/search?q={})\n" +"\n" +"misc:\n" +" -q, --quiet silence the diagnostic output on stderr\n" +" -h, --help this text\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" +" 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>+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" +" set on a page also covers the files it hands out\n" +" <mod>+K add a search keyword, as \"g URL\" with {} where the\n" +" words go\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" +"\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" +" Enter always loads what you can read. Esc, <mod>+H or a click outside\n" +" closes it.\n" +"\n" +" <mod>+G scroll to the top of the page\n" +" <mod>+Shift+G scroll to the bottom\n" +" <mod>+R or F5 reload the page\n" +" <mod>+Shift+R re-read the --css file, then reload\n" +" <mod>+D list the recent downloads for %d seconds\n" +" F12, <mod>+Shift+D toggle the developer tools\n" +" <mod>+Shift+I toggle the developer tools\n" +" <mod>+P show the current URL (top right) for %d seconds\n" +" <mod>+plus zoom in\n" +" <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_mod_name, DL_HISTORY_SECONDS, URL_TOAST_SECONDS); + + if (g_app->usage_keys) + g_app->usage_keys (s); + + g_string_append_printf (s, +"\n" +" Esc closes whatever is on screen - popup, key list, download list or a\n" +" 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" +" open every key except Esc belongs to it, so <mod>+A selects its text.\n" +"%s", + g_mod == GDK_CONTROL_MASK + ? " Use --mod alt if you would rather keep Ctrl+P for the page's" + " print dialog.\n" + : ""); + + if (to_stdout) + g_print ("%s", s->str); + else + g_printerr ("%s", s->str); + + g_free (cfg_shared); + g_free (cfg_own); + g_free (hblock); + g_free (hpath); + g_string_free (s, TRUE); +} + +static void +usage_short (const char *argv0) +{ + g_printerr ("usage: %s [%s] [options] (try %s -h)\n", + argv0, g_app->usage_arg ? g_app->usage_arg : "URL", argv0); +} + +/* ----------------------------------------------------------------- CSS */ + +/* User stylesheet injected into the pages (--css). One content manager + * per view, so the sheet has to be applied to each of them. */ +static void +css_apply_one (WebKitUserContentManager *ucm) +{ + webkit_user_content_manager_remove_all_style_sheets (ucm); + if (!g_css_data) + return; + + WebKitUserStyleSheet *ss = + webkit_user_style_sheet_new (g_css_data, + 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); +} + +void +css_reload (void) +{ + GError *err = NULL; + + g_clear_pointer (&g_css_data, g_free); + + if (g_css_path) { + if (!g_file_get_contents (g_css_path, &g_css_data, NULL, &err)) { + g_printerr ("css: %s\n", err->message); + g_error_free (err); + } else { + LOG ("css: injected %s\n", g_css_path); + } + } + + for (guint i = 0; i < g_ucms->len; i++) + css_apply_one (g_ptr_array_index (g_ucms, i)); +} + +/* + * Styling for our own widgets, not for the page. Every value comes from + * the theme, which is swov's palette and geometry by default, so the two + * programs look like one set. See the config section. + */ +static void +ui_css_install (void) +{ + Theme *t = &g_theme; + double s = t->ui_scale; + + char *c_tile = css_rgba (t->tile); + 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_text = css_rgba (t->text); + char *c_subtext = css_rgba (t->subtext); + char *c_dim = css_rgba (t->dim); + char *c_hl = css_rgba (t->hl); + char *c_hint = css_rgba (t->hint); + char *c_hltext = css_rgba (t->hltext); + char *c_urgent = css_rgba (t->urgent); + char *c_outline = css_rgba (t->outline); + + /* an empty font key means "whatever GTK is themed with" */ + char *ui_font = *t->font + ? g_strdup_printf ("font-family: \"%s\";", t->font) + : g_strdup (""); + char *mono_font = g_strdup_printf ("font-family: \"%s\";", t->font_mono); + + GString *css = g_string_new (NULL); + + /* ---- surfaces: one panel look for every overlay ---- */ + /* A panel pinned to an edge should not curve away from it: only the + * corners that face into the page are rounded, and the border on the + * touching side is dropped so the panel sits flush. */ + g_string_append_printf (css, + "box.br-dl, box.br-omni, box.br-keys, label.br-toast, label.br-urltoast {" + " background-color: %s;" + " border: %.0fpx solid %s;" + " padding: %.0fpx;" + "}" + /* top right corner: only the bottom left is free */ + "box.br-dl, label.br-toast {" + " border-radius: 0 0 0 %.0fpx;" + " border-right-width: 0;" + "}" + /* centered on the top edge: both bottom corners are free */ + "box.br-omni {" + " border-radius: 0 0 %.0fpx %.0fpx;" + " border-top-width: 0;" + "}" + /* clear of every edge: all four corners are rounded */ + "box.br-keys { border-radius: %.0fpx; }" + /* top left corner: only the bottom right is free */ + "label.br-urltoast {" + " border-radius: 0 0 %.0fpx 0;" + " border-left-width: 0; border-top-width: 0;" + "}", + c_tile, t->border, c_outline, t->pad, + t->radius, t->radius, t->radius, t->radius, t->radius); + + /* ---- primary text ---- */ + g_string_append_printf (css, + "label.br-toast, label.br-urltoast, label.br-dl-done, label.br-pick-main," + "progressbar.br-dl-bar > text {" + " color: %s; %s font-size: %.0fpx;" + "}", + c_text, ui_font, t->label_px * s); + + /* URLs and file names: fixed width, so they line up and elide sanely */ + g_string_append_printf (css, + "entry.br-omni-entry, entry.br-omni-entry > text," + "label.br-urltoast, label.br-dl-done, label.br-dl-failed," + "progressbar.br-dl-bar > text {" + " %s font-size: %.0fpx;" + "}", + mono_font, t->label_px * s); + + /* ---- secondary text ---- */ + g_string_append_printf (css, + "label.br-dl-head { color: %s; %s font-size: %.0fpx; }" + "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-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; }" + "label.br-key { color: %s; %s font-size: %.0fpx; }" + "label.br-key-desc { color: %s; %s font-size: %.0fpx; }" + "label.br-key-head { color: %s; %s font-size: %.0fpx; }" + "checkbutton.br-check, checkbutton.br-check label {" + " color: %s; %s font-size: %.0fpx;" + "}", + c_hint, ui_font, t->hint_px * s, + 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_hl, mono_font, t->title_px * s, + c_urgent, mono_font, t->title_px * s, + c_dim, mono_font, t->title_px * s, + c_hl, mono_font, t->title_px * s, + c_text, ui_font, t->title_px * s, + c_hint, ui_font, t->hint_px * s, + c_subtext, ui_font, t->title_px * s); + + /* ---- entries: the URL bar and the picker search share a look ---- */ + /* The input reads as a field: its own well, a rule under it, and the + * accent on that rule while it has the keyboard. */ + g_string_append_printf (css, + "entry.br-omni-entry > text {" + " background-color: transparent; background-image: none;" + " color: %s; caret-color: %s;" + " border: none; box-shadow: none; outline: none; margin: 0;" + "}" + "entry.br-omni-entry {" + " background-color: %s; background-image: none;" + " color: %s; caret-color: %s;" + " border: none; border-bottom: 2px solid %s;" + " border-radius: %.0fpx %.0fpx 0 0;" + " box-shadow: none; outline: none;" + " padding: %.0fpx %.0fpx; margin: 0;" + "}" + "entry.br-omni-entry:focus-within { border-bottom-color: %s; }" + "entry.br-omni-entry > text > selection {" + " background-color: %s; color: %s;" + "}", + c_text, c_hl, + c_card, c_text, c_hl, c_dim, + t->radius / 3.0, t->radius / 3.0, + t->pad / 2.0, t->pad / 2.0, + c_hl, + c_sel, c_text); + + /* ---- download rows: the bar picks up the accent ---- */ + g_string_append_printf (css, + "progressbar.br-dl-bar > trough {" + " background-color: %s; border: none;" + " border-radius: %.0fpx; min-height: %.0fpx;" + "}" + "progressbar.br-dl-bar > trough > progress {" + " background-color: %s; border: none;" + " border-radius: %.0fpx; min-height: %.0fpx;" + "}", + c_card, t->radius / 3.0, 6.0 * s, + c_hl, t->radius / 3.0, 6.0 * s); + + /* ---- picker rows ---- */ + g_string_append_printf (css, + "scrolledwindow.br-pick-scroll," + "list.br-pick-list, list.br-pick-list > row {" + " background-color: transparent; border: none;" + "}" + "list.br-pick-list > row {" + " border-radius: %.0fpx; padding: %.0fpx %.0fpx;" + "}" + "list.br-pick-list > row:hover { background-color: %s; }" + /* the selection carries the accent, so it is obvious which row + * Enter would open */ + "list.br-pick-list > row:selected { background-color: %s; }" + "list.br-pick-list > row:selected label.br-pick-main," + "list.br-pick-list > row:selected label.br-pick-sub {" + " color: %s;" + "}", + t->radius / 2.0, t->win_gap / 2.0, t->pad / 2.0, + c_hover, c_hl, c_hltext); + + GtkCssProvider *p = gtk_css_provider_new (); +#if GTK_CHECK_VERSION (4, 12, 0) + gtk_css_provider_load_from_string (p, css->str); +#else + gtk_css_provider_load_from_data (p, css->str, -1); +#endif + gtk_style_context_add_provider_for_display (gdk_display_get_default (), + GTK_STYLE_PROVIDER (p), + GTK_STYLE_PROVIDER_PRIORITY_APPLICATION); + g_object_unref (p); + + g_string_free (css, TRUE); + g_free (ui_font); g_free (mono_font); + g_free (c_tile); g_free (c_sel); g_free (c_hover); + g_free (c_card); g_free (c_text); g_free (c_subtext); + g_free (c_dim); g_free (c_hl); g_free (c_hint); + g_free (c_urgent); g_free (c_outline); g_free (c_hltext); +} + +/* ----------------------------------------------------------- clipboard */ + +static void +clip_reap (GPid pid, int status, gpointer u) +{ + (void) status; (void) u; + g_spawn_close_pid (pid); +} + +static gboolean +clip_spawn (const char *cmd, const char *text) +{ + char **argv = NULL; + GError *err = NULL; + + if (!g_shell_parse_argv (cmd, NULL, &argv, &err)) { + g_printerr ("clip: bad --clip-cmd %s: %s\n", cmd, err->message); + g_error_free (err); + return FALSE; + } + + GPid pid; + int in_fd = -1; + gboolean ok = g_spawn_async_with_pipes (NULL, argv, NULL, + G_SPAWN_SEARCH_PATH | G_SPAWN_DO_NOT_REAP_CHILD, + NULL, NULL, &pid, &in_fd, NULL, NULL, &err); + g_strfreev (argv); + + if (!ok) { + g_printerr ("clip: cannot run %s: %s\n", cmd, err->message); + g_error_free (err); + return FALSE; + } + + size_t len = strlen (text), off = 0; + while (off < len) { + ssize_t n = write (in_fd, text + off, len - off); + if (n < 0) { + if (errno == EINTR) + continue; + g_printerr ("clip: write failed: %s\n", g_strerror (errno)); + break; + } + off += (size_t) n; + } + close (in_fd); + g_child_watch_add (pid, clip_reap, NULL); + return TRUE; +} + +static void +clipboard_copy (Win *w, const char *text) +{ + if (!text || !*text) + return; + + const char *cmd = g_clip_cmd ? g_clip_cmd : DEFAULT_CLIP_CMD; + + gboolean ok; + + if (!g_ascii_strcasecmp (cmd, "internal") || !g_ascii_strcasecmp (cmd, "gtk")) { + gdk_clipboard_set_text (gtk_widget_get_clipboard (w->win), text); + LOG ("clip: copied via GTK clipboard\n"); + ok = TRUE; + } else { + ok = clip_spawn (cmd, text); + if (ok) + LOG ("clip: copied via %s\n", cmd); + } + + /* show what landed on the clipboard, not just that something did */ + char *msg = g_strdup_printf (ok ? "copied %s" : "copy failed %s", text); + toast_show (w, msg, URL_TOAST_SECONDS); + g_free (msg); +} + +/* -------------------------------------------------------------- download */ + +static Dl * +dl_get (WebKitDownload *download) +{ + return g_object_get_data (G_OBJECT (download), "dl"); +} + +static void +dl_free (gpointer p) +{ + Dl *d = p; + g_free (d->name); + g_free (d->dest); + g_free (d->want); + g_free (d); +} + +static Dl * +dl_new (WebKitDownload *download, const char *name) +{ + Dl *d = g_new0 (Dl, 1); + + d->name = g_strdup (name ? name : "download"); + d->state = DL_ACTIVE; + d->start_us = g_get_monotonic_time (); + d->rate_us = d->start_us; + + g_ptr_array_add (g_downloads, d); + while (g_downloads->len > DL_KEEP) + g_ptr_array_remove_index (g_downloads, 0); + + g_object_set_data (G_OBJECT (download), "dl", d); + downloads_tick_start (); + downloads_refresh (); /* on screen now, not at the first tick */ + return d; +} + +/* The Dl may have been pushed out of the history by newer downloads. */ +static gboolean +dl_alive (Dl *d) +{ + if (!d) + return FALSE; + for (guint i = 0; i < g_downloads->len; i++) + if (g_ptr_array_index (g_downloads, i) == d) + return TRUE; + return FALSE; +} + +static void +dl_set_name (WebKitDownload *download, const char *name) +{ + Dl *d = dl_get (download); + if (!dl_alive (d) || !name) + return; + g_free (d->name); + d->name = g_strdup (name); + downloads_refresh (); +} + +static void +dl_finish (WebKitDownload *download, DlState state) +{ + Dl *d = dl_get (download); + if (!dl_alive (d) || d->state != DL_ACTIVE) + return; + + d->state = state; + d->end_us = g_get_monotonic_time (); + + /* Not finished until the file is where it is going to stay. */ + if (state == DL_DONE && d->ask) { + d->state = DL_ASK; + downloads_tick_start (); + downloads_refresh (); + return; + } + + if (state == DL_DONE && d->overwrite && d->want && d->dest) { + if (g_rename (d->dest, d->want) == 0) { + LOG ("download: replaced %s\n", d->want); + g_free (d->name); + d->name = g_path_get_basename (d->want); + } else { + g_printerr ("download: cannot replace %s: %s\n", + d->want, g_strerror (errno)); + } + } + if (state == DL_DONE && d->total == 0) + d->total = d->received; + + downloads_tick_start (); /* keeps ticking for the linger period */ + downloads_refresh (); +} + +static char * +unique_download_path (const char *dir, const char *suggested) +{ + char *path = g_build_filename (dir, suggested, NULL); + if (!g_file_test (path, G_FILE_TEST_EXISTS)) + return path; + + const char *dot = strrchr (suggested, '.'); + char *stem; + const char *ext = ""; + + if (dot && dot != suggested) { + stem = g_strndup (suggested, (gsize) (dot - suggested)); + ext = dot; /* includes the '.' */ + } else { + stem = g_strdup (suggested); + } + + for (int i = 1; i < 10000; i++) { + char *name = g_strdup_printf ("%s (%d)%s", stem, i, ext); + g_free (path); + path = g_build_filename (dir, name, NULL); + g_free (name); + if (!g_file_test (path, G_FILE_TEST_EXISTS)) + break; + } + + g_free (stem); + return path; +} + +static gboolean +on_decide_destination (WebKitDownload *download, + const gchar *suggested_filename, + gpointer u) +{ + (void) u; + + const char *dir = dl_dir_for_download (download); + + char *absdir; + if (g_path_is_absolute (dir)) { + absdir = g_strdup (dir); + } else { + char *cwd = g_get_current_dir (); + absdir = g_build_filename (cwd, dir, NULL); + g_free (cwd); + } + + if (!dl_dir_make (absdir)) { + g_free (absdir); + webkit_download_cancel (download); + return TRUE; + } + + if (!suggested_filename || !*suggested_filename) + suggested_filename = "download"; + + /* WebKitGTK 2.52.x wants an absolute filesystem path here. */ + char *wanted = g_build_filename (absdir, suggested_filename, NULL); + char *path = unique_download_path (absdir, suggested_filename); + gboolean clash = g_strcmp0 (wanted, path) != 0; + + webkit_download_set_destination (download, path); + + char *base = g_path_get_basename (path); + dl_set_name (download, base); + g_free (base); + + LOG ("download: %s -> %s\n", suggested_filename, path); + + /* Nothing is overwritten behind your back: the file lands beside the + * old one and <mod>+Enter puts it in its place if you say so. */ + Dl *d = dl_get (download); + if (dl_alive (d)) { + g_free (d->dest); + d->dest = g_strdup (path); + g_free (d->want); + d->want = clash ? g_strdup (wanted) : NULL; + d->ask = clash; + if (clash) + downloads_refresh (); /* the question goes in the panel */ + } + + + g_free (wanted); + g_free (path); + g_free (absdir); + return TRUE; /* handled */ +} + +static void +on_download_failed (WebKitDownload *download, GError *err, gpointer u) +{ + (void) u; + const char *dest = webkit_download_get_destination (download); + g_printerr ("download: FAILED dest=%s err=%s\n", + dest ? dest : "(none)", err ? err->message : "(unknown)"); + dl_finish (download, DL_FAILED); +} + +static void +on_download_finished (WebKitDownload *download, gpointer u) +{ + (void) u; + const char *dest = webkit_download_get_destination (download); + LOG ("download: FINISHED dest=%s\n", dest ? dest : "(none)"); + dl_finish (download, DL_DONE); /* no-op if "failed" already fired */ +} + +/* Feeds the overlay on every chunk; logs at most once per 10%. */ +static void +on_download_received_data (WebKitDownload *download, guint64 data_length, gpointer u) +{ + (void) data_length; (void) u; + + Dl *d = dl_get (download); + if (dl_alive (d)) { + d->progress = webkit_download_get_estimated_progress (download); + d->received = webkit_download_get_received_data_length (download); + + if (d->total == 0) { + WebKitURIResponse *r = webkit_download_get_response (download); + if (r) + d->total = webkit_uri_response_get_content_length (r); + } + + /* resample at most twice a second, then smooth */ + gint64 now = g_get_monotonic_time (); + double dt = (now - d->rate_us) / 1e6; + + if (dt >= 0.5) { + double inst = (d->received - d->rate_bytes) / dt; + d->rate = d->rate > 0 ? 0.7 * d->rate + 0.3 * inst : inst; + d->rate_us = now; + d->rate_bytes = d->received; + } + } + + if (g_quiet) + return; + + int decile = (int) (webkit_download_get_estimated_progress (download) * 10.0); + int last = GPOINTER_TO_INT (g_object_get_data (G_OBJECT (download), "decile")); + if (decile <= last) + return; + + g_object_set_data (G_OBJECT (download), "decile", GINT_TO_POINTER (decile)); + g_printerr ("download: progress %d%%\n", decile * 10); +} + +static void +download_wire (WebKitDownload *download) +{ + WebKitURIRequest *req = webkit_download_get_request (download); + char *name = NULL; + + /* provisional name until decide-destination knows the real one */ + if (req) { + char *path = g_filename_display_basename (webkit_uri_request_get_uri (req)); + if (path && *path && !g_str_has_suffix (path, "/")) + name = path; + else + g_free (path); + } + + dl_new (download, name); + g_free (name); + + g_signal_connect (download, "decide-destination", G_CALLBACK (on_decide_destination), NULL); + g_signal_connect (download, "received-data", G_CALLBACK (on_download_received_data), NULL); + g_signal_connect (download, "finished", G_CALLBACK (on_download_finished), NULL); + g_signal_connect (download, "failed", G_CALLBACK (on_download_failed), NULL); +} + +static void +on_session_download_started (WebKitNetworkSession *session, + WebKitDownload *download, + gpointer u) +{ + (void) session; (void) u; + WebKitURIRequest *req = webkit_download_get_request (download); + LOG ("download: session started uri=%s\n", + req ? webkit_uri_request_get_uri (req) : "(unknown)"); + download_wire (download); +} + +static gboolean +on_decide_policy (WebKitWebView *view, + WebKitPolicyDecision *decision, + WebKitPolicyDecisionType type, + gpointer u) +{ + (void) view; (void) u; + + if (type != WEBKIT_POLICY_DECISION_TYPE_RESPONSE) + return FALSE; + + WebKitResponsePolicyDecision *r = WEBKIT_RESPONSE_POLICY_DECISION (decision); + gboolean supported = webkit_response_policy_decision_is_mime_type_supported (r); + + WebKitURIResponse *resp = webkit_response_policy_decision_get_response (r); + const char *mime = resp ? webkit_uri_response_get_mime_type (resp) : NULL; + + gboolean attachment = FALSE; + if (resp) { + SoupMessageHeaders *hdrs = webkit_uri_response_get_http_headers (resp); + if (hdrs) { + const char *cd = soup_message_headers_get_one (hdrs, "Content-Disposition"); + if (cd && g_strrstr (cd, "attachment")) + attachment = TRUE; + } + } + + if (!supported || attachment) { + LOG ("download: policy -> download mime=%s supported=%d attachment=%d\n", + mime ? mime : "(unknown)", supported, attachment); + webkit_policy_decision_download (decision); + return TRUE; /* handled */ + } + + return FALSE; /* let WebKit continue normally */ +} + +/* ----------------------------------------------------------- permissions */ + +static const char * +perm_type_name (WebKitPermissionRequest *req) +{ + if (WEBKIT_IS_USER_MEDIA_PERMISSION_REQUEST (req)) return "user-media"; + if (WEBKIT_IS_DEVICE_INFO_PERMISSION_REQUEST (req)) return "device-info"; + if (WEBKIT_IS_POINTER_LOCK_PERMISSION_REQUEST (req)) return "pointer-lock"; + if (WEBKIT_IS_GEOLOCATION_PERMISSION_REQUEST (req)) return "geolocation"; + if (WEBKIT_IS_NOTIFICATION_PERMISSION_REQUEST (req)) return "notification"; + if (WEBKIT_IS_CLIPBOARD_PERMISSION_REQUEST (req)) return "clipboard"; + if (WEBKIT_IS_MEDIA_KEY_SYSTEM_PERMISSION_REQUEST (req)) return "media-key-system"; + if (WEBKIT_IS_WEBSITE_DATA_ACCESS_PERMISSION_REQUEST (req)) return "website-data-access"; + return "other"; +} + +static gboolean +media_permission (WebKitPermissionRequest *req) +{ + if (g_deny_media) + webkit_permission_request_deny (req); + else + webkit_permission_request_allow (req); + return TRUE; +} + +static gboolean +on_permission (WebKitWebView *view, WebKitPermissionRequest *req, gpointer u) +{ + (void) view; (void) u; + const char *type = perm_type_name (req); + + /* Answered synchronously and without a prompt: a permission dialog here + * is time the page's getUserMedia() timeout is already counting. */ + if (WEBKIT_IS_USER_MEDIA_PERMISSION_REQUEST (req)) { + WebKitUserMediaPermissionRequest *m = WEBKIT_USER_MEDIA_PERMISSION_REQUEST (req); + LOG ("perm: %s audio=%d video=%d screen=%d -> %s\n", type, + webkit_user_media_permission_is_for_audio_device (m), + webkit_user_media_permission_is_for_video_device (m), + webkit_user_media_permission_is_for_display_device (m), + g_deny_media ? "deny" : "allow"); + return media_permission (req); + } + + /* needed so enumerateDevices() returns real labels, which most + * conferencing sites depend on */ + if (WEBKIT_IS_DEVICE_INFO_PERMISSION_REQUEST (req)) { + LOG ("perm: %s -> %s\n", type, g_deny_media ? "deny" : "allow"); + return media_permission (req); + } + + if (WEBKIT_IS_POINTER_LOCK_PERMISSION_REQUEST (req)) { + webkit_permission_request_allow (req); + return TRUE; + } + + LOG ("perm: %s -> deny\n", type); + webkit_permission_request_deny (req); + return TRUE; +} + +/* ------------------------------------------------------------ inspector */ + +static void +inspector_toggle (WebKitWebView *view) +{ + WebKitWebInspector *insp = webkit_web_view_get_inspector (view); + if (!insp) + return; + + if (webkit_web_inspector_get_web_view (insp)) + webkit_web_inspector_close (insp); + else + webkit_web_inspector_show (insp); +} + +/* open the inspector once, after the first commit (--devtools) */ +static void +on_load_changed_devtools (WebKitWebView *view, WebKitLoadEvent ev, gpointer u) +{ + (void) u; + if (ev != WEBKIT_LOAD_COMMITTED) + return; + g_signal_handlers_disconnect_by_func (view, G_CALLBACK (on_load_changed_devtools), NULL); + webkit_web_inspector_show (webkit_web_view_get_inspector (view)); +} + +/* ------------------------------------------------------------- history */ + +/* + * A flat, greppable log of every address that actually came up: + * + * 2026-08-24T10:12:03+02:00 <TAB> https://example.com/x <TAB> Example + * + * Only successful loads of http/https/file are recorded, so every line is + * a link that worked. It doubles as the back/forward list: <mod>+J walks + * WebKit's own session list first and continues into this file once that + * is exhausted, so back still gets you somewhere on a fresh window. + * + * --private leaves g_hist_path NULL and nothing is read or written. + */ + +static void +hist_entry_free (gpointer p) +{ + HistEntry *e = p; + g_free (e->uri); + g_free (e->title); + g_free (e); +} + +static HistEntry * +hist_at (guint i) +{ + return g_ptr_array_index (g_hist, i); +} + +static gboolean +history_uri_ok (const char *uri) +{ + return uri && (g_str_has_prefix (uri, "http://") || + g_str_has_prefix (uri, "https://") || + g_str_has_prefix (uri, "file://")); +} + +/* tabs and newlines would break the one-record-per-line format */ +static char * +history_clean (const char *s) +{ + char *out = g_strdup (s ? s : ""); + for (char *p = out; *p; p++) + if (*p == '\t' || *p == '\n' || *p == '\r') + *p = ' '; + return out; +} + +static void +hist_add (const char *uri, const char *title) +{ + HistEntry *e = g_new0 (HistEntry, 1); + e->uri = g_strdup (uri); + e->title = g_strdup (title ? title : ""); + g_ptr_array_add (g_hist, e); +} + +/* Read the file into g_hist, trimming it back to HIST_KEEP lines. */ +static void +history_load (void) +{ + char *data = NULL; + + if (!g_hist_path || !g_file_get_contents (g_hist_path, &data, NULL, NULL)) + return; + + char **lines = g_strsplit (data, "\n", -1); + guint n = g_strv_length (lines); + guint first = 0; + + /* the last element is the empty string after the trailing newline */ + if (n && !*lines[n - 1]) + n--; + 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'); + } + 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); +} + +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 removes the entry from the list and from the file. The file is + * rewritten from itself rather than from memory, so the timestamps of the + * surviving lines are kept: entry i is line i, and stays that way because + * loading and appending both keep the two in step. + */ +static void +history_delete (int pos) +{ + if (pos < 0 || pos >= (int) g_hist->len) + return; + + if (g_hist_path) { + char *data = NULL; + + if (g_file_get_contents (g_hist_path, &data, NULL, NULL)) { + char **lines = g_strsplit (data, "\n", -1); + GString *out = g_string_new (NULL); + int n = 0; + + for (int i = 0; lines[i]; i++) { + if (!*lines[i]) + continue; + if (n++ == pos) + continue; /* the one being removed */ + g_string_append (out, lines[i]); + g_string_append_c (out, '\n'); + } + + g_file_set_contents (g_hist_path, out->str, -1, NULL); + g_string_free (out, TRUE); + g_strfreev (lines); + g_free (data); + } + } + + LOG ("history: removed %s\n", hist_at (pos)->uri); + g_ptr_array_remove_index (g_hist, pos); + + /* every window's cursor may now point past the end */ + for (guint i = 0; i < g_wins->len; i++) { + Win *w = g_ptr_array_index (g_wins, i); + if (w->hist_pos >= (int) g_hist->len) + w->hist_pos = (int) g_hist->len - 1; + } +} + +/* dir is -1 for back, +1 for forward. */ +static void +history_go (Win *w, int dir) +{ + /* WebKit's own list first: it restores scroll position and form state */ + if (!w->hist_walk) { + gboolean can = dir < 0 ? webkit_web_view_can_go_back (w->view) + : webkit_web_view_can_go_forward (w->view); + if (can) { + w->hist_loading = TRUE; + if (dir < 0) + webkit_web_view_go_back (w->view); + else + webkit_web_view_go_forward (w->view); + + /* keep the stored cursor roughly in step with the page list */ + if (g_hist->len) + w->hist_pos = CLAMP (w->hist_pos + dir, 0, (int) g_hist->len - 1); + return; + } + } + + int next = w->hist_pos + dir; + + if (next < 0 || next >= (int) g_hist->len) { + toast_show (w, dir < 0 ? "start of history" : "end of history", 2); + return; + } + + w->hist_pos = next; + w->hist_walk = TRUE; + w->hist_loading = TRUE; + + LOG ("history: %s %s\n", dir < 0 ? "back" : "forward", hist_at (next)->uri); + webkit_web_view_load_uri (w->view, hist_at (next)->uri); +} + +/* Core's own load signals: they only feed the history. */ +static gboolean +urltoast_timeout (gpointer u) +{ + Win *w = u; + gtk_widget_set_visible (w->urltoast, FALSE); + w->urltoast_id = 0; + return G_SOURCE_REMOVE; +} + +/* The address of the page being opened, top left, while it comes up. */ +static void +urltoast_show (Win *w, const char *uri) +{ + if (!uri || !*uri || g_str_has_prefix (uri, "about:")) + return; + + gtk_label_set_text (GTK_LABEL (w->urltoast), uri); + gtk_widget_set_visible (w->urltoast, TRUE); + + if (w->urltoast_id) + g_source_remove (w->urltoast_id); + w->urltoast_id = g_timeout_add_seconds (URL_TOAST_SECONDS, urltoast_timeout, w); +} + +static void +on_load_core (WebKitWebView *view, WebKitLoadEvent ev, gpointer u) +{ + (void) u; + Win *w = win_of (view); + if (!w) + return; + + if (ev == WEBKIT_LOAD_STARTED) { + w->load_failed = FALSE; + urltoast_show (w, webkit_web_view_get_uri (view)); + } else if (ev == WEBKIT_LOAD_COMMITTED) { + urltoast_show (w, webkit_web_view_get_uri (view)); /* after redirects */ + } else if (ev == WEBKIT_LOAD_FINISHED) { + history_note (w); + } +} + +static gboolean +on_load_failed_core (WebKitWebView *view, WebKitLoadEvent ev, + gchar *uri, GError *error, gpointer u) +{ + (void) ev; (void) uri; (void) error; (void) u; + Win *w = win_of (view); + if (w) + w->load_failed = TRUE; + return FALSE; /* let WebKit show its error page */ +} + +/* ------------------------------------------------------------ omni popup */ + +/* + * One popup does both jobs, because they are the same job: an input line + * with the matching history under it. + * + * <mod>+O type an address. The list stays out of the way until Tab + * or an arrow asks for it. + * <mod>+H browse the history. The list is there from the start. + * + * Tab and Down walk forward through the matches, Shift+Tab and Up back. + * Whatever is selected is written into the input, so Enter always loads + * exactly what you can read. Esc, <mod>+H or a click outside closes it. + */ + +static gboolean +omni_matches (const HistEntry *e, const char *needle) +{ + if (!needle || !*needle) + return TRUE; + + char *u = g_utf8_casefold (e->uri, -1); + char *t = g_utf8_casefold (e->title, -1); + gboolean hit = strstr (u, needle) || strstr (t, needle); + + g_free (u); + g_free (t); + return hit; +} + +static void +omni_rebuild (Win *w) +{ + GtkWidget *child; + while ((child = gtk_widget_get_first_child (w->omnilist))) + gtk_list_box_remove (GTK_LIST_BOX (w->omnilist), child); + + char *needle = g_utf8_casefold (w->omni_needle ? w->omni_needle : "", -1); + guint shown = 0; + + /* 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); + 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); + } + + g_free (needle); +} + +/* Keep the selected row inside the scrolled area without stealing the + * focus from the input - the user is still typing into it. */ +static void +omni_scroll_to (Win *w, GtkListBoxRow *row) +{ + GtkAdjustment *adj = + gtk_scrolled_window_get_vadjustment (GTK_SCROLLED_WINDOW (w->omniscroll)); + graphene_rect_t b; + + if (!adj || !gtk_widget_compute_bounds (GTK_WIDGET (row), w->omnilist, &b)) + return; + + double top = b.origin.y; + double bot = top + b.size.height; + double val = gtk_adjustment_get_value (adj); + double page = gtk_adjustment_get_page_size (adj); + + if (top < val) + gtk_adjustment_set_value (adj, top); + else if (bot > val + page) + gtk_adjustment_set_value (adj, bot - page); +} + +/* Delete in the popup: drop the selected row and keep the place. */ +static void +omni_delete_selected (Win *w) +{ + GtkListBox *list = GTK_LIST_BOX (w->omnilist); + GtkListBoxRow *row = gtk_list_box_get_selected_row (list); + + 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"))); + + omni_rebuild (w); + + GtkListBoxRow *next = gtk_list_box_get_row_at_index (list, idx); + if (!next) + next = gtk_list_box_get_row_at_index (list, idx - 1); + if (next && gtk_list_box_row_get_selectable (next)) { + gtk_list_box_select_row (list, next); + omni_scroll_to (w, next); + } +} + +/* The selected entry is written into the input, so Enter loads what you + * see. The guard keeps that write from re-filtering the list. */ +static void +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; + + w->omni_setting = TRUE; + gtk_editable_set_text (GTK_EDITABLE (w->omnientry), hist_at (pos)->uri); + gtk_editable_set_position (GTK_EDITABLE (w->omnientry), -1); + w->omni_setting = FALSE; +} + +static void +omni_list_show (Win *w) +{ + if (w->omni_list) + return; + + omni_rebuild (w); + gtk_widget_set_visible (w->omniscroll, TRUE); + w->omni_list = TRUE; +} + +/* dir is +1 down the list, -1 up it. The first press just reveals the + * matches and takes the top one. */ +static void +omni_move (Win *w, int dir) +{ + GtkListBox *list = GTK_LIST_BOX (w->omnilist); + GtkListBoxRow *next; + + if (!w->omni_list) { + omni_list_show (w); + next = gtk_list_box_get_row_at_index (list, 0); + } else { + GtkListBoxRow *cur = gtk_list_box_get_selected_row (list); + int idx = cur ? gtk_list_box_row_get_index (cur) : -1; + next = gtk_list_box_get_row_at_index (list, idx + dir); + if (!next) + return; /* stop at the ends, do not wrap */ + } + + if (!next) + return; + + gtk_list_box_select_row (list, next); + omni_scroll_to (w, next); + omni_reflect (w, next); +} + +static void +omni_hide (Win *w) +{ + if (!gtk_widget_get_visible (w->omni)) + return; + + if (w->omni_mode == OMNI_FIND) + webkit_find_controller_search_finish (webkit_web_view_get_find_controller (w->view)); + + gtk_widget_set_visible (w->omni, FALSE); + gtk_widget_set_visible (w->omniscroll, FALSE); + w->omni_list = FALSE; + gtk_widget_grab_focus (GTK_WIDGET (w->view)); +} + +/* The select only means anything when setting a download directory. Its + * three entries name the address, its site, and everything else. */ +static void +omni_setup_scope (Win *w, OmniMode mode, const char *uri) +{ + if (mode != OMNI_DLDIR) { + gtk_widget_set_visible (w->omniscope, FALSE); + return; + } + + char *host = uri_host (uri); + char *short_uri = uri ? elide (uri, 44) : g_strdup ("this page"); + char *page = g_strdup_printf ("only %s", short_uri); + char *site = host ? g_strdup_printf ("everything on %s", host) + : g_strdup ("everything on this site"); + + const char *items[] = { page, site, "everything", NULL }; + GtkStringList *list = gtk_string_list_new (items); + + gtk_drop_down_set_model (GTK_DROP_DOWN (w->omniscope), G_LIST_MODEL (list)); + gtk_drop_down_set_selected (GTK_DROP_DOWN (w->omniscope), dl_scope_for_uri (uri)); + gtk_widget_set_visible (w->omniscope, TRUE); + + g_object_unref (list); + g_free (site); + g_free (page); + g_free (short_uri); + g_free (host); +} + +/* The mode is what separates <mod>+O, <mod>+H, <mod>+F and <mod>+S. */ +static void +omni_show (Win *w, OmniMode mode) +{ + const char *uri = webkit_web_view_get_uri (w->view); + + g_clear_pointer (&w->omni_needle, g_free); + w->omni_needle = g_strdup (""); + w->omni_mode = mode; + + w->omni_setting = TRUE; + gtk_editable_set_text (GTK_EDITABLE (w->omnientry), + mode == OMNI_URL ? (uri ? uri : "") + : mode == OMNI_DLDIR ? dl_dir_for_uri (uri) + : ""); + gtk_entry_set_placeholder_text (GTK_ENTRY (w->omnientry), + mode == OMNI_FIND ? "find in page" + : mode == OMNI_HISTORY ? "search history" + : mode == OMNI_DLDIR ? "download directory" + : mode == OMNI_SEARCH ? "s https://example.com/?q={}" + : "address"); + w->omni_setting = FALSE; + + w->omni_list = FALSE; + gtk_widget_set_visible (w->omniscroll, FALSE); + gtk_widget_set_visible (w->omnihint, mode == OMNI_FIND); + gtk_label_set_text (GTK_LABEL (w->omnihint), ""); + omni_setup_scope (w, mode, uri); + gtk_widget_set_visible (w->omni, TRUE); + + gtk_widget_grab_focus (w->omnientry); + + if (mode == OMNI_URL) + gtk_editable_select_region (GTK_EDITABLE (w->omnientry), 0, -1); + else if (mode == OMNI_HISTORY) + omni_list_show (w); +} + +static void +omni_toggle (Win *w, OmniMode mode) +{ + if (gtk_widget_get_visible (w->omni) && w->omni_mode == mode) + omni_hide (w); + else + omni_show (w, mode); +} + +/* Enter, or a click on a row: load whatever the input says. */ +static void +omni_go (Win *w) +{ + char *raw = g_strdup (gtk_editable_get_text (GTK_EDITABLE (w->omnientry))); + + omni_hide (w); + w->hist_walk = FALSE; /* typing an address ends a walk */ + + /* "s tree" with a keyword s in front is a search, not an address */ + char *search = search_expand (raw); + if (search) { + LOG ("search: %s\n", search); + webkit_web_view_load_uri (w->view, search); + g_free (search); + g_free (raw); + return; + } + + /* the front-end may claim it, e.g. bigbrowser's "diag" */ + if (g_app->load_uri && g_app->load_uri (w->view, raw)) { + g_free (raw); + return; + } + + char *uri = normalize_uri (raw); + g_free (raw); + + if (uri) { + LOG ("load: %s\n", uri); + webkit_web_view_load_uri (w->view, uri); + g_free (uri); + } +} + +/* ------------------------------------------------------------ find in page */ + +#define FIND_OPTS (WEBKIT_FIND_OPTIONS_CASE_INSENSITIVE | \ + WEBKIT_FIND_OPTIONS_WRAP_AROUND) + +/* WebKit scrolls the hit into view for us. */ +static void +find_run (Win *w) +{ + WebKitFindController *fc = webkit_web_view_get_find_controller (w->view); + const char *s = w->omni_needle ? w->omni_needle : ""; + + if (!*s) { + webkit_find_controller_search_finish (fc); + gtk_label_set_text (GTK_LABEL (w->omnihint), ""); + return; + } + + webkit_find_controller_count_matches (fc, s, FIND_OPTS, G_MAXUINT); + webkit_find_controller_search (fc, s, FIND_OPTS, G_MAXUINT); +} + +static void +find_step (Win *w, int dir) +{ + WebKitFindController *fc = webkit_web_view_get_find_controller (w->view); + + if (!w->omni_needle || !*w->omni_needle) + return; + + if (dir < 0) + webkit_find_controller_search_previous (fc); + else + webkit_find_controller_search_next (fc); +} + +static void +on_found_count (WebKitFindController *fc, guint count, gpointer u) +{ + (void) u; + Win *w = win_of (webkit_find_controller_get_web_view (fc)); + if (!w || w->omni_mode != OMNI_FIND) + return; + + char *s = count ? g_strdup_printf ("%u", count) : g_strdup ("no match"); + gtk_label_set_text (GTK_LABEL (w->omnihint), s); + g_free (s); +} + +static void +on_found_none (WebKitFindController *fc, gpointer u) +{ + (void) u; + Win *w = win_of (webkit_find_controller_get_web_view (fc)); + if (w && w->omni_mode == OMNI_FIND) + gtk_label_set_text (GTK_LABEL (w->omnihint), "no match"); +} + +/* Enter in the search-keyword popup: "s https://host/?q={}". */ +static void +omni_apply_search (Win *w) +{ + char *line = g_strstrip (g_strdup (gtk_editable_get_text (GTK_EDITABLE (w->omnientry)))); + char *sp = strchr (line, ' '); + + if (!sp || sp == line) { + toast_show (w, "want: KEY URL, with {} where the words go", URL_TOAST_SECONDS); + g_free (line); + return; + } + + *sp = '\0'; + char *url = g_strstrip (sp + 1); + + if (!*url) { + toast_show (w, "want: KEY URL, with {} where the words go", URL_TOAST_SECONDS); + g_free (line); + return; + } + + search_set (line, url); + omni_hide (w); + + char *msg = g_strdup_printf ("search %s %s", line, url); + toast_show (w, msg, URL_TOAST_SECONDS); + + g_free (msg); + g_free (line); +} + +/* Enter in the download-directory popup. */ +static void +omni_apply_dldir (Win *w) +{ + char *dir = g_strstrip (g_strdup (gtk_editable_get_text (GTK_EDITABLE (w->omnientry)))); + const char *uri = webkit_web_view_get_uri (w->view); + DlScope scope = (DlScope) gtk_drop_down_get_selected (GTK_DROP_DOWN (w->omniscope)); + + if (!*dir) { + g_free (dir); + omni_hide (w); + return; + } + + dl_dir_make (dir); + dl_dir_set (uri, dir, scope); + omni_hide (w); + + char *msg = g_strdup_printf (scope == DL_SCOPE_PAGE ? "downloads for this page %s" + : scope == DL_SCOPE_SITE ? "downloads for this site %s" + : "downloads %s", dir); + toast_show (w, msg, URL_TOAST_SECONDS); + + g_free (msg); + g_free (dir); +} + +static void +on_omni_activate (GtkEntry *entry, gpointer u) +{ + (void) entry; + Win *w = u; + + if (w->omni_mode == OMNI_FIND) + find_step (w, +1); + else if (w->omni_mode == OMNI_DLDIR) + omni_apply_dldir (w); + else if (w->omni_mode == OMNI_SEARCH) + omni_apply_search (w); + else + omni_go (w); +} + +static void +on_omni_changed (GtkEditable *e, gpointer u) +{ + Win *w = u; + + if (w->omni_setting) + return; /* our own write, not the user's */ + + g_free (w->omni_needle); + w->omni_needle = g_strdup (gtk_editable_get_text (e)); + + if (w->omni_mode == OMNI_FIND) { + find_run (w); + return; + } + + if (w->omni_list) + omni_rebuild (w); +} + +static void +on_omni_row_activated (GtkListBox *list, GtkListBoxRow *row, gpointer u) +{ + (void) list; + Win *w = u; + + omni_reflect (w, row); + omni_go (w); +} + +/* --------------------------------------------------------------- toast */ + +static gboolean +toast_timeout (gpointer u) +{ + Win *w = u; + gtk_widget_set_visible (w->toast, FALSE); + w->toast_id = 0; + return G_SOURCE_REMOVE; +} + +void +toast_show (Win *w, const char *text, guint seconds) +{ + gtk_label_set_text (GTK_LABEL (w->toast), text); + gtk_widget_set_visible (w->toast, TRUE); + + if (w->toast_id) + g_source_remove (w->toast_id); + w->toast_id = g_timeout_add_seconds (seconds, toast_timeout, w); +} +/* ---------------------------------------------------------------- zoom */ + +static void +zoom_by (Win *w, double factor) +{ + double z = webkit_web_view_get_zoom_level (w->view) * factor; + + z = CLAMP (z, ZOOM_MIN, ZOOM_MAX); + webkit_web_view_set_zoom_level (w->view, z); + + char *msg = g_strdup_printf ("zoom %.0f%%", z * 100.0); + toast_show (w, msg, 1); + g_free (msg); +} + +static void +zoom_reset (Win *w) +{ + webkit_web_view_set_zoom_level (w->view, 1.0); + toast_show (w, "zoom 100%", 1); +} + +/* ------------------------------------------------- download overlay */ + +/* One row: a progress bar with the filename in its text for a running + * download, a plain line for a finished one. */ +/* + * One download, laid out in columns rather than as one run-on string: + * + * some-long-file-name.tar.zst 45% 1.2 MB 12s + * [================= ] + * + * The name takes the space that is left and elides in the middle; the + * numbers are right aligned, so they line up down the panel and the eye + * can read one column at a time. + */ +static GtkWidget * +dl_row_new (Dl *d) +{ + GtkWidget *row = gtk_box_new (GTK_ORIENTATION_VERTICAL, 2); + GtkWidget *line = gtk_box_new (GTK_ORIENTATION_HORIZONTAL, 10); + char *name = elide (d->name, DL_NAME_CHARS); + char *status; + + GtkWidget *name_l = gtk_label_new (name); + gtk_widget_add_css_class (name_l, d->state == DL_FAILED ? "br-dl-failed" : "br-dl-name"); + if (d->state == DL_ASK) { + /* the old name is what the question is about */ + char *old = g_path_get_basename (d->want ? d->want : d->name); + gtk_label_set_text (GTK_LABEL (name_l), old); + g_free (old); + } + gtk_label_set_xalign (GTK_LABEL (name_l), 0.0); + gtk_label_set_ellipsize (GTK_LABEL (name_l), PANGO_ELLIPSIZE_MIDDLE); + gtk_widget_set_hexpand (name_l, TRUE); + gtk_box_append (GTK_BOX (line), name_l); + + if (d->state == DL_ACTIVE) { + char *eta = NULL; + + /* Needs a size, a measured rate, and enough elapsed time that the + * rate means something - otherwise show no estimate at all rather + * than a wrong one. */ + double elapsed = (g_get_monotonic_time () - d->start_us) / 1e6; + + if (d->total > d->received && d->rate > 0 && elapsed > 1.5) + eta = format_seconds ((d->total - d->received) / d->rate); + + char *got = format_size (d->received); + + if (d->total > 0) + status = g_strdup_printf ("%.0f%% %s%s%s", d->progress * 100.0, got, + eta ? " " : "", eta ? eta : ""); + else + status = g_strdup (got); /* no Content-Length, no percentage */ + + g_free (got); + g_free (eta); + } else if (d->state == DL_ASK) { + status = g_strdup ("exists: Enter replaces, Esc keeps both"); + } else if (d->state == DL_DONE) { + char *size = format_size (d->total ? d->total : d->received); + char *took = format_seconds ((d->end_us - d->start_us) / 1e6); + status = g_strdup_printf ("done %s %s", size, took); + g_free (size); + g_free (took); + } else { + status = g_strdup ("failed"); + } + + GtkWidget *status_l = gtk_label_new (status); + gtk_widget_add_css_class (status_l, + d->state == DL_FAILED ? "br-dl-failed" + : d->state == DL_ASK ? "br-dl-ask" + : "br-dl-status"); + gtk_label_set_xalign (GTK_LABEL (status_l), 1.0); + gtk_widget_set_halign (status_l, GTK_ALIGN_END); + gtk_box_append (GTK_BOX (line), status_l); + + gtk_box_append (GTK_BOX (row), line); + + /* the bar carries no text of its own: the columns above say it all */ + if (d->state == DL_ACTIVE) { + GtkWidget *bar = gtk_progress_bar_new (); + gtk_progress_bar_set_fraction (GTK_PROGRESS_BAR (bar), CLAMP (d->progress, 0.0, 1.0)); + gtk_widget_add_css_class (bar, "br-dl-bar"); + gtk_box_append (GTK_BOX (row), bar); + } + + g_free (status); + g_free (name); + return row; +} + + +/* + * Enter and Esc answer the oldest download whose name was already taken. + * The row carries the question, so there is nothing else to dismiss, and + * the transfer is only reported as finished once the bytes are where they + * are going to stay. + */ +static Dl * +dl_pending_ask (void) +{ + for (guint i = 0; i < g_downloads->len; i++) { + Dl *d = g_ptr_array_index (g_downloads, i); + if (d->ask) + return d; + } + return NULL; +} + +static gboolean +dl_answer_pending (gboolean overwrite) +{ + Dl *d = dl_pending_ask (); + if (!d) + return FALSE; + + d->ask = FALSE; + d->overwrite = overwrite; + + /* still running: dl_finish will move it when the last byte lands */ + if (d->state == DL_ASK) { + if (overwrite && d->want && d->dest) { + if (g_rename (d->dest, d->want) == 0) { + LOG ("download: replaced %s\n", d->want); + g_free (d->name); + d->name = g_path_get_basename (d->want); + } else { + g_printerr ("download: cannot replace %s: %s\n", + d->want, g_strerror (errno)); + } + } + d->state = DL_DONE; + d->end_us = g_get_monotonic_time (); + } + + downloads_tick_start (); + downloads_refresh (); + return TRUE; +} +static void +dl_panel_rebuild (Win *w) +{ + GtkWidget *child; + while ((child = gtk_widget_get_first_child (w->dlpanel))) + gtk_box_remove (GTK_BOX (w->dlpanel), child); + + gint64 now = g_get_monotonic_time (); + gint64 linger = (gint64) DL_LINGER_SECONDS * G_USEC_PER_SEC; + GPtrArray *show = g_ptr_array_new (); + guint active = 0; + + for (guint i = 0; i < g_downloads->len; i++) + if (((Dl *) g_ptr_array_index (g_downloads, i))->state == DL_ACTIVE || + ((Dl *) g_ptr_array_index (g_downloads, i))->state == DL_ASK) + active++; + + if (w->dl_history) { + for (guint i = 0; i < g_downloads->len; i++) + g_ptr_array_add (show, g_ptr_array_index (g_downloads, i)); + while (show->len > DL_HISTORY_ROWS) + g_ptr_array_remove_index (show, 0); + } else { + for (guint i = 0; i < g_downloads->len; i++) { + Dl *d = g_ptr_array_index (g_downloads, i); + if (d->state == DL_ACTIVE || d->state == DL_ASK || + now - d->end_us < linger) + g_ptr_array_add (show, d); + } + while (show->len > DL_ACTIVE_ROWS) + g_ptr_array_remove_index (show, 0); + } + + if (show->len == 0 && !w->dl_history) { + gtk_widget_set_visible (w->dlpanel, FALSE); + g_ptr_array_free (show, TRUE); + return; + } + + /* header: how many downloads there are */ + char *head; + if (w->dl_history) + head = g_downloads->len + ? g_strdup_printf ("last downloads (%u)", g_downloads->len) + : g_strdup ("no downloads yet"); + else if (active > 0) + head = g_strdup_printf ("downloading %u of %u", active, g_downloads->len); + else + head = g_strdup_printf ("downloads (%u)", g_downloads->len); + + GtkWidget *headline = gtk_box_new (GTK_ORIENTATION_HORIZONTAL, 10); + + GtkWidget *hl = gtk_label_new (head); + gtk_widget_add_css_class (hl, "br-dl-head"); + gtk_label_set_xalign (GTK_LABEL (hl), 0.0); + gtk_widget_set_hexpand (hl, TRUE); + gtk_box_append (GTK_BOX (headline), hl); + + /* where these files are going, small, on the right - <mod>+S edits it */ + GtkWidget *dirl = gtk_label_new (dl_dir_for_uri (webkit_web_view_get_uri (w->view))); + gtk_widget_add_css_class (dirl, "br-dl-dir"); + gtk_label_set_xalign (GTK_LABEL (dirl), 1.0); + gtk_label_set_ellipsize (GTK_LABEL (dirl), PANGO_ELLIPSIZE_START); + gtk_label_set_max_width_chars (GTK_LABEL (dirl), 28); + gtk_widget_set_halign (dirl, GTK_ALIGN_END); + gtk_box_append (GTK_BOX (headline), dirl); + + gtk_box_append (GTK_BOX (w->dlpanel), headline); + g_free (head); + + for (guint i = 0; i < show->len; i++) + gtk_box_append (GTK_BOX (w->dlpanel), dl_row_new (g_ptr_array_index (show, i))); + + gtk_widget_set_visible (w->dlpanel, TRUE); + g_ptr_array_free (show, TRUE); +} + +static void +downloads_refresh (void) +{ + for (guint i = 0; i < g_wins->len; i++) + dl_panel_rebuild (g_ptr_array_index (g_wins, i)); +} + +/* Runs only while something is on screen: any active download, or a + * finished one still inside its linger window. */ +static gboolean +dl_tick (gpointer u) +{ + (void) u; + + gint64 now = g_get_monotonic_time (); + gint64 linger = (gint64) DL_LINGER_SECONDS * G_USEC_PER_SEC; + gboolean busy = FALSE; + + for (guint i = 0; i < g_downloads->len && !busy; i++) { + Dl *d = g_ptr_array_index (g_downloads, i); + busy = (d->state == DL_ACTIVE) || (d->state == DL_ASK) || + (now - d->end_us < linger); + } + + downloads_refresh (); + + if (busy) + return G_SOURCE_CONTINUE; + + g_dl_tick = 0; + return G_SOURCE_REMOVE; +} + +static void +downloads_tick_start (void) +{ + if (!g_dl_tick) + g_dl_tick = g_timeout_add (DL_TICK_MS, dl_tick, NULL); +} + +static gboolean +dl_history_timeout (gpointer u) +{ + Win *w = u; + w->dl_history = FALSE; + w->dl_history_id = 0; + dl_panel_rebuild (w); + return G_SOURCE_REMOVE; +} + +/* <mod>+D: show the recent downloads, press again to dismiss. */ +static void +downloads_toggle_history (Win *w) +{ + if (w->dl_history_id) { + g_source_remove (w->dl_history_id); + w->dl_history_id = 0; + } + + w->dl_history = !w->dl_history; + + if (w->dl_history) + w->dl_history_id = g_timeout_add_seconds (DL_HISTORY_SECONDS, + dl_history_timeout, w); + dl_panel_rebuild (w); +} + +/* The overlay sits over the page, so fade it out while the pointer is on + * top of it - there may be something underneath worth reading. It is not + * click-targetable either, so this only affects what you see. */ +static void +overlay_hover_update (Win *w, double x, double y) +{ + graphene_rect_t bounds; + graphene_point_t point = GRAPHENE_POINT_INIT ((float) x, (float) y); + double want = 1.0; + + if (gtk_widget_compute_bounds (w->topright, w->win, &bounds)) { + graphene_rect_inset (&bounds, -8.0f, -8.0f); /* a little margin */ + if (bounds.size.width > 1 && graphene_rect_contains_point (&bounds, &point)) + want = 0.0; + } + + if (gtk_widget_get_opacity (w->topright) != want) + gtk_widget_set_opacity (w->topright, want); +} + +static void +on_motion (GtkEventControllerMotion *c, double x, double y, gpointer u) +{ + (void) c; + overlay_hover_update (u, x, y); +} + +static void +on_motion_leave (GtkEventControllerMotion *c, gpointer u) +{ + (void) c; + gtk_widget_set_opacity (((Win *) u)->topright, 1.0); +} + +/* ------------------------------------------------------------- windows */ + +Win * +win_of (WebKitWebView *view) +{ + return view ? g_object_get_data (G_OBJECT (view), "win") : NULL; +} + +void +view_eval (WebKitWebView *view, const char *js) +{ + webkit_web_view_evaluate_javascript (view, js, -1, NULL, NULL, NULL, NULL, NULL); +} + +/* The page's own scrolling element, not the window: that is what actually + * moves on a document with a scrolling body. */ +static void +scroll_edge (Win *w, gboolean bottom) +{ + view_eval (w->view, + bottom + ? "(function(){var e=document.scrollingElement||document.documentElement;" + "e.scrollTo(0,e.scrollHeight);})();" + : "(function(){var e=document.scrollingElement||document.documentElement;" + "e.scrollTo(0,0);})();"); +} + +static void keys_toggle (Win *w); + +/* Esc closes whatever is on screen, from the front backwards. */ +static gboolean +close_popups (Win *w) +{ + gboolean closed = FALSE; + + if (gtk_widget_get_visible (w->omni)) { + omni_hide (w); + closed = TRUE; + } + if (gtk_widget_get_visible (w->keys)) { + gtk_widget_set_visible (w->keys, FALSE); + closed = TRUE; + } + if (w->dl_history) { + if (w->dl_history_id) { + g_source_remove (w->dl_history_id); + w->dl_history_id = 0; + } + w->dl_history = FALSE; + dl_panel_rebuild (w); + closed = TRUE; + } + if (gtk_widget_get_visible (w->toast)) { + if (w->toast_id) { + g_source_remove (w->toast_id); + w->toast_id = 0; + } + gtk_widget_set_visible (w->toast, FALSE); + closed = TRUE; + } + if (gtk_widget_get_visible (w->urltoast)) { + if (w->urltoast_id) { + g_source_remove (w->urltoast_id); + w->urltoast_id = 0; + } + gtk_widget_set_visible (w->urltoast, FALSE); + closed = TRUE; + } + + return closed; +} + +static gboolean +on_key (GtkEventControllerKey *c, guint keyval, guint code, + GdkModifierType state, gpointer user_data) +{ + (void) c; (void) code; + + Win *w = user_data; + guint key = gdk_keyval_to_lower (keyval); + gboolean mod = (state & MOD_MASK_ALL) == g_mod; /* exactly our modifier */ + gboolean shift = (state & GDK_SHIFT_MASK) != 0; + + /* While the popup is open it owns the keyboard, apart from the few + * keys that drive it. Everything else reaches the input, so typing + * filters and <mod>+A still selects the text in it. */ + /* Esc belongs to whatever is on screen; with nothing up it is the + * page's own key, so it is passed on. */ + if (keyval == GDK_KEY_Escape) { + if (close_popups (w)) + return TRUE; + return dl_answer_pending (FALSE); /* keep both */ + } + + /* an unanswered download question owns Enter, but only when nothing + * of ours is on screen to type into */ + if ((keyval == GDK_KEY_Return || keyval == GDK_KEY_KP_Enter) && + !gtk_widget_get_visible (w->omni) && !gtk_widget_get_visible (w->keys) && + dl_answer_pending (TRUE)) + return TRUE; + + if (gtk_widget_get_visible (w->keys)) { + if (keyval == GDK_KEY_F1 || + (mod && (key == GDK_KEY_slash || key == GDK_KEY_question))) { + gtk_widget_set_visible (w->keys, FALSE); + return TRUE; + } + return FALSE; + } + + if (gtk_widget_get_visible (w->omni)) { + 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) || + (key == GDK_KEY_k && w->omni_mode == OMNI_SEARCH)))) { + omni_hide (w); + return TRUE; + } + + /* finding: Enter walks the hits, there is no list to steer */ + if (w->omni_mode == OMNI_FIND) { + if (keyval == GDK_KEY_Return || keyval == GDK_KEY_KP_Enter) { + find_step (w, shift ? -1 : +1); + return TRUE; + } + return FALSE; + } + + /* settings modes: no list to steer, Enter applies */ + if (w->omni_mode == OMNI_DLDIR || w->omni_mode == OMNI_SEARCH) + return FALSE; + + if (keyval == GDK_KEY_Delete || keyval == GDK_KEY_KP_Delete) { + omni_delete_selected (w); + return TRUE; + } + if (keyval == GDK_KEY_Tab || keyval == GDK_KEY_KP_Tab) { + omni_move (w, shift ? -1 : +1); + return TRUE; + } + if (keyval == GDK_KEY_ISO_Left_Tab) { /* Shift+Tab on X11 */ + omni_move (w, -1); + return TRUE; + } + if (keyval == GDK_KEY_Up || keyval == GDK_KEY_KP_Up) { omni_move (w, -1); return TRUE; } + if (keyval == GDK_KEY_Down || keyval == GDK_KEY_KP_Down) { omni_move (w, +1); return TRUE; } + return FALSE; + } + + /* --- unmodified function keys ------------------------------------ */ + if (keyval == GDK_KEY_F1) { + keys_toggle (w); + return TRUE; + } + if (keyval == GDK_KEY_F12) { + inspector_toggle (w->view); + return TRUE; + } + if (keyval == GDK_KEY_F5) { + webkit_web_view_reload (w->view); + return TRUE; + } + + if (!mod) + return FALSE; + + /* the front-end gets first refusal on everything under our modifier */ + if (g_app->key && g_app->key (w, key, shift)) + return TRUE; + + /* --- <mod>+Shift+key --------------------------------------------- */ + if (shift) { + switch (key) { + case GDK_KEY_j: /* <mod>+J goes back */ + history_go (w, +1); + return TRUE; + case GDK_KEY_g: + scroll_edge (w, TRUE); + return TRUE; + case GDK_KEY_i: + case GDK_KEY_d: /* <mod>+D itself lists downloads */ + inspector_toggle (w->view); + return TRUE; + case GDK_KEY_r: + css_reload (); + webkit_web_view_reload (w->view); + return TRUE; + default: + break; /* fall through to zoom keys */ + } + } + + /* --- <mod>+key ---------------------------------------------------- */ + switch (key) { + case GDK_KEY_j: + history_go (w, -1); + return TRUE; + + case GDK_KEY_h: + omni_toggle (w, OMNI_HISTORY); + return TRUE; + + case GDK_KEY_f: + omni_toggle (w, OMNI_FIND); + return TRUE; + + case GDK_KEY_s: + omni_toggle (w, OMNI_DLDIR); + return TRUE; + + case GDK_KEY_k: + omni_toggle (w, OMNI_SEARCH); + return TRUE; + + case GDK_KEY_slash: + case GDK_KEY_question: + keys_toggle (w); + return TRUE; + + case GDK_KEY_g: + scroll_edge (w, FALSE); + return TRUE; + + case GDK_KEY_o: + case GDK_KEY_l: /* the usual browser shortcut */ + omni_show (w, OMNI_URL); + return TRUE; + + case GDK_KEY_r: + if (shift) + break; + webkit_web_view_reload (w->view); + return TRUE; + + case GDK_KEY_d: + downloads_toggle_history (w); + return TRUE; + + case GDK_KEY_p: { + const char *uri = webkit_web_view_get_uri (w->view); + toast_show (w, uri ? uri : "(no url)", URL_TOAST_SECONDS); + return TRUE; + } + + case GDK_KEY_y: + clipboard_copy (w, webkit_web_view_get_uri (w->view)); + return TRUE; + + /* '+' usually needs Shift, and some layouts send '=' or the keypad key */ + case GDK_KEY_plus: + case GDK_KEY_equal: + case GDK_KEY_KP_Add: + zoom_by (w, ZOOM_STEP); + return TRUE; + + case GDK_KEY_minus: + case GDK_KEY_underscore: + case GDK_KEY_KP_Subtract: + zoom_by (w, 1.0 / ZOOM_STEP); + return TRUE; + + case GDK_KEY_0: + case GDK_KEY_KP_0: + zoom_reset (w); + return TRUE; + + default: + break; + } + + return FALSE; /* not ours - hand it to the page */ +} + +/* --------------------------------------------------------- key reference */ + +/* + * F1 or <mod>+/ drops the key list on screen, grouped the way the keys + * are actually used rather than alphabetically. The front-end's own keys + * are taken from its usage_keys() text, so the panel and --help can never + * drift apart. + */ + +typedef struct { + const char *key; /* NULL starts a new section, desc is its name */ + const char *desc; +} KeyRow; + +static const KeyRow g_keyrows[] = { + { NULL, "go" }, + { "%s+O", "type an address, or \"s words\" to search" }, + { "%s+H", "history" }, + { "%s+J", "back" }, + { "%s+Shift+J", "forward" }, + { "%s+R", "reload" }, + + { NULL, "in the popup" }, + { "Tab / Down", "next match" }, + { "Shift+Tab / Up", "previous match" }, + { "Enter", "open" }, + { "Delete", "remove the entry from the history" }, + { "Esc", "close" }, + + { NULL, "page" }, + { "%s+F", "find, Enter next, Shift+Enter previous" }, + { "%s+G", "scroll to the top" }, + { "%s+Shift+G", "scroll to the bottom" }, + { "%s+plus / minus / 0", "zoom in, out, reset" }, + { "%s+P", "show the current URL" }, + { "%s+Y", "copy the current URL" }, + + { NULL, "files" }, + { "%s+D", "recent downloads" }, + { "%s+S", "download directory: this page, this site, or all" }, + { "%s+K", "add a search keyword" }, + + { NULL, "tools" }, + { "F12 / %s+Shift+I", "developer tools" }, + { "%s+Shift+R", "re-read the --css file, then reload" }, + { "F1 / %s+/", "this list" }, +}; + +static void +keys_row_add (GtkGrid *grid, int *row, const char *key, const char *desc) +{ + GtkWidget *k = gtk_label_new (key); + gtk_widget_add_css_class (k, "br-key"); + gtk_label_set_xalign (GTK_LABEL (k), 0.0); + gtk_widget_set_valign (k, GTK_ALIGN_START); + gtk_grid_attach (grid, k, 0, *row, 1, 1); + + GtkWidget *d = gtk_label_new (desc); + gtk_widget_add_css_class (d, "br-key-desc"); + gtk_label_set_xalign (GTK_LABEL (d), 0.0); + gtk_label_set_wrap (GTK_LABEL (d), TRUE); + gtk_label_set_max_width_chars (GTK_LABEL (d), 46); + gtk_grid_attach (grid, d, 1, *row, 1, 1); + + (*row)++; +} + +static void +keys_head_add (GtkGrid *grid, int *row, const char *name) +{ + GtkWidget *h = gtk_label_new (name); + gtk_widget_add_css_class (h, "br-key-head"); + gtk_label_set_xalign (GTK_LABEL (h), 0.0); + gtk_widget_set_margin_top (h, *row ? (int) g_theme.pad : 0); + gtk_grid_attach (grid, h, 0, *row, 2, 1); + (*row)++; +} + +/* The front-end writes its keys as " <mod>+Shift+M what it does", with + * a run of spaces between the two. Continuation lines have no key. */ +static void +keys_add_app_rows (GtkGrid *grid, int *row) +{ + if (!g_app->usage_keys) + return; + + GString *s = g_string_new (NULL); + g_app->usage_keys (s); + + char **lines = g_strsplit (s->str, "\n", -1); + gboolean titled = FALSE; + + for (int i = 0; lines[i]; i++) { + char *line = lines[i]; + if (!*g_strchug (line)) + continue; + + char *sep = strstr (line, " "); + if (!sep) + continue; + + char *key = g_strstrip (g_strndup (line, (gsize) (sep - line))); + char *desc = g_strstrip (g_strdup (sep)); + + if (*key && *desc) { + if (!titled) { + keys_head_add (grid, row, "this browser"); + titled = TRUE; + } + keys_row_add (grid, row, key, desc); + } + + g_free (key); + g_free (desc); + } + + g_strfreev (lines); + g_string_free (s, TRUE); +} + +static GtkWidget * +keys_panel_new (void) +{ + GtkWidget *grid = gtk_grid_new (); + gtk_grid_set_column_spacing (GTK_GRID (grid), (int) g_theme.gap); + gtk_grid_set_row_spacing (GTK_GRID (grid), (int) g_theme.win_gap); + + int row = 0; + for (gsize i = 0; i < G_N_ELEMENTS (g_keyrows); i++) { + const KeyRow *r = &g_keyrows[i]; + + if (!r->key) { + keys_head_add (GTK_GRID (grid), &row, r->desc); + continue; + } + + char *key = g_strdup_printf (r->key, g_mod_name); + keys_row_add (GTK_GRID (grid), &row, key, r->desc); + g_free (key); + } + + keys_add_app_rows (GTK_GRID (grid), &row); + + GtkWidget *scroll = gtk_scrolled_window_new (); + gtk_widget_add_css_class (scroll, "br-pick-scroll"); + gtk_scrolled_window_set_policy (GTK_SCROLLED_WINDOW (scroll), + GTK_POLICY_NEVER, GTK_POLICY_AUTOMATIC); + gtk_scrolled_window_set_child (GTK_SCROLLED_WINDOW (scroll), grid); + gtk_scrolled_window_set_propagate_natural_width (GTK_SCROLLED_WINDOW (scroll), TRUE); + gtk_scrolled_window_set_propagate_natural_height (GTK_SCROLLED_WINDOW (scroll), TRUE); + gtk_scrolled_window_set_max_content_height (GTK_SCROLLED_WINDOW (scroll), 460); + + GtkWidget *box = gtk_box_new (GTK_ORIENTATION_VERTICAL, (int) g_theme.win_gap); + gtk_widget_add_css_class (box, "br-keys"); + gtk_widget_set_halign (box, GTK_ALIGN_CENTER); + gtk_widget_set_valign (box, GTK_ALIGN_CENTER); + gtk_widget_set_visible (box, FALSE); + gtk_box_append (GTK_BOX (box), scroll); + + return box; +} + +static void +keys_toggle (Win *w) +{ + gboolean on = !gtk_widget_get_visible (w->keys); + + if (on) + omni_hide (w); /* one thing on screen at a time */ + + gtk_widget_set_visible (w->keys, on); +} + +static void +on_close (GtkWindow *win, gpointer user_data) +{ + (void) win; + Win *w = user_data; + + if (w->primary || --g_windows <= 0) { + GMainLoop *loop = g_object_get_data (G_OBJECT (w->win), "loop"); + if (loop) + g_main_loop_quit (loop); + } +} + +static void +win_free (gpointer data) +{ + Win *w = data; + + if (g_app->win_gone) + g_app->win_gone (w); + + g_ptr_array_remove_fast (g_wins, w); + + if (w->toast_id) + g_source_remove (w->toast_id); + if (w->dl_history_id) + g_source_remove (w->dl_history_id); + if (w->hist_id) + g_source_remove (w->hist_id); + if (w->urltoast_id) + g_source_remove (w->urltoast_id); + g_free (w->omni_needle); + + g_free (w); +} + +static void +on_title (GObject *obj, GParamSpec *ps, gpointer user_data) +{ + (void) ps; + Win *w = user_data; + const char *t = webkit_web_view_get_title (WEBKIT_WEB_VIEW (obj)); + gtk_window_set_title (GTK_WINDOW (w->win), (t && *t) ? t : g_title); +} + +/* --------------------------------------------------------------- app id */ + +/* Wayland: the xdg_toplevel app_id, i.e. what `swaymsg -t get_tree` shows + * as app_id and what sway's `for_window [app_id="..."]` rules match on. + * X11: the WM_CLASS pair. + * + * Both come from g_get_prgname() unless a GtkApplication supplies an id; + * with prgname unset a Wayland toplevel falls back to the literal "GTK + * Application". So g_set_prgname() in browser_main() is what fixes this. + * + * X11 needs nothing further: a toplevel takes WM_CLASS's res_name from + * prgname and falls back to it for res_class too, so calling + * gdk_x11_display_set_program_class() would only repeat what prgname + * already did - and it is deprecated since GTK 4.18. */ +static void +on_window_map (GtkWidget *win, gpointer u) +{ + (void) win; (void) u; + +#ifdef GDK_WINDOWING_WAYLAND + GdkSurface *surface = gtk_native_get_surface (GTK_NATIVE (win)); + if (surface && GDK_IS_WAYLAND_TOPLEVEL (surface)) + gdk_wayland_toplevel_set_application_id (GDK_TOPLEVEL (surface), g_app_id); +#endif +} + +/* Middle click pastes the primary selection into whatever is focused. + * Claiming the press in the capture phase means the page never sees the + * button at all, which is the only reliable way to stop that: WebKitGTK + * has no setting for it. --disable-middle-click-paste. */ +static void +on_middle_press (GtkGestureClick *g, int n, double x, double y, gpointer u) +{ + (void) n; (void) x; (void) y; (void) u; + gtk_gesture_set_state (GTK_GESTURE (g), GTK_EVENT_SEQUENCE_CLAIMED); +} + +/* + * Place our overlays ourselves, so they survive a window that is narrower + * or shorter than they would like: everything is clamped to what the + * window actually has, and the popup is centered in whatever is left. + */ +static gboolean +on_overlay_position (GtkOverlay *ov, GtkWidget *child, GdkRectangle *alloc, gpointer u) +{ + Win *w = u; + int W = gtk_widget_get_width (GTK_WIDGET (ov)); + int H = gtk_widget_get_height (GTK_WIDGET (ov)); + int margin = (int) g_theme.gap; + int want, min_h, nat_h; + + if (W <= 0 || H <= 0) + return FALSE; + + if (child == w->omni) + want = w->omni_mode == OMNI_DLDIR ? 960 : 720; /* paths are long */ + else if (child == w->keys) + want = 640; + else if (child == w->topright) + want = 360; + else + return FALSE; /* not ours, let GTK align it */ + + int width = MIN (want, W - 2 * margin); + if (width < 120) + width = W; /* very narrow: take what there is */ + + gtk_widget_measure (child, GTK_ORIENTATION_VERTICAL, width, + &min_h, &nat_h, NULL, NULL); + + int height = MIN (nat_h, H); + if (height < min_h) + height = MIN (min_h, H); + + alloc->width = width; + alloc->height = height; + + if (child == w->topright) { + alloc->x = W - width; /* pinned to the top right */ + alloc->y = 0; + } else if (child == w->omni) { + alloc->x = (W - width) / 2; /* centered, hanging off the top */ + alloc->y = 0; + } else { + alloc->x = (W - width) / 2; /* centered both ways */ + alloc->y = (H - height) / 2; + } + + return TRUE; +} + +/* Dismiss the popup when the click lands anywhere else. The + * press is claimed so it does not also follow a link underneath. */ +static void +on_click_away (GtkGestureClick *g, int n, double x, double y, gpointer u) +{ + (void) n; + Win *w = u; + + GtkWidget *open_one = gtk_widget_get_visible (w->omni) ? w->omni + : gtk_widget_get_visible (w->keys) ? w->keys + : NULL; + if (!open_one) + return; + + graphene_rect_t bounds; + graphene_point_t point = GRAPHENE_POINT_INIT ((float) x, (float) y); + + if (gtk_widget_compute_bounds (open_one, w->win, &bounds) && + graphene_rect_contains_point (&bounds, &point)) + return; /* inside, let the panel have it */ + + if (open_one == w->keys) + gtk_widget_set_visible (w->keys, FALSE); + else + omni_hide (w); + + gtk_gesture_set_state (GTK_GESTURE (g), GTK_EVENT_SEQUENCE_CLAIMED); +} + +static GtkWidget * +window_new (WebKitWebView *view, gboolean primary) +{ + Win *w = g_new0 (Win, 1); + w->view = view; + w->primary = primary; + w->win = gtk_window_new (); + w->hist_pos = (int) g_hist->len - 1; + + gtk_window_set_default_size (GTK_WINDOW (w->win), 1280, 800); + gtk_window_set_title (GTK_WINDOW (w->win), g_title); + + GtkWidget *overlay = gtk_overlay_new (); + gtk_overlay_set_child (GTK_OVERLAY (overlay), GTK_WIDGET (view)); + + /* The popup: centered, hanging off the top edge, input first and the + * history under it. Only its bottom corners are rounded, the top ones + * sit flush against the window edge. */ + w->omni = gtk_box_new (GTK_ORIENTATION_VERTICAL, (int) g_theme.win_gap); + gtk_widget_add_css_class (w->omni, "br-omni"); + gtk_widget_set_halign (w->omni, GTK_ALIGN_CENTER); + gtk_widget_set_valign (w->omni, GTK_ALIGN_START); + gtk_widget_set_size_request (w->omni, -1, -1); + gtk_widget_set_visible (w->omni, FALSE); + + GtkWidget *inputline = gtk_box_new (GTK_ORIENTATION_HORIZONTAL, 10); + + w->omnientry = gtk_entry_new (); + gtk_entry_set_has_frame (GTK_ENTRY (w->omnientry), FALSE); + gtk_widget_add_css_class (w->omnientry, "br-omni-entry"); + gtk_widget_set_hexpand (w->omnientry, TRUE); + g_signal_connect (w->omnientry, "activate", G_CALLBACK (on_omni_activate), w); + g_signal_connect (w->omnientry, "changed", G_CALLBACK (on_omni_changed), w); + gtk_box_append (GTK_BOX (inputline), w->omnientry); + + /* match count while finding, empty otherwise */ + w->omnihint = gtk_label_new (""); + gtk_widget_add_css_class (w->omnihint, "br-pick-sub"); + gtk_widget_set_halign (w->omnihint, GTK_ALIGN_END); + gtk_widget_set_visible (w->omnihint, FALSE); + gtk_box_append (GTK_BOX (inputline), w->omnihint); + + gtk_box_append (GTK_BOX (w->omni), inputline); + + /* only shown when a download directory is being set */ + w->omniscope = gtk_drop_down_new (NULL, NULL); + gtk_widget_add_css_class (w->omniscope, "br-scope"); + gtk_widget_set_visible (w->omniscope, FALSE); + gtk_box_append (GTK_BOX (w->omni), w->omniscope); + + w->omnilist = gtk_list_box_new (); + gtk_widget_add_css_class (w->omnilist, "br-pick-list"); + gtk_list_box_set_selection_mode (GTK_LIST_BOX (w->omnilist), GTK_SELECTION_SINGLE); + gtk_list_box_set_activate_on_single_click (GTK_LIST_BOX (w->omnilist), TRUE); + g_signal_connect (w->omnilist, "row-activated", G_CALLBACK (on_omni_row_activated), w); + + w->omniscroll = gtk_scrolled_window_new (); + gtk_widget_add_css_class (w->omniscroll, "br-pick-scroll"); + gtk_scrolled_window_set_policy (GTK_SCROLLED_WINDOW (w->omniscroll), + GTK_POLICY_NEVER, GTK_POLICY_AUTOMATIC); + gtk_scrolled_window_set_child (GTK_SCROLLED_WINDOW (w->omniscroll), w->omnilist); + gtk_scrolled_window_set_propagate_natural_height (GTK_SCROLLED_WINDOW (w->omniscroll), TRUE); + gtk_scrolled_window_set_max_content_height (GTK_SCROLLED_WINDOW (w->omniscroll), 300); + gtk_widget_set_visible (w->omniscroll, FALSE); + gtk_box_append (GTK_BOX (w->omni), w->omniscroll); + + gtk_overlay_add_overlay (GTK_OVERLAY (overlay), w->omni); + + /* the key reference sits in the middle, clear of every edge, so all + * four of its corners are rounded */ + w->keys = keys_panel_new (); + gtk_overlay_add_overlay (GTK_OVERLAY (overlay), w->keys); + + /* The address of the page being opened, top left. It touches the top + * and the left, so only its bottom right corner is rounded. */ + w->urltoast = gtk_label_new (""); + gtk_widget_add_css_class (w->urltoast, "br-urltoast"); + gtk_widget_set_halign (w->urltoast, GTK_ALIGN_START); + gtk_widget_set_valign (w->urltoast, GTK_ALIGN_START); + gtk_widget_set_can_target (w->urltoast, FALSE); + gtk_widget_set_visible (w->urltoast, FALSE); + gtk_label_set_ellipsize (GTK_LABEL (w->urltoast), PANGO_ELLIPSIZE_MIDDLE); + gtk_label_set_max_width_chars (GTK_LABEL (w->urltoast), 70); + gtk_overlay_add_overlay (GTK_OVERLAY (overlay), w->urltoast); + + /* 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); + gtk_widget_set_halign (w->topright, GTK_ALIGN_END); + gtk_widget_set_valign (w->topright, GTK_ALIGN_START); + gtk_widget_set_can_target (w->topright, FALSE); + + w->toast = gtk_label_new (""); + gtk_widget_add_css_class (w->toast, "br-toast"); + gtk_widget_set_halign (w->toast, GTK_ALIGN_END); + gtk_widget_set_visible (w->toast, FALSE); + gtk_label_set_ellipsize (GTK_LABEL (w->toast), PANGO_ELLIPSIZE_MIDDLE); + gtk_label_set_max_width_chars (GTK_LABEL (w->toast), 90); + gtk_box_append (GTK_BOX (w->topright), w->toast); + + w->dlpanel = gtk_box_new (GTK_ORIENTATION_VERTICAL, (int) g_theme.win_gap); + gtk_widget_add_css_class (w->dlpanel, "br-dl"); + gtk_widget_set_halign (w->dlpanel, GTK_ALIGN_END); + gtk_widget_set_visible (w->dlpanel, FALSE); + gtk_widget_set_size_request (w->dlpanel, 320, -1); + gtk_box_append (GTK_BOX (w->topright), w->dlpanel); + + gtk_overlay_add_overlay (GTK_OVERLAY (overlay), w->topright); + + g_signal_connect (overlay, "get-child-position", + G_CALLBACK (on_overlay_position), w); + + gtk_window_set_child (GTK_WINDOW (w->win), overlay); + + GtkEventController *kc = gtk_event_controller_key_new (); + gtk_event_controller_set_propagation_phase (kc, GTK_PHASE_CAPTURE); + g_signal_connect (kc, "key-pressed", G_CALLBACK (on_key), w); + gtk_widget_add_controller (w->win, kc); + + GtkEventController *mc = gtk_event_controller_motion_new (); + g_signal_connect (mc, "motion", G_CALLBACK (on_motion), w); + g_signal_connect (mc, "leave", G_CALLBACK (on_motion_leave), w); + gtk_widget_add_controller (w->win, mc); + + /* a click anywhere outside the popup dismisses it */ + GtkGesture *away = gtk_gesture_click_new (); + gtk_gesture_single_set_button (GTK_GESTURE_SINGLE (away), 0); /* any button */ + gtk_event_controller_set_propagation_phase (GTK_EVENT_CONTROLLER (away), + GTK_PHASE_CAPTURE); + g_signal_connect (away, "pressed", G_CALLBACK (on_click_away), w); + gtk_widget_add_controller (w->win, GTK_EVENT_CONTROLLER (away)); + + if (g_no_middle_paste) { + GtkGesture *click = gtk_gesture_click_new (); + gtk_gesture_single_set_button (GTK_GESTURE_SINGLE (click), GDK_BUTTON_MIDDLE); + gtk_event_controller_set_propagation_phase (GTK_EVENT_CONTROLLER (click), + GTK_PHASE_CAPTURE); + g_signal_connect (click, "pressed", G_CALLBACK (on_middle_press), NULL); + gtk_widget_add_controller (w->win, GTK_EVENT_CONTROLLER (click)); + } + + if (g_follow_page_title) + g_signal_connect (view, "notify::title", G_CALLBACK (on_title), w); + + g_signal_connect (w->win, "map", G_CALLBACK (on_window_map), NULL); + g_signal_connect (w->win, "destroy", G_CALLBACK (on_close), w); + g_object_set_data_full (G_OBJECT (w->win), "win", w, win_free); + + /* history and the front-end's watchdogs need view -> window */ + g_object_set_data (G_OBJECT (view), "win", w); + + g_ptr_array_add (g_wins, w); + + if (g_app->win_ready) + g_app->win_ready (w); + + dl_panel_rebuild (w); /* picks up downloads already running */ + + g_windows++; + return w->win; +} + +static void +on_ready_to_show (WebKitWebView *view, gpointer u) +{ + (void) u; + gtk_window_present (GTK_WINDOW (window_new (view, FALSE))); +} + +/* --------------------------------------------------- user content manager */ + +/* One manager per view, so a front-end's script-message handler can tell + * which view a message came from. */ +static void +ucm_gone (gpointer data, GObject *where) +{ + (void) data; + if (g_ucms) + g_ptr_array_remove_fast (g_ucms, where); +} + +static WebKitUserContentManager * +ucm_new (void) +{ + WebKitUserContentManager *ucm = webkit_user_content_manager_new (); + + if (g_app->ucm_ready) + g_app->ucm_ready (ucm); + + css_apply_one (ucm); + + g_ptr_array_add (g_ucms, ucm); + g_object_weak_ref (G_OBJECT (ucm), ucm_gone, NULL); + return ucm; +} + +static void +view_wire (WebKitWebView *view) +{ + /* NB: in WebKitGTK 6.0 "download-started" only exists on the network + * session, not on the web view - it is wired up once in browser_main(). */ + g_signal_connect (view, "decide-policy", G_CALLBACK (on_decide_policy), NULL); + g_signal_connect (view, "permission-request", G_CALLBACK (on_permission), NULL); + g_signal_connect (view, "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); + + WebKitFindController *fc = webkit_web_view_get_find_controller (view); + g_signal_connect (fc, "counted-matches", G_CALLBACK (on_found_count), NULL); + g_signal_connect (fc, "failed-to-find-text", G_CALLBACK (on_found_none), NULL); + + if (g_app->view_ready) + g_app->view_ready (view); +} + +static WebKitWebView * +view_new (WebKitWebView *related) +{ + WebKitUserContentManager *ucm = ucm_new (); + WebKitWebView *view; + + if (related) + view = g_object_new (WEBKIT_TYPE_WEB_VIEW, + "related-view", related, + "settings", g_settings, + "user-content-manager", ucm, + NULL); + else + view = g_object_new (WEBKIT_TYPE_WEB_VIEW, + "network-session", g_session, + "settings", g_settings, + "user-content-manager", ucm, + NULL); + + /* a message handler can resolve the view from its manager */ + g_object_set_data (G_OBJECT (ucm), "view", view); + g_object_unref (ucm); /* the view owns it now */ + + webkit_web_view_set_zoom_level (view, g_zoom); + view_wire (view); + return view; +} + +static GtkWidget * +on_create (WebKitWebView *view, WebKitNavigationAction *act, gpointer u) +{ + (void) act; (void) u; + + /* popups (SSO, OAuth, "open in new window" players) share the session */ + WebKitWebView *nv = view_new (view); + g_signal_connect (nv, "ready-to-show", G_CALLBACK (on_ready_to_show), NULL); + return GTK_WIDGET (nv); +} + +/* -------------------------------------------------------------- session */ + +static void +setup_session (void) +{ + if (g_private) { + g_session = webkit_network_session_new_ephemeral (); + LOG ("profile: private (ephemeral)\n"); + return; + } + + char *cache_dir = NULL; + profile_dirs (g_profile, &g_data_dir, &cache_dir); + + if (g_clear_data) { + LOG ("clear-data: wiping profile data...\n"); + rm_rf (g_data_dir); + rm_rf (cache_dir); + } + + g_mkdir_with_parents (g_data_dir, 0700); + g_mkdir_with_parents (cache_dir, 0700); + + g_session = webkit_network_session_new (g_data_dir, cache_dir); + + WebKitCookieManager *cm = webkit_network_session_get_cookie_manager (g_session); + + /* helps with some SSO / multi-domain auth flows */ + webkit_cookie_manager_set_accept_policy (cm, WEBKIT_COOKIE_POLICY_ACCEPT_ALWAYS); + + /* persist cookies so logins survive restarts */ + char *cookie_path = g_build_filename (g_data_dir, "cookies.sqlite", NULL); + webkit_cookie_manager_set_persistent_storage (cm, cookie_path, + WEBKIT_COOKIE_PERSISTENT_STORAGE_SQLITE); + + LOG ("profile: name=%s\n", g_profile ? g_profile : "default"); + LOG ("profile: data=%s cache=%s cookies=%s\n", g_data_dir, cache_dir, cookie_path); + + g_free (cookie_path); + g_free (cache_dir); +} + +static void +setup_settings (void) +{ + g_settings = webkit_settings_new (); + + /* best effort at behaving like a mainstream browser */ + webkit_settings_set_enable_site_specific_quirks (g_settings, TRUE); + + /* Cloudflare/Turnstile sometimes correlates missing GPU features with bots */ + settings_set_bool_if_exists (g_settings, "enable-webgl", TRUE); + settings_set_bool_if_exists (g_settings, "enable-accelerated-2d-canvas", TRUE); + + /* lets a player pick a codec the build actually has, instead of + * negotiating one it cannot decode and then stalling */ + settings_set_bool_if_exists (g_settings, "enable-media-capabilities", TRUE); + + if (g_user_agent && *g_user_agent) { + webkit_settings_set_user_agent (g_settings, g_user_agent); + LOG ("ua: %s\n", g_user_agent); + } + + webkit_settings_set_enable_developer_extras (g_settings, TRUE); + webkit_settings_set_enable_media_stream (g_settings, TRUE); + webkit_settings_set_enable_webrtc (g_settings, TRUE); + webkit_settings_set_enable_mediasource (g_settings, TRUE); + webkit_settings_set_enable_encrypted_media (g_settings, TRUE); + webkit_settings_set_enable_webaudio (g_settings, TRUE); + webkit_settings_set_media_playback_requires_user_gesture (g_settings, FALSE); + webkit_settings_set_javascript_can_access_clipboard (g_settings, TRUE); + webkit_settings_set_enable_write_console_messages_to_stdout (g_settings, !g_quiet); + + if (g_app->settings_ready) + g_app->settings_ready (g_settings); +} + +/* ----------------------------------------------------------------- main */ + +int +browser_main (int argc, char **argv, const BrowserApp *app) +{ + const char *url_arg = NULL; + gboolean open_devtools = FALSE; + const char *cfg_path = NULL; + gboolean use_cfg = TRUE; + + g_app = app; + theme_defaults (); + + /* config first, so anything on the command line still wins */ + for (int i = 1; i < argc; i++) { + if ((!strcmp (argv[i], "-c") || !strcmp (argv[i], "--config")) && i + 1 < argc) + cfg_path = argv[++i]; + else if (!strcmp (argv[i], "-n") || !strcmp (argv[i], "--no-config")) + use_cfg = FALSE; + } + + if (use_cfg) { + if (cfg_path) + cfg_load_file (cfg_path); + else + cfg_load_default_chain (app->default_app_id); + } + +#define NEED_ARG(opt) \ + do { if (i + 1 >= argc) { \ + g_printerr ("%s: %s needs an argument\n", argv[0], opt); \ + return 1; \ + } } while (0) + + for (int i = 1; i < argc; i++) { + const char *a = argv[i]; + + if (!strcmp (a, "-h") || !strcmp (a, "--help")) { + usage (argv[0], TRUE); + return 0; + } else if (!strcmp (a, "-q") || !strcmp (a, "--quiet")) { + g_quiet = TRUE; + } else if (!strcmp (a, "--title")) { + NEED_ARG ("--title"); + g_free (g_title); + g_title = g_strdup (argv[++i]); + } else if (!strcmp (a, "--app-id")) { + NEED_ARG ("--app-id"); + g_free (g_app_id); + g_app_id = g_strdup (argv[++i]); + } else if (!strcmp (a, "--page-title")) { + g_want_page_title = TRUE; + } else if (!strcmp (a, "-c") || !strcmp (a, "--config")) { + NEED_ARG ("--config"); + i++; /* already applied above */ + } else if (!strcmp (a, "-n") || !strcmp (a, "--no-config")) { + /* already applied above */ + } else if (!strcmp (a, "-s") || !strcmp (a, "--set")) { + NEED_ARG ("--set"); + cfg_set_line (argv[++i]); + } else if (!strcmp (a, "--mod")) { + NEED_ARG ("--mod"); + if (!parse_mod (argv[++i])) { + g_printerr ("%s: unknown --mod %s (ctrl|alt|super|meta)\n", argv[0], argv[i]); + return 1; + } + } else if (!strcmp (a, "--clip-cmd")) { + NEED_ARG ("--clip-cmd"); + g_free (g_clip_cmd); + g_clip_cmd = g_strdup (argv[++i]); + } else if (!strcmp (a, "--zoom")) { + NEED_ARG ("--zoom"); + g_zoom = CLAMP (g_ascii_strtod (argv[++i], NULL), ZOOM_MIN, ZOOM_MAX); + } else if (!strcmp (a, "--css")) { + NEED_ARG ("--css"); + g_css_path = argv[++i]; + } else if (!strcmp (a, "--devtools")) { + open_devtools = TRUE; + } else if (!strcmp (a, "--no-media")) { + g_deny_media = TRUE; + } else if (!strcmp (a, "--enable-middle-click-paste")) { + g_no_middle_paste = FALSE; + } else if (!strcmp (a, "--download-dir")) { + NEED_ARG ("--download-dir"); + g_free (g_download_dir); + g_download_dir = g_strdup (argv[++i]); + } else if (!strcmp (a, "--private")) { + g_private = TRUE; + } else if (!strcmp (a, "--profile")) { + NEED_ARG ("--profile"); + g_free (g_profile); + g_profile = g_strdup (argv[++i]); + } else if (!strcmp (a, "--clear-data")) { + g_clear_data = TRUE; + } else if (!strcmp (a, "--user-agent")) { + NEED_ARG ("--user-agent"); + g_free (g_user_agent); + g_user_agent = g_strdup (argv[++i]); + } else if (!strcmp (a, "--ua-chrome")) { + g_free (g_user_agent); + g_user_agent = g_strdup ("Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"); + } else if (!strcmp (a, "--ua-win-chrome")) { + g_free (g_user_agent); + g_user_agent = g_strdup ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36"); + } else if (!strcmp (a, "--ua-win-edge")) { + g_free (g_user_agent); + g_user_agent = g_strdup ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36 Edg/127.0.0.0"); + } else if (!strcmp (a, "--ua-firefox")) { + g_free (g_user_agent); + g_user_agent = g_strdup ("Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0"); + } else if (!strcmp (a, "--ua-safari")) { + g_free (g_user_agent); + g_user_agent = g_strdup ("Mozilla/5.0 (Macintosh; Intel Mac OS X 14_6) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15"); + } else if (app->parse_arg && app->parse_arg (argc, argv, &i)) { + /* consumed by the front-end */ + } else if (g_str_has_prefix (a, "--") && looks_like_setting (a + 2)) { + cfg_set_line (a + 2); /* --pad=12 */ + } else if (a[0] != '-' && looks_like_setting (a) && !strstr (a, "://")) { + cfg_set_line (a); /* pad=12 */ + } else if (a[0] != '-' && !url_arg) { + url_arg = a; + } else { + g_printerr ("%s: unknown argument: %s\n", argv[0], a); + usage_short (argv[0]); + return 1; + } + } +#undef NEED_ARG + + /* The page <title> drives the window title by default. An explicit + * --title pins it instead, unless --page-title is also given - then + * --title is just the fallback for pages with no title of their own. */ + g_follow_page_title = g_want_page_title || (g_title == NULL); + + if (!g_title) + g_title = g_strdup (app->default_title); + if (!g_app_id) + g_app_id = g_strdup (app->default_app_id); + + /* must happen before the first toplevel is created: GTK reads the + * prgname when it builds the xdg_toplevel / sets WM_CLASS */ + g_set_prgname (g_app_id); + g_set_application_name (g_title); + + if (!g_download_dir) { + const char *d = g_get_user_special_dir (G_USER_DIRECTORY_DOWNLOAD); + g_download_dir = g_strdup (d ? d : "."); + } + + if (g_private && (g_profile || g_clear_data)) + LOG ("note: --private ignores --profile/--clear-data\n"); + + if (app->pre_gtk) + app->pre_gtk (); + + /* line-buffer, so our log interleaves sensibly with the web process + * writing to the same stderr */ + setvbuf (stderr, NULL, _IOLBF, 0); + setvbuf (stdout, NULL, _IOLBF, 0); + + /* a clipboard helper that dies early must not take us with it */ + signal (SIGPIPE, SIG_IGN); + + g_downloads = g_ptr_array_new_with_free_func (dl_free); + g_wins = g_ptr_array_new (); + g_ucms = g_ptr_array_new (); + + gtk_init (); + ui_css_install (); + + if (app->startup) + app->startup (); + + setup_session (); + g_signal_connect (g_session, "download-started", + G_CALLBACK (on_session_download_started), NULL); + object_set_string_if_exists (G_OBJECT (g_session), "downloads-directory", g_download_dir); + + history_setup (g_data_dir); + dlrules_setup (g_data_dir); + searches_setup (g_data_dir); + + setup_settings (); + css_reload (); + + WebKitWebView *view = view_new (NULL); + + if (open_devtools) + g_signal_connect (view, "load-changed", G_CALLBACK (on_load_changed_devtools), NULL); + + GtkWidget *win = window_new (view, TRUE); + 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. */ + if (!url_arg) { + webkit_web_view_load_uri (view, "about:blank"); + 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))) { + char *url = normalize_uri (url_arg); + if (!url) { + usage_short (argv[0]); + return 1; + } + LOG ("load: %s\n", url); + webkit_web_view_load_uri (view, url); + g_free (url); + } + gtk_window_present (GTK_WINDOW (win)); + } + + g_main_loop_run (loop); + + if (app->cleanup) + app->cleanup (); + + g_main_loop_unref (loop); + g_free (g_css_data); + g_free (g_data_dir); + g_free (g_hist_path); + g_free (g_title); + g_free (g_app_id); + g_free (g_clip_cmd); + g_free (g_download_dir); + g_free (g_profile); + g_free (g_user_agent); + g_free (g_css_owned); + g_free (g_theme.font); + g_free (g_theme.font_mono); + return 0; +} diff --git a/browser_core.h b/browser_core.h new file mode 100644 index 0000000..cbe4a7a --- /dev/null +++ b/browser_core.h @@ -0,0 +1,143 @@ +/* + * browser_core - the shared half of minibrowser and bigbrowser + * + * Everything both binaries have in common lives here: window, URL bar, + * toast, zoom, downloads, profiles, clipboard, history and the key + * handling. A front-end supplies a BrowserApp and calls browser_main(). + * + * minibrowser adds nothing. bigbrowser adds the camera / streaming layer. + */ + +#ifndef BROWSER_CORE_H +#define BROWSER_CORE_H + +#include <gtk/gtk.h> +#include <webkit/webkit.h> +#include <glib/gstdio.h> +#include <libsoup/soup.h> + +/* Modifiers we consider "significant" when matching a shortcut. + * Shift is deliberately absent: it is part of the key, not the combo. */ +#define MOD_MASK_ALL (GDK_CONTROL_MASK | GDK_ALT_MASK | GDK_SUPER_MASK | GDK_META_MASK) + +#define ZOOM_STEP 1.1 +#define ZOOM_MIN 0.25 +#define ZOOM_MAX 5.0 + +#define URL_TOAST_SECONDS 4 +#define DL_HISTORY_SECONDS 8 + +/* Per-window state. Owned by the GtkWindow (see window_new). */ +/* what the popup is doing right now */ +typedef enum { OMNI_URL, OMNI_HISTORY, OMNI_FIND, OMNI_DLDIR, OMNI_SEARCH } OmniMode; + +typedef struct { + GtkWidget *win; + WebKitWebView *view; + GtkWidget *omni; /* the centered popup, hidden by default */ + GtkWidget *omnientry; /* its input line */ + GtkWidget *omnilist; /* history matches under the input */ + GtkWidget *omniscroll; + GtkWidget *omnihint; /* match count while finding */ + GtkWidget *omniscope; /* page / site / everything, in the dir popup */ + GtkWidget *urltoast; /* the address, top left, on a new page */ + guint urltoast_id; + GtkWidget *keys; /* key reference, hidden by default */ + GtkWidget *topright; /* box holding the toast and the downloads */ + GtkWidget *toast; /* GtkLabel, hidden by default */ + GtkWidget *dlpanel; /* download overlay, rebuilt on refresh */ + guint toast_id; + gboolean dl_history; /* <mod>+D list is showing */ + guint dl_history_id; + gboolean primary; + + /* stored-history walk, see history.c section in browser_core.c */ + gboolean hist_walk; /* past the end of the session list */ + gboolean hist_loading; /* a load we started ourselves */ + int hist_pos; /* cursor into the stored history */ + gboolean load_failed; /* current load did not come up */ + guint hist_id; /* debounce before the entry is written */ + + + /* omni popup state */ + OmniMode omni_mode; + gboolean omni_list; /* the match list is showing */ + gboolean omni_setting; /* we are writing to the input ourselves */ + char *omni_needle; /* what the user actually typed */ + + gpointer ext; /* front-end state, see BrowserApp.win_* */ +} Win; + +/* + * Extension points. Every one may be NULL; minibrowser leaves almost all + * of them NULL, which is the whole point of the split. + */ +typedef struct { + const char *tagline; /* first line of --help */ + const char *default_title; + const char *default_app_id; + const char *usage_arg; /* "URL" or "URL|PATH|diag" */ + + void (*usage_options) (GString *out); /* extra option sections */ + void (*usage_keys) (GString *out); /* extra key bindings */ + + /* Return TRUE if argv[*i] was consumed; advance *i over its value. */ + gboolean (*parse_arg) (int argc, char **argv, int *i); + + void (*pre_gtk) (void); /* env, before gtk_init() */ + void (*startup) (void); /* after gtk_init() */ + void (*settings_ready)(WebKitSettings *s); + void (*ucm_ready) (WebKitUserContentManager *ucm); + void (*view_ready) (WebKitWebView *view); + void (*win_ready) (Win *w); + void (*win_gone) (Win *w); + + /* Extra shortcuts. Called with our modifier already held down. */ + gboolean (*key) (Win *w, guint key, gboolean shift); + + /* Chance to claim a raw address before it is normalised, e.g. "diag". + * Return TRUE if the front-end loaded something itself. */ + gboolean (*load_uri) (WebKitWebView *view, const char *raw); + + /* TRUE for addresses the front-end serves itself and that therefore + * do not belong in the stored history. */ + gboolean (*history_skip) (const char *uri); + + /* Front-end config keys, same file as everything else. Return TRUE if + * the key was ours. */ + gboolean (*cfg_set) (const char *key, const char *value); + + void (*cleanup) (void); +} BrowserApp; + +int browser_main (int argc, char **argv, const BrowserApp *app); + +/* -------------------------------------------------------- shared state */ + +extern WebKitSettings *g_settings; +extern WebKitNetworkSession *g_session; + +extern gboolean g_quiet; +extern gboolean g_deny_media; +extern gboolean g_private; +extern double g_zoom; +extern char *g_title; +extern char *g_app_id; +extern GPtrArray *g_wins; /* Win*, every open window */ + +extern GdkModifierType g_mod; +extern const char *g_mod_name; + +#define LOG(...) G_STMT_START { if (!g_quiet) g_printerr (__VA_ARGS__); } G_STMT_END + +/* ------------------------------------------------------ shared helpers */ + +Win *win_of (WebKitWebView *view); +void toast_show (Win *w, const char *text, guint seconds); +void css_reload (void); +void view_eval (WebKitWebView *view, const char *js); +char *normalize_uri (const char *in); +void settings_set_bool_if_exists (WebKitSettings *s, const char *prop, gboolean value); +void object_set_string_if_exists (GObject *o, const char *prop, const char *value); + +#endif /* BROWSER_CORE_H */ diff --git a/minibrowser b/minibrowser new file mode 100755 index 0000000..3cd5377 Binary files /dev/null and b/minibrowser differ diff --git a/minibrowser.c b/minibrowser.c new file mode 100644 index 0000000..a3affdd --- /dev/null +++ b/minibrowser.c @@ -0,0 +1,22 @@ +/* + * minibrowser - 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 minibrowser = { + .tagline = "minimal WebKitGTK 6.0 / GTK4 page viewer", + .default_title = "Minibrowser", + .default_app_id = "minibrowser", + .usage_arg = "URL", +}; + +int +main (int argc, char **argv) +{ + return browser_main (argc, argv, &minibrowser); +}