add greasemonkey support

This commit is contained in:
2026-10-16 00:11:22 +02:00
parent b6020a4354
commit 7bc669225a
8 changed files with 536 additions and 3 deletions
+5
View File
@@ -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:<file>`, 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.
+2
View File
@@ -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
+24 -1
View File
@@ -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] <url|query>` (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 |
+4
View File
@@ -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")) {
+2
View File
@@ -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;
};
+425
View File
@@ -0,0 +1,425 @@
#include "greasemonkey.h"
#include <QCoreApplication>
#include <QCryptographicHash>
#include <QFile>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QNetworkAccessManager>
#include <QNetworkReply>
#include <QRegularExpression>
#include <QSaveFile>
#include <QStandardPaths>
#include <QWebEngineProfile>
#include <QWebEngineScript>
#include <QWebEngineScriptCollection>
#include <memory>
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 <html> 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<Script> scripts;
QStringList missing;
const QStringList files = m_dir.entryList({QStringLiteral("*.js")}, QDir::Files, QDir::Name);
for (const QString &fileName : files) {
QFile file(m_dir.filePath(fileName));
if (!file.open(QIODevice::ReadOnly)) {
emit message(QStringLiteral("Greasemonkey: cannot read %1").arg(fileName), true);
continue;
}
Script script{fileName, QString::fromUtf8(file.readAll()), {}};
script.meta = parseMetadata(script.source);
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))
missing << url;
}
scripts << script;
}
if (missing.isEmpty()) {
install(scripts, {}, announce);
return;
}
emit message(QStringLiteral("Greasemonkey: downloading %1 @require file(s)...").arg(missing.size()),
false);
auto remaining = std::make_shared<int>(int(missing.size()));
auto failed = std::make_shared<QStringList>();
for (const QString &url : std::as_const(missing)) {
QNetworkRequest request{QUrl(url)};
request.setAttribute(QNetworkRequest::RedirectPolicyAttribute,
QNetworkRequest::NoLessSafeRedirectPolicy);
QNetworkReply *reply = m_network->get(request);
connect(reply, &QNetworkReply::finished, this,
[this, reply, url, remaining, failed, scripts, generation, announce] {
reply->deleteLater();
QSaveFile file(requirePath(url));
if (reply->error() != QNetworkReply::NoError || !file.open(QIODevice::WriteOnly)
|| file.write(reply->readAll()) < 0 || !file.commit())
*failed << url;
if (--*remaining == 0 && generation == m_generation)
install(scripts, *failed, announce);
});
}
}
void Greasemonkey::install(const QList<Script> &scripts, const QStringList &failedRequires,
bool announce)
{
QWebEngineScriptCollection *collection = m_profile->scripts();
const QList<QWebEngineScript> all = collection->toList();
for (const QWebEngineScript &old : all) {
if (old.name().startsWith(QLatin1String(kScriptNamePrefix)))
collection->remove(old);
}
int installed = 0;
QStringList skipped;
for (const Script &script : scripts) {
QString requiredCode;
bool complete = true;
for (const QString &url : script.meta.requireUrls) {
QFile file(requirePath(url));
if (failedRequires.contains(url) || !file.open(QIODevice::ReadOnly)) {
complete = false;
break;
}
requiredCode += QString::fromUtf8(file.readAll()) + QLatin1Char('\n');
}
// Running a script without the library it needs would just throw.
if (!complete) {
skipped << script.meta.name;
continue;
}
QWebEngineScript web;
web.setName(QString::fromLatin1(kScriptNamePrefix) + script.fileName);
web.setWorldId(QWebEngineScript::MainWorld);
// setSourceCode first: QtWebEngine parses the ==UserScript== block here,
// keeping @include/@exclude/@match for Chromium to check per page, and
// would overwrite an injection point set earlier.
web.setSourceCode(wrap(script.meta, requiredCode, script.source));
web.setInjectionPoint(injectionPoint(script.meta.runAt));
web.setRunsOnSubFrames(!script.meta.noFrames);
collection->insert(web);
++installed;
}
if (!skipped.isEmpty())
emit message(QStringLiteral("Greasemonkey: skipped %1 (an @require failed to download)")
.arg(skipped.join(QLatin1String(", "))),
true);
else if (announce)
emit message(QStringLiteral("Greasemonkey: loaded %1 script(s); reload pages to apply").arg(installed),
false);
}
+68
View File
@@ -0,0 +1,68 @@
#pragma once
#include <QDir>
#include <QList>
#include <QObject>
#include <QString>
#include <QStringList>
class QNetworkAccessManager;
class QWebEngineProfile;
// Greasemonkey userscripts, the way qutebrowser does them: every *.js file in
// <AppDataLocation>/greasemonkey is injected into the shared profile, so it runs
// in every window.
//
// URL matching (@include, @exclude, @match) is left to QtWebEngine, which
// parses the ==UserScript== block itself and checks it inside Chromium. att
// parses the block too, for @run-at, @noframes and @require, and for GM_info.
// Scripts run in the page's main world, wrapped in a function that provides
// the common GM_* / GM.* API (see kWrapper in greasemonkey.cpp).
class Greasemonkey : public QObject
{
Q_OBJECT
public:
struct Metadata {
QString name;
QString nameSpace;
QString description;
QString version;
QString runAt; // document-start, document-end (default) or document-idle
bool noFrames = false;
QStringList includes;
QStringList excludes;
QStringList matches;
QStringList requireUrls;
QString block; // the raw ==UserScript== block, for GM_info.scriptMetaStr
};
static Metadata parseMetadata(const QString &source);
Greasemonkey(QWebEngineProfile *profile, QObject *parent = nullptr);
QString directory() const { return m_dir.path(); }
// Re-reads the scripts. @require'd files are downloaded once and cached
// under greasemonkey/requires; scripts are installed when they are in.
// Errors are always reported; success only if announce is set.
void reload(bool announce = true);
signals:
void message(const QString &text, bool isError);
private:
struct Script {
QString fileName;
QString source;
Metadata meta;
};
QString requirePath(const QString &url) const;
void install(const QList<Script> &scripts, const QStringList &failedRequires, bool announce);
QWebEngineProfile *m_profile;
QNetworkAccessManager *m_network;
QDir m_dir;
int m_generation = 0;
};
+6 -2
View File
@@ -1,6 +1,7 @@
#include "adblock.h"
#include "browserwindow.h"
#include "gpuworkaround.h"
#include "greasemonkey.h"
#include "history.h"
#include "urlutil.h"
@@ -85,12 +86,15 @@ int main(int argc, char *argv[])
// Shared by every window; after QApplication, which QtSql needs.
History history;
const Session session{profile, adBlocker, &history};
auto *greasemonkey = new Greasemonkey(profile, &app);
const Session session{profile, adBlocker, greasemonkey, &history};
// Windows delete themselves on close; the app quits with the last one.
(new BrowserWindow(session, startUrl))->show();
// After the window exists, so its status bar shows the update messages.
// After the window exists, so its status bar shows their messages. The
// scripts are in place before the event loop starts the first page load.
adBlocker->start();
greasemonkey->reload(false);
const int status = app.exec();