add base: browser window, hints, history, cookies, adblock
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
/build/
|
||||
/result
|
||||
/result-*
|
||||
compile_commands.json
|
||||
.cache/
|
||||
.direnv/
|
||||
/adblock/target/
|
||||
@@ -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`.
|
||||
@@ -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}
|
||||
)
|
||||
@@ -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.
|
||||
Generated
+583
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user