buffr
Vim-modal browser. Native shell, GPU-accelerated compositing via CEF. Keyboard first. No Electron. No web UI for chrome.
This site is the user-facing docs surface. The chapter list on the left covers:
- Getting started — build from source, run the dev tree.
- Running on macOS — Homebrew prerequisites, CEF vendoring, and direct
cargo runbehavior for local Mac development. - Configuration — every section of
config.toml:[general],[startup],[search],[theme],[privacy],[downloads],[hint],[crash_reporter],[updates],[accessibility],[idle_inhibit],[engines],[keymap]. - Keymap — every default page-mode binding, with a reference for the vim-flavoured action grammar.
- Multi-tab — multi-tab
BrowserHost, session restore, pinned tabs. - Hint mode —
f/Ffollow-by-letter overlay. - Context menu — buffr's own right-click menu, and
buffr-src:view-source. - Updates — the once-a-day GitHub release check, opt-out, and the manual
--check-for-updatesCLI. - Privacy — what buffr stores, what it never does, and the one network request it makes by default. Telemetry is opt-in and local-only; there is no collector to send it to.
- Accessibility — CEF renderer accessibility, keyboard-first chrome, high-contrast theme.
- Packaging — Linux
.deb/.rpm/.tar.gz/ AUR / Flatpak / Snap; macOS.app+.dmg; Windows MSI. - macOS signing — Developer-ID + notarization plan (not yet implemented).
- Windows packaging — the WiX 3 MSI layout.
- UI stack ADR — why CEF off-screen rendering composited with
wgpuin one winit window, instead of a separate chrome window or a CPU-blitted strip.
Source repo: https://github.com/kryptic-sh/buffr.
Install
macOS (Homebrew)
brew install --cask kryptic-sh/tap/buffr
Arch Linux (AUR)
paru -S buffr-bin
Pre-built binaries for Windows (MSI), Debian/Ubuntu (deb), Fedora/RHEL (rpm), Snap, and Flatpak are available on the releases page.
buffr — developer setup
Prerequisites
- Rust 1.95 — the MSRV (
rust-versionin the rootCargo.toml), pinned for local builds byrust-toolchain.toml(channel = "1.95.0");rustupinstalls it automatically on first build. CI does not use the pin: every job in.github/workflows/ci.ymlsets upstable. - A C/C++ toolchain (CEF links against system libraries).
- Linux:
libgtk-3,libnss3,libnspr4,libatk1.0,libatk-bridge2.0,libxcomposite1,libxdamage1,libxrandr2,libxkbcommon0,libxshmfence1,libdrm2,libgbm1,libpango-1.0,libasound2,libx11-xcb1,libcups2,libxss1,libxtst6. - macOS 12+, Xcode command-line tools.
- Windows 10+, MSVC build tools.
For a Mac-specific first-run checklist, including Homebrew packages and the
plain cargo run CEF layout, see
docs/site/macos-running.md.
First build
git clone git@github.com:kryptic-sh/buffr.git
cd buffr
# Vendor the CEF binary distribution (several hundred MB extracted).
# Drops files under `vendor/cef/<platform>/`.
cargo xtask fetch-cef
# Build the workspace (default-members builds all three binaries).
cargo build
# Run. Three binaries exist, so bare `cargo run` is ambiguous — name one.
# `buffr` is the supervisor and spawns `buffr-app` from its own directory.
cargo run --bin buffr
cargo xtask fetch-cef accepts:
--platform <PLATFORM>(alias--target) — override host detection, useful when cross-prepping. Accepted values, fromfetch_cefinxtask/src/main.rs:linux64(default on Linux),linuxarm64,macosarm64,macosx64,windows64,windowsarm64.--version <PREFIX>— version prefix to match in the Spotify CDN (index.json). Defaults toCEF_VERSION_PREFIXinxtask/src/main.rs, which must match the libcef version thecefcrate binds. That pairing is load-bearing:cef 152.xwraps libcef152.0.6, so the prefix is152..
Override the CEF tree location with CEF_PATH=... (mirrors
tauri-apps/cef-rs). When unset, crates/buffr-cef/build.rs falls back to
vendor/cef/<platform>/.
vendor/cef/ is in .gitignore. Re-run cargo xtask fetch-cef after bumping
the cef crate version.
Layout
buffr/
├── apps/
│ ├── buffr/ # supervisor binary (spawns + restarts buffr-app)
│ ├── buffr-app/ # browser binary (window, CEF lifecycle, chrome)
│ ├── buffr-helper/ # CEF subprocess helper (macOS Helper.app)
│ └── buffr-poc/ # EXCLUDED from the workspace — see below
├── crates/
│ ├── buffr-engine/ # BrowserEngine trait, routing, buffr:// server
│ ├── buffr-cef/ # CEF integration: host, handlers, build.rs
│ ├── buffr-core/ # engine-agnostic core: hints, edit, updates, …
│ ├── buffr-modal/ # vim page-mode FSM + keymap trie
│ ├── buffr-ui/ # chrome: statusline, tab strip, input bar
│ ├── buffr-config/ # config loading (TOML) + hot reload
│ ├── buffr-store/ # shared SQLite open/tune + migration runner
│ ├── buffr-history/ # history store
│ ├── buffr-bookmarks/ # bookmark store + Netscape import
│ ├── buffr-downloads/ # download tracking
│ ├── buffr-zoom/ # per-domain zoom persistence
│ ├── buffr-permissions/ # per-origin permission store
│ ├── buffr-view-source/ # buffr-src: rendering
│ └── buffr-webkit/ # EXCLUDED from the workspace — see below
├── xtask/ # cargo xtask: fetch-cef, packaging
├── fuzz/ # EXCLUDED from the workspace (cargo-fuzz)
├── vendor/cef/ # downloaded CEF binaries (gitignored)
├── docs/ # backlog (site sources live in docs/site/)
│ └── site/ # mdBook src — this file
└── TODO.md # near-term task list
crates/buffr-webkit (an experimental WPE WebKit backend) and apps/buffr-poc
(a Wayland subsurface-embedding proof of concept built on it) are in the
exclude list in the root Cargo.toml. They are Linux-only, need
wpewebkit-2.0 system packages that CI does not install, and are not built by
CI. Build them by hand:
cargo build --manifest-path crates/buffr-webkit/Cargo.toml
cargo build --manifest-path apps/buffr-poc/Cargo.toml
Running
RUST_LOG=buffr=debug,buffr_core=debug cargo run --bin buffr
To run the browser directly (without supervision):
RUST_LOG=buffr_app=debug,buffr_core=debug cargo run --bin buffr-app
Wayland
Linux requires a Wayland session. buffr-app checks XDG_SESSION_TYPE at
startup — before CEF init or any window creation — and exits with a clear
message when it is not wayland. X11/XWayland is not a supported target.
The page is rendered off-screen (CEF windowless mode) and composited with the
chrome into one window via wgpu on every platform; there is no XWayland
round-trip and no CEF child window on Linux. See
docs/site/ui-stack.md.
macOS bundling
CEF on macOS requires a strict app-bundle layout: the libcef framework must live
at Contents/Frameworks/Chromium Embedded Framework.framework/, and CEF's
helper subprocesses must be launched out of a nested
Contents/Frameworks/Buffr Helper.app/. The main binary loads the framework at
startup via cef-rs's LibraryLoader (helper=false); the helper does the
same with helper=true so the framework path resolves relative to its own
deeper bundle position (../../.. vs ../Frameworks).
The xtask bundle-macos subcommand assembles all of this:
# Vendor a macOS CEF distribution (cross-fetch from a Linux dev box is fine).
cargo xtask fetch-cef --platform macosarm64
# Build + assemble Buffr.app under target/release/.
cargo xtask bundle-macos --release
# Optional ad-hoc signing (gatekeeper-bypassed local runs only).
codesign --force --deep --sign - target/release/Buffr.app
# Run.
open target/release/Buffr.app
Notes:
- The compiled helper binary is
buffr-helper(with hyphen) but the bundle convention renames it toBuffr Helper(space-separated) during the copy. No Cargo changes needed. - The bundle ships the full four-helper layout macOS's sandbox model expects:
Buffr Helper.app,Buffr Helper (GPU).app,Buffr Helper (Renderer).app, andBuffr Helper (Plugin).app, each with its own plist fromxtask/templates/. The bundle test inxtask/src/main.rsasserts all four exist. - No
buffr.icnsis bundled yet; the plist references the file so Finder picks it up once we ship one. Until then macOS uses a generic app icon. - The bundle script runs on Linux too — useful for catching script regressions
in CI without booting a macOS runner. Real macOS CEF framework not on disk?
Set
BUFFR_BUNDLE_FRAMEWORK_DIR=<any-dir>to short-circuit the framework-existence check; bundle assembly still finishes, the resulting app just won't run. - Distribution-grade signing + notarization is documented in
docs/site/macos-signing.md. Phase 6 work.
Linux packaging
Four Linux distribution paths — deb, rpm, tarball, aur — all producible
from a single Linux dev box:
cargo xtask package-linux --release --variant all
ls target/dist/linux/
# buffr-<version>-amd64.deb
# buffr-<version>-x86_64.rpm
# buffr-<version>-x86_64.tar.gz
<version> is the workspace version from the root Cargo.toml; the xtask
stamps it into every filename, so there is nothing to keep in sync by hand.
dpkg-deb is auto-detected; when it is missing the xtask leaves the staging
tree at target/<profile>/buffr-deb/ and prints a warning rather than failing.
The AUR PKGBUILD is regenerated at pkg/aur/PKGBUILD with the current workspace
version on every run.
Full guide (layout, depends, glibc, sandbox caveats, signing TODO):
docs/site/packaging.md.
Crash-restart supervisor
buffr IS the supervisor — it spawns buffr-app (the browser binary) as a
child process group, detects non-zero exit and UI hangs (via a heartbeat
socket), and relaunches it with a 250 ms cooldown. After 3 crashes/hangs in 30
seconds it halts and points at ~/.local/share/buffr/crashes/. Unix and Windows
both have supervisor implementations (the Windows half uses Job Objects and a
named-pipe heartbeat).
The heartbeat socket and the clean-shutdown flag live inside a private per-uid
directory — $XDG_RUNTIME_DIR/buffr, else $TMPDIR/buffr-<uid> created 0700
and re-verified (owner + mode + not-a-symlink) after creation.
# Default: supervisor auto-spawns buffr-app (found next to its own exe,
# then on $PATH).
./buffr
# Smoke-test the supervisor without a real browser binary:
BUFFR_CHILD_BIN=/bin/true ./buffr
# Tune or disable the hang watchdog:
./buffr --heartbeat-timeout 8
./buffr --heartbeat-disable
Useful commands
cargo fmt --all
cargo clippy --workspace --all-targets -- -D warnings
cargo test --workspace
Where things live
| Concern | File |
|---|---|
| Supervisor / restart loop | apps/buffr/src/main.rs |
| Subprocess dispatch | apps/buffr-app/src/main.rs::main |
cef::App impl | crates/buffr-cef/src/app.rs |
| Browser creation | crates/buffr-cef/src/host.rs |
| CEF callback handlers | crates/buffr-cef/src/handlers.rs |
| CEF link + resource copy | crates/buffr-cef/build.rs |
| CEF download | xtask/src/main.rs::fetch_cef |
| Engine trait + routing | crates/buffr-engine/src/engine.rs |
| Page mode FSM | crates/buffr-modal/src/lib.rs |
hjkl-engine integration | crates/buffr-modal/src/edit_mode.rs |
| Statusline + font | crates/buffr-ui/src/lib.rs |
| Find-in-page sink | crates/buffr-core/src/find.rs |
| Hint / edit console IPC | crates/buffr-core/src/console_sentinel.rs |
| Config schema + loader | crates/buffr-config/src/lib.rs |
| Shared SQLite plumbing | crates/buffr-store/src/lib.rs |
| History store | crates/buffr-history/src/lib.rs |
| Bookmarks store | crates/buffr-bookmarks/src/lib.rs |
| Downloads store | crates/buffr-downloads/src/lib.rs |
UI
Chrome (statusline, tab strip, input bar, prompts) lives in crates/buffr-ui.
Rendering decisions are in docs/site/ui-stack.md: CEF renders
the page off-screen and the app composites page + chrome into one winit window
with wgpu on every platform. The 30-pixel statusline
(buffr_ui::STATUSLINE_HEIGHT) rasterizes glyphs with fontdue at a fixed 15
px, with per-glyph advance widths (crates/buffr-ui/src/font.rs). Find-in-page
is wired through BrowserHost::start_find / stop_find; the --find <query>
flag on buffr-app exercises the round trip headlessly.
Storage
Per-user state resolves through hjkl-config's XDG helpers — $XDG_DATA_HOME
(default ~/.local/share) and $XDG_CACHE_HOME (default ~/.cache), with
buffr as the directory name (buffr-debug in debug builds). The directories
crate is not used:
| Path | Owner |
|---|---|
~/.local/share/buffr/ (CEF root_cache_path) | Cookies, Local Storage, IndexedDB, HTTP Cache, GPU shader cache. |
~/.local/share/buffr/engines/<id>/ | Per-engine namespace. Computed and passed, but CEF ignores it — see config.md. |
~/.local/share/buffr/history.sqlite | History DB (buffr-history). |
~/.local/share/buffr/bookmarks.sqlite | Bookmarks DB (buffr-bookmarks). |
~/.local/share/buffr/downloads.sqlite | Downloads DB (buffr-downloads). |
~/.local/share/buffr/zoom.sqlite | Per-site zoom levels (buffr-zoom). |
~/.local/share/buffr/permissions.sqlite | Per-origin permission decisions (buffr-permissions). |
~/.local/share/buffr/favicons.sqlite | Favicon cache (buffr_core::FaviconCache). |
~/.local/share/buffr/session.json | Saved tab session (see multi-tab.md). |
~/.local/share/buffr/launch.json | Crash-loop tracker (apps/buffr-app/src/crash_guard.rs). |
~/.local/share/buffr/usage-counters.json | Opt-in local telemetry counters (off by default). |
~/.local/share/buffr/crashes/ | Opt-in panic reports, <stamp>_<seq>.json (off by default). |
~/.local/share/buffr/update-cache.json | Cached GitHub release check (see updates.md). |
~/.cache/buffr/ | Created at startup; used to derive the single-instance profile id. CEF stores nothing here. |
CEF state is under XDG_DATA_HOME, not XDG_CACHE_HOME. buffr-app passes
the data dir as CEF's root_cache_path, so cookies and local storage sit
alongside the SQLite stores. The XDG spec allows ~/.cache contents to be
deleted without warning, which is not survivable for a browser profile.
history.sqlite runs in WAL mode, so you'll also see history.sqlite-wal /
history.sqlite-shm next to it during a live session — that's normal. Schema
migrations are forward-only and recorded in a schema_version table; the
migration runner is crates/buffr-store/src/lib.rs and the frecency query lives
in crates/buffr-history/src/lib.rs.
macOS and Windows use the same XDG layout — there is no
~/Library/Application Support or %APPDATA% special case, and
$XDG_DATA_HOME / $XDG_CACHE_HOME are honored everywhere.
Config
buffr-config reads ~/.config/buffr/config.toml — the same path on Linux,
macOS, and Windows, with $XDG_CONFIG_HOME honored everywhere (debug builds use
buffr-debug). Schema reference: docs/site/config.md. A
copy-pasteable defaults file ships at
config.example.toml at the repo root — drop it
into $XDG_CONFIG_HOME/buffr/config.toml to start customising.
buffr --check-config # validate ~/.config/buffr/config.toml
buffr --print-config # dump the resolved (defaults + overrides) TOML
buffr --config /tmp/foo.toml # use a non-default path
buffr --homepage about:blank # override general.homepage for one run
Bookmarks
buffr-bookmarks ships an SQLite-backed bookmark store with tag support and a
Netscape HTML importer. Schema and Netscape parsing notes are in the module docs
at crates/buffr-bookmarks/src/lib.rs. There's no bookmarks UI yet; the CLI
flags exist for import + debugging:
# Import a Netscape HTML export (Chrome / Firefox / Edge "Export bookmarks…").
buffr --import-bookmarks ~/Downloads/bookmarks.html
# Stdout: `imported N bookmarks`
# List every stored bookmark (id\turl\ttitle\t[tag,tag]).
buffr --list-bookmarks
# List every distinct tag, sorted alphabetically.
buffr --list-bookmarks-tags
All three flags short-circuit before CEF init, so they work without a display server.
Zoom
buffr-zoom ships an SQLite-backed per-site zoom-level store. The CEF
LoadHandler::on_load_end callback restores the persisted level for the domain
on every load; the ZoomIn / ZoomOut / ZoomReset page actions write
through. Schema lives in the module docs at crates/buffr-zoom/src/lib.rs.
# Print every override (`<domain>\t<level>`).
buffr --list-zoom
# Wipe every override.
buffr --clear-zoom
Both flags short-circuit before CEF init.
Private mode
buffr --private
Private mode roots the entire profile under a tempfile::TempDir
($TMPDIR/buffr-private-<pid>-<rand>/{cache,data}) and opens every SQLite store
in-memory. The tempdir is deleted on shutdown; nothing persists across restarts.
The window title is stamped buffr — PRIVATE — NORMAL so the privacy state is
obvious from the taskbar.
Caveats:
- This is single-window incognito, not Tor-Browser-grade compartmentalisation. There is no IPC isolation from other buffr processes; running a persistent and a private buffr concurrently shares the same renderer/GPU service-worker pool.
- The clear-on-exit hook is a no-op in private mode — the tempdir's
Dropalready removes everything. - Multi-profile / per-window incognito (one persistent window plus one private
window in the same process) is not implemented —
--privateis a whole-process switch.
Clear-on-exit
[privacy] clear_on_exit (in config.toml) lists data categories that buffr
wipes after the event loop returns and before cef::shutdown(). Cookies route
through CEF's global cookie manager; history / bookmarks / downloads call
clear_all on their respective stores. See
config.example.toml for the full list of valid
entries.
The cache and local_storage entries are currently broken:
run_clear_on_exit in apps/buffr-app/src/main.rs deletes
<XDG_CACHE_HOME>/buffr/Cache and <XDG_CACHE_HOME>/buffr/Local Storage, but
CEF writes both under its root_cache_path, which is the data dir. Both deletes
therefore hit a directory that was never populated and log
clear_on_exit: dir absent — skipping.
Running on macOS
This is the shortest path for running the development build from a fresh clone on macOS.
Prerequisites
- macOS 12 or newer.
- Xcode command-line tools:
xcode-select --install
- Rust from
rustup. The repo pins the toolchain inrust-toolchain.toml, so Cargo installs the required Rust version on first use. - CMake and Ninja, used by the CEF build wrapper:
brew install cmake ninja
First run
From the workspace root:
cargo xtask fetch-cef
cargo build
cargo run --bin buffr-app
cargo xtask fetch-cef downloads the host CEF binary distribution and extracts
it under vendor/cef/macosarm64 on Apple Silicon or vendor/cef/macosx64 on
Intel Macs. vendor/cef/ is intentionally gitignored.
Bare cargo run does not work — the workspace has three binaries (buffr,
buffr-app, buffr-helper) and cargo cannot pick one.
cargo run --bin buffr-app runs the browser directly; cargo run --bin buffr
runs it under the crash-restart supervisor (build first so buffr-app sits next
to it). The build stages the CEF framework under target/Frameworks/ and the
CEF GPU support dylibs next to the binary in target/debug/. The macOS runtime
uses CEF off-screen rendering (OSR), so page content and buffr's
tabbar/statusbar are composited into the same winit window.
Runtime paths
buffr is XDG-everywhere, so the dev run writes profile state to the same
directories it uses on Linux (a debug build adds the -debug suffix):
~/.local/share/buffr-debug/
Everything lives there: the SQLite stores (history, bookmarks, downloads,
permissions, zoom, favicons) and CEF's own profile tree — cookies,
Local Storage, and the HTTP cache — because buffr-app passes the data dir as
CEF's root_cache_path. ~/.cache/buffr-debug/ is created too, but CEF writes
nothing there. See config.md.
Use --private for an in-memory/private data session:
cargo run --bin buffr-app -- --private
Useful commands
# More startup detail.
RUST_LOG=buffr_app=debug,buffr_core=debug cargo run --bin buffr-app
# Validate config without starting CEF.
cargo run --bin buffr-app -- --check-config
# Build the macOS app bundle under target/release/Buffr.app.
cargo xtask bundle-macos --release
The .app bundle path is still the right shape for packaging and signing. The
loose cargo run --bin … path is for local development and uses explicit CEF
settings so the loose binary can find the staged framework, resources, and
subprocess path.
buffr — configuration
User config is a single TOML file. Every key has a default; the loader emits an
error with a line/column span when a key is misspelt, unknown, or has the wrong
type. A copy-pasteable defaults-equivalent lives at
config.example.toml at the repo root.
File location
buffr is XDG-everywhere: the same path on all three platforms.
| Platform | Path |
|---|---|
| Linux | $XDG_CONFIG_HOME/buffr/config.toml (~/.config/buffr/…) |
| macOS | $XDG_CONFIG_HOME/buffr/config.toml (~/.config/buffr/…) |
| Windows | %XDG_CONFIG_HOME%\buffr\config.toml (~\.config\buffr\…) |
Path resolution goes through hjkl_config::config_path::<Config>() (see
crates/buffr-config/src/loader.rs). $XDG_CONFIG_HOME is honored on every
platform; there is no ~/Library/Application Support or %APPDATA% fallback,
and buffr does not depend on the directories crate. Debug builds use
buffr-debug instead of buffr as the directory name so a dev tree never
shares state with an installed release.
Everything else the browser persists lives under XDG_DATA_HOME —
~/.local/share/buffr/ by default, ~/.local/share/buffr-debug/ in debug
builds. That includes the six SQLite stores (history.sqlite,
bookmarks.sqlite, downloads.sqlite, zoom.sqlite, permissions.sqlite,
favicons.sqlite), session.json, update-cache.json, usage-counters.json,
crashes/, and CEF's own profile tree — cookies, Local Storage,
IndexedDB, and the HTTP Cache directory all sit in there together, because
apps/buffr-app/src/main.rs passes the data dir as CEF's root_cache_path.
That is deliberate: the XDG spec says ~/.cache contents may be deleted at any
time without warning, and losing cookies and local storage to a tmpfiles sweep
is not acceptable. ~/.cache/buffr/ (XDG_CACHE_HOME) is still created at
startup and is used to derive the single-instance profile id, but CEF does not
store anything there.
Per-engine subtrees are namespaced as ~/.local/share/buffr/engines/<id>/. See
[engines] for what that currently does — and does not — isolate.
Override the config file per-run with --config <PATH>.
Config-related CLI flags
These are the flags that touch config specifically. They are not the full
CLI surface — buffr --help lists ~30 flags (bookmark/history/download/zoom/
permission dumps, --private, --audit-keymap, update flags, and so on).
| Flag | Effect |
|---|---|
--print-config | Print the resolved (defaults + user overrides) config; exit 0. |
--check-config | Validate the config file; exit non-zero on parse / schema error. |
--config <PATH> | Override the XDG-discovered config path. |
--homepage <URL> | Override general.homepage for this run only. |
--engine <NAME> | Ignore [engines] and route every tab through <NAME> (cef). |
--private | In-memory stores + throwaway CEF cache; forces telemetry off. |
--audit-keymap | Print every default-bound PageAction and its keys; exit 0. |
Both --print-config and --check-config short-circuit before CEF initializes,
so they're safe to run on a headless host.
The flags above are parsed by the browser binary (
buffr-app,apps/buffr-app/src/main.rs). Thebuffrsupervisor takes only--heartbeat-timeout,--heartbeat-disable, and--help/--version; everything else it forwards verbatim to the child, sobuffr --check-configworks from the user's point of view.
Schema
The 13 sections below are the complete Config surface
(crates/buffr-config/src/lib.rs).
[general]
| Key | Type | Default | Notes |
|---|---|---|---|
homepage | string | buffr://new | Initial URL on first window. |
leader | string | " " (space) | Exactly one character. Validated. |
show_favicons | bool | true | false skips favicon render and the CEF icon fetch. |
[startup]
restore_session = true reopens the previous session's tabs on launch (opt-in —
default false). A fresh tab (o/O/:tabnew) opens new_tab_url (default
about:blank); the cold-start tab 0 still opens general.homepage.
| Key | Type | Default | Notes |
|---|---|---|---|
restore_session | bool | false | true restores the previous session's tabs on launch (opt-in). |
new_tab_url | string | about:blank | URL fresh tabs (o/O/:tabnew) open. |
[search]
| Key | Type | Default | Notes |
|---|---|---|---|
default_engine | string | duckduckgo | Must reference a [search.engines.<name>] block. |
[search.engines.<name>] blocks define each engine:
[search.engines.duckduckgo]
url = "https://duckduckgo.com/?q={query}"
prefix = "ddg" # optional
[search.engines.github]
url = "https://github.com/search?q={query}"
prefix = "gh"
{query} is replaced with the URL-encoded omnibar input.
prefix is an optional shortcut keyword. When set, an omnibar input of
<prefix> <query> routes to that engine instead of default_engine — e.g.
gh tokio searches GitHub, g rust closures searches Google, plain cats
falls through to the default. Bare prefix words with no query (e.g. g) fall
through to the default so they still produce a useful result. Prefix collisions
across engines are rejected at config validation time.
[theme]
Every colour is a 7-character #RRGGBB string. An unparseable value is a
hard config error (--check-config exits non-zero) — it is no longer
silently replaced by the built-in default.
| Key | Type | Default | Notes |
|---|---|---|---|
accent | string | #7aa2f7 | Statusline mode block, omnibar caret, hint labels, active tab. |
cert_secure | string | #66e08a | Secure cert indicator (lock dot, find counts). |
cert_insecure | string | #e05a5a | Insecure cert indicator. |
private | string | #ffc8c8 | PRIVATE marker on the statusline. |
progress | string | #66c2ff | Page-load progress bar. |
update | string | #e0c85a | Update-available indicator (* upd). |
high_contrast | bool | false | Overrides every colour above; see accessibility.md. |
[privacy]
| Key | Type | Default | Notes |
|---|---|---|---|
enable_telemetry | bool | false | Opt-in local-only counters. No network endpoint exists. |
clear_on_exit | string[] | [] | Any of cookies, cache, history, bookmarks, downloads, local_storage. |
skip_schemes | string[] | ["about", "cef", "chrome", "data", "file"] | URL schemes never recorded in history (case-insensitive). |
Telemetry is opt-in, local-only, and has no network endpoint — there is no collector to send counters to. The only network request buffr makes by default is the update check; see privacy.md and updates.md.
[downloads]
| Key | Type | Default | Notes |
|---|---|---|---|
default_dir | path? | unset | Unset resolves at runtime: dirs::download_dir(), then $HOME/Downloads, then the cwd. |
open_on_finish | bool | false | Launch the file via xdg-open / open / start on completion. |
ask_each_time | bool | false | true shows the OS Save-As dialog and suppresses the notification strip. |
show_notifications | bool | true | Chrome strip on download start/finish (2 s started, 4 s finished). |
[hint]
| Key | Type | Default | Notes |
|---|---|---|---|
alphabet | string | asdfghjkl;weruio | Label alphabet. Validated: non-empty, ASCII-only, no duplicates. |
See hint-mode.md.
[crash_reporter]
| Key | Type | Default | Notes |
|---|---|---|---|
enabled | bool | false | Opt-in Rust panic hook writing JSON under <data>/crashes/. |
purge_after_days | u32 | 30 | Cutoff for --purge-crashes. Must be > 0; 0 is rejected at load. |
[updates]
| Key | Type | Default | Notes |
|---|---|---|---|
enabled | bool | true | The only network request buffr makes by default. |
check_interval_hours | u32 | 24 | Must be > 0. |
github_repo | string | kryptic-sh/buffr | owner/repo slug; shape-validated. |
See updates.md.
[accessibility]
| Key | Type | Default | Notes |
|---|---|---|---|
force_renderer_accessibility | bool | false | Passes --force-renderer-accessibility to the CEF renderers. |
[engines]
| Key | Type | Default | Notes |
|---|---|---|---|
default | string | cef | Engine id used when no rule matches. Must name an instance. |
instances | table[] | [] | [[engines.instances]] — empty synthesises one cef instance. |
rules | table[] | [] | [[engines.rules]] — ordered; first host-glob match wins. |
[engines]
default = "cef"
[[engines.instances]]
id = "cef"
backend = "cef"
# data_dir = "/tmp/cef-b-cache" # accepted, but has no effect today
[[engines.rules]]
match = "*.figma.com"
engine = "cef"
backend accepts only "cef". It is the sole backend the browser can
construct: the WPE WebKit backend (crates/buffr-webkit) is excluded from the
workspace, Linux-only, and not built by CI. --engine <NAME> overrides the
whole section for one run and likewise only accepts cef.
data_dir and per-engine isolation
data_dir on an instance is parsed and plumbed all the way through —
buffr-app resolves it (explicit value, else <data>/engines/<id>/) and hands
it to the backend as BackendOpenOptions::data_dir. The CEF backend then
discards it: BrowserHost::new_with_options
(crates/buffr-cef/src/host.rs) does let _ = data_dir; and creates no
per-engine RequestContext, because CEF's Alloy runtime collapses a child
context's cache_path back onto the global Default/ profile anyway
(kryptic-sh/buffr#158).
Net effect today: every engine instance shares one on-disk profile — the
root_cache_path, which is the data dir described under
File location. Setting data_dir changes nothing you can
observe. The key is kept so configs do not break when per-engine isolation lands
(it needs the Chrome runtime, not Alloy).
[keymap.<mode>]
Mode is one of normal, visual, command, hint. Each entry maps a
vim-notation key sequence to a PageAction:
[keymap.normal]
"j" = "scroll_down"
"5j" = "scroll_down(5)"
"/" = "find(forward = true)"
"<Esc>" = "enter_mode(\"normal\")"
The full default keymap lives in keymap.md.
There is deliberately no [keymap.insert]. Insert mode forwards every key
straight to the page so the focused field handles typing natively — a binding
there would shadow whatever the user is typing, and the engine never consults
the keymap while in Insert anyway. The section is a hard validation error rather
than a silent no-op. Press <Esc> to leave Insert mode, then use a
[keymap.normal] binding.
Action notation
- Unit variants — bare snake_case name.
"scroll_down","reload","tab_close", etc. - Count-bearing scrolls —
name(N)whereN >= 0. Applies toscroll_up,scroll_down,scroll_left,scroll_right. - Find —
find(forward = true)orfind(forward = false). - Mode transition —
enter_mode("<mode>")with a quoted mode name.
Anything else surfaces a validation error pointing at the offending key.
[idle_inhibit]
Keeps the screen awake while video (or optionally audio) is playing in the focused window. Backed by four platform implementations:
- Linux Wayland —
zwp_idle_inhibit_manager_v1protocol. - Linux X11 —
org.freedesktop.ScreenSaver.Inhibitover D-Bus. - macOS —
IOPMAssertionCreateWithName(NoDisplaySleepAssertion). - Windows —
SetThreadExecutionState(ES_DISPLAY_REQUIRED)on a worker thread.
The inhibitor is acquired and released at runtime; no restart needed. The
video/audio signal comes from the JS media probe (__buffr_media__ console
sentinel) plus CEF's audio callbacks, re-evaluated every frame in
about_to_wait:
enabled && (video || (inhibit_audio_only && audio)) && (!require_focus || window_focused).
| Key | Type | Default | Notes |
|---|---|---|---|
enabled | bool | true | Master switch. false disables the feature entirely — no inhibitor is ever acquired. |
inhibit_audio_only | bool | false | When true, audio-only activity (no video) also triggers the inhibitor. |
require_focus | bool | true | When true, the inhibitor is held only while the buffr window has OS-level focus. Set to false to inhibit even when the window is in the background (useful for PiP setups). |
[idle_inhibit]
enabled = true
inhibit_audio_only = false
require_focus = true
Hot reload
The watcher uses notify with a 250ms debounce. On a successful reload, the
keymap only is swapped on the running engine — homepage, theme, startup, and
search settings still require a restart for now (full hot-apply is Phase 5+
work). A failed reload (parse or validate error) is logged and the previous
config stays live.
Validation rules
general.leadermust be exactly one character.search.default_enginemust reference an existing[search.engines.<name>]block.search.engines.<name>.prefix(when set) must be non-empty and unique across all engines.hint.alphabetmust be non-empty, ASCII-only, duplicate-free, and at least two characters.- All six
[theme]colours must parse as#RRGGBB. A typo is a hard error — it is no longer silently swapped for the built-in colour. crash_reporter.purge_after_daysmust be> 0.updates.check_interval_hoursmust be> 0;updates.github_repomust be anowner/reposlug.engines.defaultmust be non-empty and name a declared (or synthesised) instance; instance ids must be unique; every rule'senginemust resolve.- Every keymap binding's key sequence must parse via the engine's
parse_keys, and its action notation must match the table above. [keymap.insert]is rejected outright — Insert mode has no bindable keymap.- Unknown top-level keys, unknown nested keys, and unknown enum variants all
error out (
#[serde(deny_unknown_fields)]).
buffr default keymap (page mode)
Reference for the default page-mode bindings shipped by
buffr_modal::Keymap::default_bindings.
Leader key: the default is a single space (general.leader = " " in
Config), so the one <leader> binding below (<leader>p → PinTab) is typed
as <Space>p out of the box. Set [general] leader = "\\" for the vim
convention; build_keymap feeds that character to Keymap::default_bindings,
so every <leader> chord follows the config. (buffr --audit-keymap prints the
raw table strings, so a leader chord shows as the literal token <leader>p
rather than the key you actually press — Keymap::audit_default_bindings
ignores the leader it is handed. Cosmetic only.)
Defaults mirror Vieb (stock
app/renderer/input.js). Intentional divergences are flagged inline with [buffr].
The engine speaks vim-flavoured chord notation. <C-...> = Ctrl, <S-...> =
Shift, <M-...> / <A-...> = Alt, <D-...> = Super (Cmd on macOS), <leader>
= configured leader char.
Modes
| Mode | Trigger | Notes |
|---|---|---|
Normal | initial / <Esc> | Default; bindings below. |
Visual | left-drag ≥ 4 px | Text selection in the page. y yanks, <Esc> cancels. |
Command | : or e | Command line / omnibar focused. <Esc> returns. |
Hint | f / F | DOM hint overlay active. <Esc> returns. |
Pending | (transient) | Multi-key prefix in flight. Not user-bindable. |
Insert | text-field focus | Forwarded to Engine::feed_edit_mode_key. |
Count prefix
- Leading digits accumulate:
5jscrolls down 5 lines,12Gjumps to line 12 (when implemented).0alone is bindable (vim convention: column 0); digits 1-9 always start a count.
Ambiguity timeout
When a binding is a prefix of a longer one (g vs gg), the engine waits up to
Engine::timeout() (default 1000ms). If the user does not extend the prefix,
the shorter action fires.
Normal-mode bindings
Scroll
| Keys | Action | Notes |
|---|---|---|
j | ScrollDown(1) | |
k | ScrollUp(1) | |
h | ScrollLeft(1) | |
l | ScrollRight(1) | |
<Down> | ScrollDown(1) | |
<Up> | ScrollUp(1) | |
<Left> | ScrollLeft(1) | |
<Right> | ScrollRight(1) | |
<C-e> | ScrollDown(1) | |
<C-y> | ScrollUp(1) | |
<C-d> | ScrollHalfPageDown | |
<C-u> | ScrollHalfPageUp | |
<C-f> | ScrollFullPageDown | |
<C-b> | ScrollFullPageUp | |
<PageDown> | ScrollFullPageDown | |
<PageUp> | ScrollFullPageUp | |
gg | ScrollTop | |
G | ScrollBottom | |
<Home> | ScrollTop | |
<End> | ScrollBottom |
Tabs
| Keys | Action | Notes |
|---|---|---|
H | TabPrev | [buffr] Vieb uses H for history-back. |
L | TabNext | [buffr] Vieb uses L for history-forward. |
gt | TabNext | |
gT | TabPrev | |
o | TabNewRight | [buffr] Open tab to the right of active; omnibar opens so you type a URL. |
O | TabNewLeft | [buffr] Open tab to the left of active; omnibar opens so you type a URL. |
<C-t> | TabNewRight | Conventional-browser alternate for o. |
d | TabClose | |
<C-w> | TabClose | Deliberately a leaf — there are no <C-w>X prefix chords. |
u | ReopenClosedTab | Stack-based: repeated u undoes successive closes. |
<C-S-t> | ReopenClosedTab | Conventional-browser alternate for u. |
<leader>p | PinTab | Default leader is space, i.e. <Space>p. |
p | PasteUrl { after: true } | Open the clipboard URL in a tab to the right. Non-URL clipboard = no-op. |
P | PasteUrl { after: false } | Same, to the left. |
<C-S-h> | MoveTabLeft | Shuffle the active tab one slot left. |
<C-S-l> | MoveTabRight | Shuffle the active tab one slot right. |
TabClose (and :q) close the active tab. The application only exits when the
last tab is gone. PinTab toggles the pinned bit (pinned tabs sort to the front
— pin does not prevent close). There is no PageAction for duplicating a
tab — the capability exists only as the "Duplicate Tab" entry in the tab-strip
right-click menu (ContextMenuItem::TabDuplicate). See
multi-tab.md and context-menu.md.
History
| Keys | Action | Notes |
|---|---|---|
J | HistoryBack | [buffr] Vieb uses J for next-tab. |
K | HistoryForward | [buffr] Vieb uses K for previous-tab. |
<C-o> | HistoryBack | |
<C-i> | HistoryForward |
Reload / stop
| Keys | Action | Notes |
|---|---|---|
r | Reload | |
R | ReloadHard | |
<C-r> | ReloadHard |
Note:
<Esc>is not bound toStopLoadingin Normal mode; it isExitInsertMode— it blurs the focused DOM element and resets the engine to Normal unconditionally.
<C-c>isStopLoading(a buffr extension);yisYankUrl.
Omnibar / command line
| Keys | Action | Notes |
|---|---|---|
e | OpenOmnibar | |
<C-l> | OpenOmnibar | |
: | OpenCommandLine | |
; | OpenCommandLine | [buffr] alias; Vieb uses ; for hints. |
Hints
| Keys | Action |
|---|---|
f | EnterHintMode |
F | EnterHintModeBackground |
Find
| Keys | Action |
|---|---|
/ | Find { forward: true } |
? | Find { forward: false } |
n | FindNext |
N | FindPrev |
Yank
| Keys | Action | Notes |
|---|---|---|
y | YankUrl |
Zoom
| Keys | Action | Notes |
|---|---|---|
+ | ZoomIn | |
= | ZoomIn | Matches Chromium's Ctrl+= alias for zoom-in. |
- | ZoomOut | |
_ | ZoomOut | |
0 | ZoomReset | |
) | ZoomReset | |
<C-0> | ZoomReset | Vieb-style alias for the conventional chord. |
DevTools
| Keys | Action |
|---|---|
<F12> | OpenDevTools |
<C-S-i> | OpenDevTools |
Insert mode
| Keys | Action | Notes |
|---|---|---|
i | FocusFirstInput | [buffr] Same as gi — JS focuses first form input; focusin auto-promotes to Insert. |
gi | FocusFirstInput | [buffr] Vieb's insertAtFirstInput. JS focuses first input; focusin auto-promotes. |
<Esc> | ExitInsertMode | Blurs the active DOM element; resets edit state and engine to Normal unconditionally. |
EnterInsertModeremains in the action enum for advanced user config (e.g.[keymap.normal] "<F2>" = "enter_insert_mode") but is unbound by default.
Visual-mode bindings
Visual mode is entered automatically by dragging with the left mouse button in the page area (more than a 4 px threshold); the embedded CEF view renders the selection itself. There is no key that enters Visual mode by default.
| Keys | Action | Notes |
|---|---|---|
y | YankSelection | Copies the page selection via CEF's native frame.copy(). |
<C-c> | YankSelection | Same. |
<Esc> | EnterMode(Normal) | Cancels without yanking. |
Hint- and Command-mode bindings
| Mode | Keys | Action |
|---|---|---|
Hint | <Esc> | EnterMode(Normal) |
Command | <Esc> | EnterMode(Normal) |
Every other keystroke in those modes is consumed by the hint filter or the input bar — see the overlay table below.
Mode transitions
The engine reads the resolved [PageAction] and auto-transitions:
OpenOmnibar,OpenCommandLine→CommandEnterHintMode,EnterHintModeBackground→HintEnterInsertMode→Insert(trie bypassed;feed_edit_mode_keytakes over)ExitInsertMode→Normal(blurs DOM active element; clears EditFocus)EnterMode(m)→m
<Esc> is bound in Normal to ExitInsertMode and in Visual / Command / Hint to
EnterMode(Normal) so every mode has a guaranteed escape hatch.
In-overlay shortcuts (command line / omnibar)
When : opens the command line or e/<C-l> opens the omnibar, all keystrokes
route to the input bar instead of the page-mode trie. The bindings below mirror
readline / vim's command-line conventions.
| Keys | Action |
|---|---|
<Esc> / <C-c> | Cancel — close overlay, return to Normal mode. |
<CR> | Confirm — dispatch the command or navigate to the URL. |
<Tab> / <Down> | Move suggestion selection one row down (clamps at last). |
<S-Tab> / <Up> | Move suggestion selection one row up (clears at top). |
<Left> / <Right> | Move cursor through the buffer. |
<BS> | Delete the codepoint before the cursor. |
<C-u> | Clear the entire buffer. |
<C-w> | Delete the word before the cursor. |
<C-v> | Paste clipboard text, with CR/LF stripped. |
<Space> | Literal space (the toolkit reports it as a named key). |
In-prompt shortcuts (permissions)
When a page asks for a permission (camera, microphone, geolocation, notifications, clipboard, MIDI sysex, …) buffr surfaces a prompt strip and routes keystrokes to it until the request is resolved. The page content does not see these keys.
| Keys | Action |
|---|---|
a / y | Allow once (no row written). |
A / Y | Allow + remember for this origin. |
d / n | Deny once (no row written). |
D / N | Deny + remember for this origin. |
s | Synonym for D — deny + remember. |
<Esc> | Defer — Dismiss / cancel(), no persistence. |
If multiple requests pile up they queue; the statusline shows (N more pending)
on the prompt strip. After resolving one the next prompt appears on the
following frame.
See
crates/buffr-permissions/README.md
for the decision-precedence rules.
Mouse / context menu
| Gesture / input | Action |
|---|---|
| Right-click (page area) | Open context menu. Items depend on the hit-test target (see context-menu.md). |
<Up> / <Down> | Move row selection in the open menu. |
<Enter> | Activate selected menu item. |
<Esc> | Dismiss menu without action. |
| Click outside panel | Dismiss menu without action. |
| Any non-navigation key | Dismiss menu and pass key to normal page-mode dispatcher. |
| Left-click (tab strip) | Switch tab and close the omnibar overlay (parity with gt/gT). |
| Two-finger swipe right | HistoryBack (≥ 150 px horizontal, 2× more horiz than vertical). |
| Two-finger swipe left | HistoryForward (same threshold). |
Vieb chords intentionally NOT mapped
The following Vieb normal-mode actions have no buffr PageAction equivalent and
are skipped until those features land:
| Vieb chord(s) | Vieb action | Reason not mapped |
|---|---|---|
v | startVisualSelect | Visual mode is mouse-entered; no keyboard entry chord yet |
<C-v> | toVisualMode | Same |
<C-p> | previousTab (pointer) | Pointer mode not implemented |
<C-n> | nextTab (pointer) | Pointer mode not implemented |
m / M | setMark / restoreMark | Marks not implemented |
<C-s> | downloadLink | No DownloadLink action |
s / S | toSearchMode (special) | Covered by / / ? |
<C-a> / <C-x> | incrementUrl / decrement | No URL increment action |
<kPlus> / <kMinus> | zoomIn / zoomOut | kPlus/kMinus not a named key in buffr parser; covered by +/- |
<C-Tab> / <C-S-Tab> | nextTab / prevTab | Covered by H/L and gt/gT |
Note that p / P, u, <C-t>, and <C-f> are bound — see the Tabs and
Scroll tables above. Only their Vieb semantics differ: buffr's p/P paste a
clipboard URL into a new tab, u reopens the last closed tab, <C-t> opens a
tab to the right, and <C-f> is a full-page scroll (Vieb's pointer-mode variant
is what is unmapped).
Customising
Bindings come from a static table in crates/buffr-modal/src/keymap.rs. User
overrides go in ~/.config/buffr/config.toml under [keymap.<mode>] — see
config.md for the full schema and action notation. The watcher
reloads the keymap on file changes (250ms debounced).
Multi-tab architecture
BrowserHost is a manager owning a
Vec<Tab> of CEF browsers. All tabs belong to the same window (the winit
window the embedder constructed); only the active browser is visible. Switching
tabs flips visibility and focus.
Single Client, many Browsers
buffr_cef::handlers::make_client is called once per open_tab. Every client
returned from that factory shares the same Arc<History>, Arc<Downloads>,
Arc<ZoomStore>, plus the find / hint mailboxes. This means new visits,
downloads, and zoom rows all funnel into one set of sinks — the chrome doesn't
have to demux per-tab.
Each Tab owns its own cef::Browser returned from
browser_host_create_browser_sync. Tab IDs are minted by the manager (monotonic
AtomicU64) and are independent of CEF's own Browser::identifier(), which can
collide on close+reopen.
Tab switching
#![allow(unused)] fn main() { prev.host().was_hidden(true); prev.host().set_focus(false); next.host().was_hidden(false); next.host().was_resized(); next.host().set_focus(true); }
The was_resized call exists because hidden browsers don't repaint, and when
they come back the cached size may not match the current chrome geometry.
Calling was_resized forces CEF's renderer to re-layout.
There is no native child-window stacking to manage: every tab renders off-screen
(windowless_rendering_enabled = 1) and the app composites the active tab's
buffer itself, so visibility is entirely a matter of which buffer gets drawn.
See ui-stack.md.
set_focus(true) is enough for keyboard input to route to the new tab — CEF
dispatches synthesized focus events internally when the host's focus bit flips.
Session restore
On startup buffr reads ~/.local/share/buffr/session.json (resolved via
hjkl_config::data_dir — XDG on every platform, buffr-debug in debug builds).
When the file exists, the first entry navigates the initial tab; the rest open
in the background. CLI --new-tab <url> URLs append after the session list.
Crash-loop detection quarantines the session file and skips restore entirely.
Pinned and unpinned URLs live in two flat string arrays, not one array of
objects. The runtime tab order is pinned ++ tabs, and active indexes into
that combined list (apps/buffr-app/src/session.rs). The struct is
#[serde(deny_unknown_fields)], so a hand-written file with extra keys is
rejected. The schema is versioned so a future format bump can ignore stale
files.
{
"version": 1,
"pinned": ["https://example.com"],
"tabs": ["https://kryptic.sh"],
"active": 0,
}
--no-restore skips the read (homepage opens in a single tab) and still writes
a fresh session on exit. --list-session prints the saved file's entries to
stdout, one per line, as <flag>\t<url> where <flag> is * for pinned and a
single space otherwise, then exits without launching CEF. Schema version is
printed on stderr for diagnostic clarity.
Fresh installs
On the very first launch, session.json does not exist. The runtime opens a
single tab loading general.homepage from the user's TOML config (default
buffr://new).
:q semantics
:q, :quit, d, and <C-w> all close the active tab. Only when the last
tab is closed does the application exit. There is no separate "force-quit the
whole app" command yet — close the OS window.
Pinned tabs
Pinned tabs are marked with a leading * in the tab strip and are toggled with
<leader>p (a space plus p with the default leader). Pinning does not
prevent close; it does reorder — enforce_pinned_ordering moves pinned tabs
ahead of unpinned ones in the strip while keeping the active tab selected.
Private mode
--private swaps the on-disk profile dirs for an ephemeral TempDir. With
multi-tab, every tab in a private launch shares that single temp profile —
there is no per-tab profile mixing. Session restore is skipped under
--private; the saved file is not read or rewritten.
Per-tab session state
TabSession (find query + hint session) lives inside each Tab and restores
naturally when the tab regains focus. The injected hint JS is scoped to the
active main frame, so other tabs cannot see it. Find-in-page survives tab
switches because the query is stashed on the inactive tab's
TabSession.find_query.
OSR sleep on occlusion
Shipped in v0.3.0. When the buffr window is hidden behind other windows or on an inactive workspace, CEF's paint scheduler pauses and the wgpu present pipeline short-circuits — eliminating the CPU/GPU spin on hidden workspaces.
Trigger: WindowEvent::Occluded(true) from winit calls
BrowserHost::osr_sleep, which in turn calls was_hidden(true) on the active
tab's CEF browser host. The wgpu frame loop skips get_current_texture() and
present() while sleep is active.
Heuristic fallback: Hyprland and some other compositors do not fire
Occluded on workspace switches. A present_us watchdog kicks in after:
- 1 frame taking > 500 ms, or
- 3 of the last 5 frames taking > 100 ms.
When the heuristic trips, the render thread applies the same osr_sleep path as
a real Occluded event. Sleep clears on WindowEvent::Occluded(false) or on
any user input that reaches the window.
Ctrl+C during sleep is handled: the ctrlc crate dispatches
BuffrUserEvent::Shutdown via EventLoopProxy::send_event, waking winit
immediately rather than waiting for compositor activity.
Note: was_hidden on the active tab preserves audio playback (CEF 147 behaviour
on Linux). Background tabs already called was_hidden(true) at switch time; OSR
sleep is additive on top of that.
Hint mode — DOM-injected overlay labels
Vimium-style follow-by-letter-label hints: press f to enter hint mode, type a
few letters, the matched element gets clicked. F is nominally the
background-tab variant, but it still commits as a same-tab click and only logs a
tracing::warn! breadcrumb; routing the commit through open_tab_background is
not implemented.
Architecture: DOM injection
Hints render as real <div class="buffr-hint-overlay"> elements appended to the
page DOM. The host injects crates/buffr-core/assets/hint.js via
cef::Frame::execute_java_script after substituting four placeholders
(__ALPHABET__, __LABELS__, __SELECTORS__, and %%SENTINEL%% — the
per-session nonce, see The nonce). The JS enumerates visible
matching elements, assigns sequential data-buffr-hint-id attributes, and
renders an overlay div per target.
This sidesteps compositing the labels ourselves. The chrome layer has since
moved to OSR + wgpu (see ui-stack.md), but the hints stayed
in the page DOM: they need per-element geometry that the renderer already knows,
and keeping them there costs no extra compositor work.
IPC: console-log scraping (chosen)
CEF -> Rust uses the console-log fallback path, not cef_process_message_t.
The injected JS calls
console.log("__buffr_hint__:" + nonce + ":" + JSON.stringify(payload))
and BuffrDisplayHandler::on_console_message (in
crates/buffr-cef/src/handlers.rs) pattern-matches the sentinel via the shared
buffr_core::console_sentinel helper, parses the JSON tail with serde_json,
and writes into a one-slot HintEventSink
(Arc<Mutex<Option<HintConsoleEvent>>>). The host drains the sink each tick
from BrowserHost::pump_hint_events.
The nonce
on_console_message has no frame argument, so without authentication any
frame — including a third-party ad iframe — could emit a sentinel line and have
it accepted. A page doing that could point the next hint keystroke at an element
it chose, pin the idle inhibitor on so the screen never locks, or push text into
the yank-to-clipboard path.
So every sentinel line carries a 128-bit nonce (buffr_core::console_nonce),
minted from the OS CSPRNG and spliced into the injected script:
<sentinel><nonce>:<json>
The page nonce rotates on every main-frame load and the hint nonce on every
enter_hint_mode. Nonces only ever reach main frames, so a subframe can never
learn one. The match is also anchored at the start of the console line
rather than located with find anywhere in it.
Two consequences worth knowing:
- This is not a boundary against the top frame. The injected script runs in
the page, and injection happens at
on_load_end— after page script has run — so a page that hooksconsole.logfirst reads the nonce and can forge for itself. What the nonce closes is cross-frame forgery and cross-load replay. The complete fix is a realcef_process_message_tchannel; this is defence in depth on a transport that is structurally observable. - Anchoring is an availability trade. A page that wraps
console.logto prepend its own format string (%cINFO …) now hides our payload too, so hint and edit mode stop working on it. Accepted deliberately: on such a page the nonce is readable anyway, so the alternative is a channel the page controls.
The cleaner cef_process_message_t IPC channel was rejected for v1 because it
requires a renderer-side RenderProcessHandler registered via
CefApp::on_render_process_handler, plus a V8 binding so JS can call
frame->SendProcessMessage(PID_BROWSER, msg). That's helper-subprocess plumbing
for a single one-way "hint list" message. Console-log scraping reuses the
display handler we already wired and works identically end-to-end. If the hint
list ever needs to flow at animation rates (live scroll-position updates), we'll
revisit.
Rust -> CEF stays on execute_java_script: the host calls
window.__buffrHintFilter(typed), __buffrHintCommit(id), or
__buffrHintCancel() from BrowserHost::feed_hint_key / backspace_hint /
cancel_hint.
JS surface
The injected script exposes three globals on window:
__buffrHintFilter(typed)— hide every overlay whose label doesn't start withtyped(via thebuffr-hint-hiddenclass,display: none). Overlays that still match are re-shown, with the already-typed prefix wrapped in a<span class="buffr-hint-typed-prefix">so the user sees how far they've narrowed the label.__buffrHintCommit(elementId)— focus + click the element with the matchingdata-buffr-hint-target-id, then call__buffrHintCancel()to clean up.__buffrHintCancel()— remove every injected overlay div, strip everydata-buffr-hint-target-idattribute, and null out the three globals.
CSS
Every overlay carries the class buffr-hint-overlay. The injected
<style id="buffr-hint-style"> tag pins:
position: fixedz-index: 2147483647(max int32 — page stacking contexts can't shadow the hints); the literal lives incrates/buffr-core/assets/hint.js- vivid yellow background (
#FFD83A), black text, a#C8AA10border, andfont: bold 11px/1.4 -apple-system,BlinkMacSystemFont,"Segoe UI",monospace— so on mainstream platforms the label renders in the system UI font, withmonospaceonly as a last-resort fallback pointer-events: noneso the page below stays interactive.buffr-hint-overlay.buffr-hint-hidden { display: none !important }— applied by the filter callback to non-matching overlays.buffr-hint-overlay .buffr-hint-typed-prefix { opacity: .45; text-decoration: line-through }— applied to the child span holding the already-typed prefix of a still-matching label
Label algorithm
HintAlphabet::labels_for(count) is a port of Vimium's hud.js BFS:
- Empty-string seed in a queue, walked breadth-first.
- Each pop expands by every alphabet char (prepended).
- Stop once the unexpanded slice (
queue[offset..]) holds enough. - Reverse each entry, then sort by alphabet position.
This guarantees uniqueness, no-prefix-collisions, and that the first N enumerated elements get the shortest labels.
Config
[hint] alphabet = "asdfghjkl;weruio" controls the character set. Validation
rejects empty, non-ASCII, duplicate-bearing, and shorter-than-two-character
inputs at config-load time, so the runtime path never has to handle them.
Right-click context menu
Shipped in v0.4.0 (#23). Replaces Chromium's native context menu with a
custom-rendered buffr-ui::ContextMenuOverlay panel, driven by CEF's
ContextMenuHandler. Items are bucketed by hit-test type; only the items
relevant to the click target are shown.
Triggering the menu
Right-click anywhere in the page area. The menu appears at the cursor position
(clamped to the viewport). Dismiss it with <Esc>, a click outside the panel,
or by activating an item.
Navigation in the menu: <Up> / <Down> move row selection. <Enter>
activates the selected item. Disabled rows (greyed text) cannot be activated.
Any non-navigation key dismisses the menu and is passed to the normal page-mode
dispatcher.
Bucket priority
When multiple flags apply to a click target, the highest-priority bucket wins:
| Priority | Bucket | Trigger condition |
|---|---|---|
| 1 | Editable | TYPEFLAG_EDITABLE or is_editable |
| 2 | Link | TYPEFLAG_LINK or a non-empty link_url |
| 3 | Image | TYPEFLAG_MEDIA + MEDIATYPE_IMAGE |
| 4 | Media | TYPEFLAG_MEDIA + MEDIATYPE_VIDEO/AUDIO |
| 5 | Selection | TYPEFLAG_SELECTION or a non-empty selection, and not Editable |
| 6 | Page | Fallback — always shown when no bucket matches |
Right-clicks on the tab strip do not go through this table at all; they build a separate model — see Tab strip below.
Per-bucket items
Page (fallback)
Shown on a right-click on the page background, margin, or any element that doesn't match a higher-priority bucket.
| Variant | Label | Notes |
|---|---|---|
HistoryBack { enabled: bool } | Back | Greyed when there is no previous history entry. |
HistoryForward { enabled: bool } | Forward | Greyed when there is no forward history entry. |
Reload | Reload | Only when the page is not loading. |
StopLoading | Stop Loading | Only while the page is loading (replaces Reload). |
ViewPageSource | View Page Source | Opens buffr-src:<url> in a new tab. See below. |
InspectElement | Inspect Element | Opens DevTools at the right-click hit-point. |
Link
Right-clicking a hyperlink (<a href="..."> or any element with a URL).
| Variant | Label | Notes |
|---|---|---|
OpenLinkInNewTab | Open Link in New Tab | Foreground tab. |
OpenLinkInBackgroundTab | Open Link in Background Tab | Background tab (tab strip focus unchanged). |
OpenLinkInNewWindow | Open Link in New Window | Currently treated as a new tab (#18). |
CopyLinkAddress | Copy Link Address | Writes the URL to the clipboard. |
SaveLinkAs | Save Link As… | Triggers a CEF download of the link URL. |
InspectElement | Inspect Element | Opens DevTools at the right-click hit-point. |
Image
Right-clicking an <img> or other image-type media element.
| Variant | Label | Notes |
|---|---|---|
OpenImageInNewTab | Open Image in New Tab | Navigates to the image URL directly. |
SaveImageAs | Save Image As… | Triggers a CEF download. |
CopyImage | Copy Image | Fetches off-thread, transcodes to PNG, writes clipboard. |
CopyImageAddress | Copy Image Address | Writes the image URL as text to the clipboard. |
InspectElement | Inspect Element | Opens DevTools at the right-click hit-point. |
CopyImage falls back to writing the image URL as text when the clipboard
backend doesn't support image MIME (e.g. OSC52 over SSH).
Media (video / audio)
Right-clicking a <video> or <audio> element. Items whose media-state flag is
not set are omitted (e.g. MediaSaveAs only appears when CAN_SAVE is
reported; PictureInPicture only when CAN_PICTURE_IN_PICTURE is reported).
| Variant | Label | Notes |
|---|---|---|
MediaPlayPause { playing: bool } | Pause / Play | "Pause" when playing; "Play" when paused. |
MediaMute { muted: bool } | Mute / Unmute | "Unmute" when already muted. |
MediaLoop { looped: bool } | Enable/Disable Loop | Toggles <video>.loop. |
MediaShowControls | Show Controls | Toggling native controls; only shown when CAN_TOGGLE_CONTROLS. |
MediaSaveAs | Save Media As… | Only shown when CAN_SAVE flag is set. |
CopyMediaAddress | Copy Media Address | Writes the media URL as text to the clipboard. |
PictureInPicture | Picture in Picture | Only shown when CAN_PICTURE_IN_PICTURE is set. |
InspectElement | Inspect Element | Opens DevTools at the right-click hit-point. |
All media actions resolve the target element via document.elementFromPoint
with a querySelector('video, audio') fallback for sites (e.g. YouTube) where
the click target is a sibling/overlay rather than an ancestor of the media
element.
Selection
Right-clicking a text selection that is not inside an editable field.
| Variant | Label | Notes |
|---|---|---|
CopySelection | Copy | Writes selection_text to clipboard (CEF fallback). |
SearchSelection | Search for Selection | Opens the selection as a search query in a new tab. |
InspectElement | Inspect Element | Opens DevTools at the right-click hit-point. |
Editable
Right-clicking an editable field (<input>, <textarea>, contenteditable). All
ops are dispatched as CEF frame edit commands to the focused frame.
| Variant | Label | Notes |
|---|---|---|
Cut | Cut | cef_frame_t edit command. |
Copy | Copy | cef_frame_t edit command. |
Paste | Paste | cef_frame_t edit command. |
PasteAsPlainText | Paste as Plain Text | Strips rich formatting before insert. |
SelectAll | Select All | cef_frame_t edit command. |
Undo | Undo | cef_frame_t edit command. |
Redo | Redo | cef_frame_t edit command. |
Tab strip
Right-clicking an entry in the tab strip builds its own model via
build_tab_model(tab_count, tab_index, pinned) in
crates/buffr-core/src/context_menu.rs — it does not consult the page hit-test
buckets above.
| Variant | Label |
|---|---|
TabReload | Reload Tab |
TabDuplicate | Duplicate Tab |
TabPin | Pin Tab / Unpin Tab |
TabCopyUrl | Copy Tab URL |
TabClose | Close Tab |
TabCloseOthers | Close Other Tabs |
TabCloseToRight | Close Tabs to the Right |
TabPin's label flips on the tab's current pinned bit. TabCloseOthers is
enabled only when tab_count > 1 and TabCloseToRight only when
tab_index + 1 < tab_count; otherwise they render dimmed and cannot be
activated.
buffr-src: URL prefix
ViewPageSource navigates to buffr-src:<url> — the user-facing alias for
Chromium's view-source: scheme. Navigations to buffr-src:<url> are rewritten
to view-source:<url> at the CEF boundary. The omnibar, tab strip, and session
file all store the buffr-src: prefixed form uniformly.
You can also type buffr-src:https://example.com directly in the omnibar to
view source without using the menu.
What buffr-src: refuses to fetch
The scheme handler runs in the browser process, outside Chromium's network stack, so it enforces its own allowlist before fetching:
- The underlying URL must be
http:orhttps:. Everything else (file:,data:,ftp:, a nestedbuffr-src:, …) is refused. - Non-public destinations — loopback, link-local
169.254/16(including the cloud metadata endpoint), and RFC1918 ranges — are refused unless the page that initiated the navigation is already on that same host. That keeps "view source" working for buffr's ownbuffr://pages (served from127.0.0.1) while stopping a public page from pivoting into your LAN.
A rejected target still renders: you get an error page explaining which rule fired, not a silent failure.
Known issues
PictureInPictureno-ops on YouTube. The item firesmedia_picture_in_picture, which calls the browser's PiP API via JS injection. YouTube's embedded player rejects PiP calls unless they are initiated from a trusted user gesture — CEF's JS injection does not carry transient user activation across the OSR boundary. Tracked in issue #31.
buffr — update channel
A version-check + manual-update flow. No automatic binary replacement. Real auto-update needs signing infrastructure (Apple Developer ID + notarization on macOS, Authenticode on Windows, a signing service we don't have yet) so it's deferred to post-1.0. What ships today:
- Once per
[updates] check_interval_hours(default 24 h), buffr makes one HTTP GET against the GitHub releases API:https://api.github.com/repos/{repo}/releases/latest. - The result is cached at
<data>/update-cache.json. - The statusline reads the cache on launch; if a newer release exists it shows
* upd. If the cache is older thancheck_interval_hoursit shows* upd?(stale — we don't know if it's still current). - The user runs
buffr --check-for-updatesto refresh manually. There is no in-chrome "update now" button (no signed binary swap to trigger).
CLI
buffr --check-for-updates # hits the network, prints status, exits 0
buffr --update-status # reads cache, prints status, exits 0
Output format:
up-to-date <current_version>
available <current> <latest> <tag> <html_url>
stale <last_checked_rfc3339> <latest> <tag> <html_url>
disabled
error <message>
Config
[updates]
# Master switch. When false, buffr makes ZERO network calls — the
# `--check-for-updates` flag short-circuits to "disabled" without
# touching the network. The statusline indicator never appears.
enabled = true
# How often `--check-for-updates` is allowed to actually hit GitHub.
# Reads inside the window are served from the disk cache. Minimum 1.
check_interval_hours = 24
# `owner/repo` slug. Forks point this at their own repo.
github_repo = "kryptic-sh/buffr"
What gets sent
A single GET to a public REST endpoint. The request carries no PII:
- Path:
/repos/{repo}/releases/latest - Headers:
User-Agent: buffr/<version>(mandatory — GitHub rejects user-agent-less requests) andAccept: application/vnd.github+json. - No cookies, no auth token, no telemetry payload.
GitHub logs the request like any other API request (IP + timestamp). buffr does not receive that log; we do not run our own collector.
Dismissing a release — API only, not reachable yet
UpdateChecker::dismiss(version) records a release in the cache as "ignored".
Subsequent check_cached/check_now for the same version resolve to UpToDate
instead of Available. Filtering happens at read time, not write time: the
cache stays the source of truth for "what GitHub last reported".
Not user-facing. Nothing outside
updates.rsand its unit tests callsdismiss()— there is no CLI flag and no chrome affordance for it, so a user cannot currently dismiss a release. Both the dismiss entry point and a--reset-update-dismissalsflag to clear the list are outstanding work.
Implementation
crates/buffr-core/src/updates.rs—UpdateChecker,UpdateStatus,HttpClienttrait,UreqClientimpl.crates/buffr-config/src/lib.rs—[updates]section schema + validation (repo shape, non-zero interval).apps/buffr-app/src/main.rs—--check-for-updates/--update-statusCLI short-circuits. Statusline* updindicator wired to the cache read. (Thebuffrsupervisor takes only--heartbeat-timeout/--heartbeat-disableand forwards everything else tobuffr-app, so typingbuffr --check-for-updatesstill works.)
The trait HttpClient exists so unit tests can drive the state machine without
touching the real network. The real network path uses ureq 3.x
(Agent::config_builder()) with a 5 s connect + 5 s read timeout.
buffr — privacy
Two opt-in surfaces — telemetry counters and the crash reporter — are both off by default and both local-only. buffr never sends usage or crash data to a network endpoint. Not now, not ever, not even to a kryptic-owned server. The implementation is a deliberate no-op that documents the design rather than a stub waiting for an endpoint.
There is one network request buffr makes by default — see Update channel below. It can be disabled.
Update channel — one HTTP GET per day
Default-on. [updates] enabled = true in the user config. buffr makes one
HTTP GET per check_interval_hours (default 24 h) against
https://api.github.com/repos/kryptic-sh/buffr/releases/latest. The request
carries no PII: only a User-Agent: buffr/<version> header (which GitHub
mandates) and an Accept: application/vnd.github+json header. No cookies, no
auth token, no telemetry payload. GitHub logs the request like any public API
request (IP + timestamp); buffr does not run its own collector.
To disable entirely, set [updates] enabled = false in config.toml. That path
makes zero network calls — the --check-for-updates CLI flag short-circuits
without opening a socket. See Updates for the full surface.
Telemetry — opt-in usage counters
Off by default. Set [privacy] enable_telemetry = true in config.toml to opt
in. When enabled, buffr writes anonymous integer counters to:
~/.local/share/buffr/usage-counters.json
The data directory is XDG-everywhere — the same ~/.local/share/buffr/ on
Linux, macOS, and Windows ($XDG_DATA_HOME is honored on every platform). Debug
builds use buffr-debug instead of buffr.
The file is pretty-printed JSON. After one app start it looks like:
{
"app_starts": 1
}
Counters tracked:
| Key | Increments on |
|---|---|
app_starts | Successful CEF init. |
tabs_opened | Every BrowserHost::open_tab (foreground + background). |
pages_loaded | Every main-frame LoadHandler::on_load_end. |
searches_run | Omnibar input that falls through to the search-engine route. |
bookmarks_added | :bookmark cmdline (Netscape import is intentionally not). |
downloads_completed | DownloadHandler reports is_complete(). |
Counters flush every 60 s in the background plus once at clean shutdown. There is no code path that opens a network socket for telemetry — there is no endpoint to disable, no opt-out flag to flip; the network surface simply does not exist.
If you want to share counters with someone, write a script that reads the JSON
and curls it to wherever you choose. buffr will not do this for you.
CLI:
buffr --telemetry-status # print enabled/disabled, path, and current counts
buffr --reset-telemetry # truncate counters to {}
--private mode forces telemetry off regardless of the config flag — the whole
point of --private is "leave no traces".
Crash reporter — opt-in local panic capture
Off by default. Set [crash_reporter] enabled = true to opt in. When enabled,
buffr installs a std::panic::set_hook that captures the panic message,
panic-site location, and a Backtrace::force_capture (always on, regardless of
RUST_BACKTRACE) and writes a JSON report to:
~/.local/share/buffr/crashes/<RFC3339-timestamp>_<seq>.json
Filename pattern: YYYY-MM-DDTHH-MM-SS.sssZ_<N>.json — colons swapped for
dashes so the path is portable to FAT/Windows, plus a process-wide counter. The
timestamp is only millisecond-precise, so the counter (and an O_EXCL-style
create_new open with retry) keeps two threads that unwind in the same
millisecond from overwriting each other's report.
CEF's BrowserProcessHandler does not expose an on_uncaught_exception
callback in the libcef buffr links (152.0.6, via the cef crate 152.x) —
the only on_uncaught_exception is on the renderer- process
RenderProcessHandler and only fires for V8 exceptions (JavaScript errors).
Native CEF crashes are caught by Chromium's internal crashpad/ breakpad
pipeline, which buffr does not currently configure (it requires a
crashpad_handler binary plus a symbol-server URL — both Phase 7 work). Phase 6
ships the panic-hook reporter only.
Reports are kept locally. Inspect them by hand:
buffr --list-crashes # one line per report: <ts>\t<version>\t<location>\t<msg>
buffr --purge-crashes # delete reports older than crash_reporter.purge_after_days
If you want to send a report to someone, mail the JSON file. buffr never uploads.
buffr — accessibility
Honest status: web content is accessible (CEF feature); native chrome currently isn't. Keyboard-only operation is comprehensive. A high-contrast theme is available.
Web content (CEF renderer accessibility tree)
When [accessibility] force_renderer_accessibility = true, buffr's
App::on_before_command_line_processing injects the
--force-renderer-accessibility Chromium switch. This causes the renderer to
build the accessibility tree for every page; platform screen readers
(Orca/AT-SPI on Linux, VoiceOver/NSAccessibility on macOS, NVDA/JAWS via MSAA on
Windows) consume that tree the same way they would for Chromium proper.
The default is false because building the tree is a non-trivial per-frame cost
users without an AT don't need. Users who rely on a screen reader should enable
it on first launch.
The cef crate buffr pins (152.x, wrapping libcef 152.0.6) does not expose
a Settings::accessibility_state field; the command-line switch path is the
supported wiring. (There is also a SetAccessibilityState method on the
per-browser host that can be flipped later, but the command-line switch covers
every renderer at process start.)
Native chrome — keyboard-first, no AT bridge yet
The statusline, tab strip, command bar, omnibar, and permissions prompt are
rasterized on the CPU into a pixel buffer and uploaded to the GPU with wgpu
(softbuffer was replaced by the wgpu present layer). They are not part of
any DOM and are not exposed via platform accessibility APIs. (The hint
overlay is the exception: it is injected into the page DOM — see
hint-mode.md.) Real cross-platform native a11y bridges
(AT-SPI, NSAccessibility, MSAA) are substantial multi- platform work and are
deferred to post-1.0.
Until then, every chrome surface is reachable via the keyboard:
:/;— command linee/<C-l>— omnibaro/O— new tab right / left (omnibar opens for the URL)f/F— hint mode (foreground / background)gt/gT,H/L— next / prev tabdor<C-w>— close tab;uor<C-S-t>— reopen closed tab<Space>p(<leader>p) — pin/unpin the active tab<C-S-h>/<C-S-l>— move the active tab left / rightJ/K(or<C-o>/<C-i>) — history back / forwardr/<C-r>— reload / hard reload//?/n/N— find / find-backwards / next-match / prev-matchy— yank the URL (in Visual mode, the selection)+/=/-/0— zoom in / in / out / reset<F12>/<C-S-i>— devtools
Run buffr --audit-keymap to print the full table from any shell, or read
keymap.md. The every_user_facing_action_has_a_default_binding
unit test guards against drift: a new PageAction variant lands in
buffr-modal → either it gets a default binding or the test fails. (Caveat: the
test scans the static binding table rather than the built trie, so a chord bound
twice can mask a shadowed action — StopLoading is currently in that state, see
keymap.md.)
High-contrast theme
[theme] high_contrast = true switches the chrome palette to:
| Token | Default (accent-derived) | High-contrast |
|---|---|---|
accent | #7aa2f7 (theme.accent) | #ffff00 |
bg | accent blended 92 % black | #000000 |
bg_lifted | accent blended 85 % black | #101010 |
fg | #eeeeee | #ffffff |
fg_dim | #a0a8ac | #c0c0c0 |
cert_secure | #66e08a | #ffffff |
cert_insecure | #e05a5a | #ffffff |
private | #ffc8c8 | #ffffff |
progress | #66c2ff | #ffffff |
update | #e0c85a | #ffffff |
The values pass WCAG AAA contrast against each other on the chrome surfaces.
They live in Palette::high_contrast() in crates/buffr-ui/src/lib.rs, and
they override every [theme] colour from the config.
What's deferred (post-1.0)
- AT-SPI bridge for the chrome on Linux.
- NSAccessibility bridge on macOS.
- MSAA + UI Automation bridge on Windows.
- Larger-text option for the chrome font (
crates/buffr-ui/src/font.rsrasterizes at a fixedTARGET_PX = 15.0with no user scale). - Reduced-motion preference. There is no way to turn chrome animation off: the
ASCII splash loading animation (
apps/buffr-app/src/loading_anim.rs, a ~2 s cycle at 12 fps) and the page-load progress bar both animate unconditionally.
If any of these block your daily use, file an issue at https://github.com/kryptic-sh/buffr/issues.
buffr — Packaging
Distribution artifacts for all three tier-1 targets. Everything below is unsigned — no signing step exists in any workflow yet:
| Platform | Driver | Output |
|---|---|---|
| Linux | cargo xtask package-linux | .deb + .rpm + .tar.gz + AUR PKGBUILD |
| Linux | flatpak-builder (CI) | .flatpak (single-file bundle) |
| Linux | snapcraft (CI) | .snap (classic confinement, single-file bundle) |
| macOS | cargo xtask package-macos-dmg | target/dist/macos/buffr-<ver>-arm64.dmg |
| Windows | cargo xtask package-windows-msi | target/dist/windows/buffr-<ver>-<x64|arm64>.msi |
The macOS bundle assembly (driving the DMG) lives in
docs/site/macos-signing.md; the Windows MSI flow has its
own docs/site/windows-packaging.md. The rest of this
document covers Linux end-to-end.
Linux
cargo xtask package-linux ships four Linux distribution paths, all producible
from a single Linux dev box:
| Format | Tooling | Audience |
|---|---|---|
.deb | dpkg-deb | Debian / Ubuntu / Mint. |
.rpm | rpmbuild | Fedora / RHEL / openSUSE. |
.tar.gz | tar | Distro-agnostic portable tree. |
| PKGBUILD | makepkg (user-side) | Arch / Manjaro / EndeavourOS. |
Flatpak and Snap bundles are produced by CI, not by the xtask — see Flatpak and Snap below.
None of these are signed. Signing is separate trust-store work that has not landed; the artifacts here are installable but Gatekeeper-equivalent prompts will warn the user.
Building all four
cd buffr
cargo xtask fetch-cef # vendor CEF if not already
cargo xtask package-linux --release # default --variant all
ls target/dist/linux/
You'll get:
target/dist/linux/
├── buffr-<version>-amd64.deb
├── buffr-<version>-x86_64.rpm
└── buffr-<version>-x86_64.tar.gz
<version> is [workspace.package] version from the root Cargo.toml, stamped
in by the xtask.
The PKGBUILD is written to pkg/aur/PKGBUILD (in-tree, not under target/) —
its pkgver field is rewritten to match the workspace version on every run.
Variant flags
cargo xtask package-linux --variant deb
cargo xtask package-linux --variant rpm
cargo xtask package-linux --variant tarball # `tar` is accepted too
cargo xtask package-linux --variant aur
cargo xtask package-linux --variant all # default
Anything else is rejected by LinuxVariant::parse in xtask/src/main.rs.
Add --release to use the release-profile binaries; without it the debug
binaries land in the package (slow, large, useful for smoke testing the bundle
scripts).
Tooling fall-back
dpkg-deb is checked on $PATH. If absent (Arch / Fedora hosts without the
dpkg package), the staging tree at target/<profile>/buffr-deb/ is left in
place and a warning is printed; the .deb itself is not produced. rpmbuild is
checked the same way — xtask: rpmbuild not on PATH; skipping rpm build — and
the tarball leg shells out to tar. A missing tool never fails the run.
.deb
sudo dpkg -i target/dist/linux/buffr-*-amd64.deb
sudo apt-get install -f # auto-resolve any missing depends
Layout on disk:
/opt/buffr/ (binaries + CEF runtime payload)
├── buffr (supervisor — Linux entrypoint)
├── buffr-app (browser; rpath=$ORIGIN finds libcef.so)
├── buffr-helper (CEF subprocess helper)
├── libcef.so
├── *.pak / icudtl.dat / v8_context_snapshot.bin
├── locales/
└── icon.png
/usr/share/applications/buffr.desktop
/usr/share/icons/hicolor/512x512/apps/buffr.png
/usr/local/bin/buffr -> /opt/buffr/buffr (postinst symlinks)
/usr/local/bin/buffr-app -> /opt/buffr/buffr-app
/usr/local/bin/buffr-helper -> /opt/buffr/buffr-helper
The postinst hook also refreshes gtk-update-icon-cache and
update-desktop-database best-effort — missing tooling is not an error. The
prerm hook removes the /usr/local/bin/buffr* symlinks if (and only if) they
still point back at /opt/buffr/.
Apt depends
libgtk-3-0, libnss3, libxss1, libasound2, libgbm1,
libxshmfence1, libxkbcommon0, libxkbcommon-x11-0, libgles2
libgtk-3-0 transitively brings in libatk-1.0-0, libatk-bridge-2.0-0,
libpango-1.0-0, libcairo2, libdbus-1-3, libdrm2, libxcomposite1,
libxdamage1, libxrandr2, libxext6, libxfixes3, libxrender1 — so we
don't list those explicitly. libnspr4 and libcups2 are pulled by libcef.so
directly but ship as default-installed on every modern Debian/Ubuntu desktop
image. If you hit a libnspr4.so / libcups.so.2 not-found error on a minimal
container, sudo apt-get install -f resolves it.
Signing
Not done in this round. To sign locally:
dpkg-sig --sign builder target/dist/linux/buffr-*-amd64.deb
You need a GPG key the user has imported. CI release signing is Phase 6 follow-up.
AUR PKGBUILD
The PKGBUILD assumes a tagged release on GitHub at
https://github.com/kryptic-sh/buffr/archive/v${pkgver}.tar.gz. Until a tag
actually ships, makepkg will 404. The sha256sums=('SKIP') entry is
intentional — replace it with the tarball's real digest at release time:
updpkgsums pkg/aur/PKGBUILD
pkgver is rewritten on every cargo xtask package-linux invocation to match
[workspace.package].version; manual edits are clobbered.
Local install
Copy pkg/aur/PKGBUILD (and pkg/buffr.desktop + pkg/buffr.png, which the
package() step references) to a clean dir and:
makepkg -si
makedepends
rust cargo cmake
Plus the runtime depends:
gtk3 nss libxss alsa-lib mesa libxshmfence libxkbcommon
libxkbcommon-x11 libglvnd
libglvnd provides libGLES.so.2 — Arch's equivalent of Debian's libgles2.
Sandbox caveat
CEF on Linux uses a SUID sandbox helper by default. Every Linux package here
ships the unprivileged binary and no chrome-sandbox helper; CEF falls back
to the namespace sandbox when the kernel allows unprivileged user
namespaces. On hosts where that has been turned off (some hardened-kernel
distros), buffr will warn and continue without sandboxing. Re-enabling means
either flipping the sysctl or shipping a SUID helper at
/opt/buffr/chrome-sandbox — the latter is not implemented.
Icon — placeholder
pkg/buffr.png is a 512×512 placeholder generated with ImageMagick (#7aa2f7
lowercase "b" on #1a1a1a). The real icon will live at the same path; the
.deb, .rpm, tarball, PKGBUILD, and the flatpak job all point at it.
Replacing the file and re-running cargo xtask package-linux is enough to ship
a new icon.
CI
The linux-package job in .github/workflows/ci.yml runs the full
cargo xtask package-linux --release --variant all pipeline. It is skipped on
pull requests (if: github.event_name != 'pull_request') and runs on pushes
to main, on v* tags, and on workflow_dispatch. The same gate applies to
macos-package, windows-package, flatpak, and snap. It:
- caches the CEF binary distribution,
- runs
dpkg-deb -Iagainst the produced.debandrpm -qpiagainst the.rpmto assert valid metadata, - validates the tarball and smoke-tests the extracted binaries,
- uploads the
.deb/.rpm/.tar.gzplus.sha256sidecars as workflow artifacts (if-no-files-found: error).
Release publishing lives in the same workflow, not a separate one: on a v*
tag, publish-github-release gathers every packaging job's artifacts and
attaches them to the GitHub release, then aur-bin, brew-tap, and
scoop-bucket push the downstream manifests.
macOS
cargo xtask bundle-macos --release assembles Buffr.app (with the four-helper
layout — see macos-signing.md).
cargo xtask package-macos-dmg --release then wraps it into
target/dist/macos/buffr-<ver>-<arch>.dmg via hdiutil create … -format UDZO
(macOS hosts) or genisoimage (Linux fallback, smoke testing only).
The DMG embeds:
Buffr.app/(full bundle, including all four helpers + CEF framework)Applications -> /Applicationssymlink (drag-target)
Unsigned in this round. After download, first-run users must clear the quarantine xattr Gatekeeper attaches:
xattr -d com.apple.quarantine /Applications/Buffr.app
The CI macos-package job runs the full pipeline on a macos-latest runner and
uploads the DMG as a build artifact. Signing + notarization are not
implemented anywhere in CI yet; see macos-signing.md
for the plan.
Windows
cargo xtask package-windows-msi --release produces
target/dist/windows/buffr-<ver>-<x64|arm64>.msi from a hand-rolled WiX 3
source (xtask/templates/buffr.wxs). Full layout, registry directives,
uninstall behaviour, and cross-build prerequisites are documented in
windows-packaging.md.
Unsigned. SmartScreen will warn the user on first run until Authenticode signing lands.
The CI windows-package job runs the full pipeline over a two-entry matrix —
windows-latest / x86_64-pc-windows-msvc and windows-11-arm /
aarch64-pc-windows-msvc — with the WiX 3 toolset installed, and uploads a
.msi plus a .zip per arch as build artifacts.
Flatpak
flatpak/sh.kryptic.buffr.yml builds a single-file .flatpak bundle from the
runtime tarball emitted by cargo xtask package-linux --variant tarball. CI
extracts the tarball into flatpak/payload/ (the manifest's module is
type: dir, path: payload), invokes flatpak-builder, and attaches
buffr-<ver>-<arch>.flatpak to the GitHub release. Users install with:
flatpak install --user ./buffr-<ver>-amd64.flatpak
Runtime is org.gnome.Platform//47. We don't link GTK from buffr's own code
(the chrome is wgpu + winit + a bitmap font), but libcef.so itself depends on
libgtk-3.so.0 for Chromium's native dialogs (file picker, color picker,
printing). The GNOME Platform provides GTK3 from a shared layer, so we don't
have to bundle it.
finish-args mirrors the Brave/Vivaldi flatpaks closely — Wayland + fallback
X11 + pulseaudio + DRI for GPU + narrow xdg-config/data/cache filesystem access
- DBus name reservations for MPRIS and notifications. CEF subprocess helpers run
inside the same sandbox via plain
execve; noflatpak-spawnshim is needed.
Phase 2 — Flathub
The current manifest is correct for direct-bundle distribution but not for Flathub submission. Phase 2 work, deferred:
- Replace
type: dir, path: payloadwith atype: archive, url: <release URL>sha256entry — Flathub requires reproducible network sources.
- Add
<release>and<screenshots>entries to the AppStream metainfo. - Verify
--filesystem=xdg-config/buffris the narrowest set Flathub accepts.
Future — drop GTK dependency (option 3)
Long-term, we'd like to swap to org.freedesktop.Platform//24.08 and route all
native dialogs through xdg-desktop-portal so the flatpak base doesn't identify
us as a GNOME app. CEF supports portal-based file pickers via an
--enable-features=DesktopPortalFileChooser switch, which buffr does not
currently pass — the command-line hook is
crates/buffr-cef/src/app.rs::on_before_command_line_processing and the switch
is absent from it. The printing and colour-picker paths still need
investigation. Tracked separately because it affects the .deb and .rpm runtime
deps too — if we patch CEF / disable GTK fallbacks, the deb's libgtk-3-0
Depends and the rpm's gtk3 Requires can drop.
Snap
snap/snapcraft.yaml builds a .snap bundle from the same runtime tarball the
flatpak job uses. CI extracts the tarball into payload/ at the repo root
(snapcraft resolves source: payload relative to the project root, not the
snap/ directory) and runs snapcore/action-build@v1, which boots an LXD VM,
runs snapcraft, and emits buffr-<ver>-<arch>.snap. Users install with:
snap install --dangerous --classic ./buffr-<ver>-amd64.snap
Phase 1 ships classic confinement because that's the simplest path for ad-hoc distribution. Until the Snap Store registration is filed, the snap is bundled on GitHub Releases.
Phase 2 — Snap Store + strict confinement
Modern Chromium-based snaps (Firefox, Brave, Chromium, Edge, Vivaldi) all run
strict confinement with the browser-support interface — classic for a
browser is unconventional today and likely to be flagged by Snap Store
reviewers. Phase 2 redesign:
confinement: strict
extensions:
- gnome # GTK3 + portal integration shared from the host
apps:
buffr:
plugs:
- browser-support
- network
- network-bind
- audio-playback
- audio-record
- opengl
- x11
- wayland
- desktop
- desktop-legacy
- gsettings
- removable-media
- screen-inhibit-control
The gnome extension shares GTK3 with the host instead of bundling it inside
the snap (saves ~150 MB). Tracked alongside the flatpak option-3 work since both
touch CEF's GTK use.
macOS code signing + notarization (stub)
Status: aspirational. None of the signing or notarization described here is implemented — no workflow in
.github/workflows/signs anything.cargo xtask bundle-macosskips signing entirely; assembled bundles only run after ad-hoc local signing (codesign --force --deep --sign -). The one section that describes shipped behaviour is Helper-flavor split and DMG production.
Why signing matters
macOS Gatekeeper refuses to run unsigned (or ad-hoc-signed) bundles downloaded
from the internet. To ship Buffr.app (or its .dmg wrapper) to end users we
need:
- Apple Developer ID — a paid developer account, with a
Developer ID Applicationcertificate provisioned in the keychain of the build host (or signing service). - Hardened Runtime —
codesign --options runtimeon every Mach-O in the bundle. CEF requires several entitlements relaxations; see below. - Notarization — submit the signed
.app(zipped or in a.dmg) to Apple's notary service vianotarytool. Apple staples a ticket back onto the artifact. - Stapling —
xcrun stapler staple Buffr.appso first-launch works offline.
Bundle signing order
CEF bundles must be signed inside-out:
Contents/Frameworks/Chromium Embedded Framework.framework/Versions/A/Libraries/*.dylibContents/Frameworks/Chromium Embedded Framework.frameworkContents/Frameworks/Buffr Helper.appplus the three flavored bundles —Buffr Helper (GPU).app,(Renderer).app,(Plugin).app— which the bundler already produces (see below)Contents/MacOS/buffr(the main bundle binary, signed last with the bundle plist)
codesign --deep sometimes works but is unreliable for nested helper bundles
with their own plists. The bundle script will eventually grow per-component
signing logic.
Entitlements
CEF's renderer / GPU / plugin helpers each need slightly different entitlements files. At minimum:
com.apple.security.cs.allow-jit— V8.com.apple.security.cs.allow-unsigned-executable-memory— sandboxed third-party plugins on older Chromium drops.com.apple.security.cs.disable-library-validation— load CEF from outside the bundle's signed framework root.com.apple.security.cs.disable-executable-page-protection— only on helpers; required for Chromium's V8.
The Chromium upstream cef/tests/cefclient/resources/mac/*.entitlements files
are the reference; adapted copies would be vendored when signing lands. None are
in the tree today.
Helper-flavor split (current layout)
cargo xtask bundle-macos ships four helper bundles inside
Buffr.app/Contents/Frameworks/ — Apple's full sandboxing model wants one
helper per subprocess type so per-flavor entitlements can differ:
| Bundle name | Bundle id | Plist template | Subprocess type |
|---|---|---|---|
Buffr Helper.app | sh.kryptic.buffr.helper | xtask/templates/helper.plist | utility / generic worker |
Buffr Helper (GPU).app | sh.kryptic.buffr.helper.gpu | xtask/templates/helper-gpu.plist | GPU process |
Buffr Helper (Renderer).app | sh.kryptic.buffr.helper.renderer | xtask/templates/helper-renderer.plist | renderer process |
Buffr Helper (Plugin).app | sh.kryptic.buffr.helper.plugin | xtask/templates/helper-plugin.plist | plugin (PPAPI / WASM) |
Apple requires every nested .app's Mach-O have a distinct file name; each
bundle's Contents/MacOS/Buffr Helper (Flavor) is a fs::copy of the same
buffr-helper binary (notarisation rejects symlinks for executables).
cef-rs 147 only resolves a single browser_subprocess_path, so today every
subprocess type is launched out of the unbranded Buffr Helper.app. The other
three bundles are still shipped (each a full copy of the helper binary, so the
bundle grows accordingly) so future signing only needs per-flavor entitlements
plus a path-resolver hook — when cef-rs grows on_browser_process_handler_path
(or equivalent) we point each subprocess at its branded helper, no bundle layout
migration required.
DMG production
cargo xtask package-macos-dmg [--release] wraps the bundle into
target/dist/macos/buffr-<version>-<arch>.dmg (arm64 on Apple silicon hosts,
x86_64 on Intel). Implementation:
- The bundle from
bundle-macosis copied intotarget/<profile>/dmg-staging/Buffr.app/. - A relative
Applications -> /Applicationssymlink is created next to it as the drag-target. hdiutil create -volname buffr -srcfolder dmg-staging -ov -format UDZOruns on macOS.- On Linux dev hosts (no
hdiutil) the script falls back togenisoimage— the resulting image mounts on macOS but loses the Finder layout affordances; only useful for smoke-testing the staging step. CI on amacos-latestrunner exercises the realhdiutilpath. - If neither tool is on
PATHthe staging tree is left in place and a clear warning is printed; nothing fails.
The DMG is unsigned. After download, first-run users must clear the quarantine xattr that Gatekeeper attaches to web-downloaded files:
xattr -d com.apple.quarantine /Applications/Buffr.app
Once Developer-ID signing + notarization land (next section), Gatekeeper will accept the bundle without manual intervention.
Notarization tooling
# zip the bundle
ditto -c -k --keepParent target/release/Buffr.app buffr.zip
# submit
xcrun notarytool submit buffr.zip \
--apple-id $APPLE_ID --team-id $TEAM_ID --password $APP_SPECIFIC_PWD \
--wait
# staple
xcrun stapler staple target/release/Buffr.app
CI integration (GitHub Actions secrets, ephemeral keychain via
security create-keychain, etc.) would go in .github/workflows/ci.yml, next
to the existing macos-package and publish-github-release jobs — there is no
separate release.yml in this repo.
buffr — Windows packaging (MSI)
An MSI installer for Windows 10+. Like the Linux packages and the macOS .dmg,
it is unsigned — Authenticode signing is not implemented anywhere in CI.
Driver
cargo xtask package-windows-msi --release
ls target/dist/windows/
# buffr-<version>-<x64|arm64>.msi
# buffr.wxs
# payload/ (binaries + libcef.dll + paks + locales/)
Internally:
- Render
xtask/templates/buffr.wxswith{VERSION}/{INSTALL_DIR}/{ARCH}substituted and write totarget/dist/windows/buffr.wxs. - Locate
buffr-app.exe,buffr-helper.exe,libcef.dll,icudtl.dat,*.pak, andlocales/from one of:target/<profile>/(native Windows host),target/x86_64-pc-windows-msvc/<profile>/(cross from Windows),target/x86_64-pc-windows-gnu/<profile>/(Linux cross — see below).
- Stage the payload under
target/dist/windows/payload/. - Run
candle.exe(XML →.wixobj) andlight.exe(.wixobj→.msi) from the WiX 3 toolset.
WiX version
The .wxs targets the WiX 3 namespace
(http://schemas.microsoft.com/wix/2006/wi) with <Product> at the root. WiX 3
tooling is the most broadly available baseline today; WiX 4 / 5 changed the
namespace, renamed root elements, and shipped a unified wix.exe driver. The
older candle + light are still on every CI Windows runner, and they produce
identical MSIs for our needs (no per-user install, no MSIX, no bundle).
Install layout
C:\Program Files\buffr\
├── buffr-app.exe
├── buffr-helper.exe
├── libcef.dll
├── icudtl.dat
├── *.pak
└── locales\
Plus:
- Start menu shortcut:
Programs\buffr\buffr.lnk - Desktop shortcut:
Desktop\buffr.lnk - Registry entry under
HKLM\SOFTWARE\kryptic\buffrrecordingInstallPathandVersion.
Uninstall
WiX <RemoveFolder> and <RemoveRegistryKey Action="removeOnUninstall">
directives ensure clean removal:
- The
Program Files\buffr\directory and its contents are deleted. - The Start menu shortcut + desktop shortcut are removed.
- The HKLM registry hive (
SOFTWARE\kryptic\buffr) is deleted. - The HKCU keypaths used to anchor shortcut components are removed for the installing user (other users keep theirs — by design).
MajorUpgrade is configured so installing a newer version automatically removes
the old one before laying down the new payload.
Cross-build prerequisites (Linux → Windows)
If you want to produce the MSI from a Linux dev box without a Windows VM:
- Add the cross target:
rustup target add x86_64-pc-windows-gnu - Install MinGW:
pacman -S mingw-w64-gcc(Arch) /apt-get install gcc-mingw-w64-x86-64(Debian). - Cross-build:
cargo build --target x86_64-pc-windows-gnu --release -p buffr-app -p buffr-helper. - Run
cargo xtask package-windows-msi --release— it will pick up the cross-target output automatically.
Caveat: CEF-147 binary distributions are built against MSVC and link against
the Microsoft C runtime; the cef crate's libcef.lib import library is
MSVC-format. Cross-linking from MinGW (x86_64-pc-windows-gnu) against an MSVC
libcef.lib is not officially supported and may fail at link time. The reliable
path is a native Windows host with the Visual Studio Build Tools installed. The
CI windows-package job uses GitHub-hosted windows-latest and
windows-11-arm runners (which have VS Build Tools preinstalled) for the same
reason.
Tooling fall-back
Both candle.exe and light.exe are auto-detected on PATH. If either is
missing the script stops after writing target/dist/windows/buffr.wxs (and the
payload tree, if Windows binaries exist) and prints a warning. CI on the
windows-latest runner installs the WiX 3 toolset and exercises the full build.
If the Windows payload itself is unavailable (running on a fresh Linux host
without a cross-build), cargo xtask package-windows-msi still writes the
buffr.wxs source to target/dist/windows/ for inspection — the MSI step is
skipped with a clear message.
Authenticode signing — planned, not implemented
No workflow signs the MSI today. The intended local command is:
signtool sign /fd sha256 \
/tr http://timestamp.digicert.com /td sha256 \
/a buffr-<version>-x64.msi
Requires an EV or OV code-signing certificate provisioned on the build host.
Without signing, SmartScreen will warn the user on first run; with EV signing
reputation accrues immediately, OV reputation accrues over time. CI integration
(Azure Key Vault, ephemeral keychain, etc.) would land alongside the macOS
notarization steps in .github/workflows/ci.yml, which is where release
publishing already lives — there is no separate release.yml.
UI stack — chrome rendering decision
This ADR records the rendering stack chosen for buffr's native chrome — statusline, tab strip, command line, hint overlay.
Options
- A —
softbufferstrip in the samewinitwindow. Chrome lives in a CPU-blitted strip docked to the bottom (or top) of the buffr window. CEF's child window is sized to the remaining rectangle and reparented throughWindowInfo::parent_window. One window, no compositor placement, no GPU dependency. - B — separate top-level
winitwindows for chrome. Each chrome panel is its own OS window positioned over the CEF window. Avoids resizing CEF, but Linux compositors (especially Wayland) routinely refuse client-requested positioning and z-ordering. Fragile. - C — OSR +
wgpucompositor. CEF paints into a buffer viaCefRenderHandler::OnPaint; chrome is drawn aswgpuquads on top. Required for hint mode (per-pixel composition over the live page) and native Wayland. Pulls inwgpu,naga, shaders, plus the OSR plumbing theosrfeature already scaffolds.
Superseded: options A and B are historical alternatives — the decision below (option C, wgpu OSR) is the only path, and no windowed or softbuffer chrome mode remains in the tree.
Decision — Option C, wgpu OSR on every platform
CEF paints the page into an off-screen buffer, then the app composites that
buffer plus the tab strip, overlays, and statusline into the same winit window
with wgpu. This is now the path on all platforms — windowless_rendering
is enabled unconditionally and there is no native child-window mode left in the
tree.
Linux needs OSR because X11/XWayland child-window embedding is not supported. macOS also uses OSR because AppKit child views do not layer predictably with buffr's custom chrome: the native CEF child can cover the tabbar/statusline or land at a different origin than the chrome compositor.
Why OSR wins now
- One
winitwindow — no inter-window placement bugs. - Page and chrome share one coordinate system.
- Hints, command overlays, tabbar, statusline, and page content can be composed in z-order by the renderer.
- CEF child-view geometry does not need platform-specific AppKit/X11 resizing.
Historical: the windowed exception
Earlier revisions mapped RawWindowHandle::Win32(_) onto a HostMode::Windowed
path that parented CEF as a native child window. That mode has been removed —
HostMode no longer has a windowed variant and Windows composites through OSR
like everything else.
Layout
Constants below are the live values; crates/buffr-ui is the source of truth
(STATUSLINE_HEIGHT in src/lib.rs, TAB_STRIP_HEIGHT in src/tab_strip.rs,
INPUT_HEIGHT / SUGGESTION_ROW_HEIGHT / MAX_SUGGESTIONS in
src/input_bar.rs).
STATUSLINE_HEIGHT = 30pixels, docked to the bottom of the buffr window.TAB_STRIP_HEIGHT = 34pixels, sits above the CEF page area and below the optional input bar. Always painted (zero tabs renders an empty bar in the strip's bg colour).INPUT_HEIGHT = 28pixels, docked to the top when the command line or omnibar is open. The input strip is hidden when the overlay is closed and the page region reclaims those rows.- Suggestion dropdown: each row is
SUGGESTION_ROW_HEIGHT, which is defined asSTATUSLINE_HEIGHT(30 px);MAX_SUGGESTIONS = 8rows. Stacks below the input strip when populated; the dropdown rectangle also shrinks the CEF child rect so suggestions never overlap the page. - CEF page rect:
(0, overlay_h + TAB_STRIP_HEIGHT, w, h - overlay_h - TAB_STRIP_HEIGHT - STATUSLINE_HEIGHT), whereoverlay_hisINPUT_HEIGHT + dropdown_rows * STATUSLINE_HEIGHTwhen an overlay is open,0otherwise. In OSR mode this rect becomes the CEFview_rectand the renderer composites the painted buffer at the same position. Whenever overlays open or close, the app re-issues the resize so CEF re-flows the page area. - Renderer surface: a single
wgpusurface sized to the full window. Each frame composites the page, tab strip, statusline, overlays, hints, and popups in one pass.
Update — 2026-05-03
Dedicated wgpu-render worker thread (v0.3.0).
All wgpu mutating calls now run on a dedicated wgpu-render worker thread.
The UI thread does only:
surface.get_current_texture()— acquire the swapchain image.- Chrome paint closure (CPU-only rasterize of tab strip / statusline).
- OSR pixel
memcpyinto a staging buffer. try_send(RenderCommand)over a capacity-1 mailbox channel.
The worker thread handles queue.write_texture, queue.submit, and
surface_texture.present(). This decouples the UI event loop from Wayland
compositor backpressure: present() was previously blocking the main thread for
multiple seconds on Hyprland workspace switches, causing perceived freezes.
Additional constraints introduced alongside the worker:
frames_in_flightcounter gatesget_current_texture()— only one acquiredSurfaceTextureoutstanding at a time (desired_maximum_frame_latency = 1).Renderer::dropwrapssurfaceanddeviceinManuallyDrop; when the worker is mid-present()during shutdown, the wgpu state is leaked rather than triggering a "Surface in use" panic.resize()deferssurface.configure()into apending_resizeslot when the worker holds an outstandingSurfaceTexture; the nextframe()applies it once the worker drains.