From 7bc669225a24738aab2b75ab2aae204bcc98dd0f Mon Sep 17 00:00:00 2001 From: Asmir A Date: Fri, 16 Oct 2026 00:11:22 +0200 Subject: [PATCH] add greasemonkey support --- CLAUDE.md | 5 + CMakeLists.txt | 2 + README.md | 25 ++- src/browserwindow.cpp | 4 + src/browserwindow.h | 2 + src/greasemonkey.cpp | 425 ++++++++++++++++++++++++++++++++++++++++++ src/greasemonkey.h | 68 +++++++ src/main.cpp | 8 +- 8 files changed, 536 insertions(+), 3 deletions(-) create mode 100644 src/greasemonkey.cpp create mode 100644 src/greasemonkey.h diff --git a/CLAUDE.md b/CLAUDE.md index 94d2609..2707f9b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -51,6 +51,11 @@ The only tests are the Rust crate's (`nix develop -c sh -c 'cd adblock && cargo - `main.cpp` creates `AdBlocker` *after* the profile, so it is destroyed after it. - **History** (`History`, `src/history.cpp`) is SQLite through QtSql, run synchronously on the GUI thread with WAL. `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`. + - 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. - 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 005ff78..7b6e164 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -38,6 +38,8 @@ qt_add_executable(att src/completionview.h src/gpuworkaround.cpp src/gpuworkaround.h + src/greasemonkey.cpp + src/greasemonkey.h src/hints.cpp src/hints.h src/history.cpp diff --git a/README.md b/README.md index 021b30c..01ba770 100644 --- a/README.md +++ b/README.md @@ -117,12 +117,34 @@ The lists are downloaded on first start and compiled into `:adblock-update` re-downloads them. `:adblock-toggle` switches blocking off and on, for when a site breaks. +## Greasemonkey + +Userscripts go in `~/.local/share/att/att/greasemonkey/` as `*.js` files. They +are loaded at startup; after changing them, run `:greasemonkey-reload` and reload +the page. Like qutebrowser, att: + +- runs scripts in the page's own JavaScript context (so `unsafeWindow` is just + `window`), in iframes too unless `@noframes` is set; +- honours `@include`, `@exclude` and `@match` (checked by QtWebEngine itself; a + script with none of them runs everywhere), `@run-at document-start` / + `document-end` (the default) / `document-idle`, and `@require`, which is + downloaded once into `greasemonkey/requires/`; +- provides `GM_info`, `GM_setValue`/`GM_getValue`/`GM_deleteValue`/`GM_listValues`, + `GM_addStyle`, `GM_addElement`, `GM_xmlhttpRequest`, `GM_openInTab`, + `GM_setClipboard`, `GM_log`, `GM_notification` and the promise-based `GM.*` + versions. + +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. + ## 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`, -`:adblock-update`, `:adblock-toggle`. +`:greasemonkey-reload`, `: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. @@ -136,6 +158,7 @@ an existing local path, or free text, which goes to DuckDuckGo. | `src/main.cpp` | `QApplication`, CLI parsing, stylesheet | | `src/browserwindow` | the window, mode state machine, key dispatch, commands | | `src/commandline` | the `:` prompt | +| `src/greasemonkey` | userscript loading and the GM_* API wrapper | | `src/history` | SQLite browsing history | | `src/completionview`| the `:open` completion list | | `src/statusbar` | mode / URL / progress line | diff --git a/src/browserwindow.cpp b/src/browserwindow.cpp index 45d290a..965f974 100644 --- a/src/browserwindow.cpp +++ b/src/browserwindow.cpp @@ -3,6 +3,7 @@ #include "adblock.h" #include "commandline.h" #include "completionview.h" +#include "greasemonkey.h" #include "hints.h" #include "history.h" #include "statusbar.h" @@ -61,6 +62,7 @@ BrowserWindow::BrowserWindow(const Session &session, const QUrl &startUrl, QWidg connect(m_commandLine, &QLineEdit::textEdited, this, &BrowserWindow::updateCompletion); connect(m_commandLine, &CommandLine::completionCycled, this, &BrowserWindow::cycleCompletion); connect(m_session.adBlocker, &AdBlocker::message, m_status, &StatusBar::showMessage); + connect(m_session.greasemonkey, &Greasemonkey::message, m_status, &StatusBar::showMessage); qApp->installEventFilter(this); @@ -473,6 +475,8 @@ void BrowserWindow::runCommand(const QString &command) } else { m_status->showMessage(QStringLiteral("Could not clear history"), true); } + } else if (name == QLatin1String("greasemonkey-reload")) { + m_session.greasemonkey->reload(); } else if (name == QLatin1String("adblock-update")) { m_session.adBlocker->update(); } else if (name == QLatin1String("adblock-toggle")) { diff --git a/src/browserwindow.h b/src/browserwindow.h index e7c16be..1ed9b43 100644 --- a/src/browserwindow.h +++ b/src/browserwindow.h @@ -8,6 +8,7 @@ class AdBlocker; class CommandLine; class CompletionView; +class Greasemonkey; class History; class StatusBar; class QWebEngineProfile; @@ -17,6 +18,7 @@ class QWebEngineView; struct Session { QWebEngineProfile *profile; AdBlocker *adBlocker; + Greasemonkey *greasemonkey; History *history; }; diff --git a/src/greasemonkey.cpp b/src/greasemonkey.cpp new file mode 100644 index 0000000..25817b5 --- /dev/null +++ b/src/greasemonkey.cpp @@ -0,0 +1,425 @@ +#include "greasemonkey.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace { + +constexpr auto kScriptNamePrefix = "att-greasemonkey:"; + +// Runs around every userscript, in the page's main world. The GM functions are +// locals of the wrapper, so the page itself can't see or call them. +// +// GM_setValue & co. are backed by the page's localStorage under a per-script +// prefix, as in qutebrowser: values are per site, not shared across sites as in +// browser-extension userscript managers. GM_xmlhttpRequest is a plain XHR, so +// it is subject to CORS. +constexpr auto kWrapperHead = R"JS((function () { +'use strict'; +const GM_info = @INFO@; +const __attStorePrefix = '__att_gm__' + @KEY@ + '__'; +const __attMemoryStore = new Map(); + +function __attStore() { + try { + const s = window.localStorage; + s.length; // throws on opaque origins + return s; + } catch (e) { + return { + getItem: k => (__attMemoryStore.has(k) ? __attMemoryStore.get(k) : null), + setItem: (k, v) => __attMemoryStore.set(k, String(v)), + removeItem: k => __attMemoryStore.delete(k), + get length() { return __attMemoryStore.size; }, + key: i => Array.from(__attMemoryStore.keys())[i] ?? null, + }; + } +} + +function GM_setValue(key, value) { + __attStore().setItem(__attStorePrefix + key, JSON.stringify(value)); +} +function GM_getValue(key, defaultValue) { + const raw = __attStore().getItem(__attStorePrefix + key); + if (raw === null) + return defaultValue; + try { + return JSON.parse(raw); + } catch (e) { + return defaultValue; + } +} +function GM_deleteValue(key) { + __attStore().removeItem(__attStorePrefix + key); +} +function GM_listValues() { + const store = __attStore(); + const keys = []; + for (let i = 0; i < store.length; i++) { + const k = store.key(i); + if (k && k.startsWith(__attStorePrefix)) + keys.push(k.slice(__attStorePrefix.length)); + } + return keys; +} + +function GM_addStyle(css) { + const style = document.createElement('style'); + style.textContent = css; + const parent = document.head || document.documentElement; + if (parent) { + parent.appendChild(style); + } else { + // @run-at document-start runs before exists, which is exactly + // when style scripts want to run, to avoid a flash of unstyled page. + new MutationObserver((_, observer) => { + const p = document.head || document.documentElement; + if (p) { + observer.disconnect(); + p.appendChild(style); + } + }).observe(document, { childList: true, subtree: true }); + } + return style; +} + +function GM_addElement(parent, tag, attributes) { + if (typeof parent === 'string') { + attributes = tag; + tag = parent; + parent = document.head || document.documentElement; + } + const el = document.createElement(tag); + for (const [k, v] of Object.entries(attributes || {})) { + if (k === 'textContent') + el.textContent = v; + else + el.setAttribute(k, v); + } + parent.appendChild(el); + return el; +} + +function GM_openInTab(url) { + return window.open(url, '_blank'); +} + +function GM_setClipboard(text) { + navigator.clipboard?.writeText(String(text)).catch(() => {}); +} + +function GM_log(...args) { + console.log(...args); +} + +function GM_notification(details, title) { + const text = typeof details === 'string' ? details : details.text; + console.info('[' + (title || GM_info.script.name) + '] ' + text); +} + +// Userscript managers add these to their extension menu; att has none, but +// scripts commonly call them unconditionally. +function GM_registerMenuCommand() { return 0; } +function GM_unregisterMenuCommand() {} + +function GM_getResourceText() { return null; } +function GM_getResourceURL() { return null; } + +function GM_xmlhttpRequest(details) { + const xhr = new XMLHttpRequest(); + const response = () => ({ + readyState: xhr.readyState, + responseHeaders: xhr.getAllResponseHeaders(), + responseText: xhr.responseType === '' || xhr.responseType === 'text' ? xhr.responseText : '', + response: xhr.response, + status: xhr.status, + statusText: xhr.statusText, + finalUrl: xhr.responseURL, + context: details.context, + }); + const on = name => () => details[name] && details[name](response()); + xhr.onload = on('onload'); + xhr.onerror = on('onerror'); + xhr.onabort = on('onabort'); + xhr.ontimeout = on('ontimeout'); + xhr.onprogress = on('onprogress'); + xhr.onreadystatechange = on('onreadystatechange'); + xhr.open(details.method || 'GET', details.url, true, details.user, details.password); + if (details.responseType) + xhr.responseType = details.responseType; + if (details.timeout) + xhr.timeout = details.timeout; + if (details.overrideMimeType) + xhr.overrideMimeType(details.overrideMimeType); + for (const [k, v] of Object.entries(details.headers || {})) + xhr.setRequestHeader(k, v); + xhr.send(details.data); + return { abort: () => xhr.abort() }; +} + +// The promise-based Greasemonkey 4 API. +const GM = { + info: GM_info, + setValue: async (k, v) => GM_setValue(k, v), + getValue: async (k, d) => GM_getValue(k, d), + deleteValue: async k => GM_deleteValue(k), + listValues: async () => GM_listValues(), + addStyle: async css => GM_addStyle(css), + addElement: (...a) => GM_addElement(...a), + openInTab: async url => GM_openInTab(url), + setClipboard: async text => GM_setClipboard(text), + notification: async (d, t) => GM_notification(d, t), + registerMenuCommand: async () => 0, + getResourceUrl: async () => null, + xmlHttpRequest: details => new Promise((resolve, reject) => { + GM_xmlhttpRequest(Object.assign({}, details, { + onload: r => { details.onload && details.onload(r); resolve(r); }, + onerror: r => { details.onerror && details.onerror(r); reject(r); }, + })); + }), +}; + +// The scripts already run in the page's own world. +const unsafeWindow = window; + +)JS"; + +constexpr auto kWrapperTail = R"JS( +})(); +)JS"; + +QJsonArray toJson(const QStringList &list) +{ + return QJsonArray::fromStringList(list); +} + +QString jsString(const QString &s) +{ + // A JSON string is a valid JS string literal. Wrap it in an array to get + // QJsonDocument to serialize it, then strip the brackets. + const QByteArray json = QJsonDocument(QJsonArray{s}).toJson(QJsonDocument::Compact); + return QString::fromUtf8(json.mid(1, json.size() - 2)); +} + +QString wrap(const Greasemonkey::Metadata &meta, const QString &requiredCode, const QString &source) +{ + QJsonObject script{ + {QStringLiteral("name"), meta.name}, + {QStringLiteral("namespace"), meta.nameSpace}, + {QStringLiteral("description"), meta.description}, + {QStringLiteral("version"), meta.version}, + {QStringLiteral("includes"), toJson(meta.includes)}, + {QStringLiteral("excludes"), toJson(meta.excludes)}, + {QStringLiteral("matches"), toJson(meta.matches)}, + {QStringLiteral("run-at"), meta.runAt}, + }; + QJsonObject info{ + {QStringLiteral("script"), script}, + {QStringLiteral("scriptMetaStr"), meta.block}, + {QStringLiteral("scriptHandler"), QStringLiteral("att")}, + {QStringLiteral("version"), QCoreApplication::applicationVersion()}, + }; + + QString head = QString::fromUtf8(kWrapperHead); + head.replace(QLatin1String("@INFO@"), + QString::fromUtf8(QJsonDocument(info).toJson(QJsonDocument::Compact))); + head.replace(QLatin1String("@KEY@"), jsString(meta.nameSpace + QLatin1Char('/') + meta.name)); + + // Newlines between the parts: a script may end in a // comment without one, + // which would otherwise swallow the closing "})();". + return head + requiredCode + QLatin1Char('\n') + source + QString::fromUtf8(kWrapperTail); +} + +QWebEngineScript::InjectionPoint injectionPoint(const QString &runAt) +{ + if (runAt == QLatin1String("document-start")) + return QWebEngineScript::DocumentCreation; + if (runAt == QLatin1String("document-idle")) + return QWebEngineScript::Deferred; + return QWebEngineScript::DocumentReady; // Greasemonkey's default. +} + +} // namespace + +Greasemonkey::Metadata Greasemonkey::parseMetadata(const QString &source) +{ + Metadata meta; + meta.runAt = QStringLiteral("document-end"); + + static const QRegularExpression line(QStringLiteral(R"(^\s*//\s*@(\S+)(?:[ \t]+(.*?))?\s*$)")); + + bool inBlock = false; + QStringList block; + for (const QStringView raw : QStringView(source).split(QLatin1Char('\n'))) { + const QString text = raw.trimmed().toString(); + if (!inBlock) { + if (text.startsWith(QLatin1String("// ==UserScript=="))) + inBlock = true; + continue; + } + if (text.startsWith(QLatin1String("// ==/UserScript=="))) + break; + block << text; + + const QRegularExpressionMatch m = line.match(text); + if (!m.hasMatch()) + continue; + const QString key = m.captured(1); + const QString value = m.captured(2); + if (key == QLatin1String("name")) + meta.name = value; + else if (key == QLatin1String("namespace")) + meta.nameSpace = value; + else if (key == QLatin1String("description")) + meta.description = value; + else if (key == QLatin1String("version")) + meta.version = value; + else if (key == QLatin1String("run-at")) + meta.runAt = value; + else if (key == QLatin1String("noframes")) + meta.noFrames = true; + else if (key == QLatin1String("include")) + meta.includes << value; + else if (key == QLatin1String("exclude")) + meta.excludes << value; + else if (key == QLatin1String("match")) + meta.matches << value; + else if (key == QLatin1String("require")) + meta.requireUrls << value; + } + meta.block = block.join(QLatin1Char('\n')); + return meta; +} + +Greasemonkey::Greasemonkey(QWebEngineProfile *profile, QObject *parent) + : QObject(parent) + , m_profile(profile) + , m_network(new QNetworkAccessManager(this)) + , m_dir(QStandardPaths::writableLocation(QStandardPaths::AppDataLocation) + + QStringLiteral("/greasemonkey")) +{ + m_dir.mkpath(QStringLiteral("requires")); +} + +QString Greasemonkey::requirePath(const QString &url) const +{ + const QByteArray hash = QCryptographicHash::hash(url.toUtf8(), QCryptographicHash::Sha1).toHex(); + return m_dir.filePath(QStringLiteral("requires/") + QString::fromLatin1(hash) + QStringLiteral(".js")); +} + +void Greasemonkey::reload(bool announce) +{ + // A reload that starts while an older one is still downloading wins. + const int generation = ++m_generation; + + QList