add greasemonkey support
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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")) {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user