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 modef/F follow-by-letter overlay.
  • Updates — the once-a-day GitHub release check, opt-out, and the manual --check-for-updates CLI.
  • 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; rustup will 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 to 147. to track the cef crate.

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

PlatformArchive (compressed)ExtractedNotes
linux64~140 MB~480 MBTier 1 (primary dev target).
macosarm64~150 MB~520 MBTier 1 (cargo xtask bundle-macos).
macosx64~150 MB~520 MBTier 1.
windows64~165 MB~530 MBTier 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 to buffr Helper (space-separated) during the copy. No Cargo changes needed.
  • This round ships a single buffr Helper.app used for all subprocess types. macOS's full sandbox model wants Helper, Helper (GPU), Helper (Renderer), and Helper (Plugin) — that split is deferred to Phase 6 when proper signing + sandbox entitlements land.
  • No buffr.icns is 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

ConcernFile
Subprocess dispatchapps/buffr/src/main.rs::main
cef::App implcrates/buffr-core/src/app.rs
Browser creationcrates/buffr-core/src/host.rs
CEF callback handlerscrates/buffr-core/src/handlers.rs
CEF link + resource copycrates/buffr-core/build.rs
CEF downloadxtask/src/main.rs::fetch_cef
Page mode FSMcrates/buffr-modal/src/lib.rs
hjkl-engine integrationcrates/buffr-modal/src/host.rs
Statusline + bitmap fontcrates/buffr-ui/src/lib.rs
Find-in-page sinkcrates/buffr-core/src/find.rs
Config schema + loadercrates/buffr-config/src/lib.rs
History storecrates/buffr-history/src/lib.rs
Bookmarks storecrates/buffr-bookmarks/src/lib.rs
Downloads storecrates/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:

PathOwner
~/.cache/buffr/CEF cache (cookies, GPU shader cache).
~/.local/share/buffr/history.sqliteHistory DB (Phase 5, buffr-history).
~/.local/share/buffr/bookmarks.sqliteBookmarks DB (Phase 5, buffr-bookmarks).
~/.local/share/buffr/downloads.sqliteDownloads DB (Phase 5, buffr-downloads).
~/.local/share/buffr/zoom.sqlitePer-site zoom levels (Phase 5, buffr-zoom).
~/.local/share/buffr/permissions.sqlitePer-origin permission decisions (Phase 5, buffr-permissions).
~/.local/share/buffr/usage-counters.jsonOpt-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 Drop already 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

PlatformPath
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

FlagEffect
--print-configPrint the resolved (defaults + user overrides) config; exit 0.
--check-configValidate 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]

KeyTypeDefaultNotes
homepagestringhttps://example.comInitial URL on first window.
leaderstring\Exactly one character. Validated.

[startup]

KeyTypeDefaultNotes
restore_sessionboolfalsePhase 5 work; parsed but no-op for now.
new_tab_urlstringabout:blankURL for tab_new.
KeyTypeDefaultNotes
default_enginestringduckduckgoMust 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]

KeyTypeDefaultNotes
accentstring#7aa2f7Hex color used for the status line.
modeenumautoauto | dark | light.

[privacy]

KeyTypeDefaultNotes
enable_telemetryboolfalseReserved. buffr never sends telemetry.
clear_on_exitstring[][]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 scrollsname(N) where N >= 0. Applies to scroll_up, scroll_down, scroll_left, scroll_right.
  • Findfind(forward = true) or find(forward = false).
  • Mode transitionenter_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.leader must be exactly one character.
  • search.default_engine must 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

ModeTriggerNotes
Normalinitial / <Esc>Default; bindings below.
Visual(Phase 3)Selection-bearing motions. <Esc> returns to Normal.
Command: or oCommand line / omnibar focused. <Esc> returns.
Hintf / FDOM hint overlay active. <Esc> returns.
Pending(transient)Multi-key prefix in flight. Not user-bindable.
Edittext-field focusForwarded to hjkl_editor::Editor once Phase 2 ships.

Count and register prefixes

  • Count — leading digits accumulate: 5j scrolls down 5 lines, 12G jumps to line 12 (when implemented). 0 alone 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

KeysAction
jScrollDown(1)
kScrollUp(1)
hScrollLeft(1)
lScrollRight(1)
<C-d>ScrollHalfPageDown
<C-u>ScrollHalfPageUp
<C-f>ScrollFullPageDown
<C-b>ScrollFullPageUp
ggScrollTop
GScrollBottom

Tabs

KeysAction
gtTabNext
gTTabPrev
<C-w>cTabClose
tTabNew
<C-w>nDuplicateTab
<C-w>pPinTab

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

KeysAction
HHistoryBack
LHistoryForward

Reload / stop

KeysAction
rReload
<C-r>ReloadHard
<C-c>StopLoading

Omnibar / command line

KeysAction
oOpenOmnibar
:OpenCommandLine

Hints

KeysAction
fEnterHintMode
FEnterHintModeBackground

Find

KeysAction
/Find { forward: true }
?Find { forward: false }
nFindNext
NFindPrev

Yank

KeysAction
yYankUrl

Zoom

KeysAction
+ZoomIn
-ZoomOut
=ZoomReset

DevTools

KeysAction
<C-S-i>OpenDevTools

Mode transitions

The engine reads the resolved [PageAction] and auto-transitions:

  • OpenOmnibar, OpenCommandLineCommand
  • EnterHintMode, EnterHintModeBackgroundHint
  • EnterEditModeEdit (trie bypassed; feed_edit_mode_key takes 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.

KeysAction
<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.

KeysAction
a / yAllow once (no row written).
A / YAllow + remember for this origin.
d / nDeny once (no row written).
D / NDeny + remember for this origin.
sSynonym 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 with typed.
  • __buffrHintCommit(elementId) — focus + click the element with the matching data-buffr-hint-target-id, then call __buffrHintCancel() to clean up.
  • __buffrHintCancel() — remove every injected overlay div, strip every data-buffr-hint-target-id attribute, 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: fixed
  • z-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: none so the page below stays interactive
  • additional buffr-hint-typed (dimmed) and buffr-hint-hidden (display:none) classes the filter callback toggles

Label algorithm

HintAlphabet::labels_for(count) is a port of Vimium's hud.js BFS:

  1. Empty-string seed in a queue, walked breadth-first.
  2. Each pop expands by every alphabet char (prepended).
  3. Stop once the unexpanded slice (queue[offset..]) holds enough.
  4. 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:

  1. 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.
  2. The result is cached at <data>/update-cache.json.
  3. The statusline reads the cache on launch; if a newer release exists it shows * upd. If the cache is older than check_interval_hours it shows * upd? (stale — we don't know if it's still current).
  4. The user runs buffr --check-for-updates to 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) and Accept: 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.rsUpdateChecker, UpdateStatus, HttpClient trait, UreqClient impl.
  • 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-status CLI short-circuits. Statusline * upd indicator 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:

KeyIncrements on
app_startsSuccessful CEF init.
tabs_openedEvery BrowserHost::open_tab (foreground + background).
pages_loadedEvery main-frame LoadHandler::on_load_end.
searches_runOmnibar input that falls through to the search-engine route.
bookmarks_added:bookmark cmdline (Netscape import is intentionally not).
downloads_completedDownloadHandler 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 line
  • o — omnibar
  • f / F — hint mode
  • gt / gT — next/prev tab
  • <C-w>c / <C-w>n / <C-w>p — close / duplicate / pin
  • H / L — back / forward
  • r / <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:

TokenDefaultHigh-contrast
bgper-mode0x000000
fg0xEEEEEE0xFFFFFF
accentper-mode0xFFFF00
accent_dimper-mode0xC0C0C0

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.rs are 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):

PlatformDriverOutput
Linuxcargo xtask package-linuxAppImage + .deb + AUR PKGBUILD
macOScargo xtask package-macos-dmgtarget/dist/macos/buffr-<ver>-<arch>.dmg
Windowscargo xtask package-windows-msitarget/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:

FormatToolingAudience
AppImageappimagetoolDistro-agnostic single-file blob.
.debdpkg-debDebian / Ubuntu / Mint.
PKGBUILDmakepkg (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:

  1. appimagetool — checked on $PATH first; falls back to vendor/appimagetool/appimagetool-x86_64.AppImage; if neither exists, downloaded from the upstream continuous release. The download is cached in vendor/appimagetool/ and CI keys an actions/cache@v4 entry off it. If the tool can't be obtained at all (no internet), the buffr.AppDir staging directory is left in place under target/<profile>/ and a warning is printed.
  2. dpkg-deb — 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.

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-helper
  • usr/lib/libcef.so + *.pak + icudtl.dat + v8_context_snapshot.bin
  • usr/lib/locales/<lang>.pak
  • AppRun launcher (sets LD_LIBRARY_PATH and execs usr/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 appimagetool binary,
  • runs dpkg-deb -I against the produced .deb to assert valid metadata,
  • asserts the AppImage is an executable ELF (it's an AppImage magic squashfs ELF, so file recognises 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 -> /Applications symlink (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-macos skips 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:

  1. Apple Developer ID — a paid developer account, with a Developer ID Application certificate provisioned in the keychain of the build host (or signing service).
  2. Hardened Runtimecodesign --options runtime on every Mach-O in the bundle. CEF requires several entitlements relaxations; see below.
  3. Notarization — submit the signed .app (zipped or in a .dmg) to Apple's notary service via notarytool. Apple staples a ticket back onto the artifact.
  4. Staplingxcrun stapler staple buffr.app so first-launch works offline.

Bundle signing order

CEF bundles must be signed inside-out:

  1. Contents/Frameworks/Chromium Embedded Framework.framework/Versions/A/Libraries/*.dylib
  2. Contents/Frameworks/Chromium Embedded Framework.framework
  3. Contents/Frameworks/buffr Helper.app (and any Helper (GPU/Renderer/Plugin).app once the multi-helper split lands)
  4. 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 nameBundle idPlist templateSubprocess type
buffr Helper.appsh.kryptic.buffr.helperxtask/templates/helper.plistutility / generic worker
buffr Helper (GPU).appsh.kryptic.buffr.helper.gpuxtask/templates/helper-gpu.plistGPU process
buffr Helper (Renderer).appsh.kryptic.buffr.helper.rendererxtask/templates/helper-renderer.plistrenderer process
buffr Helper (Plugin).appsh.kryptic.buffr.helper.pluginxtask/templates/helper-plugin.plistplugin (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:

  1. The bundle from bundle-macos is copied into target/<profile>/dmg-staging/buffr.app/.
  2. A relative Applications -> /Applications symlink is created next to it as the drag-target.
  3. hdiutil create -volname buffr -srcfolder dmg-staging -ov -format UDZO runs on macOS.
  4. On Linux dev hosts (no hdiutil) the script falls back to genisoimage — the resulting image mounts on macOS but loses the Finder layout affordances; only useful for smoke-testing the staging step. CI on a macos-latest runner exercises the real hdiutil path.
  5. If neither tool is on PATH the 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:

  1. Render xtask/templates/buffr.wxs with {VERSION} / {INSTALL_DIR} / {ARCH} substituted and write to target/dist/windows/buffr.wxs.
  2. Locate buffr.exe, buffr-helper.exe, libcef.dll, icudtl.dat, *.pak, and locales/ 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).
  3. Stage the payload under target/dist/windows/payload/.
  4. Run candle.exe (XML → .wixobj) and light.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\buffr recording InstallPath and Version.

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:

  1. Add the cross target: rustup target add x86_64-pc-windows-gnu
  2. Install MinGW: pacman -S mingw-w64-gcc (Arch) / apt-get install gcc-mingw-w64-x86-64 (Debian).
  3. Cross-build: cargo build --target x86_64-pc-windows-gnu --release -p buffr -p buffr-helper.
  4. 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 — softbuffer strip in the same winit window. 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 through WindowInfo::parent_window. One window, no compositor placement, no GPU dependency.
  • B — separate top-level winit windows 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 + wgpu compositor. CEF paints into a buffer via CefRenderHandler::OnPaint; chrome is drawn as wgpu quads on top. Required for hint mode (per-pixel composition over the live page) and native Wayland. Pulls in wgpu, naga, shaders, plus the OSR plumbing the osr feature 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 winit window — no inter-window placement bugs.
  • No wgpu dependency 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 = 24 pixels, docked to the bottom of the buffr window.
  • TAB_STRIP_HEIGHT = 30 pixels, 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 = 28 pixels, 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), where overlay_h is INPUT_HEIGHT + dropdown_rows * STATUSLINE_HEIGHT when an overlay is open, 0 otherwise. The X11 XID is passed as WindowInfo::parent_window at creation time; on resize the host walks every tab and calls cef::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::Surface sized 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_damage is called with up to two damage rects so the page area is never repainted by softbuffer.