commit a841178cf737d9299f87c72d53749da1c531b02e Author: Asmir A Date: Thu Oct 15 23:03:56 2026 +0200 add base: browser window, hints, history, cookies, adblock diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5bcac16 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +/build/ +/result +/result-* +compile_commands.json +.cache/ +.direnv/ +/adblock/target/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..fc7d0cf --- /dev/null +++ b/CLAUDE.md @@ -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`. diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..50d40fc --- /dev/null +++ b/CMakeLists.txt @@ -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} +) diff --git a/README.md b/README.md new file mode 100644 index 0000000..21b8f40 --- /dev/null +++ b/README.md @@ -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 ` (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. diff --git a/adblock/Cargo.lock b/adblock/Cargo.lock new file mode 100644 index 0000000..d83764c --- /dev/null +++ b/adblock/Cargo.lock @@ -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" diff --git a/adblock/Cargo.toml b/adblock/Cargo.toml new file mode 100644 index 0000000..4cb0c1e --- /dev/null +++ b/adblock/Cargo.toml @@ -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 diff --git a/adblock/att_adblock.h b/adblock/att_adblock.h new file mode 100644 index 0000000..5e9c5c0 --- /dev/null +++ b/adblock/att_adblock.h @@ -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 +#include +#include + +#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 diff --git a/adblock/src/lib.rs b/adblock/src/lib.rs new file mode 100644 index 0000000..e05c091 --- /dev/null +++ b/adblock/src/lib.rs @@ -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); + } + } +} diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..a04c966 --- /dev/null +++ b/flake.lock @@ -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 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..d139eb6 --- /dev/null +++ b/flake.nix @@ -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); + }; +} diff --git a/package.nix b/package.nix new file mode 100644 index 0000000..8ed2fdc --- /dev/null +++ b/package.nix @@ -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; + }; +}) diff --git a/src/adblock.cpp b/src/adblock.cpp new file mode 100644 index 0000000..f6a1580 --- /dev/null +++ b/src/adblock.cpp @@ -0,0 +1,225 @@ +#include "adblock.h" + +#include "att_adblock.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +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; + +EnginePtr wrap(AttAdblockEngine *engine) +{ + if (!engine) + return {}; + return EnginePtr(engine, [](const AttAdblockEngine *e) { + att_adblock_engine_free(const_cast(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(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(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(this); + connect(watcher, &QFutureWatcher::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(); + 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) +{ + 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(this); + connect(watcher, &QFutureWatcher::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(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; +} diff --git a/src/adblock.h b/src/adblock.h new file mode 100644 index 0000000..5e963b0 --- /dev/null +++ b/src/adblock.h @@ -0,0 +1,57 @@ +#pragma once + +#include +#include + +#include +#include +#include + +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; + + struct Download; + void compile(const std::shared_ptr &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; +}; diff --git a/src/browserwindow.cpp b/src/browserwindow.cpp new file mode 100644 index 0000000..18c36fd --- /dev/null +++ b/src/browserwindow.cpp @@ -0,0 +1,410 @@ +#include "browserwindow.h" + +#include "adblock.h" +#include "commandline.h" +#include "hints.h" +#include "statusbar.h" +#include "urlutil.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +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(watched))) + return QMainWindow::eventFilter(watched, event); + + auto *key = static_cast(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); +} diff --git a/src/browserwindow.h b/src/browserwindow.h new file mode 100644 index 0000000..1ce90d5 --- /dev/null +++ b/src/browserwindow.h @@ -0,0 +1,61 @@ +#pragma once + +#include +#include +#include +#include + +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; +}; diff --git a/src/commandline.cpp b/src/commandline.cpp new file mode 100644 index 0000000..82c24c8 --- /dev/null +++ b/src/commandline.cpp @@ -0,0 +1,117 @@ +#include "commandline.h" + +#include +#include + +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); +} diff --git a/src/commandline.h b/src/commandline.h new file mode 100644 index 0000000..703f7ab --- /dev/null +++ b/src/commandline.h @@ -0,0 +1,36 @@ +#pragma once + +#include +#include + +// 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; +}; diff --git a/src/gpuworkaround.cpp b/src/gpuworkaround.cpp new file mode 100644 index 0000000..dec212e --- /dev/null +++ b/src/gpuworkaround.cpp @@ -0,0 +1,75 @@ +#include "gpuworkaround.h" + +#include +#include +#include +#include +#include + +#include + +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 diff --git a/src/gpuworkaround.h b/src/gpuworkaround.h new file mode 100644 index 0000000..6dce89c --- /dev/null +++ b/src/gpuworkaround.h @@ -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 diff --git a/src/hints.cpp b/src/hints.cpp new file mode 100644 index 0000000..e65697b --- /dev/null +++ b/src/hints.cpp @@ -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