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.
- Configuration — the
[general],[search],[theme],[privacy],[updates],[accessibility],[keymap]sections. - 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. - 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.
- Accessibility — CEF renderer accessibility, keyboard-first chrome, high-contrast theme.
- Packaging — Linux AppImage /
.deb/ AUR; macOS.app+.dmg; Windows MSI. - macOS signing — Developer-ID + notarization plan.
- UI stack ADR — why winit + softbuffer for chrome instead of full OSR.
Source repo: https://github.com/kryptic-sh/buffr.
buffr — developer setup
Prerequisites
- Rust 1.95 (pinned via
rust-toolchain.toml;rustupwill install automatically on first build). - 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.
First build
git clone git@github.com:kryptic-sh/buffr.git
cd buffr
# Vendor the CEF binary distribution (~500 MB extracted).
# Drops files under `vendor/cef/<platform>/`.
cargo xtask fetch-cef
# Build the workspace.
cargo build
# Run.
cargo run -p buffr
cargo xtask fetch-cef accepts:
--platform <linux64 | macosarm64 | macosx64 | windows64>— override the host detection (useful when cross-prepping).--version <X.Y>— version prefix to match in the Spotify CDN (index.json). Defaults to147.to track thecefcrate.
Override the CEF tree location with CEF_PATH=... (mirrors
tauri-apps/cef-rs). When unset, buffr-core/build.rs falls back to
vendor/cef/<platform>/.
CEF binary distribution — size + platform matrix
| Platform | Archive (compressed) | Extracted | Notes |
|---|---|---|---|
linux64 | ~140 MB | ~480 MB | Tier 1 (primary dev target). |
macosarm64 | ~150 MB | ~520 MB | Tier 1 (cargo xtask bundle-macos). |
macosx64 | ~150 MB | ~520 MB | Tier 1. |
windows64 | ~165 MB | ~530 MB | Tier 2. |
vendor/cef/ is in .gitignore. Re-run cargo xtask fetch-cef after bumping
the cef crate version.
Layout
buffr/
├── apps/
│ ├── buffr/ # main binary (browser process)
│ └── buffr-helper/ # CEF subprocess helper (macOS Helper.app)
├── crates/
│ ├── buffr-core/ # CEF lifecycle + browser host + build.rs
│ ├── buffr-modal/ # vim-style mode + keybind engine
│ ├── buffr-ui/ # chrome, command palette, hint overlay
│ └── buffr-config/ # config loading (TOML)
├── xtask/ # cargo xtask: fetch-cef, etc.
├── vendor/cef/ # downloaded CEF binaries (gitignored)
├── docs/ # this file
└── PLAN.md # phase roadmap
Running
RUST_LOG=buffr=debug,buffr_core=debug cargo run -p buffr
Wayland
The default build embeds CEF as a windowed child of an X11 window — that's the
only mode CEF supports on Linux. On Wayland sessions buffr forces winit's X11
backend at startup (EventLoopBuilderExtX11::with_x11()), so the compositor
transparently proxies the X11 traffic through XWayland.
This works on every major Wayland desktop that ships XWayland — GNOME, KDE,
Sway, Hyprland — which is the default on essentially every distribution. Minimal
compositors without XWayland (e.g. a stock weston build) won't work until
native Wayland support lands.
Native Wayland (no XWayland round-trip) is Phase 3 work, gated behind the osr
feature:
# Currently panics at runtime — only compiles. Tracking issue: PLAN.md Phase 3.
cargo run -p buffr --features osr
The OSR path will run CEF in windowless mode, blitting paint events onto a winit-owned Wayland surface via wgpu.
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. - This round ships a single
buffr Helper.appused for all subprocess types. macOS's full sandbox model wantsHelper,Helper (GPU),Helper (Renderer), andHelper (Plugin)— that split is deferred to Phase 6 when proper signing + sandbox entitlements land. - 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/macos-signing.md. Phase 6 work.
Linux packaging
Three Linux distribution paths, all producible from a single Linux dev box:
cargo xtask package-linux --release --variant all
ls target/dist/linux/
# buffr-0.0.1-x86_64.AppImage
# buffr-0.0.1-amd64.deb
appimagetool and dpkg-deb are auto-detected; if either is missing the xtask
leaves the staging directory in place 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/packaging.md.
Useful commands
cargo fmt --all
cargo clippy --workspace --all-targets -- -D warnings
cargo test --workspace
Where things live
| Concern | File |
|---|---|
| Subprocess dispatch | apps/buffr/src/main.rs::main |
cef::App impl | crates/buffr-core/src/app.rs |
| Browser creation | crates/buffr-core/src/host.rs |
| CEF callback handlers | crates/buffr-core/src/handlers.rs |
| CEF link + resource copy | crates/buffr-core/build.rs |
| CEF download | xtask/src/main.rs::fetch_cef |
| Page mode FSM | crates/buffr-modal/src/lib.rs |
hjkl-engine integration | crates/buffr-modal/src/host.rs |
| Statusline + bitmap font | crates/buffr-ui/src/lib.rs |
| Find-in-page sink | crates/buffr-core/src/find.rs |
| Config schema + loader | crates/buffr-config/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
Phase 3 chrome (statusline today; tab strip / command bar / hint mode later)
lives in crates/buffr-ui. Rendering decisions are in
docs/ui-stack.md: a softbuffer strip docked to the bottom
of the buffr winit window, with the CEF child window sized to the remaining
rect. The 24-pixel statusline draws via a hand-rolled 6x10 bitmap font in
crates/buffr-ui/src/font.rs. Find-in-page is wired through
BrowserHost::start_find / stop_find; a --find <query> CLI flag on
apps/buffr exercises the round trip without a command bar (the Phase 3b
dependency that blocks Find { forward } action UI).
Storage
Per-user state buffr writes lives under directories::ProjectDirs resolution
for sh.kryptic.buffr. On Linux that's:
| Path | Owner |
|---|---|
~/.cache/buffr/ | CEF cache (cookies, GPU shader cache). |
~/.local/share/buffr/history.sqlite | History DB (Phase 5, buffr-history). |
~/.local/share/buffr/bookmarks.sqlite | Bookmarks DB (Phase 5, buffr-bookmarks). |
~/.local/share/buffr/downloads.sqlite | Downloads DB (Phase 5, buffr-downloads). |
~/.local/share/buffr/zoom.sqlite | Per-site zoom levels (Phase 5, buffr-zoom). |
~/.local/share/buffr/permissions.sqlite | Per-origin permission decisions (Phase 5, buffr-permissions). |
~/.local/share/buffr/usage-counters.json | Opt-in local telemetry counters (Phase 6; off by default). |
~/.local/share/buffr/crashes/ | Opt-in panic reports (Phase 6; off by default). |
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; see
crates/buffr-history/README.md for the
schema and frecency formula.
macOS uses ~/Library/Application Support/sh.kryptic.buffr/ and
~/Library/Caches/sh.kryptic.buffr/; Windows uses
%APPDATA%\kryptic\buffr\data\ / %LOCALAPPDATA%\kryptic\buffr\cache\.
Config
buffr-config reads ~/.config/buffr/config.toml (or the OS-specific XDG
equivalent). Schema reference: docs/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 live in
crates/buffr-bookmarks/README.md.
There's no UI yet (Phase 5b alongside the omnibar); 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
crates/buffr-zoom/README.md.
# 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 Phase 5 tabs work.
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 CookieManager::delete_cookies; cache and local-storage
are directory-tree wipes under Settings::root_cache_path; history / bookmarks
/ downloads call clear_all on their respective stores. See
config.example.toml for the full list of valid
entries.
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
| Platform | Path |
|---|---|
| Linux | $XDG_CONFIG_HOME/buffr/config.toml |
| macOS | ~/Library/Application Support/buffr/config.toml |
| Windows | %APPDATA%\buffr\config.toml |
Path resolution goes through
directories::ProjectDirs::from("sh", "kryptic", "buffr"). Override per-run
with --config <PATH>.
CLI flags
| 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 XDG-discovered config path. |
--homepage <URL> | Override general.homepage for this run only. |
Both --print-config and --check-config short-circuit before CEF initializes,
so they're safe to run on a headless host.
Schema
[general]
| Key | Type | Default | Notes |
|---|---|---|---|
homepage | string | https://example.com | Initial URL on first window. |
leader | string | \ | Exactly one character. Validated. |
[startup]
| Key | Type | Default | Notes |
|---|---|---|---|
restore_session | bool | false | Phase 5 work; parsed but no-op for now. |
new_tab_url | string | about:blank | URL for tab_new. |
[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}"
{query} is replaced with the URL-encoded omnibar input.
[theme]
| Key | Type | Default | Notes |
|---|---|---|---|
accent | string | #7aa2f7 | Hex color used for the status line. |
mode | enum | auto | auto | dark | light. |
[privacy]
| Key | Type | Default | Notes |
|---|---|---|---|
enable_telemetry | bool | false | Reserved. buffr never sends telemetry. |
clear_on_exit | string[] | [] | Phase 5+; e.g. ["cookies", "history"]. |
[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.
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.
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.- Every keymap binding's key sequence must parse via the engine's
parse_keys, and its action notation must match the table above. - 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. All entries assume the leader key is
\ (vim default); the leader is configurable per-profile via
Keymap::set_leader.
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 | (Phase 3) | Selection-bearing motions. <Esc> returns to Normal. |
Command | : or o | 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. |
Edit | text-field focus | Forwarded to hjkl_editor::Editor once Phase 2 ships. |
Count and register prefixes
- Count — 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. - Register —
"<char>selects a register before a yank. Phase 2 captures register state on the engine but does not yet thread it through to actions. Yank-to-register lands with Phase 5.
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 |
|---|---|
j | ScrollDown(1) |
k | ScrollUp(1) |
h | ScrollLeft(1) |
l | ScrollRight(1) |
<C-d> | ScrollHalfPageDown |
<C-u> | ScrollHalfPageUp |
<C-f> | ScrollFullPageDown |
<C-b> | ScrollFullPageUp |
gg | ScrollTop |
G | ScrollBottom |
Tabs
| Keys | Action |
|---|---|
gt | TabNext |
gT | TabPrev |
<C-w>c | TabClose |
t | TabNew |
<C-w>n | DuplicateTab |
<C-w>p | PinTab |
TabClose (and :q) close the active tab. The application only exits when the
last tab is gone. DuplicateTab clones the active tab's URL into a fresh tab;
PinTab toggles the pinned bit (sort hint only — pin does not prevent close).
See multi-tab.md.
History
| Keys | Action |
|---|---|
H | HistoryBack |
L | HistoryForward |
Reload / stop
| Keys | Action |
|---|---|
r | Reload |
<C-r> | ReloadHard |
<C-c> | StopLoading |
Omnibar / command line
| Keys | Action |
|---|---|
o | OpenOmnibar |
: | OpenCommandLine |
Hints
| Keys | Action |
|---|---|
f | EnterHintMode |
F | EnterHintModeBackground |
Find
| Keys | Action |
|---|---|
/ | Find { forward: true } |
? | Find { forward: false } |
n | FindNext |
N | FindPrev |
Yank
| Keys | Action |
|---|---|
y | YankUrl |
Zoom
| Keys | Action |
|---|---|
+ | ZoomIn |
- | ZoomOut |
= | ZoomReset |
DevTools
| Keys | Action |
|---|---|
<C-S-i> | OpenDevTools |
Mode transitions
The engine reads the resolved [PageAction] and auto-transitions:
OpenOmnibar,OpenCommandLine→CommandEnterHintMode,EnterHintModeBackground→HintEnterEditMode→Edit(trie bypassed;feed_edit_mode_keytakes over)EnterMode(m)→m
<Esc> is bound 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 o/O 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 (cycles to 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. |
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.
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
Phase 3 (tab strip) and Phase 5 (tabs / session restore) share one design: the
BrowserHost is a manager owning a
Vec<Tab> of CEF browsers. All tabs are parented to the same X11 window
(the winit window the embedder constructed); only the active browser is visible.
Switching tabs flips visibility and focus.
Single Client, many Browsers
buffr-core::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.
X11 stacking caveat
was_hidden(true) is sufficient on XWayland and most X11 compositors — the
embedded X window stops drawing and the now-active sibling becomes the visible
top child. On window managers that aggressively cache sub-window stacking, an
XRaiseWindow / XConfigureWindow follow-up might be needed; cef-rs 147
doesn't expose that, so we lean on was_hidden + was_resized and document the
gap rather than vendor xlib bindings.
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
directories::ProjectDirs("sh", "kryptic", "buffr").data_dir()). 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. Each entry
is { url, pinned }; the schema is versioned so a future format bump can ignore
stale files.
{
"version": 1,
"tabs": [
{ "url": "https://kryptic.sh", "pinned": false },
{ "url": "https://example.com", "pinned": true },
],
}
--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 (*\t<url> for pinned, \t<url> otherwise) and 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
about:blank).
:q semantics
:q, :quit, and <C-w>c 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. The flag is purely
informational today: pin does not prevent close, only signals sort order to
chrome (the host stores tabs in user-visible order; pin-first sorting is left to
the renderer).
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.
Hint mode — DOM-injected overlay labels
Phase 3 of PLAN.md ships Vimium-style follow-by-letter-label hints: press f
to enter hint mode, type a few letters, the matched element gets clicked. F is
the background-tab variant — single-tab buffr falls back to a same-tab click
with a tracing::warn! breadcrumb until the tab strip lands.
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 three placeholders
(__ALPHABET__, __LABELS__, __SELECTORS__). The JS enumerates visible
matching elements, assigns sequential data-buffr-hint-id attributes, and
renders an overlay div per target.
This sidesteps the cross-process compositor work the OSR + wgpu path would have
required. docs/ui-stack.md records that compositing overlays on top of CEF's
surface is the trigger to migrate the chrome layer to OSR; we deferred that by
punting the rendering into the page itself instead.
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__:" + JSON.stringify(payload))
and BuffrDisplayHandler::on_console_message (in
crates/buffr-core/src/handlers.rs) pattern-matches the sentinel, 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 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)— dim every overlay whose label doesn't start withtyped.__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(HINT_OVERLAY_Z_INDEX, max int32 — page stacking contexts can't shadow the hints)- vivid yellow background (
#FFD83A), dark text, monospace 11px pointer-events: noneso the page below stays interactive- additional
buffr-hint-typed(dimmed) andbuffr-hint-hidden(display:none) classes the filter callback toggles
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 inputs at config-load time so the runtime
path never has to handle them.
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
# Reserved for the post-1.0 nightly tag stream. Today only `stable`
# resolves cleanly.
channel = "stable"
# 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
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". A future --reset-update-dismissals flag (TODO)
will clear the dismiss list.
Implementation
crates/buffr-core/src/updates.rs—UpdateChecker,UpdateStatus,HttpClienttrait,UreqClientimpl.crates/buffr-config/src/lib.rs—[updates]section schema + validation (channel allow-list, repo shape, non-zero interval).apps/buffr/src/main.rs—--check-for-updates/--update-statusCLI short-circuits. Statusline* updindicator wired to the cache read.
The trait HttpClient exists so unit tests can drive the state machine
without touching the real network. The real network path uses ureq 2.x
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
(macOS: ~/Library/Application Support/sh.kryptic.buffr/usage-counters.json;
Windows: %APPDATA%\kryptic\buffr\data\usage-counters.json.)
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>.json
Filename pattern: YYYY-MM-DDTHH-MM-SS.sssZ.json (colons swapped for dashes so
the path is portable to FAT/Windows).
CEF's BrowserProcessHandler does not expose an on_uncaught_exception
callback in libcef-147 — 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-147 binding 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, hint overlay, and permissions
prompt are software-rendered via softbuffer. They are not part of any
DOM and are not exposed via platform accessibility APIs. 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 lineo— omnibarf/F— hint modegt/gT— next/prev tab<C-w>c/<C-w>n/<C-w>p— close / duplicate / pinH/L— back / forwardr/<C-r>— reload / hard reload//?/n/N— find / find-prev / next-match / prev-match<C-S-i>— devtools
Run buffr --audit-keymap to print the full table from any shell. The
every_user_facing_action_has_a_default_binding unit test guards this list
against drift: a new PageAction variant lands in buffr-modal → either it
gets a default binding or the test fails.
High-contrast theme
[theme] high_contrast = true switches the chrome palette to:
| Token | Default | High-contrast |
|---|---|---|
bg | per-mode | 0x000000 |
fg | 0xEEEEEE | 0xFFFFFF |
accent | per-mode | 0xFFFF00 |
accent_dim | per-mode | 0xC0C0C0 |
The values pass WCAG AAA contrast against each other on the chrome surfaces.
Colour values live in crates/buffr-ui/src/lib.rs as HC_BG, HC_FG,
HC_ACCENT, HC_ACCENT_DIM.
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 bitmap font (the 6×10 glyphs in
crates/buffr-ui/src/font.rsare fixed-size). - Reduced-motion preference (currently no animations besides cursor blink).
If any of these block your daily use, file an issue at https://github.com/kryptic-sh/buffr/issues.
buffr — Packaging
Phase 6 lands distribution artifacts for all three tier-1 targets, all unsigned in this round (signing infrastructure is the next step):
| Platform | Driver | Output |
|---|---|---|
| Linux | cargo xtask package-linux | AppImage + .deb + AUR PKGBUILD |
| macOS | cargo xtask package-macos-dmg | target/dist/macos/buffr-<ver>-<arch>.dmg |
| Windows | cargo xtask package-windows-msi | target/dist/windows/buffr-<ver>-x64.msi |
The macOS bundle assembly (driving the DMG) lives in
docs/macos-signing.md; the Windows MSI flow has its own
docs/windows-packaging.md. The rest of this document
covers Linux end-to-end.
Linux
Phase 6 ships three Linux distribution paths, all producible from a single Linux dev box:
| Format | Tooling | Audience |
|---|---|---|
| AppImage | appimagetool | Distro-agnostic single-file blob. |
.deb | dpkg-deb | Debian / Ubuntu / Mint. |
| PKGBUILD | makepkg (user-side) | Arch / Manjaro / EndeavourOS. |
None of these are signed in this round. Signing lives in the release pipeline (Phase 6, separate trust-store work). The artifacts here are installable but Gatekeeper-equivalent prompts will warn the user.
Building all three
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-0.0.1-x86_64.AppImage # ~350 MiB squashfs
└── buffr-0.0.1-amd64.deb # ~330 MiB
The PKGBUILD is written to pkg/aur/PKGBUILD (in-tree, not under target/) —
its version field is rewritten to match [workspace.package] version on every
run.
Variant flags
cargo xtask package-linux --variant appimage
cargo xtask package-linux --variant deb
cargo xtask package-linux --variant aur
cargo xtask package-linux --variant all # default
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
appimagetool and dpkg-deb are auto-detected:
appimagetool— checked on$PATHfirst; falls back tovendor/appimagetool/appimagetool-x86_64.AppImage; if neither exists, downloaded from the upstreamcontinuousrelease. The download is cached invendor/appimagetool/and CI keys anactions/cache@v4entry off it. If the tool can't be obtained at all (no internet), thebuffr.AppDirstaging directory is left in place undertarget/<profile>/and a warning is printed.dpkg-deb— checked on$PATH. If absent (Arch / Fedora hosts without thedpkgpackage), the staging tree attarget/<profile>/buffr-deb/is left in place and a warning is printed. The.debitself is not produced.
AppImage
chmod +x target/dist/linux/buffr-*-x86_64.AppImage
./target/dist/linux/buffr-*-x86_64.AppImage
The AppImage embeds:
usr/bin/buffr+usr/bin/buffr-helperusr/lib/libcef.so+*.pak+icudtl.dat+v8_context_snapshot.binusr/lib/locales/<lang>.pakAppRunlauncher (setsLD_LIBRARY_PATHand execsusr/bin/buffr)buffr.desktop+buffr.png(placeholder icon)
Glibc requirement
The bundled CEF expects glibc >= 2.28. Distros older than the following will
fail at load time with version 'GLIBC_2.28' not found:
- Ubuntu 18.04 (glibc 2.27) — not supported
- Ubuntu 20.04+ — supported
- Debian 10 (Buster, glibc 2.28) — supported
- Debian 11+ — supported
- RHEL 8+ — supported
Fuse / --appimage-extract
If the host doesn't have libfuse2 installed, AppImages fail with
/dev/fuse: Permission denied. Workaround:
./buffr-*.AppImage --appimage-extract produces a squashfs-root/ directory
you can run ./squashfs-root/AppRun out of.
.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 (main exe; rpath=$ORIGIN finds libcef.so)
├── buffr-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 symlink)
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 symlink if (and only if) it
still points back at /opt/buffr/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 libgbm 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. Both the AppImage and the
.deb ship the unprivileged binary; CEF will fall back to the namespace
sandbox if the kernel supports unprivileged_userns_clone (default on every
distro since 2018). On hosts where that's been turned off (some hardened-kernel
distros, or sysctl kernel.unprivileged_userns_clone=0), buffr will warn and
continue without sandboxing. To re-enable, the sysadmin needs to flip the sysctl
or the package needs to ship a SUID helper at /opt/buffr/chrome-sandbox —
Phase 6+ work.
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
AppImage / .deb / PKGBUILD 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 on every PR. It:
- caches the CEF binary distribution (~480 MiB extracted),
- caches the downloaded
appimagetoolbinary, - runs
dpkg-deb -Iagainst the produced.debto assert valid metadata, - asserts the AppImage is an executable ELF (it's an
AppImagemagic squashfs ELF, sofilerecognises ELF).
No artifacts are uploaded — the Phase 6 release pipeline replaces this with proper artifact retention.
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 land in the eventual
release.yml workflow.
Windows
cargo xtask package-windows-msi --release produces
target/dist/windows/buffr-<ver>-x64.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 in this round. SmartScreen will warn the user on first run until Authenticode signing lands.
The CI windows-package job runs the full pipeline on a windows-latest runner
with the WiX 3 toolset installed and uploads the MSI as a build artifact.
macOS code signing + notarization (stub)
Status: Phase 6 work. This document is a placeholder describing what real macOS distribution will need. The current
cargo xtask bundle-macosskips signing entirely; assembled bundles only run after ad-hoc local signing (codesign --force --deep --sign -).
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.app(and anyHelper (GPU/Renderer/Plugin).apponce the multi-helper split lands)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; we'll vendor adapted copies once Phase 6 lands.
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 (~80 MiB extra) so future signing only needs
per-flavor entitlements + 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 in this round. 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.) will live in .github/workflows/release.yml
once we cut the first signed nightly.
buffr — Windows packaging (MSI)
Phase 6 ships an MSI installer for Windows 10+. Like the Linux .deb / AppImage
and the macOS .dmg, it is unsigned in this round; Authenticode signing
lives in the post-Phase-6 release pipeline.
Driver
cargo xtask package-windows-msi --release
ls target/dist/windows/
# buffr-<version>-x64.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.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.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 -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 the windows-latest GitHub-hosted runner (which
has 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 (Phase 6 follow-up)
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. Detailed CI
integration (Azure Key Vault, ephemeral keychain, etc.) lives alongside the
macOS notarization steps in the eventual .github/workflows/release.yml.
UI stack — chrome rendering decision
Phase 3 of PLAN.md introduces native chrome (statusline, tab strip, command
line, hint overlay). This ADR records the rendering stack chosen for the first
batch of chrome — statusline today, tab strip + command line later in Phase 3.
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.
Decision — Option A with softbuffer = "0.4"
softbuffer is small, depends only on platform window-handle crates, and a
single-line statusline rendered with a bundled bitmap font is trivial to
software-blit. The current CEF embedding is windowed (X11/XWayland on Linux),
which already requires us to give CEF its own subrectangle inside the winit
window — A composes naturally with that. C drags in a GPU stack we do not
otherwise need at this phase.
Why A wins now
- One
winitwindow — no inter-window placement bugs. - No
wgpudependency for a 24-px strip. - CEF's windowed embedding stays in charge of page rendering.
- Future tab strip and command line slot into the same
softbuffer::Surface.
Trigger to migrate to C
Hint mode requires drawing labelled overlays on top of the live page, anchored
to DOM rectangles, and updating at scroll/animation rates. That is per-pixel
compositing, which a CPU strip cannot do without flicker or expensive readback.
When hint mode lands (later in Phase 3), the chrome layer migrates to the OSR +
wgpu path that crates/buffr-core/src/osr.rs already scaffolds. Statusline
and tab strip may stay on A or be ported in the same change — whichever costs
less.
Layout
STATUSLINE_HEIGHT = 24pixels, docked to the bottom of the buffr window.TAB_STRIP_HEIGHT = 30pixels, 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
STATUSLINE_HEIGHT(24 px) tall, max 8 rows. Stacks below the input strip when populated; the dropdown rectangle also shrinks the CEF child rect so suggestions never overlap the page. - CEF child window 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. The X11 XID is passed asWindowInfo::parent_windowat creation time; on resize the host walks every tab and callscef::Browser::host().was_resized()after winit reports the new size. Whenever the overlay opens / closes we re-issue the resize so CEF re-flows the page area. - Statusline + input paint surface: a single
softbuffer::Surfacesized to the full window. Each frame we paint the bottom strip (statusline) and, when an overlay is active, the top strip (input bar + dropdown). The middle page region is owned by CEF and never touched by us;present_with_damageis called with up to two damage rects so the page area is never repainted by softbuffer.