qbrsh
A fast, keyboard-driven web browser in Rust, built on WebKitGTK 6 / GTK 4 with a hand-rolled Elm-style (TEA) core.
Architecture
All application state lives in one owned State. Every source of change (key
presses, WebKit signals, IPC, worker results) produces a Msg drained by a
single consumer on glib's main context. The pure update(&mut State, Msg) -> Vec<Effect> is the only place state mutates. It returns Effect values that an
effect runner carries out, and results return as new messages. There is no
shared interior mutability, no re-entrancy, and no polling timers.
sources ─▶ Msg queue ─▶ update(&mut State) ─▶ [Effect] ─▶ runner ─▶ results back as Msg
src/core/: the engine-agnostic TEA core (state, msg, effect, update, command, key/trie/bindings, completion), fully unit-tested without GTK.src/engine/: theEngineViewtrait and the WebKitGTK 6 backend (the only placewebkit6types appear).src/input/,src/ui/,src/app.rs: GDK key translation, the window, and the effect runner that drives GTK/WebKit.src/history.rs: browsing history on a dedicated SQLite writer thread.src/plugin.rs: the Rune plugin runtime.
Build & run
Requires gtk4 and webkitgtk-6.0 (and gst-plugins-good + gst-libav for
media). Then:
cargo run
Keybindings (Normal mode)
| Key | Action | Key | Action |
|---|---|---|---|
h j k l |
scroll | f / F |
hint: follow / open in tab |
gg / G |
top / bottom | H / L |
back / forward |
<C-f>/<C-b> |
page down/up | r / R |
reload / hard reload |
<C-d>/<C-u> |
half page | J / K |
next / prev tab |
o / O |
open / open current URL | d / u |
close / reopen tab |
t |
open in new tab | gC gJ gK |
clone / move tab |
: |
command line | <A-1>..<A-9> |
focus tab N |
i / Esc |
insert / leave mode | yy / yt |
yank url / title |
m / b |
save / load quickmark | M / gb |
bookmark / load |
td |
toggle dark mode | co |
close other tabs |
/ |
find in page | n / N |
next / prev match |
zi / zo |
zoom in / out | zz |
reset zoom |
Counts work (e.g. 5j). Type /text on the command line to search the page,
then n to step forward through matches (wrapping at the end). N steps
backward, but is best-effort: WebKitGTK's backward search cannot reach
hidden/collapsed content (so it can't step back into a closed accordion) and
corrupts the find controller's heap, which surfaces as a harmless
free(): corrupted unsorted chunks message when the browser exits. Prefer n
(it wraps to every match) if you want to avoid that. In command mode, Tab/Shift-Tab
move the highlight through the completion list (your typed text stays in the
command line), Space applies the highlighted item so you can continue with an
argument, and Enter runs the highlighted item (or the typed text if none is
selected).
Commands
:open, :tabopen, :back, :forward, :reload, :tab-close/next/prev/focus,
:tab-clone/move/only, :undo, :hint, :yank, :quickmark-save/load/del,
:bookmark-add/load/del, :find-next/prev, :zoom-in/out/reset, :zoom <pct>,
:set, :config-source, :darkmode, :session-save/load, :plugin-reload,
:permissions, :quit.
Downloads are saved to your XDG downloads directory with a safe, non-colliding filename; start, completion, and failure are reported in the status bar.
Configuration
~/.config/qbrsh/config.toml (all fields optional):
= "https://duckduckgo.com"
[]
= "#1a1a2e"
= "#e0e0e0"
= "#ffd76e"
[]
= "monospace"
= 11
[]
# Default page zoom for new tabs (1.0 = 100%).
= 1.0
[]
# Default policy for a capability with no rule: ask, allow, or deny.
# "ask" shows an interactive prompt (allow once / deny once / always / deny-always).
= "ask"
[]
# Per-site overrides, matched by exact host or subdomain suffix. A bare policy
# applies to every capability; a table sets capabilities (geolocation,
# notifications, camera, microphone) independently.
= "allow"
= { = "allow", = "deny" }
Permissions are decided per capability. When a capability's policy is ask, a
prompt appears: y allow once, n deny once, a always allow, d always
deny (Esc denies). "Always" choices and edits made in the management view
(:permissions, where a/d/s set and x revokes the selected rule)
persist to a data-dir store, separate from your hand-written config.toml.
Change settings live with :set colors.accent "#ff0000",
:set permissions.example.com allow (all capabilities), or
:set permissions.example.com.camera deny (one capability), and reload the file
with :config-source.
Extensibility
qbrsh does not run Firefox/Chrome extensions. Extensibility is native:
-
Ad blocking: built-in domain blocking, applied both at navigation (frames, popups) and as a WebKit content filter for subresources (images, scripts, XHR). Extend via
~/.local/share/qbrsh/adblock, one domain per line. -
Plugins: Rune scripts in
~/.local/share/qbrsh/plugins/*.rn. Seeexamples/plugins/example.rn. TheqbrshAPI:command,open,message,eval_js(s).await(suspends the plugin and returns the page result), andon(event, handler)for cold-event hooks (page_load,tab_open,command). The Rune sandbox blocks ambient host access (no filesystem, network, or process) and plugins run under an instruction budget. Note, though, that plugins are trusted code:eval_jsreads the active page's DOM andopen/commandcan navigate, which together suffice to exfiltrate data. Run untrusted automation over the IPC interface, not as a plugin. Reload with:plugin-reload. -
Automation: drive the browser from an external process over the IPC control interface, a JSON-RPC socket at
$XDG_RUNTIME_DIR/qbrsh/ipc.sock. Send newline-delimited requests, for example:|Launching
qbrsh <url>while an instance is running forwards the URL to it.
Resource use
To balance memory against isolation, tabs of the same site share a WebKit web
process, while different sites run in separate processes. A renderer crash
therefore affects only the same-site tabs sharing that process; each shows a
recoverable error page and can be reloaded with r. This uses more memory than
forcing every tab into one process, which is the intended trade: one site cannot
read another site's data. The in-memory back/forward page cache is disabled, so
navigating back or forward reloads the page (from the resource cache where
possible) rather than restoring it instantly. Run :memory to see current
resident memory and the number of live views.
License
Licensed under either of MIT or Apache-2.0 at your option.