|
|
|
@@ -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);
|
|
|
|
|
}
|