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), 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.
CompletionViewandMessageVieware not in the layout. They float over the bottom of the view (placeCompletion/placeMessages, re-run on the view'sResize;MessageViewemitschanged()when a message comes or goes).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. Wheel deltas are screen pixels, so the page zoom shrinks them;j/kgo throughscrollByCssPx, which multiplies by the zoom soconfig::kScrollStepPxmeans CSS px like qutebrowser'sscroll-px.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.- Data paths: the application name is
att_browserand there is deliberately no organization name, soAppDataLocationis~/.local/share/att_browser(andCacheLocation~/.cache/att_browser).migrateOldDataDirsinmain.cppmoves the pre-renameatt/attdirectories there once, before anything opens a file. - 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_browser'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,kScrollStepPx), 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, with qutebrowser's default colours;@FONT@is replaced withconfig::kFont(the hint script does the same in its CSS, and scales its labels by1 / zoomso 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 byobjectNameand by dynamic properties, re-polished on change: the status bar'smode(insert turns it green), the URL label'sstate(https/error/hover) and a message'slevel. - 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 throughBrowserWindow::showMessageto theMessageView, never to the bar. The scroll position comes fromQWebEnginePage::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.