Files
att_browser/CLAUDE.md
T

10 KiB

CLAUDE.md

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

What this is

att 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 [url]                                        # run the dev build
nix build                                                               # package build (→ ./result)
nix run . -- example.com

Run ./build/att 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 is not in the layout. It floats over the bottom of the view (placeCompletion, re-run on the view's Resize).
    • 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. 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.
  • 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'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), 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. Widgets are styled by objectName and by dynamic properties: StatusBar toggles error and re-polishes the label to pick up the change.

Windows, not tabs. att 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.