Files

12 KiB

CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

What this is

att_browser is a small, keyboard-driven QtWebEngine browser modelled on qutebrowser, in C++20 / Qt 6 (pinned nixpkgs ships Qt 6.11). The README is the user-facing reference for key bindings, modes and commands. When you add a binding, mode or command, update its tables there.

Build and run

Everything goes through the Nix flake:

nix develop -c sh -c 'cmake -B build -G Ninja && cmake --build build'   # dev build
nix develop -c ./build/att_browser [url]                                        # run the dev build
nix build                                                               # package build (→ ./result)
nix run . -- example.com

Run ./build/att_browser from inside nix develop. The shell hook exports QT_PLUGIN_PATH, QML2_IMPORT_PATH and QTWEBENGINEPROCESS_PATH, because wrapQtAppsHook only wraps installed binaries. Run outside the dev shell, the binary can't find Qt's plugins.

package.nix uses a lib.fileset source: CMakeLists.txt, src/ and the listed adblock/ files. Every new source file must also be added to qt_add_executable in CMakeLists.txt, and git added: the flake only sees tracked files, so nix build fails on untracked sources (nix build path:. works around that). The crate's cargo dependencies are vendored from adblock/Cargo.lock (importCargoLock + cargoSetupHook) for the offline sandbox build, so commit Cargo.lock changes along with Cargo.toml ones.

The only tests are the Rust crate's (nix develop -c sh -c 'cd adblock && cargo test'). There is no linter. The dev shell includes clang-tools (clang-format, clangd) and gdb.

Architecture

BrowserWindow (src/browserwindow.cpp) is the core of the browser. Each one owns a single QWebEngineView, the StatusBar and the CommandLine, and it runs the mode state machine (Normal, Insert, Command, Hint).

  • Key handling uses an application-wide event filter (qApp->installEventFilter, BrowserWindow::eventFilter), not keyPressEvent. QtWebEngine delivers keys straight to its internal render widget, so the window would never see them otherwise. Dispatch order:
    1. Command mode passes keys through to the CommandLine.
    2. Escape / Ctrl-[ always return to normal mode.
    3. Insert mode passes keys through to the page.
    4. Hint mode swallows every key.
    5. Normal mode goes to handleNormalModeKey. Bindings are if (text == "x") chains, and two-key sequences (gg) use m_pendingKeys.
  • Never change the page's size from UI chrome. Every view resize makes the page re-layout, which on YouTube visibly jumps the player.
    • CompletionView and MessageView are not in the layout. They float over the bottom of the view (placeCompletion / placeMessages, re-run on the view's Resize; MessageView emits changed() when a message comes or goes).
    • StatusBar and CommandLine share one fixed height.
    • Open the command line only through openCommandLine, which hides the status bar before showing the command line. Showing a widget re-runs the layout at once, so the other order shrinks and regrows the page.
  • setMode is where entering and leaving a mode takes effect. It updates the status line and focus. Leaving Hint also runs the hint-clear script.
  • Page interaction mostly goes through page()->runJavaScript, except for input that should feel native:
    • Scrolling (scrollBy, used by j/k/h/l, Ctrl-d/u/f/b and gg/G) sends a pixel-delta QWheelEvent to m_view->focusProxy() at the pointer, which is what a touchpad sends. Chromium then scrolls whatever is under the pointer. Wheel deltas are screen pixels, so the page zoom shrinks them; j/k go through scrollByCssPx, which multiplies by the zoom so config::kScrollStepPx means CSS px like qutebrowser's scroll-px. window.scrollBy only moves the document, so it did nothing on sites that scroll an inner element.
    • Hint clicks are real mouse events (see below).
  • Hint mode (src/hints.cpp):
    • The state lives in JS (window.__attHints), injected into QWebEngineScript::ApplicationWorld. That world shares the DOM with the page but keeps its JS globals isolated from the page's scripts.
    • C++ sends one call per key (keyScript / backspaceScript / clearScript). JS returns a {state: ...} map, and handleHintResult acts on it. That map is the protocol, and it is documented in hints.h.
    • Results are asynchronous, so handleHintResult ignores them once the mode is no longer Hint.
    • Following a hint sends a real QMouseEvent press/release to m_view->focusProxy() (the render widget). The position is in CSS px multiplied by the zoom factor.
  • CommandLine keeps the leading : in its text; deleting it cancels. It emits commandEntered(cmd) without the :, and BrowserWindow::runCommand parses it as name plus the rest of the line.
  • urlutil::fromUserInput turns what the user typed into a URL. In order, it tries an existing local path, a full URL, then something that looks like a host (gets https://). Anything else becomes a DuckDuckGo search.
  • Data paths: the application name is att_browser and there is deliberately no organization name, so AppDataLocation is ~/.local/share/att_browser (and CacheLocation ~/.cache/att_browser). migrateOldDataDirs in main.cpp moves the pre-rename att/att directories there once, before anything opens a file.
  • The QWebEngineProfile is created in main.cpp and passed into BrowserWindow. It is a named, on-disk profile with ForcePersistentCookies, because Qt 6's default profile is off-the-record. It is parented to the QApplication so it outlives the view's page. Qt warns if a profile is destroyed while a page still uses it.
  • Adblocking is split across two parts:
    • adblock/ is a Rust staticlib exposing a hand-written C API (att_adblock.h, kept in sync with src/lib.rs by hand) over Brave's adblock crate. Corrosion builds it as part of cmake --build, always in release. Its single-thread default feature is off, which makes Engine Send + Sync.
    • AdBlocker (src/adblock.cpp) is the profile's QWebEngineUrlRequestInterceptor. interceptRequest may run off the GUI thread, so it reads the engine through a mutex-guarded shared_ptr that update() swaps.
    • Lists are compiled on a QtConcurrent worker and cached as adblock-cache.dat in AppDataLocation. A cache that fails to deserialize (e.g. after an adblock-rust upgrade) triggers a re-download.
    • main.cpp creates AdBlocker after the profile, so it is destroyed after it.
  • History (History, src/history.cpp) is SQLite through QtSql, run synchronously on the GUI thread with WAL. visits is an append-only log; urls (one row per URL, upserted) is what completion searches. Visits are recorded on loadFinished(true), not urlChanged. URLs are stored as toDisplayString() with the fragment removed, so a titleChanged after an in-page jump still updates the right row.
  • :open completion: BrowserWindow::updateCompletion runs on the command line's textEdited and on entering command mode. It parses :open/:o and shows History::search results in CompletionView, which never takes focus. CommandLine::event intercepts Tab/Backtab, because QWidget::event would otherwise turn them into focus changes. Picking an entry uses setCommandText, which doesn't emit textEdited, so it doesn't re-filter.
  • Greasemonkey (Greasemonkey, src/greasemonkey.cpp, part of the Session) inserts each greasemonkey/*.js into profile->scripts() as a QWebEngineScript named att-greasemonkey:<file>, in MainWorld.
    • URL matching is left to QtWebEngine, which parses the ==UserScript== block during setSourceCode and checks @include/@exclude/@match in Chromium. So the block must stay in the source. setSourceCode also overwrites the script's name (with @name) and injection point, so setName and setInjectionPoint must come after it. install() finds the old scripts by the att-greasemonkey: name prefix to remove them; a lost prefix makes scripts run twice after a reload.
    • att_browser's own parseMetadata is for run-at, noframes, @require and GM_info. A @require is cached by the SHA-1 of its URL. It must be https, and an optional #sha256= pin is checked against the cached file on every install (requireProblem).
    • Scripts are wrapped in an IIFE (kWrapperHead/kWrapperTail) that defines the GM API as locals.
    • At document-start, document.documentElement is still null, which the GM helpers must tolerate.
  • Hardening (src/hardening.cpp): applyToProfile sets the restrictive QWebEngineSettings and the third-party cookie filter. The filter runs on the network thread and must stay a pure function. main.cpp applies it right after creating the profile, and warns in the first window if sandboxDisabledReason finds the sandbox off. StatusBar::setUrl takes a QUrl so it can mark plain-HTTP pages (insecure property).
  • User agent (src/useragent.cpp): useragent::apply sets the profile's UA string and its QWebEngineClientHints, so the two don't contradict each other. Chromium sends the low-entropy hints (brands, platform, mobile) even with setAllClientHintsEnabled(false), so every preset sets a platform; the brand ("Google Chrome", "Microsoft Edge") goes in through setFullVersionList. Chrome-based presets use the engine's own major version. :useragent completion reuses CompletionView with name / string columns.
  • Settings live as constants in src/config.h (config::kZoomDefault, config::kZoomLevels, kBlockThirdPartyCookies, kPdfViewer, kUserAgent, kScrollStepPx), named after qutebrowser's settings, until there's a config file. Zoom is per window: setZoom / zoomStep in BrowserWindow, applied before the first load. QtWebEngine keeps a view's zoom across navigations, so it doesn't need re-applying.
  • The UI stylesheet is the kStyleSheet string in src/main.cpp, with qutebrowser's default colours; @FONT@ is replaced with config::kFont (the hint script does the same in its CSS, and scales its labels by 1 / zoom so page zoom doesn't enlarge them). Stylesheet fonts and colours don't propagate to child widgets, so rules name the labels (QWidget#statusBar QLabel). Widgets are styled by objectName and by dynamic properties, re-polished on change: the status bar's mode (insert turns it green), the URL label's state (https/error/hover) and a message's level.
  • Status bar (StatusBar) is laid out like qutebrowser's: mode, key string (m_pendingKeys), URL (an eliding label, so a long URL can't widen the window), scroll position, progress bar. All messages go through BrowserWindow::showMessage to the MessageView, never to the bar. The scroll position comes from QWebEnginePage::scrollPosition/contentsSize, which are in zoomed pixels like the view's height, not CSS pixels.

Windows, not tabs. att_browser deliberately has no tab bar, because the user's window manager does the tabbing. Map qutebrowser's tab features onto top-level windows: d closes the window, O / :open -w (or -t) opens a new one. Each BrowserWindow is one page, with WA_DeleteOnClose. All windows share a Session (profile, AdBlocker, History) that main() owns. After app.exec(), main() deletes any window still alive before the history and profile go away. :quit closes all windows; :close closes one.

newWindowRequested isn't handled yet, so target=_blank and window.open go nowhere. Hint mode rewrites link targets to _self to work around this.