From 495a0696ba51ce2e098fee7b1feecbce124bb069 Mon Sep 17 00:00:00 2001 From: Asmir A Date: Sat, 17 Oct 2026 10:35:14 +0200 Subject: [PATCH] add hardening, zoom and useragent options --- CLAUDE.md | 15 ++- CMakeLists.txt | 5 + README.md | 76 ++++++++++++++- src/browserwindow.cpp | 218 +++++++++++++++++++++++++++++++++++------- src/browserwindow.h | 5 + src/completionview.h | 10 +- src/config.h | 35 +++++++ src/greasemonkey.cpp | 102 +++++++++++++++++--- src/hardening.cpp | 52 ++++++++++ src/hardening.h | 18 ++++ src/main.cpp | 16 +++- src/statusbar.cpp | 27 +++--- src/statusbar.h | 8 +- src/useragent.cpp | 179 ++++++++++++++++++++++++++++++++++ src/useragent.h | 28 ++++++ 15 files changed, 724 insertions(+), 70 deletions(-) create mode 100644 src/config.h create mode 100644 src/hardening.cpp create mode 100644 src/hardening.h create mode 100644 src/useragent.cpp create mode 100644 src/useragent.h diff --git a/CLAUDE.md b/CLAUDE.md index 5535b9d..31c393b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -33,8 +33,14 @@ The only tests are the Rust crate's (`nix develop -c sh -c 'cd adblock && cargo 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`. +- **Never change the page's size from UI chrome.** Every view resize makes the page re-layout, which on YouTube visibly jumps the player. + - `CompletionView` is not in the layout. It floats over the bottom of the view (`placeCompletion`, re-run on the view's `Resize`). + - `StatusBar` and `CommandLine` share one fixed height. + - Open the command line only through `openCommandLine`, which hides the status bar *before* showing the command line. Showing a widget re-runs the layout at once, so the other order shrinks and regrows the page. - **`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. +- **Page interaction mostly goes through `page()->runJavaScript`**, except for input that should feel native: + - Scrolling (`scrollBy`, used by `j/k/h/l`, `Ctrl-d/u/f/b` and `gg/G`) sends a pixel-delta `QWheelEvent` to `m_view->focusProxy()` at the pointer, which is what a touchpad sends. Chromium then scrolls whatever is under the pointer. `window.scrollBy` only moves the document, so it did nothing on sites that scroll an inner element. + - Hint clicks are real mouse events (see below). - **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`. @@ -51,10 +57,13 @@ The only tests are the Rust crate's (`nix develop -c sh -c 'cd adblock && cargo - **History** (`History`, `src/history.cpp`) is SQLite through QtSql, run synchronously on the GUI thread with WAL. `visits` is an append-only log; `urls` (one row per URL, upserted) is what completion searches. Visits are recorded on `loadFinished(true)`, not `urlChanged`. URLs are stored as `toDisplayString()` with the fragment removed, so a `titleChanged` after an in-page jump still updates the right row. - **`:open` completion:** `BrowserWindow::updateCompletion` runs on the command line's `textEdited` and on entering command mode. It parses `:open`/`:o` and shows `History::search` results in `CompletionView`, which never takes focus. `CommandLine::event` intercepts Tab/Backtab, because `QWidget::event` would otherwise turn them into focus changes. Picking an entry uses `setCommandText`, which doesn't emit `textEdited`, so it doesn't re-filter. - **Greasemonkey** (`Greasemonkey`, `src/greasemonkey.cpp`, part of the `Session`) inserts each `greasemonkey/*.js` into `profile->scripts()` as a `QWebEngineScript` named `att-greasemonkey:`, in `MainWorld`. - - URL matching is left to QtWebEngine, which parses the `==UserScript==` block during `setSourceCode` and checks `@include/@exclude/@match` in Chromium. So the block must stay in the source, and `setInjectionPoint` must come *after* `setSourceCode`. - - att's own `parseMetadata` is for run-at, noframes, `@require` (cached by SHA-1 of the URL) and `GM_info`. + - URL matching is left to QtWebEngine, which parses the `==UserScript==` block during `setSourceCode` and checks `@include/@exclude/@match` in Chromium. So the block must stay in the source. `setSourceCode` also overwrites the script's name (with `@name`) and injection point, so `setName` and `setInjectionPoint` must come *after* it. `install()` finds the old scripts by the `att-greasemonkey:` name prefix to remove them; a lost prefix makes scripts run twice after a reload. + - att's own `parseMetadata` is for run-at, noframes, `@require` and `GM_info`. A `@require` is cached by the SHA-1 of its URL. It must be `https`, and an optional `#sha256=` pin is checked against the cached file on every install (`requireProblem`). - Scripts are wrapped in an IIFE (`kWrapperHead`/`kWrapperTail`) that defines the GM API as locals. - At `document-start`, `document.documentElement` is still null, which the GM helpers must tolerate. +- **Hardening** (`src/hardening.cpp`): `applyToProfile` sets the restrictive `QWebEngineSettings` and the third-party cookie filter. The filter runs on the network thread and must stay a pure function. `main.cpp` applies it right after creating the profile, and warns in the first window if `sandboxDisabledReason` finds the sandbox off. `StatusBar::setUrl` takes a `QUrl` so it can mark plain-HTTP pages (`insecure` property). +- **User agent** (`src/useragent.cpp`): `useragent::apply` sets the profile's UA string *and* its `QWebEngineClientHints`, so the two don't contradict each other. Chromium sends the low-entropy hints (brands, platform, mobile) even with `setAllClientHintsEnabled(false)`, so every preset sets a platform; the brand ("Google Chrome", "Microsoft Edge") goes in through `setFullVersionList`. Chrome-based presets use the engine's own major version. `:useragent` completion reuses `CompletionView` with name / string columns. +- **Settings** live as constants in `src/config.h` (`config::kZoomDefault`, `config::kZoomLevels`, `kBlockThirdPartyCookies`, `kPdfViewer`, `kUserAgent`), named after qutebrowser's settings, until there's a config file. Zoom is per window: `setZoom` / `zoomStep` in `BrowserWindow`, applied before the first load. QtWebEngine keeps a view's zoom across navigations, so it doesn't need re-applying. - The UI stylesheet is the `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. **Windows, not tabs.** att deliberately has no tab bar, because the user's window manager does the tabbing. Map qutebrowser's tab features onto top-level windows: `d` closes the window, `O` / `:open -w` (or `-t`) opens a new one. Each `BrowserWindow` is one page, with `WA_DeleteOnClose`. All windows share a `Session` (profile, `AdBlocker`, `History`) that `main()` owns. After `app.exec()`, `main()` deletes any window still alive before the history and profile go away. `:quit` closes all windows; `:close` closes one. diff --git a/CMakeLists.txt b/CMakeLists.txt index f915a45..86b5f69 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -36,8 +36,11 @@ qt_add_executable(att src/commandline.h src/completionview.cpp src/completionview.h + src/config.h src/greasemonkey.cpp src/greasemonkey.h + src/hardening.cpp + src/hardening.h src/hints.cpp src/hints.h src/history.cpp @@ -46,6 +49,8 @@ qt_add_executable(att src/statusbar.h src/urlutil.cpp src/urlutil.h + src/useragent.cpp + src/useragent.h ) target_include_directories(att PRIVATE src adblock) diff --git a/README.md b/README.md index 01ba770..7b0d8bf 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,7 @@ is hidden while a page is fullscreen. | `H` / `L` | back / forward in history | | `r` / `R` | reload / reload bypassing cache | | `y` | yank the current URL to the clipboard | -| `+` `-` `=` | zoom in / out / reset | +| `+` `-` `=` | zoom in / out a level, back to default | The command line keeps a history (`Up`/`Down`) and aborts on `Escape`, `Ctrl-c`, or if you delete the leading `:`. @@ -77,7 +77,8 @@ or if you delete the leading `:`. While typing `:open` (or `:o`), a completion list of previously visited pages appears above it, most recent first. Every word you type must appear in the URL or the title. `Tab` / `Shift-Tab` cycle through the list and put the selected URL -on the command line; `Enter` opens it. +on the command line; `Enter` opens it. `:useragent` completes preset names the +same way. ## Hint mode @@ -134,17 +135,82 @@ the page. Like qutebrowser, att: `GM_setClipboard`, `GM_log`, `GM_notification` and the promise-based `GM.*` versions. +`@require` must be `https://`; see *Security* for pinning with `#sha256=`. + Limits: stored values live in the site's `localStorage`, so they are per site rather than shared across sites. `GM_xmlhttpRequest` is a plain XHR, so it can't make cross-origin requests the server doesn't allow (CORS). `@resource` isn't supported. `GM_openInTab` does nothing until new-window requests are handled. +## Security + +On top of Chromium's own protections, att: + +- blocks third-party cookies, which stops most cross-site tracking. A few embedded + logins (e.g. some comment widgets) may stop working. +- doesn't let local HTML files (`file://`) read other local files. QtWebEngine + allows that by default, so opening a malicious downloaded page could leak e.g. + `~/.ssh`. +- keeps WebRTC from revealing your LAN address, and doesn't let scripts open + pop-up windows. +- pins the safe defaults for mixed content, clipboard access and plugins. +- shows plain `http://` URLs in orange in the status bar. +- only accepts Greasemonkey `@require` files over HTTPS. A `@require` URL may pin + its content as in Tampermonkey (`…#sha256=` or `…#sha256-`), + and the script is skipped if the file doesn't match. +- warns at startup if Chromium's sandbox has been disabled + (`QTWEBENGINE_DISABLE_SANDBOX`, `--no-sandbox`). + +The engine's security fixes come from QtWebEngine, so keep it current with +`nix flake update`. `:version` shows the Chromium version and the security patch +level it carries. + +## User agent + +`:useragent ` (alias `:ua`) changes the browser identity that every +window sends; with no name it shows the current one. Requests use the new one at +once, and `navigator.userAgent` from each page's next load. The client hints +(`Sec-CH-UA*`, `navigator.userAgentData`) are set to match the preset's brand +and platform. + +| Preset | Claims to be | +| ----------------- | ------------------------------------------------- | +| `chrome-linux` | Chrome on Linux (the default) | +| `chrome-windows` | Chrome on Windows | +| `chrome-mac` | Chrome on macOS | +| `chrome-android` | Chrome on Android (sites may serve mobile pages) | +| `edge-windows` | Edge on Windows | +| `firefox-linux`, `firefox-windows`, `firefox-mac` | Firefox | +| `safari-mac`, `safari-iphone` | Safari | +| `qtwebengine` | QtWebEngine's own string, which names it | + +Chrome and Edge claim the engine's real Chromium major version (as qutebrowser +does), so sites don't use features the engine lacks. The Firefox and Safari +versions are pinned in `src/useragent.cpp`. Those presets only change what att +says, not how it behaves: Chromium's low-entropy client hints still name +Chromium, and feature detection still sees Chromium. + +## Settings + +att has no config file yet. The settings live in `src/config.h`, named after +their qutebrowser equivalents; change them there and rebuild: + +| Setting | Default | Meaning | +| -------------- | ------------------------------- | ---------------------------------------- | +| `zoom.default` | 125% | zoom of new windows; `=` returns to it | +| `zoom.levels` | 25%, 33%, 50%, … 125%, 150%, … 500% | the steps `+` / `-` move through | +| `content.cookies.accept` | no third-party | `kBlockThirdPartyCookies` | +| `content.pdfjs` | on | `kPdfViewer`: Chromium's PDF viewer | +| `content.headers.user_agent` | `chrome-linux` | `kUserAgent`: the preset att starts with | + ## Commands `:open [-w] ` (alias `:o`; `-w`, or qutebrowser's `-t`, opens a new window), `:close` (alias `:tab-close`, closes this window), `:quit` (alias `:q`, closes all windows), `:reload`, `:back`, `:forward`, `:stop`, `:history-clear`, -`:greasemonkey-reload`, `:adblock-update`, `:adblock-toggle`. +`:greasemonkey-reload`, `:adblock-update`, `:adblock-toggle`, `:zoom [level]` +(`150` or `150%`; no level means the default), `:zoom-in`, `:zoom-out`, +`:useragent [preset]` (alias `:ua`), `:version`. `:open` accepts a full URL, a bare host (`example.com` → `https://example.com`), an existing local path, or free text, which goes to DuckDuckGo. @@ -159,13 +225,15 @@ an existing local path, or free text, which goes to DuckDuckGo. | `src/browserwindow` | the window, mode state machine, key dispatch, commands | | `src/commandline` | the `:` prompt | | `src/greasemonkey` | userscript loading and the GM_* API wrapper | +| `src/hardening` | profile security settings, sandbox check | | `src/history` | SQLite browsing history | -| `src/completionview`| the `:open` completion list | +| `src/completionview`| the `:open` / `:useragent` completion list | | `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` | +| `src/useragent` | user-agent presets and matching client hints | Key events are intercepted with an **application-wide event filter** (`BrowserWindow::eventFilter`) rather than `QWidget::keyPressEvent`, because diff --git a/src/browserwindow.cpp b/src/browserwindow.cpp index 965f974..fa5e7b7 100644 --- a/src/browserwindow.cpp +++ b/src/browserwindow.cpp @@ -2,33 +2,40 @@ #include "adblock.h" #include "commandline.h" +#include "config.h" #include "completionview.h" #include "greasemonkey.h" #include "hints.h" #include "history.h" #include "statusbar.h" #include "urlutil.h" +#include "useragent.h" #include #include +#include #include #include #include #include #include #include +#include #include #include #include #include #include +#include #include namespace { constexpr int kScrollStep = 60; -constexpr double kZoomStep = 0.1; constexpr int kCompletionLimit = 100; +// What QtWebEngine accepts. +constexpr double kZoomMin = 0.25; +constexpr double kZoomMax = 5.0; // ":open [-w|-t] " or ":o ..."; captures the prefix, flag included, // and the query. @@ -38,6 +45,13 @@ const QRegularExpression &openCommand() return re; } +// ":useragent " or ":ua ..."; captures the prefix and the name. +const QRegularExpression &userAgentCommand() +{ + static const QRegularExpression re(QStringLiteral("^(:(?:useragent|ua) +)(.*)$")); + return re; +} + } // namespace BrowserWindow::BrowserWindow(const Session &session, const QUrl &startUrl, QWidget *parent) @@ -67,6 +81,7 @@ BrowserWindow::BrowserWindow(const Session &session, const QUrl &startUrl, QWidg qApp->installEventFilter(this); setMode(Mode::Normal); + setZoom(config::kZoomDefault, false); m_view->setUrl(startUrl); focusView(); } @@ -81,18 +96,29 @@ void BrowserWindow::setupUi() layout->setContentsMargins(0, 0, 0, 0); layout->setSpacing(0); layout->addWidget(m_view, 1); - layout->addWidget(m_completion); layout->addWidget(m_status); layout->addWidget(m_commandLine); setCentralWidget(central); + // Nothing that comes and goes may change the page's size: every resize + // makes the page re-layout, which on YouTube means the player visibly + // jumps. So the completion floats over the bottom of the page instead of + // taking space in the layout (see placeCompletion), and the status bar and + // the command line, which replace each other, get the same fixed height. + m_completion->setParent(central); + m_status->ensurePolished(); + m_commandLine->ensurePolished(); + const int barHeight = qMax(m_status->sizeHint().height(), m_commandLine->sizeHint().height()); + m_status->setFixedHeight(barHeight); + m_commandLine->setFixedHeight(barHeight); + m_commandLine->hide(); } void BrowserWindow::connectView() { connect(m_view, &QWebEngineView::urlChanged, this, [this](const QUrl &url) { - m_status->setUrl(url.toDisplayString()); + m_status->setUrl(url); // The hints belong to the page that is going away. if (m_mode == Mode::Hint) setMode(Mode::Normal); @@ -124,6 +150,11 @@ void BrowserWindow::connectView() }); } +void BrowserWindow::showMessage(const QString &text, bool isError) +{ + m_status->showMessage(text, isError); +} + void BrowserWindow::setMode(Mode mode) { if (m_mode == Mode::Hint && mode != Mode::Hint) @@ -181,6 +212,9 @@ void BrowserWindow::updateStatusVisibility() bool BrowserWindow::eventFilter(QObject *watched, QEvent *event) { + if (watched == m_view && event->type() == QEvent::Resize && m_completion->isVisible()) + placeCompletion(); + if (event->type() != QEvent::KeyPress || !isAncestorOf(qobject_cast(watched))) return QMainWindow::eventFilter(watched, event); @@ -256,10 +290,9 @@ bool BrowserWindow::handleNormalModeKey(QKeyEvent *event) } if (sequence == QLatin1String("go") || sequence == QLatin1String("gO")) { // Edit the current URL, to open it here or in a new window. - m_commandLine->openWith((sequence == QLatin1String("go") ? QStringLiteral(":open ") - : QStringLiteral(":open -w ")) - + m_view->url().toString()); - setMode(Mode::Command); + openCommandLine((sequence == QLatin1String("go") ? QStringLiteral(":open ") + : QStringLiteral(":open -w ")) + + m_view->url().toString()); return true; } return true; // Swallow the unknown sequence rather than leaking it. @@ -271,13 +304,11 @@ bool BrowserWindow::handleNormalModeKey(QKeyEvent *event) } if (text == QLatin1String("o")) { - m_commandLine->openWith(QStringLiteral(":open ")); - setMode(Mode::Command); + openCommandLine(QStringLiteral(":open ")); return true; } if (text == QLatin1String("O")) { - m_commandLine->openWith(QStringLiteral(":open -w ")); - setMode(Mode::Command); + openCommandLine(QStringLiteral(":open -w ")); return true; } if (text == QLatin1String("d")) { @@ -285,8 +316,7 @@ bool BrowserWindow::handleNormalModeKey(QKeyEvent *event) return true; } if (text == QLatin1String(":")) { - m_commandLine->openWith(QStringLiteral(":")); - setMode(Mode::Command); + openCommandLine(QStringLiteral(":")); return true; } if (text == QLatin1String("i")) { @@ -340,15 +370,15 @@ bool BrowserWindow::handleNormalModeKey(QKeyEvent *event) return true; } if (text == QLatin1String("+")) { - m_view->setZoomFactor(m_view->zoomFactor() + kZoomStep); + zoomStep(1); return true; } if (text == QLatin1String("-")) { - m_view->setZoomFactor(qMax(0.25, m_view->zoomFactor() - kZoomStep)); + zoomStep(-1); return true; } if (text == QLatin1String("=")) { - m_view->setZoomFactor(1.0); + setZoom(config::kZoomDefault); return true; } @@ -467,6 +497,46 @@ void BrowserWindow::runCommand(const QString &command) m_view->forward(); } else if (name == QLatin1String("stop")) { m_view->stop(); + } else if (name == QLatin1String("version")) { + // The Chromium security patch level is what says how far behind + // Chrome's fixes this build is; "nix flake update" moves it forward. + m_status->showMessage(QStringLiteral("att %1, Qt %2, Chromium %3, security patches %4") + .arg(QCoreApplication::applicationVersion(), + QString::fromLatin1(qVersion()), + QString::fromLatin1(qWebEngineChromiumVersion()), + QString::fromLatin1(qWebEngineChromiumSecurityPatchVersion()))); + } else if (name == QLatin1String("zoom")) { + if (args.isEmpty()) { + setZoom(config::kZoomDefault); + } else { + // "150" or "150%", like qutebrowser. + QString level = args; + if (level.endsWith(QLatin1Char('%'))) + level.chop(1); + bool ok = false; + const double percent = level.toDouble(&ok); + if (ok && percent >= kZoomMin * 100 && percent <= kZoomMax * 100) + setZoom(percent / 100); + else + m_status->showMessage(QStringLiteral("Zoom level must be %1% to %2%: %3") + .arg(kZoomMin * 100).arg(kZoomMax * 100).arg(args), + true); + } + } else if (name == QLatin1String("zoom-in")) { + zoomStep(1); + } else if (name == QLatin1String("zoom-out")) { + zoomStep(-1); + } else if (name == QLatin1String("useragent") || name == QLatin1String("ua")) { + if (args.isEmpty()) { + m_status->showMessage(QStringLiteral("User agent: %1").arg(useragent::current())); + } else if (useragent::apply(m_session.profile, args)) { + // Requests use it at once; navigator.userAgent changes with the + // next load, so say so rather than leave the page inconsistent. + m_status->showMessage( + QStringLiteral("User agent: %1 (applies from the next page load)").arg(args)); + } else { + m_status->showMessage(QStringLiteral("Unknown user agent: %1").arg(args), true); + } } else if (name == QLatin1String("history-clear")) { if (m_session.history->clear()) { // Also forget Chromium's own record, which colours visited links. @@ -488,28 +558,63 @@ void BrowserWindow::runCommand(const QString &command) } } +void BrowserWindow::openCommandLine(const QString &text) +{ + // Hide the status bar before the command line appears: showing a widget + // re-runs the layout immediately, and with both bars visible for a moment + // the page would shrink and grow back (two resizes) every time. + m_status->hide(); + m_commandLine->openWith(text); + setMode(Mode::Command); +} + void BrowserWindow::updateCompletion() { - const QRegularExpressionMatch match = openCommand().match(m_commandLine->text()); - if (!match.hasMatch()) { + const QString text = m_commandLine->text(); + if (const QRegularExpressionMatch match = openCommand().match(text); match.hasMatch()) { + const QStringList words = match.captured(2).split(QLatin1Char(' '), Qt::SkipEmptyParts); + m_completion->setEntries(m_session.history->search(words, kCompletionLimit)); + } else if (const QRegularExpressionMatch match = userAgentCommand().match(text); + match.hasMatch()) { + // Preset name in the first column, the string it sends in the second. + const QString query = match.captured(2).trimmed(); + QList entries; + for (const useragent::Preset &preset : useragent::presets()) { + if (preset.name.contains(query, Qt::CaseInsensitive)) + entries.append({preset.name, preset.userAgent}); + } + m_completion->setEntries(entries); + } else { m_completion->setEntries({}); return; } - const QStringList words = match.captured(2).split(QLatin1Char(' '), Qt::SkipEmptyParts); - m_completion->setEntries(m_session.history->search(words, kCompletionLimit)); + placeCompletion(); +} + +void BrowserWindow::placeCompletion() +{ + // Bottom-aligned over the page, just above the command line. + const QRect page = m_view->geometry(); + const int height = qMin(m_completion->height(), page.height()); + m_completion->setGeometry(page.left(), page.bottom() + 1 - height, page.width(), height); + m_completion->raise(); } void BrowserWindow::cycleCompletion(int step) { if (!m_completion->isVisible()) return; - const QString url = m_completion->cycle(step); - if (url.isEmpty()) + const QString choice = m_completion->cycle(step); + if (choice.isEmpty()) return; - // Keep whichever of ":o " / ":open " the user typed. - const QRegularExpressionMatch match = openCommand().match(m_commandLine->text()); + // Keep the command as the user typed it (":o " or ":open ", ":ua " or + // ":useragent "). + const QString text = m_commandLine->text(); + QRegularExpressionMatch match = userAgentCommand().match(text); + if (!match.hasMatch()) + match = openCommand().match(text); const QString prefix = match.hasMatch() ? match.captured(1) : QStringLiteral(":open "); - m_commandLine->setCommandText(prefix + url); + m_commandLine->setCommandText(prefix + choice); } void BrowserWindow::openUrl(const QString &input, bool newWindow) @@ -529,21 +634,70 @@ void BrowserWindow::openUrl(const QString &input, bool newWindow) void BrowserWindow::scrollBy(int dx, int dy) { - m_view->page()->runJavaScript( - QStringLiteral("window.scrollBy(%1, %2);").arg(dx).arg(dy)); + // A pixel-precise wheel event, which is what a touchpad sends, rather than + // window.scrollBy(): Chromium then scrolls whatever is under the pointer, + // the way the touchpad does. window.scrollBy() only ever moves the + // document, so on sites that scroll an inner element instead (most web + // apps) it did nothing at all. + QWidget *target = m_view->focusProxy(); + if (!target) + return; + + QPointF pos = target->mapFromGlobal(QCursor::pos()); + if (!target->rect().contains(pos.toPoint())) + pos = QRectF(target->rect()).center(); + + // Qt's wheel deltas point the way the content moves, the opposite of the + // scroll direction. + const QPoint pixels(-dx, -dy); + QWheelEvent event(pos, target->mapToGlobal(pos), pixels, pixels * 2, Qt::NoButton, + Qt::NoModifier, Qt::NoScrollPhase, false); + QCoreApplication::sendEvent(target, &event); } void BrowserWindow::scrollPages(double factor) { - m_view->page()->runJavaScript( - QStringLiteral("window.scrollBy(0, window.innerHeight * %1);").arg(factor)); + scrollBy(0, qRound(m_view->height() * factor)); } void BrowserWindow::scrollToEnd(bool bottom) { - m_view->page()->runJavaScript( - bottom ? QStringLiteral("window.scrollTo(0, document.body.scrollHeight);") - : QStringLiteral("window.scrollTo(0, 0);")); + // Far more than any page is tall; Chromium stops at the end. + constexpr int kFar = 10'000'000; + scrollBy(0, bottom ? kFar : -kFar); +} + +void BrowserWindow::setZoom(double factor, bool announce) +{ + const double zoom = qBound(kZoomMin, factor, kZoomMax); + m_view->setZoomFactor(zoom); + if (announce) + m_status->showMessage(QStringLiteral("Zoom level: %1%").arg(qRound(zoom * 100))); +} + +void BrowserWindow::zoomStep(int direction) +{ + // The next level strictly beyond the current zoom, so a zoom set with + // ":zoom 130" still steps to 150 / 125. The epsilon absorbs 0.33 vs 1/3. + constexpr double kEpsilon = 0.001; + const double current = m_view->zoomFactor(); + const auto &levels = config::kZoomLevels; + if (direction > 0) { + for (double level : levels) { + if (level > current + kEpsilon) { + setZoom(level); + return; + } + } + } else { + for (auto it = levels.rbegin(); it != levels.rend(); ++it) { + if (*it < current - kEpsilon) { + setZoom(*it); + return; + } + } + } + m_status->showMessage(QStringLiteral("Zoom level: %1% (limit)").arg(qRound(current * 100))); } void BrowserWindow::focusView() diff --git a/src/browserwindow.h b/src/browserwindow.h index 1ed9b43..b9943f3 100644 --- a/src/browserwindow.h +++ b/src/browserwindow.h @@ -40,6 +40,7 @@ public: BrowserWindow(const Session &session, const QUrl &startUrl, QWidget *parent = nullptr); void setMode(Mode mode); + void showMessage(const QString &text, bool isError = false); protected: // Key handling lives in an application-wide filter because QtWebEngine @@ -59,13 +60,17 @@ private: void handleHintResult(const QVariantMap &result); void clickAt(const QPointF &pos); void runCommand(const QString &command); + void openCommandLine(const QString &text); void updateCompletion(); void cycleCompletion(int step); + void placeCompletion(); void openUrl(const QString &input, bool newWindow); void scrollBy(int dx, int dy); void scrollPages(double factor); void scrollToEnd(bool bottom); void focusView(); + void setZoom(double factor, bool announce = true); + void zoomStep(int direction); void setPageFullScreen(bool on); void updateStatusVisibility(); diff --git a/src/completionview.h b/src/completionview.h index 331c7d9..54e6a8b 100644 --- a/src/completionview.h +++ b/src/completionview.h @@ -4,9 +4,10 @@ #include -// The list above the command line offering history entries for ":open", with -// URL and title columns, like qutebrowser's completion widget. It never takes -// focus: keys stay with the CommandLine, which drives it via cycle(). +// The list above the command line, like qutebrowser's completion widget: history +// for ":open" (URL and title columns) or presets for ":useragent" (name and +// string). It never takes focus: keys stay with the CommandLine, which drives +// it via cycle(). class CompletionView : public QTreeWidget { Q_OBJECT @@ -17,6 +18,7 @@ public: // Shows the entries with nothing selected, or hides the view if empty. void setEntries(const QList &entries); - // Moves the selection step rows (wrapping) and returns the selected URL. + // Moves the selection step rows (wrapping) and returns the first + // column of the selected row. QString cycle(int step); }; diff --git a/src/config.h b/src/config.h new file mode 100644 index 0000000..a94fbd4 --- /dev/null +++ b/src/config.h @@ -0,0 +1,35 @@ +#pragma once + +#include + +// User-facing settings, named after their qutebrowser equivalents. att has no +// config file yet; until it does, change them here and rebuild. +namespace config { + +// zoom.default: the zoom every new window starts at, and what "=" and a bare +// ":zoom" return to. 1.0 is 100%. +inline constexpr double kZoomDefault = 1.25; + +// zoom.levels: the steps "+" / "-" (":zoom-in" / ":zoom-out") move through. +// qutebrowser's defaults; QtWebEngine accepts 25% to 500%. +inline constexpr std::array kZoomLevels = { + 0.25, 0.33, 0.50, 0.67, 0.75, 0.90, 1.00, 1.10, + 1.25, 1.50, 1.75, 2.00, 2.50, 3.00, 4.00, 5.00, +}; + +// content.cookies.accept = no-3rdparty: refuse cookies a page's third-party +// requests try to set or send. Blocks most cross-site tracking; a few embedded +// logins (e.g. some comment widgets) stop working. +inline constexpr bool kBlockThirdPartyCookies = true; + +// content.pdfjs (roughly): Chromium's built-in PDF viewer. PDFium is a classic +// source of exploitable bugs, but att can't download files, so without it a PDF +// can't be opened at all. +inline constexpr bool kPdfViewer = true; + +// content.headers.user_agent: the preset (see ":useragent" and useragent.cpp) +// att starts with. Chrome on Linux is the most common UA that matches the +// engine, so sites treat att as the browser it actually is. +inline constexpr auto kUserAgent = "chrome-linux"; + +} // namespace config diff --git a/src/greasemonkey.cpp b/src/greasemonkey.cpp index 25817b5..00b0326 100644 --- a/src/greasemonkey.cpp +++ b/src/greasemonkey.cpp @@ -253,6 +253,64 @@ QWebEngineScript::InjectionPoint injectionPoint(const QString &runAt) return QWebEngineScript::DocumentReady; // Greasemonkey's default. } +// @require'd code runs with the script's full power on every page it matches, +// so a tampered download is as bad as a malicious script. Only HTTPS is +// allowed (downloads also refuse redirects away from HTTPS), and a URL may pin +// its content in Tampermonkey's format: "#sha256=", or the SRI +// form "#sha256-", optionally among other comma/semicolon-separated +// hashes, which are ignored. +struct RequirePin { + QByteArray sha256; // empty: not pinned + QString error; // the pin is malformed +}; + +RequirePin requirePin(const QUrl &url) +{ + static const QRegularExpression part(QStringLiteral("^sha256[=-](.+)$"), + QRegularExpression::CaseInsensitiveOption); + RequirePin pin; + const QStringList parts = url.fragment(QUrl::FullyDecoded) + .split(QRegularExpression(QStringLiteral("[,;]")), Qt::SkipEmptyParts); + for (const QString &p : parts) { + const QRegularExpressionMatch m = part.match(p.trimmed()); + if (!m.hasMatch()) + continue; + const QByteArray value = m.captured(1).toLatin1(); + static const QRegularExpression hex(QStringLiteral("^[0-9a-fA-F]{64}$")); + QByteArray digest; + if (hex.match(QString::fromLatin1(value)).hasMatch()) { + digest = QByteArray::fromHex(value); + } else { + const auto decoded = QByteArray::fromBase64Encoding( + value, QByteArray::Base64Encoding | QByteArray::AbortOnBase64DecodingErrors); + const auto decodedUrl = QByteArray::fromBase64Encoding( + value, QByteArray::Base64UrlEncoding | QByteArray::AbortOnBase64DecodingErrors); + digest = decoded ? *decoded : decodedUrl ? *decodedUrl : QByteArray(); + } + if (digest.size() != 32) { + pin.error = QStringLiteral("malformed sha256 pin"); + return pin; + } + pin.sha256 = digest; + } + return pin; +} + +// Why the @require at url can't be used, or an empty string if it can. +QString requireProblem(const QUrl &url, const QByteArray &content) +{ + if (url.scheme() != QLatin1String("https")) + return QStringLiteral("@require is not https: %1").arg(url.toDisplayString()); + const RequirePin pin = requirePin(url); + if (!pin.error.isEmpty()) + return QStringLiteral("%1 in %2").arg(pin.error, url.toDisplayString()); + if (!pin.sha256.isEmpty() + && QCryptographicHash::hash(content, QCryptographicHash::Sha256) != pin.sha256) + return QStringLiteral("@require does not match its sha256 pin: %1") + .arg(url.toDisplayString(QUrl::RemoveFragment)); + return {}; +} + } // namespace Greasemonkey::Metadata Greasemonkey::parseMetadata(const QString &source) @@ -340,7 +398,9 @@ void Greasemonkey::reload(bool announce) if (script.meta.name.isEmpty()) script.meta.name = fileName; for (const QString &url : std::as_const(script.meta.requireUrls)) { - if (!QFile::exists(requirePath(url)) && !missing.contains(url)) + // install() reports the non-https ones; they are never fetched. + if (QUrl(url).scheme() == QLatin1String("https") && !QFile::exists(requirePath(url)) + && !missing.contains(url)) missing << url; } scripts << script; @@ -356,7 +416,7 @@ void Greasemonkey::reload(bool announce) auto remaining = std::make_shared(int(missing.size())); auto failed = std::make_shared(); for (const QString &url : std::as_const(missing)) { - QNetworkRequest request{QUrl(url)}; + QNetworkRequest request{QUrl(url).adjusted(QUrl::RemoveFragment)}; request.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::NoLessSafeRedirectPolicy); QNetworkReply *reply = m_network->get(request); @@ -387,28 +447,43 @@ void Greasemonkey::install(const QList