add base: browser window, hints, history, cookies, adblock

This commit is contained in:
2026-10-15 23:03:56 +02:00
commit a841178cf7
26 changed files with 2711 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
/build/
/result
/result-*
compile_commands.json
.cache/
.direnv/
/adblock/target/
+54
View File
@@ -0,0 +1,54 @@
# 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:
```sh
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`. 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. It 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`.
- **`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 goes through `page()->runJavaScript`.** Scrolling, for example, is done with JS calls rather than Qt APIs.
- **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.
- **`gpuworkaround::apply()`** must run before `QApplication` is constructed, because Chromium reads `QTWEBENGINE_CHROMIUM_FLAGS` from the environment. It adds `--disable-accelerated-video-decode` only on Wayland with QtWebEngine < 6.11.2 (QTBUG-145344). Set `ATT_NO_GPU_WORKAROUND=1` to skip it.
- **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.
- 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.
There are no tabs yet. `createWindow` isn't implemented, so `target=_blank` and `window.open` go nowhere. To work around this, hint mode rewrites link targets to `_self`.
+62
View File
@@ -0,0 +1,62 @@
cmake_minimum_required(VERSION 3.21)
project(att
VERSION 0.1.0
DESCRIPTION "Minimal keyboard-driven QtWebEngine browser"
LANGUAGES CXX
)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_AUTOMOC ON)
include(GNUInstallDirs)
find_package(Qt6 6.5 REQUIRED COMPONENTS Core Gui Widgets Network Concurrent WebEngineCore WebEngineWidgets)
find_package(Corrosion REQUIRED)
qt_standard_project_setup()
# Brave's adblock-rust behind a small C API. Always built in release: a debug
# build of the engine is far too slow to compile EasyList or check requests.
corrosion_import_crate(
MANIFEST_PATH adblock/Cargo.toml
CRATES att_adblock
CRATE_TYPES staticlib
PROFILE release
)
qt_add_executable(att
src/adblock.cpp
src/adblock.h
src/main.cpp
src/browserwindow.cpp
src/browserwindow.h
src/commandline.cpp
src/commandline.h
src/gpuworkaround.cpp
src/gpuworkaround.h
src/hints.cpp
src/hints.h
src/statusbar.cpp
src/statusbar.h
src/urlutil.cpp
src/urlutil.h
)
target_include_directories(att PRIVATE src adblock)
target_link_libraries(att PRIVATE
att_adblock
Qt6::Core
Qt6::Gui
Qt6::Widgets
Qt6::Network
Qt6::Concurrent
Qt6::WebEngineCore
Qt6::WebEngineWidgets
)
install(TARGETS att
RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}
)
+140
View File
@@ -0,0 +1,140 @@
# att
A small, keyboard-driven QtWebEngine browser in the spirit of qutebrowser.
This is the skeleton: one window, one page, a qutebrowser-style status line and
`:` command line, `f` link hints and adblocking. No tabs and no config file yet.
## Running
```sh
nix run . # open the default start page
nix run . -- news.ycombinator.com
```
## Developing
```sh
nix develop
cmake -B build -G Ninja
cmake --build build
./build/att
```
The dev shell exports `QT_PLUGIN_PATH`, `QML2_IMPORT_PATH` and
`QTWEBENGINEPROCESS_PATH` by hand, because `wrapQtAppsHook` only wraps binaries
that have been *installed* — a binary run straight out of `./build` would
otherwise fail to find Qt's platform plugin.
Pinned to `nixos-26.05` (current NixOS stable), which ships Qt 6.11.
## Modes
Like qutebrowser, keys mean different things depending on the mode:
| Mode | How you get there | Behaviour |
| --------- | ---------------------------- | ---------------------------------- |
| `normal` | default, `Escape` | keys are browser bindings |
| `insert` | `i` | keys go to the page (type in forms)|
| `command` | `o`, `O`, `:` | keys go to the command line |
| `hint` | `f` | keys pick a link label |
`Escape` (or `Ctrl-[`) always returns to normal mode.
> Note: unlike qutebrowser, att does not yet auto-switch to insert mode when you
> click into a text field on a page — press `i` first. Doing it automatically
> needs a JS focus-tracking script over QWebChannel; see *Next steps*.
## Key bindings (normal mode)
| Key | Action |
| -------------- | ---------------------------------------- |
| `o` | open the command line prefilled `:open ` |
| `O` | same, prefilled with the current URL |
| `:` | open a bare command line |
| `i` | enter insert mode |
| `f` | hint mode: follow a link by its label |
| `Escape` | back to normal mode |
| `h` `j` `k` `l`| scroll |
| `Ctrl-d/u` | scroll half a page |
| `Ctrl-f/b` | scroll a full page |
| `gg` / `G` | jump to top / bottom |
| `H` / `L` | back / forward in history |
| `r` / `R` | reload / reload bypassing cache |
| `y` | yank the current URL to the clipboard |
| `+` `-` `=` | zoom in / out / reset |
The command line keeps a history (`Up`/`Down`) and aborts on `Escape`, `Ctrl-c`,
or if you delete the leading `:`.
## Hint mode
`f` labels every clickable element in the viewport with home-row letters
(`asdfghjkl`). Type a label to click it; `Backspace` undoes a letter, `Escape`
cancels. The click is a real mouse event sent to the render widget, so it works
on sites that ignore `element.click()`. Following a text field drops you into
insert mode. Links that would open a new window are opened in place, since there
are no tabs yet.
The hint script runs in `QWebEngineScript::ApplicationWorld`, so the page's own
JavaScript can't see or interfere with it. It doesn't descend into iframes or
shadow DOM yet.
## Browsing data
Cookies, local storage and history are kept in a persistent QtWebEngine profile
under `~/.local/share/att/att/QtWebEngine/default` (the cache is in
`~/.cache/att/att/`). Session cookies are saved too, like qutebrowser's
`content.cookies.store`, so you stay logged in across restarts. Delete that
directory to start fresh.
## Adblocking
Network requests are checked against EasyList and EasyPrivacy with Brave's
[adblock-rust](https://github.com/brave/adblock-rust), the same engine and
default lists as qutebrowser. Blocked requests never leave the browser. There is
no cosmetic filtering (element hiding), so ads served from the site's own URLs
still get through.
The lists are downloaded on first start and compiled into
`~/.local/share/att/att/adblock-cache.dat`, which later starts just load.
`:adblock-update` re-downloads them. `:adblock-toggle` switches blocking off and
on, for when a site breaks.
## Commands
`:open <url|query>` (alias `:o`), `:quit` (alias `:q`), `:reload`, `:back`,
`:forward`, `:stop`, `:adblock-update`, `:adblock-toggle`.
`:open` accepts a full URL, a bare host (`example.com` → `https://example.com`),
an existing local path, or free text, which goes to DuckDuckGo.
## Layout
| File | Role |
| ------------------- | ------------------------------------------------------- |
| `flake.nix` | pinned inputs, package, dev shell |
| `package.nix` | the derivation itself, usable via `callPackage` |
| `src/main.cpp` | `QApplication`, CLI parsing, stylesheet |
| `src/browserwindow` | the window, mode state machine, key dispatch, commands |
| `src/commandline` | the `:` prompt |
| `src/statusbar` | mode / URL / progress line |
| `src/adblock` | request interceptor around the adblock engine |
| `adblock/` | Rust crate: C API over adblock-rust, built by Corrosion |
| `src/hints` | the injected hint-mode script |
| `src/urlutil` | "what the user typed" → `QUrl` |
Key events are intercepted with an **application-wide event filter**
(`BrowserWindow::eventFilter`) rather than `QWidget::keyPressEvent`, because
QtWebEngine delivers key presses directly to its internal render widget; the
parent window never sees them otherwise.
## Next steps
- Tabs (`QTabWidget` + a per-tab `QWebEngineView`), `gt`/`gT`, `d` to close.
- Auto insert mode: inject a `QWebEngineScript` that reports `focusin` /
`focusout` on editable elements back over `QWebChannel`.
- More hint modes: `F` (new tab, once there are tabs), `;y` to yank a link.
- `/` and `?` search with `n`/`N`, using `QWebEnginePage::findText`.
- A config file for bindings, start page and search engine.
- Counts before commands (`3j`) and a proper command parser with completion.
+583
View File
@@ -0,0 +1,583 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "adblock"
version = "0.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f44b96a666a23c12acad7c688bfe8638a7094e7eabe765b09a6864ab991c676d"
dependencies = [
"addr",
"arrayvec",
"base64",
"bitflags",
"flatbuffers",
"idna",
"itertools",
"memchr",
"percent-encoding",
"precomputed-hash",
"regex",
"rustc-hash",
"seahash",
"serde",
"serde_json",
"thiserror",
"url",
]
[[package]]
name = "addr"
version = "0.15.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a93b8a41dbe230ad5087cc721f8d41611de654542180586b315d9f4cf6b72bef"
dependencies = [
"psl",
"psl-types",
]
[[package]]
name = "aho-corasick"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba"
dependencies = [
"memchr",
]
[[package]]
name = "arrayvec"
version = "0.7.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"
[[package]]
name = "att_adblock"
version = "0.1.0"
dependencies = [
"adblock",
]
[[package]]
name = "base64"
version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "bitflags"
version = "2.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
dependencies = [
"serde_core",
]
[[package]]
name = "displaydoc"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.9",
]
[[package]]
name = "either"
version = "1.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be"
[[package]]
name = "flatbuffers"
version = "25.12.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35f6839d7b3b98adde531effaf34f0c2badc6f4735d26fe74709d8e513a96ef3"
dependencies = [
"bitflags",
"rustc_version",
]
[[package]]
name = "form_urlencoded"
version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
dependencies = [
"percent-encoding",
]
[[package]]
name = "icu_collections"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513"
dependencies = [
"displaydoc",
"potential_utf",
"utf8_iter",
"yoke",
"zerofrom",
"zerovec",
]
[[package]]
name = "icu_locale_core"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb"
dependencies = [
"displaydoc",
"litemap",
"tinystr",
"writeable",
"zerovec",
]
[[package]]
name = "icu_normalizer"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f"
dependencies = [
"icu_collections",
"icu_normalizer_data",
"icu_properties",
"icu_provider",
"smallvec",
"zerovec",
]
[[package]]
name = "icu_normalizer_data"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0"
[[package]]
name = "icu_properties"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148"
dependencies = [
"displaydoc",
"icu_collections",
"icu_locale_core",
"icu_properties_data",
"icu_provider",
"zerotrie",
"zerovec",
]
[[package]]
name = "icu_properties_data"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa"
[[package]]
name = "icu_provider"
version = "2.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73"
dependencies = [
"displaydoc",
"icu_locale_core",
"writeable",
"yoke",
"zerofrom",
"zerotrie",
"zerovec",
]
[[package]]
name = "idna"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
dependencies = [
"idna_adapter",
"smallvec",
"utf8_iter",
]
[[package]]
name = "idna_adapter"
version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
dependencies = [
"icu_normalizer",
"icu_properties",
]
[[package]]
name = "itertools"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285"
dependencies = [
"either",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "litemap"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "percent-encoding"
version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "potential_utf"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661"
dependencies = [
"zerovec",
]
[[package]]
name = "precomputed-hash"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "psl"
version = "2.1.241"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7319b480e204d67e759cad7886178335d26324c7c8573c126cc9dfa7350d064"
dependencies = [
"psl-types",
]
[[package]]
name = "psl-types"
version = "2.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac"
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "regex"
version = "1.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d"
dependencies = [
"aho-corasick",
"memchr",
"regex-automata",
"regex-syntax",
]
[[package]]
name = "regex-automata"
version = "0.4.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2"
dependencies = [
"aho-corasick",
"memchr",
"regex-syntax",
]
[[package]]
name = "regex-syntax"
version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
[[package]]
name = "rustc-hash"
version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
[[package]]
name = "rustc_version"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
dependencies = [
"semver",
]
[[package]]
name = "seahash"
version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b"
[[package]]
name = "semver"
version = "1.0.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
[[package]]
name = "serde"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.9",
]
[[package]]
name = "serde_json"
version = "1.0.154"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "smallvec"
version = "1.16.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b"
[[package]]
name = "stable_deref_trait"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
[[package]]
name = "syn"
version = "2.0.119"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "syn"
version = "3.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "synstructure"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.9",
]
[[package]]
name = "thiserror"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "tinystr"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643"
dependencies = [
"displaydoc",
"zerovec",
]
[[package]]
name = "unicode-ident"
version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
[[package]]
name = "url"
version = "2.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
dependencies = [
"form_urlencoded",
"idna",
"percent-encoding",
"serde",
]
[[package]]
name = "utf8_iter"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]]
name = "writeable"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc"
[[package]]
name = "yoke"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
dependencies = [
"stable_deref_trait",
"yoke-derive",
"zerofrom",
]
[[package]]
name = "yoke-derive"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.9",
"synstructure",
]
[[package]]
name = "zerofrom"
version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
dependencies = [
"zerofrom-derive",
]
[[package]]
name = "zerofrom-derive"
version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.9",
"synstructure",
]
[[package]]
name = "zerotrie"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f"
dependencies = [
"displaydoc",
"yoke",
"zerofrom",
]
[[package]]
name = "zerovec"
version = "0.11.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8"
dependencies = [
"yoke",
"zerofrom",
"zerovec-derive",
]
[[package]]
name = "zerovec-derive"
version = "0.11.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.9",
]
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
+19
View File
@@ -0,0 +1,19 @@
[package]
name = "att_adblock"
version = "0.1.0"
edition = "2021"
publish = false
# A thin C API over Brave's adblock-rust, linked into att as a static library
# (see src/adblock.h for the C++ side and att_adblock.h for the declarations).
[lib]
crate-type = ["staticlib"]
[dependencies]
# Without the default "single-thread" feature the Engine is Send + Sync, so it
# can be built on a worker thread and queried from QtWebEngine's IO thread.
adblock = { version = "0.13.3", default-features = false, features = ["embedded-domain-resolver", "full-regex-handling"] }
[profile.release]
lto = true
codegen-units = 1
+37
View File
@@ -0,0 +1,37 @@
#pragma once
// C API of the att_adblock Rust crate (adblock/src/lib.rs), a thin wrapper
// over Brave's adblock-rust. Keep the two in sync by hand.
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#ifdef __cplusplus
extern "C" {
#endif
typedef struct AttAdblockEngine AttAdblockEngine;
// Compiles filter list text; several lists may be concatenated. Null on failure.
AttAdblockEngine *att_adblock_engine_from_rules(const uint8_t *rules, size_t len);
// Loads data from att_adblock_engine_serialize. Null if it is corrupt or from an
// incompatible adblock-rust version.
AttAdblockEngine *att_adblock_engine_deserialize(const uint8_t *data, size_t len);
// The returned buffer must be released with att_adblock_buffer_free.
uint8_t *att_adblock_engine_serialize(const AttAdblockEngine *engine, size_t *out_len);
void att_adblock_buffer_free(uint8_t *data, size_t len);
void att_adblock_engine_free(AttAdblockEngine *engine);
// All strings are NUL-terminated UTF-8. request_type uses webRequest names
// ("script", "image", "xhr", "sub_frame", ...). Thread-safe.
bool att_adblock_should_block(const AttAdblockEngine *engine, const char *url,
const char *source_url, const char *request_type,
const char *method);
#ifdef __cplusplus
}
#endif
+182
View File
@@ -0,0 +1,182 @@
//! C API over adblock-rust's network blocking. Declared in `att_adblock.h`.
//!
//! Every entry point catches panics: unwinding into C++ is undefined behaviour,
//! and a bad filter list or URL should never take the browser down.
use std::ffi::{c_char, CStr};
use std::panic::{catch_unwind, AssertUnwindSafe};
use std::ptr;
use adblock::lists::{FilterSet, ParseOptions};
use adblock::request::Request;
use adblock::Engine;
pub struct AttAdblockEngine(Engine);
fn into_raw(engine: Engine) -> *mut AttAdblockEngine {
Box::into_raw(Box::new(AttAdblockEngine(engine)))
}
unsafe fn bytes<'a>(data: *const u8, len: usize) -> &'a [u8] {
if data.is_null() || len == 0 {
&[]
} else {
std::slice::from_raw_parts(data, len)
}
}
unsafe fn str_arg<'a>(s: *const c_char) -> Option<&'a str> {
if s.is_null() {
None
} else {
CStr::from_ptr(s).to_str().ok()
}
}
/// Compiles an engine from filter list text (Adblock Plus / uBlock syntax;
/// several lists may simply be concatenated). Returns null on failure.
#[no_mangle]
pub unsafe extern "C" fn att_adblock_engine_from_rules(
rules: *const u8,
len: usize,
) -> *mut AttAdblockEngine {
let text = String::from_utf8_lossy(bytes(rules, len)).into_owned();
catch_unwind(move || {
let mut set = FilterSet::new(false);
set.add_filter_list(text, ParseOptions::default());
into_raw(Engine::new_with_filter_set(set))
})
.unwrap_or(ptr::null_mut())
}
/// Loads an engine saved by `att_adblock_engine_serialize`. Returns null if
/// the data is corrupt or was written by an incompatible adblock-rust version.
#[no_mangle]
pub unsafe extern "C" fn att_adblock_engine_deserialize(
data: *const u8,
len: usize,
) -> *mut AttAdblockEngine {
let data = bytes(data, len);
catch_unwind(|| {
let mut engine = Engine::default();
match engine.deserialize(data) {
Ok(()) => into_raw(engine),
Err(_) => ptr::null_mut(),
}
})
.unwrap_or(ptr::null_mut())
}
/// Serializes the engine. The returned buffer of `*out_len` bytes must be
/// released with `att_adblock_buffer_free`. Returns null on failure.
#[no_mangle]
pub unsafe extern "C" fn att_adblock_engine_serialize(
engine: *const AttAdblockEngine,
out_len: *mut usize,
) -> *mut u8 {
if engine.is_null() || out_len.is_null() {
return ptr::null_mut();
}
let engine = &(*engine).0;
match catch_unwind(AssertUnwindSafe(|| engine.serialize())) {
Ok(data) => {
let data = data.into_boxed_slice();
*out_len = data.len();
Box::into_raw(data) as *mut u8
}
Err(_) => ptr::null_mut(),
}
}
#[no_mangle]
pub unsafe extern "C" fn att_adblock_buffer_free(data: *mut u8, len: usize) {
if !data.is_null() {
drop(Box::from_raw(ptr::slice_from_raw_parts_mut(data, len)));
}
}
#[no_mangle]
pub unsafe extern "C" fn att_adblock_engine_free(engine: *mut AttAdblockEngine) {
if !engine.is_null() {
drop(Box::from_raw(engine));
}
}
/// Whether a request for `url`, made by a page at `source_url`, should be
/// blocked. `request_type` uses the webRequest names ("script", "image",
/// "xhr", "sub_frame", ...) and `method` an HTTP method, in any case. Safe to
/// call from several threads at once.
#[no_mangle]
pub unsafe extern "C" fn att_adblock_should_block(
engine: *const AttAdblockEngine,
url: *const c_char,
source_url: *const c_char,
request_type: *const c_char,
method: *const c_char,
) -> bool {
if engine.is_null() {
return false;
}
let engine = &(*engine).0;
let (Some(url), Some(source_url), Some(request_type)) =
(str_arg(url), str_arg(source_url), str_arg(request_type))
else {
return false;
};
let method = str_arg(method).unwrap_or("GET").to_ascii_lowercase();
catch_unwind(AssertUnwindSafe(|| {
match Request::new(url, source_url, request_type, &method) {
Ok(request) => engine.check_network_request(&request).should_block(),
Err(_) => false,
}
}))
.unwrap_or(false)
}
#[cfg(test)]
mod tests {
use super::*;
use std::ffi::CString;
fn check(engine: *const AttAdblockEngine, url: &str, source: &str, ty: &str) -> bool {
let (u, s, t, m) = (
CString::new(url).unwrap(),
CString::new(source).unwrap(),
CString::new(ty).unwrap(),
CString::new("GET").unwrap(),
);
unsafe { att_adblock_should_block(engine, u.as_ptr(), s.as_ptr(), t.as_ptr(), m.as_ptr()) }
}
#[test]
fn blocks_exceptions_and_round_trips() {
let rules = b"||ads.example.com^\n/banner/*$image\n@@||ads.example.com/ok.js\n";
unsafe {
let engine = att_adblock_engine_from_rules(rules.as_ptr(), rules.len());
assert!(!engine.is_null());
let page = "https://news.example.org/";
assert!(check(engine, "https://ads.example.com/x.js", page, "script"));
assert!(!check(engine, "https://ads.example.com/ok.js", page, "script"));
assert!(check(engine, "https://cdn.example.org/banner/a.png", page, "image"));
assert!(!check(engine, "https://cdn.example.org/banner/a.js", page, "script"));
assert!(!check(engine, "https://news.example.org/app.js", page, "script"));
let mut len = 0;
let data = att_adblock_engine_serialize(engine, &mut len);
assert!(!data.is_null() && len > 0);
let restored = att_adblock_engine_deserialize(data, len);
att_adblock_buffer_free(data, len);
assert!(!restored.is_null());
assert!(check(restored, "https://ads.example.com/x.js", page, "script"));
assert!(!check(restored, "https://ads.example.com/ok.js", page, "script"));
let junk = b"not an engine";
assert!(att_adblock_engine_deserialize(junk.as_ptr(), junk.len()).is_null());
att_adblock_engine_free(engine);
att_adblock_engine_free(restored);
}
}
}
Generated
+26
View File
@@ -0,0 +1,26 @@
{
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1792093246,
"narHash": "sha256-SYZEnBiO/jVvVDp5RelSm5Ox4OPijQMukg5cetzXAzg=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "8d71b6f9b2c06470b95ebbfa857cc70890976d07",
"type": "github"
},
"original": {
"owner": "NixOS",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}
+52
View File
@@ -0,0 +1,52 @@
{
description = "att - a minimal, qutebrowser-like QtWebEngine browser";
inputs = {
# Pinned to the current NixOS stable release.
nixpkgs.url = "github:NixOS/nixpkgs";
};
outputs = { self, nixpkgs }:
let
systems = [ "x86_64-linux" "aarch64-linux" ];
forAllSystems = f:
nixpkgs.lib.genAttrs systems (system: f nixpkgs.legacyPackages.${system});
in
{
packages = forAllSystems (pkgs: rec {
att = pkgs.callPackage ./package.nix { };
default = att;
});
apps = forAllSystems (pkgs: rec {
att = {
type = "app";
program = "${self.packages.${pkgs.stdenv.hostPlatform.system}.att}/bin/att";
};
default = att;
});
devShells = forAllSystems (pkgs: {
default = pkgs.mkShell {
inputsFrom = [ self.packages.${pkgs.stdenv.hostPlatform.system}.att ];
packages = with pkgs; [
clang-tools
gdb
];
# wrapQtAppsHook only wraps installed binaries, so a binary run straight
# out of ./build has no idea where Qt's plugins live. Export the same
# variables the wrapper would have set.
shellHook = with pkgs.qt6; ''
export QT_PLUGIN_PATH="${qtbase}/${qtbase.qtPluginPrefix}:${qtwebengine}/${qtbase.qtPluginPrefix}"
export QML2_IMPORT_PATH="${qtdeclarative}/${qtbase.qtQmlPrefix}:${qtwebengine}/${qtbase.qtQmlPrefix}"
export QTWEBENGINEPROCESS_PATH="${qtwebengine}/libexec/QtWebEngineProcess"
echo "att dev shell - cmake -B build -G Ninja && cmake --build build && ./build/att"
'';
};
});
formatter = forAllSystems (pkgs: pkgs.nixpkgs-fmt);
};
}
+56
View File
@@ -0,0 +1,56 @@
{ lib
, stdenv
, cmake
, ninja
, cargo
, rustc
, rustPlatform
, corrosion
, qt6
}:
stdenv.mkDerivation (finalAttrs: {
pname = "att";
version = "0.1.0";
src = lib.fileset.toSource {
root = ./.;
fileset = lib.fileset.unions [
./CMakeLists.txt
./src
./adblock/Cargo.toml
./adblock/Cargo.lock
./adblock/att_adblock.h
./adblock/src
];
};
# The adblock crate's dependencies, vendored from Cargo.lock so Corrosion's
# cargo invocation works offline inside the build sandbox.
cargoRoot = "adblock";
cargoDeps = rustPlatform.importCargoLock {
lockFile = ./adblock/Cargo.lock;
};
nativeBuildInputs = [
cmake
ninja
cargo
rustc
rustPlatform.cargoSetupHook
corrosion
qt6.wrapQtAppsHook
];
buildInputs = with qt6; [
qtbase
qtwebengine
];
meta = {
description = "Minimal keyboard-driven QtWebEngine browser";
mainProgram = "att";
platforms = lib.platforms.linux;
license = lib.licenses.mit;
};
})
+225
View File
@@ -0,0 +1,225 @@
#include "adblock.h"
#include "att_adblock.h"
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QFutureWatcher>
#include <QNetworkAccessManager>
#include <QNetworkReply>
#include <QSaveFile>
#include <QStandardPaths>
#include <QWebEngineUrlRequestInfo>
#include <QtConcurrent>
namespace {
// qutebrowser's default content.blocking.adblock.lists.
constexpr const char *kLists[] = {
"https://easylist.to/easylist/easylist.txt",
"https://easylist.to/easylist/easyprivacy.txt",
};
constexpr int kListCount = std::size(kLists);
using EnginePtr = std::shared_ptr<const AttAdblockEngine>;
EnginePtr wrap(AttAdblockEngine *engine)
{
if (!engine)
return {};
return EnginePtr(engine, [](const AttAdblockEngine *e) {
att_adblock_engine_free(const_cast<AttAdblockEngine *>(e));
});
}
EnginePtr loadCache(const QString &path)
{
QFile file(path);
if (!file.open(QIODevice::ReadOnly))
return {};
const QByteArray data = file.readAll();
return wrap(att_adblock_engine_deserialize(
reinterpret_cast<const uint8_t *>(data.constData()), size_t(data.size())));
}
bool saveCache(const AttAdblockEngine *engine, const QString &path)
{
size_t len = 0;
uint8_t *data = att_adblock_engine_serialize(engine, &len);
if (!data)
return false;
QDir().mkpath(QFileInfo(path).absolutePath());
QSaveFile file(path);
const bool ok = file.open(QIODevice::WriteOnly)
&& file.write(reinterpret_cast<const char *>(data), qint64(len)) == qint64(len)
&& file.commit();
att_adblock_buffer_free(data, len);
return ok;
}
// The webRequest names adblock-rust matches $script, $image, ... options
// against. Same mapping as qutebrowser's braveadblock.py.
const char *requestType(QWebEngineUrlRequestInfo::ResourceType type)
{
switch (type) {
case QWebEngineUrlRequestInfo::ResourceTypeMainFrame:
return "main_frame";
case QWebEngineUrlRequestInfo::ResourceTypeSubFrame:
return "sub_frame";
case QWebEngineUrlRequestInfo::ResourceTypeStylesheet:
return "stylesheet";
case QWebEngineUrlRequestInfo::ResourceTypeScript:
return "script";
case QWebEngineUrlRequestInfo::ResourceTypeImage:
case QWebEngineUrlRequestInfo::ResourceTypeFavicon:
return "image";
case QWebEngineUrlRequestInfo::ResourceTypeFontResource:
return "font";
case QWebEngineUrlRequestInfo::ResourceTypeObject:
return "object";
case QWebEngineUrlRequestInfo::ResourceTypeMedia:
return "media";
case QWebEngineUrlRequestInfo::ResourceTypeXhr:
return "xhr";
case QWebEngineUrlRequestInfo::ResourceTypePing:
return "ping";
case QWebEngineUrlRequestInfo::ResourceTypeCspReport:
return "csp_report";
case QWebEngineUrlRequestInfo::ResourceTypeWebSocket:
return "websocket";
default:
return "other";
}
}
} // namespace
struct AdBlocker::Download
{
QByteArray lists[kListCount];
int remaining = kListCount;
int failed = 0;
};
AdBlocker::AdBlocker(QObject *parent)
: QWebEngineUrlRequestInterceptor(parent)
, m_network(new QNetworkAccessManager(this))
, m_cachePath(QStandardPaths::writableLocation(QStandardPaths::AppDataLocation)
+ QStringLiteral("/adblock-cache.dat"))
{
}
void AdBlocker::interceptRequest(QWebEngineUrlRequestInfo &info)
{
if (!m_enabled)
return;
const QUrl url = info.requestUrl();
const QString scheme = url.scheme();
if (scheme != QLatin1String("https") && scheme != QLatin1String("http")
&& scheme != QLatin1String("wss") && scheme != QLatin1String("ws"))
return;
const EnginePtr current = engine();
if (!current)
return;
const QByteArray requestUrl = url.toEncoded();
const QByteArray sourceUrl = info.firstPartyUrl().toEncoded();
const QByteArray method = info.requestMethod();
if (att_adblock_should_block(current.get(), requestUrl.constData(), sourceUrl.constData(),
requestType(info.resourceType()), method.constData()))
info.block(true);
}
void AdBlocker::start()
{
auto *watcher = new QFutureWatcher<EnginePtr>(this);
connect(watcher, &QFutureWatcher<EnginePtr>::finished, this, [this, watcher] {
watcher->deleteLater();
if (EnginePtr cached = watcher->result())
setEngine(std::move(cached));
else
update(); // First run, or a cache from an incompatible adblock-rust.
});
watcher->setFuture(QtConcurrent::run(loadCache, m_cachePath));
}
void AdBlocker::update()
{
if (m_updating) {
emit message(QStringLiteral("Adblock update already running"), false);
return;
}
m_updating = true;
emit message(QStringLiteral("Downloading adblock lists..."), false);
auto download = std::make_shared<Download>();
for (int i = 0; i < kListCount; ++i) {
QNetworkReply *reply = m_network->get(QNetworkRequest(QUrl(QString::fromLatin1(kLists[i]))));
connect(reply, &QNetworkReply::finished, this, [this, reply, download, i] {
reply->deleteLater();
if (reply->error() == QNetworkReply::NoError)
download->lists[i] = reply->readAll();
else
download->failed++;
if (--download->remaining == 0)
compile(download);
});
}
}
void AdBlocker::compile(const std::shared_ptr<Download> &download)
{
if (download->failed == kListCount) {
m_updating = false;
emit message(QStringLiteral("Adblock update failed: could not download any list"), true);
return;
}
QByteArray rules;
for (const QByteArray &list : download->lists)
rules += list + '\n';
// Compiling EasyList takes a moment, so keep it off the GUI thread.
auto *watcher = new QFutureWatcher<EnginePtr>(this);
connect(watcher, &QFutureWatcher<EnginePtr>::finished, this, [this, watcher, download] {
watcher->deleteLater();
m_updating = false;
EnginePtr compiled = watcher->result();
if (!compiled) {
emit message(QStringLiteral("Adblock update failed: could not compile the lists"), true);
return;
}
setEngine(std::move(compiled));
if (download->failed > 0)
emit message(QStringLiteral("Adblock updated, but %1 of %2 lists failed to download")
.arg(download->failed)
.arg(kListCount),
true);
else
emit message(QStringLiteral("Adblock lists updated"), false);
});
watcher->setFuture(QtConcurrent::run([rules, path = m_cachePath] {
EnginePtr engine = wrap(att_adblock_engine_from_rules(
reinterpret_cast<const uint8_t *>(rules.constData()), size_t(rules.size())));
if (engine)
saveCache(engine.get(), path);
return engine;
}));
}
void AdBlocker::setEngine(EnginePtr engine)
{
const std::lock_guard lock(m_engineMutex);
m_engine = std::move(engine);
}
AdBlocker::EnginePtr AdBlocker::engine() const
{
const std::lock_guard lock(m_engineMutex);
return m_engine;
}
+57
View File
@@ -0,0 +1,57 @@
#pragma once
#include <QString>
#include <QWebEngineUrlRequestInterceptor>
#include <atomic>
#include <memory>
#include <mutex>
struct AttAdblockEngine;
class QNetworkAccessManager;
// Network-level ad blocking with Brave's adblock-rust (the adblock/ crate),
// wired up the way qutebrowser does it: a request interceptor on the profile
// asks the engine about every request and blocks the matches. There is no
// cosmetic (element hiding) filtering.
//
// The compiled engine is cached on disk, so startup only deserializes it;
// update() re-downloads the filter lists and recompiles off the GUI thread.
class AdBlocker : public QWebEngineUrlRequestInterceptor
{
Q_OBJECT
public:
explicit AdBlocker(QObject *parent = nullptr);
// May run on QtWebEngine's IO thread.
void interceptRequest(QWebEngineUrlRequestInfo &info) override;
// Loads the cached engine, or runs update() if there is none yet.
void start();
void update();
bool isEnabled() const { return m_enabled; }
void setEnabled(bool enabled) { m_enabled = enabled; }
signals:
void message(const QString &text, bool isError);
private:
using EnginePtr = std::shared_ptr<const AttAdblockEngine>;
struct Download;
void compile(const std::shared_ptr<Download> &download);
void setEngine(EnginePtr engine);
EnginePtr engine() const;
QNetworkAccessManager *m_network;
QString m_cachePath;
bool m_updating = false;
std::atomic_bool m_enabled = true;
// interceptRequest copies the pointer under the lock, so an update can swap
// in a new engine while a check on the old one is still running.
mutable std::mutex m_engineMutex;
EnginePtr m_engine;
};
+410
View File
@@ -0,0 +1,410 @@
#include "browserwindow.h"
#include "adblock.h"
#include "commandline.h"
#include "hints.h"
#include "statusbar.h"
#include "urlutil.h"
#include <QApplication>
#include <QClipboard>
#include <QEvent>
#include <QKeyEvent>
#include <QMouseEvent>
#include <QVBoxLayout>
#include <QWebEnginePage>
#include <QWebEngineScript>
#include <QWebEngineView>
#include <QWidget>
namespace {
constexpr int kScrollStep = 60;
constexpr double kZoomStep = 0.1;
} // namespace
BrowserWindow::BrowserWindow(QWebEngineProfile *profile, AdBlocker *adBlocker,
const QUrl &startUrl, QWidget *parent)
: QMainWindow(parent)
, m_adBlocker(adBlocker)
, m_view(new QWebEngineView(profile, this))
, m_status(new StatusBar(this))
, m_commandLine(new CommandLine(this))
{
setupUi();
connectView();
connect(m_commandLine, &CommandLine::commandEntered, this, [this](const QString &c) {
setMode(Mode::Normal);
runCommand(c);
});
connect(m_commandLine, &CommandLine::cancelled, this, [this] {
setMode(Mode::Normal);
});
connect(m_adBlocker, &AdBlocker::message, m_status, &StatusBar::showMessage);
qApp->installEventFilter(this);
setMode(Mode::Normal);
m_view->setUrl(startUrl);
focusView();
}
void BrowserWindow::setupUi()
{
setWindowTitle(QStringLiteral("att"));
resize(1280, 800);
auto *central = new QWidget(this);
auto *layout = new QVBoxLayout(central);
layout->setContentsMargins(0, 0, 0, 0);
layout->setSpacing(0);
layout->addWidget(m_view, 1);
layout->addWidget(m_status);
layout->addWidget(m_commandLine);
setCentralWidget(central);
m_commandLine->hide();
}
void BrowserWindow::connectView()
{
connect(m_view, &QWebEngineView::urlChanged, this, [this](const QUrl &url) {
m_status->setUrl(url.toDisplayString());
// The hints belong to the page that is going away.
if (m_mode == Mode::Hint)
setMode(Mode::Normal);
});
connect(m_view, &QWebEngineView::loadProgress, m_status, &StatusBar::setProgress);
connect(m_view, &QWebEngineView::titleChanged, this, [this](const QString &title) {
setWindowTitle(title.isEmpty() ? QStringLiteral("att")
: QStringLiteral("%1 - att").arg(title));
});
connect(m_view, &QWebEngineView::loadFinished, this, [this](bool ok) {
m_status->setProgress(100);
if (!ok)
m_status->showMessage(QStringLiteral("Failed to load page"), true);
});
}
void BrowserWindow::setMode(Mode mode)
{
if (m_mode == Mode::Hint && mode != Mode::Hint)
runHintScript(hints::clearScript());
m_mode = mode;
m_pendingKeys.clear();
switch (mode) {
case Mode::Normal:
m_status->setMode(QString());
m_status->show();
focusView();
break;
case Mode::Insert:
m_status->setMode(QStringLiteral("insert"));
m_status->show();
focusView();
break;
case Mode::Command:
m_status->hide();
break;
case Mode::Hint:
m_status->setMode(QStringLiteral("hint"));
m_status->show();
break;
}
}
bool BrowserWindow::eventFilter(QObject *watched, QEvent *event)
{
if (event->type() != QEvent::KeyPress || !isAncestorOf(qobject_cast<QWidget *>(watched)))
return QMainWindow::eventFilter(watched, event);
auto *key = static_cast<QKeyEvent *>(event);
if (m_mode == Mode::Command)
return QMainWindow::eventFilter(watched, event);
if (key->key() == Qt::Key_Escape
|| (key->key() == Qt::Key_BracketLeft && key->modifiers() == Qt::ControlModifier)) {
setMode(Mode::Normal);
return true;
}
if (m_mode == Mode::Insert)
return QMainWindow::eventFilter(watched, event);
if (m_mode == Mode::Hint) {
handleHintModeKey(key);
return true;
}
if (handleNormalModeKey(key))
return true;
return QMainWindow::eventFilter(watched, event);
}
bool BrowserWindow::handleNormalModeKey(QKeyEvent *event)
{
const QString text = event->text();
const Qt::KeyboardModifiers mods = event->modifiers();
if (mods.testFlag(Qt::ControlModifier)) {
switch (event->key()) {
case Qt::Key_D:
scrollPages(0.5);
return true;
case Qt::Key_U:
scrollPages(-0.5);
return true;
case Qt::Key_F:
scrollPages(1.0);
return true;
case Qt::Key_B:
scrollPages(-1.0);
return true;
default:
return false;
}
}
if (mods.testFlag(Qt::AltModifier) || mods.testFlag(Qt::MetaModifier))
return false;
// Two-key sequences starting with "g".
if (!m_pendingKeys.isEmpty()) {
const QString sequence = m_pendingKeys + text;
m_pendingKeys.clear();
if (sequence == QLatin1String("gg")) {
scrollToEnd(false);
return true;
}
return true; // Swallow the unknown sequence rather than leaking it.
}
if (text == QLatin1String("g")) {
m_pendingKeys = text;
return true;
}
if (text == QLatin1String("o")) {
m_commandLine->openWith(QStringLiteral(":open "));
setMode(Mode::Command);
return true;
}
if (text == QLatin1String("O")) {
// Edit the current URL.
m_commandLine->openWith(QStringLiteral(":open ") + m_view->url().toString());
setMode(Mode::Command);
return true;
}
if (text == QLatin1String(":")) {
m_commandLine->openWith(QStringLiteral(":"));
setMode(Mode::Command);
return true;
}
if (text == QLatin1String("i")) {
setMode(Mode::Insert);
return true;
}
if (text == QLatin1String("f")) {
startHints();
return true;
}
if (text == QLatin1String("r")) {
m_view->reload();
return true;
}
if (text == QLatin1String("R")) {
m_view->triggerPageAction(QWebEnginePage::ReloadAndBypassCache);
return true;
}
if (text == QLatin1String("H")) {
m_view->back();
return true;
}
if (text == QLatin1String("L")) {
m_view->forward();
return true;
}
if (text == QLatin1String("j")) {
scrollBy(0, kScrollStep);
return true;
}
if (text == QLatin1String("k")) {
scrollBy(0, -kScrollStep);
return true;
}
if (text == QLatin1String("h")) {
scrollBy(-kScrollStep, 0);
return true;
}
if (text == QLatin1String("l")) {
scrollBy(kScrollStep, 0);
return true;
}
if (text == QLatin1String("G")) {
scrollToEnd(true);
return true;
}
if (text == QLatin1String("y")) {
const QString url = m_view->url().toString();
qApp->clipboard()->setText(url);
m_status->showMessage(QStringLiteral("Yanked %1").arg(url));
return true;
}
if (text == QLatin1String("+")) {
m_view->setZoomFactor(m_view->zoomFactor() + kZoomStep);
return true;
}
if (text == QLatin1String("-")) {
m_view->setZoomFactor(qMax(0.25, m_view->zoomFactor() - kZoomStep));
return true;
}
if (text == QLatin1String("=")) {
m_view->setZoomFactor(1.0);
return true;
}
return false;
}
void BrowserWindow::handleHintModeKey(QKeyEvent *event)
{
if (event->key() == Qt::Key_Backspace) {
runHintScript(hints::backspaceScript());
return;
}
const QString text = event->text().toLower();
if (text.size() == 1 && hints::chars().contains(text))
runHintScript(hints::keyScript(text.at(0)));
// Anything else is swallowed, so a stray key can't trigger a page shortcut.
}
void BrowserWindow::startHints()
{
setMode(Mode::Hint);
runHintScript(hints::showScript());
}
void BrowserWindow::runHintScript(const QString &script)
{
m_view->page()->runJavaScript(script, QWebEngineScript::ApplicationWorld,
[this](const QVariant &result) {
handleHintResult(result.toMap());
});
}
void BrowserWindow::handleHintResult(const QVariantMap &result)
{
// Results can arrive after the user already left hint mode, e.g. a key that
// was still in flight when Escape was pressed.
if (m_mode != Mode::Hint)
return;
const QString state = result.value(QStringLiteral("state")).toString();
if (state == QLatin1String("shown")) {
if (result.value(QStringLiteral("count")).toInt() == 0) {
setMode(Mode::Normal);
m_status->showMessage(QStringLiteral("No elements found"), true);
}
} else if (state == QLatin1String("follow")) {
const QPointF pos(result.value(QStringLiteral("x")).toDouble(),
result.value(QStringLiteral("y")).toDouble());
setMode(Mode::Normal);
clickAt(pos * m_view->zoomFactor());
if (result.value(QStringLiteral("editable")).toBool())
setMode(Mode::Insert);
} else if (state == QLatin1String("gone")) {
setMode(Mode::Normal);
m_status->showMessage(QStringLiteral("Element went away"), true);
} else if (state == QLatin1String("none") || state.isEmpty()) {
// The script failed, or the page navigated and took the hints with it.
setMode(Mode::Normal);
}
}
void BrowserWindow::clickAt(const QPointF &pos)
{
// A real mouse click rather than element.click(): plenty of sites only
// listen for mousedown/pointerdown, and it gives the page a trusted event.
QWidget *target = m_view->focusProxy();
if (!target)
return;
const QPointF global = target->mapToGlobal(pos);
QMouseEvent press(QEvent::MouseButtonPress, pos, global, Qt::LeftButton,
Qt::LeftButton, Qt::NoModifier);
QMouseEvent release(QEvent::MouseButtonRelease, pos, global, Qt::LeftButton,
Qt::NoButton, Qt::NoModifier);
QCoreApplication::sendEvent(target, &press);
QCoreApplication::sendEvent(target, &release);
}
void BrowserWindow::runCommand(const QString &command)
{
const QString name = command.section(QLatin1Char(' '), 0, 0);
const QString args = command.section(QLatin1Char(' '), 1).trimmed();
if (name == QLatin1String("open") || name == QLatin1String("o")) {
openUrl(args);
} else if (name == QLatin1String("quit") || name == QLatin1String("q")) {
close();
} else if (name == QLatin1String("reload")) {
m_view->reload();
} else if (name == QLatin1String("back")) {
m_view->back();
} else if (name == QLatin1String("forward")) {
m_view->forward();
} else if (name == QLatin1String("stop")) {
m_view->stop();
} else if (name == QLatin1String("adblock-update")) {
m_adBlocker->update();
} else if (name == QLatin1String("adblock-toggle")) {
m_adBlocker->setEnabled(!m_adBlocker->isEnabled());
m_status->showMessage(m_adBlocker->isEnabled() ? QStringLiteral("Adblock enabled")
: QStringLiteral("Adblock disabled"));
} else {
m_status->showMessage(QStringLiteral("Unknown command: %1").arg(name), true);
}
}
void BrowserWindow::openUrl(const QString &input)
{
const QUrl url = urlutil::fromUserInput(input);
if (!url.isValid() || url.isEmpty()) {
m_status->showMessage(QStringLiteral("Invalid URL: %1").arg(input), true);
return;
}
m_view->setUrl(url);
focusView();
}
void BrowserWindow::scrollBy(int dx, int dy)
{
m_view->page()->runJavaScript(
QStringLiteral("window.scrollBy(%1, %2);").arg(dx).arg(dy));
}
void BrowserWindow::scrollPages(double factor)
{
m_view->page()->runJavaScript(
QStringLiteral("window.scrollBy(0, window.innerHeight * %1);").arg(factor));
}
void BrowserWindow::scrollToEnd(bool bottom)
{
m_view->page()->runJavaScript(
bottom ? QStringLiteral("window.scrollTo(0, document.body.scrollHeight);")
: QStringLiteral("window.scrollTo(0, 0);"));
}
void BrowserWindow::focusView()
{
m_view->setFocus(Qt::OtherFocusReason);
}
+61
View File
@@ -0,0 +1,61 @@
#pragma once
#include <QMainWindow>
#include <QString>
#include <QUrl>
#include <QVariantMap>
class AdBlocker;
class CommandLine;
class StatusBar;
class QWebEngineProfile;
class QWebEngineView;
class BrowserWindow : public QMainWindow
{
Q_OBJECT
public:
enum class Mode {
Normal, // keys are browser bindings
Insert, // keys go to the page, for typing into forms
Command, // keys go to the command line
Hint, // keys pick a link label
};
BrowserWindow(QWebEngineProfile *profile, AdBlocker *adBlocker, const QUrl &startUrl,
QWidget *parent = nullptr);
void setMode(Mode mode);
protected:
// Key handling lives in an application-wide filter because QtWebEngine
// delivers key events straight to its internal render widget, which never
// gives the parent window a chance to see them first.
bool eventFilter(QObject *watched, QEvent *event) override;
private:
void setupUi();
void connectView();
bool handleNormalModeKey(QKeyEvent *event);
void handleHintModeKey(QKeyEvent *event);
void startHints();
void runHintScript(const QString &script);
void handleHintResult(const QVariantMap &result);
void clickAt(const QPointF &pos);
void runCommand(const QString &command);
void openUrl(const QString &input);
void scrollBy(int dx, int dy);
void scrollPages(double factor);
void scrollToEnd(bool bottom);
void focusView();
AdBlocker *m_adBlocker;
QWebEngineView *m_view;
StatusBar *m_status;
CommandLine *m_commandLine;
Mode m_mode = Mode::Normal;
QString m_pendingKeys;
};
+117
View File
@@ -0,0 +1,117 @@
#include "commandline.h"
#include <QKeyEvent>
#include <QSignalBlocker>
CommandLine::CommandLine(QWidget *parent)
: QLineEdit(parent)
{
setFrame(false);
setObjectName(QStringLiteral("commandLine"));
connect(this, &QLineEdit::returnPressed, this, &CommandLine::onReturnPressed);
connect(this, &QLineEdit::textChanged, this, [this](const QString &text) {
// The user edited the leading ":" away - treat that as an abort.
if (isVisible() && !text.startsWith(QLatin1Char(':')))
cancel();
});
}
void CommandLine::openWith(const QString &text)
{
m_historyPos = m_history.size();
setTextSilently(text.startsWith(QLatin1Char(':')) ? text
: QLatin1Char(':') + text);
show();
setFocus(Qt::OtherFocusReason);
deselect();
end(false);
}
void CommandLine::keyPressEvent(QKeyEvent *event)
{
switch (event->key()) {
case Qt::Key_Escape:
cancel();
return;
case Qt::Key_Up:
recallHistory(-1);
return;
case Qt::Key_Down:
recallHistory(1);
return;
case Qt::Key_Home:
// Never let the cursor sit to the left of the ":".
setCursorPosition(1);
return;
case Qt::Key_C:
case Qt::Key_BracketLeft:
if (event->modifiers().testFlag(Qt::ControlModifier)) {
cancel();
return;
}
break;
default:
break;
}
QLineEdit::keyPressEvent(event);
if (cursorPosition() == 0)
setCursorPosition(1);
}
void CommandLine::focusOutEvent(QFocusEvent *event)
{
QLineEdit::focusOutEvent(event);
if (isVisible())
cancel();
}
void CommandLine::onReturnPressed()
{
const QString command = text().mid(1).trimmed();
if (!command.isEmpty() && (m_history.isEmpty() || m_history.last() != command))
m_history.append(command);
m_historyPos = m_history.size();
dismiss();
if (command.isEmpty())
emit cancelled();
else
emit commandEntered(command);
}
void CommandLine::recallHistory(int direction)
{
if (m_history.isEmpty())
return;
const int pos = qBound(0, m_historyPos + direction, m_history.size());
if (pos == m_historyPos)
return;
m_historyPos = pos;
setTextSilently(pos == m_history.size() ? QStringLiteral(":")
: QLatin1Char(':') + m_history.at(pos));
end(false);
}
void CommandLine::cancel()
{
dismiss();
emit cancelled();
}
void CommandLine::dismiss()
{
setTextSilently(QString());
hide();
}
void CommandLine::setTextSilently(const QString &text)
{
const QSignalBlocker blocker(this);
setText(text);
}
+36
View File
@@ -0,0 +1,36 @@
#pragma once
#include <QLineEdit>
#include <QStringList>
// The bottom-of-the-window ":" prompt. Like qutebrowser's, the leading ":" is
// part of the text: deleting it aborts the command.
class CommandLine : public QLineEdit
{
Q_OBJECT
public:
explicit CommandLine(QWidget *parent = nullptr);
// text should include the leading ":", e.g. ":open ".
void openWith(const QString &text);
signals:
// Emitted with the command only, i.e. without the leading ":".
void commandEntered(const QString &command);
void cancelled();
protected:
void keyPressEvent(QKeyEvent *event) override;
void focusOutEvent(QFocusEvent *event) override;
private:
void onReturnPressed();
void recallHistory(int direction);
void cancel();
void dismiss();
void setTextSilently(const QString &text);
QStringList m_history;
int m_historyPos = 0;
};
+75
View File
@@ -0,0 +1,75 @@
#include "gpuworkaround.h"
#include <QByteArray>
#include <QLatin1String>
#include <QString>
#include <QVersionNumber>
#include <QtWebEngineCore/qtwebenginecoreglobal.h>
#include <cstdlib>
namespace {
constexpr auto kChromiumFlagsVar = "QTWEBENGINE_CHROMIUM_FLAGS";
constexpr auto kDisableVideoDecode = "--disable-accelerated-video-decode";
// First release carrying the multi-planar NativePixmap import fix.
const QVersionNumber kFixedIn(6, 11, 2);
bool envIsSet(const char *name)
{
const char *value = std::getenv(name);
return value != nullptr && *value != '\0';
}
bool envIsTruthy(const char *name)
{
const char *value = std::getenv(name);
if (value == nullptr || *value == '\0')
return false;
const QLatin1String s(value);
return s != QLatin1String("0") && s != QLatin1String("false");
}
// The runtime version, not QTWEBENGINECORE_VERSION: linking against a fixed
// QtWebEngine should drop the workaround without rebuilding att.
bool webEngineHasFix()
{
QString version = QString::fromLatin1(qWebEngineVersion());
const QVersionNumber parsed = QVersionNumber::fromString(version);
return !parsed.isNull() && parsed >= kFixedIn;
}
// X11/GLX only ever imports BGRA8888 buffers, so it never hits the bug.
bool onWayland()
{
const char *forced = std::getenv("QT_QPA_PLATFORM");
if (forced != nullptr && *forced != '\0')
return QLatin1String(forced).startsWith(QLatin1String("wayland"));
return envIsSet("WAYLAND_DISPLAY");
}
} // namespace
namespace gpuworkaround {
void apply()
{
if (envIsTruthy("ATT_NO_GPU_WORKAROUND"))
return;
if (!onWayland() || webEngineHasFix())
return;
QByteArray flags(qgetenv(kChromiumFlagsVar));
// Leave an explicit opinion about video decoding alone, whichever way it goes.
if (flags.contains("accelerated-video-decode"))
return;
if (!flags.isEmpty())
flags.append(' ');
flags.append(kDisableVideoDecode);
qputenv(kChromiumFlagsVar, flags);
}
} // namespace gpuworkaround
+15
View File
@@ -0,0 +1,15 @@
#pragma once
namespace gpuworkaround {
// Works around QTBUG-145344: on Wayland, QtWebEngine before 6.11.2 cannot import
// the multi-planar (YUV420) buffers that VA-API hardware decoding hands it, so
// video renders as black/flickering while the log fills with
// "SharedImageManager::ProduceSkia: ... non-existent mailbox". Disables
// accelerated video decoding only where the bug applies.
//
// Must be called before QApplication is constructed. Set ATT_NO_GPU_WORKAROUND=1
// to skip it.
void apply();
} // namespace gpuworkaround
+217
View File
@@ -0,0 +1,217 @@
#include "hints.h"
namespace {
constexpr auto kChars = "asdfghjkl";
// Installs window.__attHints and shows the labels. "@CHARS@" is substituted
// with kChars before running.
constexpr auto kShowScript = R"JS(
(function () {
'use strict';
if (window.__attHints)
window.__attHints.clear();
const CHARS = '@CHARS@';
const SELECTOR = [
'a[href]', 'area[href]', 'button', 'input:not([type=hidden])', 'select',
'textarea', 'summary', 'label', '[onclick]', '[contenteditable=""]',
'[contenteditable=true]', '[role=link]', '[role=button]',
'[role=checkbox]', '[role=radio]', '[role=tab]', '[role=menuitem]',
'[role=option]', '[role=switch]', '[tabindex]:not([tabindex="-1"])',
].join(',');
const TEXT_INPUTS = new Set(['', 'text', 'search', 'email', 'url', 'tel',
'password', 'number', 'date', 'datetime-local',
'month', 'time', 'week']);
function visibleRect(el) {
const style = getComputedStyle(el);
if (style.visibility !== 'visible' || parseFloat(style.opacity) === 0)
return null;
// getClientRects rather than getBoundingClientRect so a link wrapped
// over two lines gets its label on the first line, not in the gap.
for (const r of el.getClientRects()) {
if (r.width < 1 || r.height < 1)
continue;
if (r.bottom <= 0 || r.right <= 0 || r.top >= innerHeight || r.left >= innerWidth)
continue;
return r;
}
return null;
}
function isEditable(el) {
if (el.isContentEditable || el.tagName === 'TEXTAREA')
return true;
return el.tagName === 'INPUT' && TEXT_INPUTS.has((el.getAttribute('type') || '').toLowerCase());
}
// All labels have the same length, so none is a prefix of another.
function makeLabels(count) {
const n = CHARS.length;
let len = 1;
while (Math.pow(n, len) < count)
len++;
const labels = [];
for (let i = 0; i < count; i++) {
let label = '';
for (let j = 0, k = i; j < len; j++, k = Math.floor(k / n))
label = CHARS[k % n] + label;
labels.push(label);
}
return labels;
}
const targets = [];
const seen = new Set();
for (const el of document.querySelectorAll(SELECTOR)) {
if (el.disabled || seen.has(el))
continue;
const rect = visibleRect(el);
if (!rect)
continue;
// A <label> that wraps its input would otherwise get two hints.
if (el.tagName === 'LABEL' && el.control && el.contains(el.control) && visibleRect(el.control))
continue;
seen.add(el);
targets.push({ el, rect });
}
// A shadow root keeps the page's stylesheets off the labels.
const host = document.createElement('att-hints');
host.style.cssText = 'all: initial; position: fixed; inset: 0; z-index: 2147483647; pointer-events: none;';
const root = host.attachShadow({ mode: 'closed' });
const style = document.createElement('style');
style.textContent = `
span.hint {
position: absolute;
padding: 0 2px;
background: linear-gradient(to bottom, #fff785, #ffc542);
border: 1px solid #c38a22;
border-radius: 3px;
color: #000;
font: bold 12px monospace;
line-height: normal;
text-transform: uppercase;
white-space: nowrap;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
}
span.typed { color: #8a6d00; }
`;
root.appendChild(style);
const labels = makeLabels(targets.length);
const hints = targets.map((t, i) => {
const span = document.createElement('span');
span.className = 'hint';
span.style.left = Math.max(0, t.rect.left) + 'px';
span.style.top = Math.max(0, t.rect.top) + 'px';
span.textContent = labels[i];
root.appendChild(span);
return { el: t.el, label: labels[i], span };
});
(document.documentElement || document.body).appendChild(host);
let typed = '';
function render() {
for (const h of hints) {
const match = h.label.startsWith(typed);
h.span.style.display = match ? '' : 'none';
if (!match)
continue;
h.span.textContent = '';
if (typed) {
const t = document.createElement('span');
t.className = 'typed';
t.textContent = typed;
h.span.appendChild(t);
}
h.span.appendChild(document.createTextNode(h.label.slice(typed.length)));
}
}
function follow(h) {
const el = h.el;
if (!el.isConnected)
return { state: 'gone' };
el.scrollIntoView({ block: 'nearest', inline: 'nearest' });
const r = visibleRect(el) || el.getBoundingClientRect();
// att has no tabs, so a link meant for a new window would go nowhere.
if ((el.tagName === 'A' || el.tagName === 'AREA') && el.target && !/^_(self|top|parent)$/i.test(el.target))
el.target = '_self';
const x = Math.min(Math.max(r.left + r.width / 2, 0), innerWidth - 1);
const y = Math.min(Math.max(r.top + r.height / 2, 0), innerHeight - 1);
return { state: 'follow', x, y, editable: isEditable(el) };
}
window.__attHints = {
key(ch) {
const candidate = typed + ch;
const matches = hints.filter(h => h.label.startsWith(candidate));
if (matches.length === 0)
return { state: 'nomatch' };
typed = candidate;
if (matches.length === 1) {
this.clear();
return follow(matches[0]);
}
render();
return { state: 'partial' };
},
backspace() {
typed = typed.slice(0, -1);
render();
return { state: 'partial' };
},
clear() {
host.remove();
if (window.__attHints === this)
delete window.__attHints;
return { state: 'none' };
},
};
if (hints.length === 0)
window.__attHints.clear();
return { state: 'shown', count: hints.length };
})();
)JS";
QString callScript(const QString &call)
{
return QStringLiteral("window.__attHints ? window.__attHints.%1 : { state: 'none' };")
.arg(call);
}
} // namespace
namespace hints {
QString chars()
{
return QString::fromLatin1(kChars);
}
QString showScript()
{
return QString::fromUtf8(kShowScript).replace(QLatin1String("@CHARS@"), chars());
}
QString keyScript(QChar key)
{
// Only ever called with one of chars(), so no quoting is needed.
return callScript(QStringLiteral("key('%1')").arg(key));
}
QString backspaceScript()
{
return callScript(QStringLiteral("backspace()"));
}
QString clearScript()
{
return callScript(QStringLiteral("clear()"));
}
} // namespace hints
+29
View File
@@ -0,0 +1,29 @@
#pragma once
#include <QString>
// Hint mode ("f"): JavaScript that labels the clickable elements in the
// viewport and narrows them down as the user types. All of it runs in
// QWebEngineScript::ApplicationWorld, so the page's own scripts can neither see
// nor tamper with the hint state; only the DOM is shared.
//
// Every script evaluates to a result map with a "state" key:
// "shown" - hints are on screen; "count" says how many
// "partial" - more than one hint still matches what was typed
// "nomatch" - the key matched nothing and was ignored
// "follow" - one hint left; "x"/"y" is the point to click, in CSS pixels
// relative to the viewport, and "editable" says whether it is a
// text field
// "gone" - the chosen element disappeared from the page
// "none" - there is no hint session to act on
namespace hints {
// The characters labels are built from - qutebrowser's home-row default.
QString chars();
QString showScript();
QString keyScript(QChar key);
QString backspaceScript();
QString clearScript();
} // namespace hints
+83
View File
@@ -0,0 +1,83 @@
#include "adblock.h"
#include "browserwindow.h"
#include "gpuworkaround.h"
#include "urlutil.h"
#include <QApplication>
#include <QCommandLineParser>
#include <QWebEngineProfile>
namespace {
constexpr auto kDefaultUrl = "https://duckduckgo.com/";
constexpr auto kStyleSheet = R"(
QWidget#statusBar, QLineEdit#commandLine {
background-color: #1c1c1c;
color: #d8d8d8;
font-family: monospace;
}
QLabel#statusMode {
color: #7fbf7f;
font-weight: bold;
}
QLabel#statusUrl[error="true"] {
color: #ff6b6b;
}
QLabel#statusProgress {
color: #8a8a8a;
}
QLineEdit#commandLine {
padding: 1px 4px;
selection-background-color: #445566;
}
)";
} // namespace
int main(int argc, char *argv[])
{
// Chromium reads its flags out of the environment, so this has to happen
// before QtWebEngine gets a chance to initialize.
gpuworkaround::apply();
QApplication app(argc, argv);
QCoreApplication::setApplicationName(QStringLiteral("att"));
QCoreApplication::setApplicationVersion(QStringLiteral("0.1.0"));
QCoreApplication::setOrganizationName(QStringLiteral("att"));
QCommandLineParser parser;
parser.setApplicationDescription(
QStringLiteral("att - a minimal, keyboard-driven browser"));
parser.addHelpOption();
parser.addVersionOption();
parser.addPositionalArgument(QStringLiteral("url"), QStringLiteral("URL to open."));
parser.process(app);
app.setStyleSheet(QString::fromUtf8(kStyleSheet));
const QStringList args = parser.positionalArguments();
const QUrl startUrl = args.isEmpty() ? QUrl(QString::fromLatin1(kDefaultUrl))
: urlutil::fromUserInput(args.first());
// Qt 6's default profile is off-the-record, so nothing - cookies, logins,
// local storage - would survive a restart. A named profile lives under
// QStandardPaths::AppDataLocation. It is created before the window and
// parented to the app so it outlives the window's page, as Qt requires.
auto *profile = new QWebEngineProfile(QStringLiteral("default"), &app);
// Like qutebrowser's content.cookies.store: keep session cookies too, so
// sites that log in with one don't log you out on every restart.
profile->setPersistentCookiesPolicy(QWebEngineProfile::ForcePersistentCookies);
// Created after the profile so it is also destroyed after it: the profile
// does not own its interceptor and must not outlive it.
auto *adBlocker = new AdBlocker(&app);
profile->setUrlRequestInterceptor(adBlocker);
BrowserWindow window(profile, adBlocker, startUrl);
window.show();
// After the window exists, so its status bar shows the update messages.
adBlocker->start();
return app.exec();
}
+68
View File
@@ -0,0 +1,68 @@
#include "statusbar.h"
#include <QHBoxLayout>
#include <QLabel>
#include <QStyle>
#include <QTimer>
StatusBar::StatusBar(QWidget *parent)
: QWidget(parent)
, m_mode(new QLabel(this))
, m_url(new QLabel(this))
, m_progress(new QLabel(this))
{
setObjectName(QStringLiteral("statusBar"));
m_mode->setObjectName(QStringLiteral("statusMode"));
m_url->setObjectName(QStringLiteral("statusUrl"));
m_progress->setObjectName(QStringLiteral("statusProgress"));
m_url->setTextInteractionFlags(Qt::NoTextInteraction);
auto *layout = new QHBoxLayout(this);
layout->setContentsMargins(4, 1, 4, 1);
layout->setSpacing(8);
layout->addWidget(m_mode);
layout->addWidget(m_url, 1);
layout->addWidget(m_progress);
}
void StatusBar::setMode(const QString &mode)
{
m_mode->setText(mode.isEmpty() ? QString()
: QStringLiteral("-- %1 --").arg(mode.toUpper()));
m_mode->setVisible(!mode.isEmpty());
}
void StatusBar::setUrl(const QString &url)
{
m_urlText = url;
m_url->setText(url);
}
void StatusBar::setProgress(int percent)
{
const bool loading = percent > 0 && percent < 100;
m_progress->setText(loading ? QStringLiteral("[%1%]").arg(percent) : QString());
m_progress->setVisible(loading);
}
void StatusBar::showMessage(const QString &message, bool isError)
{
m_url->setProperty("error", isError);
m_url->setText(message);
// Re-polish so the stylesheet picks up the changed property.
m_url->style()->unpolish(m_url);
m_url->style()->polish(m_url);
QTimer::singleShot(4000, this, &StatusBar::clearMessage);
}
void StatusBar::clearMessage()
{
m_url->setProperty("error", false);
m_url->style()->unpolish(m_url);
m_url->style()->polish(m_url);
m_url->setText(m_urlText);
}
+28
View File
@@ -0,0 +1,28 @@
#pragma once
#include <QWidget>
class QLabel;
// The line that replaces the command line when no command is being typed:
// current mode on the left, URL in the middle, load progress on the right.
class StatusBar : public QWidget
{
Q_OBJECT
public:
explicit StatusBar(QWidget *parent = nullptr);
void setMode(const QString &mode);
void setUrl(const QString &url);
void setProgress(int percent);
// Transient feedback: errors, unknown commands, yanked URLs.
void showMessage(const QString &message, bool isError = false);
void clearMessage();
private:
QLabel *m_mode;
QLabel *m_url;
QLabel *m_progress;
QString m_urlText;
};
+59
View File
@@ -0,0 +1,59 @@
#include "urlutil.h"
#include <QDir>
#include <QFileInfo>
#include <QRegularExpression>
#include <QUrlQuery>
namespace {
constexpr auto kSearchEngine = "https://duckduckgo.com/";
bool looksLikeHost(const QString &s)
{
if (s.contains(QLatin1Char(' ')))
return false;
if (s == QLatin1String("localhost") || s.startsWith(QLatin1String("localhost:")))
return true;
// host.tld, optionally with a port and/or a path.
static const QRegularExpression re(
QStringLiteral(R"(^[\w-]+(\.[\w-]+)+(:\d+)?(/.*)?$)"));
return re.match(s).hasMatch();
}
} // namespace
namespace urlutil {
QUrl fromUserInput(const QString &input)
{
const QString s = input.trimmed();
if (s.isEmpty())
return {};
if (s.startsWith(QLatin1Char('/')) || s.startsWith(QLatin1String("~/"))
|| s.startsWith(QLatin1String("./"))) {
QString path = s;
if (path.startsWith(QLatin1Char('~')))
path.replace(0, 1, QDir::homePath());
const QFileInfo info(path);
if (info.exists())
return QUrl::fromLocalFile(info.absoluteFilePath());
}
const QUrl url(s, QUrl::TolerantMode);
if (url.isValid() && !url.scheme().isEmpty() && !url.isRelative())
return url;
if (looksLikeHost(s))
return QUrl(QStringLiteral("https://") + s, QUrl::TolerantMode);
QUrl search(QString::fromLatin1(kSearchEngine));
QUrlQuery query;
query.addQueryItem(QStringLiteral("q"), s);
search.setQuery(query);
return search;
}
} // namespace urlutil
+13
View File
@@ -0,0 +1,13 @@
#pragma once
#include <QString>
#include <QUrl>
namespace urlutil {
// Turns whatever the user typed into the command line into a URL: a real URL is
// kept as-is, something that looks like a host gets an https:// scheme, and
// anything else is handed to the search engine.
QUrl fromUserInput(const QString &input);
} // namespace urlutil