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), notkeyPressEvent. QtWebEngine delivers keys straight to its internal render widget, so the window would never see them otherwise. Dispatch order:- Command mode passes keys through to the
CommandLine. - Escape / Ctrl-[ always return to normal mode.
- Insert mode passes keys through to the page.
- Hint mode swallows every key.
- Normal mode goes to
handleNormalModeKey. Bindings areif (text == "x")chains, and two-key sequences (gg) usem_pendingKeys.
- Command mode passes keys through to the
- Never change the page's size from UI chrome. Every view resize makes the page re-layout, which on YouTube visibly jumps the player.
CompletionViewis not in the layout. It floats over the bottom of the view (placeCompletion, re-run on the view'sResize).StatusBarandCommandLineshare 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.
setModeis where entering and leaving a mode takes effect. It updates the status line and focus. LeavingHintalso runs the hint-clear script.- Page interaction mostly goes through
page()->runJavaScript, except for input that should feel native:- Scrolling (
scrollBy, used byj/k/h/l,Ctrl-d/u/f/bandgg/G) sends a pixel-deltaQWheelEventtom_view->focusProxy()at the pointer, which is what a touchpad sends. Chromium then scrolls whatever is under the pointer.window.scrollByonly moves the document, so it did nothing on sites that scroll an inner element. - Hint clicks are real mouse events (see below).
- Scrolling (
- Hint mode (
src/hints.cpp):- The state lives in JS (
window.__attHints), injected intoQWebEngineScript::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, andhandleHintResultacts on it. That map is the protocol, and it is documented inhints.h. - Results are asynchronous, so
handleHintResultignores them once the mode is no longerHint. - Following a hint sends a real
QMouseEventpress/release tom_view->focusProxy()(the render widget). The position is in CSS px multiplied by the zoom factor.
- The state lives in JS (
CommandLinekeeps the leading:in its text; deleting it cancels. It emitscommandEntered(cmd)without the:, andBrowserWindow::runCommandparses it as name plus the rest of the line.urlutil::fromUserInputturns 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 (getshttps://). Anything else becomes a DuckDuckGo search.- The
QWebEngineProfileis created inmain.cppand passed intoBrowserWindow. It is a named, on-disk profile withForcePersistentCookies, because Qt 6's default profile is off-the-record. It is parented to theQApplicationso 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 withsrc/lib.rsby hand) over Brave'sadblockcrate. Corrosion builds it as part ofcmake --build, always in release. Itssingle-threaddefault feature is off, which makesEngineSend + Sync.AdBlocker(src/adblock.cpp) is the profile'sQWebEngineUrlRequestInterceptor.interceptRequestmay run off the GUI thread, so it reads the engine through a mutex-guardedshared_ptrthatupdate()swaps.- Lists are compiled on a
QtConcurrentworker and cached asadblock-cache.datin AppDataLocation. A cache that fails to deserialize (e.g. after an adblock-rust upgrade) triggers a re-download. main.cppcreatesAdBlockerafter 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.visitsis an append-only log;urls(one row per URL, upserted) is what completion searches. Visits are recorded onloadFinished(true), noturlChanged. URLs are stored astoDisplayString()with the fragment removed, so atitleChangedafter an in-page jump still updates the right row. :opencompletion:BrowserWindow::updateCompletionruns on the command line'stextEditedand on entering command mode. It parses:open/:oand showsHistory::searchresults inCompletionView, which never takes focus.CommandLine::eventintercepts Tab/Backtab, becauseQWidget::eventwould otherwise turn them into focus changes. Picking an entry usessetCommandText, which doesn't emittextEdited, so it doesn't re-filter.- Greasemonkey (
Greasemonkey,src/greasemonkey.cpp, part of theSession) inserts eachgreasemonkey/*.jsintoprofile->scripts()as aQWebEngineScriptnamedatt-greasemonkey:<file>, inMainWorld.- URL matching is left to QtWebEngine, which parses the
==UserScript==block duringsetSourceCodeand checks@include/@exclude/@matchin Chromium. So the block must stay in the source.setSourceCodealso overwrites the script's name (with@name) and injection point, sosetNameandsetInjectionPointmust come after it.install()finds the old scripts by theatt-greasemonkey:name prefix to remove them; a lost prefix makes scripts run twice after a reload. - att's own
parseMetadatais for run-at, noframes,@requireandGM_info. A@requireis cached by the SHA-1 of its URL. It must behttps, 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.documentElementis still null, which the GM helpers must tolerate.
- URL matching is left to QtWebEngine, which parses the
- Hardening (
src/hardening.cpp):applyToProfilesets the restrictiveQWebEngineSettingsand the third-party cookie filter. The filter runs on the network thread and must stay a pure function.main.cppapplies it right after creating the profile, and warns in the first window ifsandboxDisabledReasonfinds the sandbox off.StatusBar::setUrltakes aQUrlso it can mark plain-HTTP pages (insecureproperty). - User agent (
src/useragent.cpp):useragent::applysets the profile's UA string and itsQWebEngineClientHints, so the two don't contradict each other. Chromium sends the low-entropy hints (brands, platform, mobile) even withsetAllClientHintsEnabled(false), so every preset sets a platform; the brand ("Google Chrome", "Microsoft Edge") goes in throughsetFullVersionList. Chrome-based presets use the engine's own major version.:useragentcompletion reusesCompletionViewwith 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/zoomStepinBrowserWindow, 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
kStyleSheetstring insrc/main.cpp. Widgets are styled byobjectNameand by dynamic properties:StatusBartoggleserrorand 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.