add base: browser window, hints, history, cookies, adblock
This commit is contained in:
+225
@@ -0,0 +1,225 @@
|
||||
#include "adblock.h"
|
||||
|
||||
#include "att_adblock.h"
|
||||
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
#include <QFutureWatcher>
|
||||
#include <QNetworkAccessManager>
|
||||
#include <QNetworkReply>
|
||||
#include <QSaveFile>
|
||||
#include <QStandardPaths>
|
||||
#include <QWebEngineUrlRequestInfo>
|
||||
#include <QtConcurrent>
|
||||
|
||||
namespace {
|
||||
|
||||
// qutebrowser's default content.blocking.adblock.lists.
|
||||
constexpr const char *kLists[] = {
|
||||
"https://easylist.to/easylist/easylist.txt",
|
||||
"https://easylist.to/easylist/easyprivacy.txt",
|
||||
};
|
||||
constexpr int kListCount = std::size(kLists);
|
||||
|
||||
using EnginePtr = std::shared_ptr<const AttAdblockEngine>;
|
||||
|
||||
EnginePtr wrap(AttAdblockEngine *engine)
|
||||
{
|
||||
if (!engine)
|
||||
return {};
|
||||
return EnginePtr(engine, [](const AttAdblockEngine *e) {
|
||||
att_adblock_engine_free(const_cast<AttAdblockEngine *>(e));
|
||||
});
|
||||
}
|
||||
|
||||
EnginePtr loadCache(const QString &path)
|
||||
{
|
||||
QFile file(path);
|
||||
if (!file.open(QIODevice::ReadOnly))
|
||||
return {};
|
||||
const QByteArray data = file.readAll();
|
||||
return wrap(att_adblock_engine_deserialize(
|
||||
reinterpret_cast<const uint8_t *>(data.constData()), size_t(data.size())));
|
||||
}
|
||||
|
||||
bool saveCache(const AttAdblockEngine *engine, const QString &path)
|
||||
{
|
||||
size_t len = 0;
|
||||
uint8_t *data = att_adblock_engine_serialize(engine, &len);
|
||||
if (!data)
|
||||
return false;
|
||||
|
||||
QDir().mkpath(QFileInfo(path).absolutePath());
|
||||
QSaveFile file(path);
|
||||
const bool ok = file.open(QIODevice::WriteOnly)
|
||||
&& file.write(reinterpret_cast<const char *>(data), qint64(len)) == qint64(len)
|
||||
&& file.commit();
|
||||
att_adblock_buffer_free(data, len);
|
||||
return ok;
|
||||
}
|
||||
|
||||
// The webRequest names adblock-rust matches $script, $image, ... options
|
||||
// against. Same mapping as qutebrowser's braveadblock.py.
|
||||
const char *requestType(QWebEngineUrlRequestInfo::ResourceType type)
|
||||
{
|
||||
switch (type) {
|
||||
case QWebEngineUrlRequestInfo::ResourceTypeMainFrame:
|
||||
return "main_frame";
|
||||
case QWebEngineUrlRequestInfo::ResourceTypeSubFrame:
|
||||
return "sub_frame";
|
||||
case QWebEngineUrlRequestInfo::ResourceTypeStylesheet:
|
||||
return "stylesheet";
|
||||
case QWebEngineUrlRequestInfo::ResourceTypeScript:
|
||||
return "script";
|
||||
case QWebEngineUrlRequestInfo::ResourceTypeImage:
|
||||
case QWebEngineUrlRequestInfo::ResourceTypeFavicon:
|
||||
return "image";
|
||||
case QWebEngineUrlRequestInfo::ResourceTypeFontResource:
|
||||
return "font";
|
||||
case QWebEngineUrlRequestInfo::ResourceTypeObject:
|
||||
return "object";
|
||||
case QWebEngineUrlRequestInfo::ResourceTypeMedia:
|
||||
return "media";
|
||||
case QWebEngineUrlRequestInfo::ResourceTypeXhr:
|
||||
return "xhr";
|
||||
case QWebEngineUrlRequestInfo::ResourceTypePing:
|
||||
return "ping";
|
||||
case QWebEngineUrlRequestInfo::ResourceTypeCspReport:
|
||||
return "csp_report";
|
||||
case QWebEngineUrlRequestInfo::ResourceTypeWebSocket:
|
||||
return "websocket";
|
||||
default:
|
||||
return "other";
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
struct AdBlocker::Download
|
||||
{
|
||||
QByteArray lists[kListCount];
|
||||
int remaining = kListCount;
|
||||
int failed = 0;
|
||||
};
|
||||
|
||||
AdBlocker::AdBlocker(QObject *parent)
|
||||
: QWebEngineUrlRequestInterceptor(parent)
|
||||
, m_network(new QNetworkAccessManager(this))
|
||||
, m_cachePath(QStandardPaths::writableLocation(QStandardPaths::AppDataLocation)
|
||||
+ QStringLiteral("/adblock-cache.dat"))
|
||||
{
|
||||
}
|
||||
|
||||
void AdBlocker::interceptRequest(QWebEngineUrlRequestInfo &info)
|
||||
{
|
||||
if (!m_enabled)
|
||||
return;
|
||||
|
||||
const QUrl url = info.requestUrl();
|
||||
const QString scheme = url.scheme();
|
||||
if (scheme != QLatin1String("https") && scheme != QLatin1String("http")
|
||||
&& scheme != QLatin1String("wss") && scheme != QLatin1String("ws"))
|
||||
return;
|
||||
|
||||
const EnginePtr current = engine();
|
||||
if (!current)
|
||||
return;
|
||||
|
||||
const QByteArray requestUrl = url.toEncoded();
|
||||
const QByteArray sourceUrl = info.firstPartyUrl().toEncoded();
|
||||
const QByteArray method = info.requestMethod();
|
||||
|
||||
if (att_adblock_should_block(current.get(), requestUrl.constData(), sourceUrl.constData(),
|
||||
requestType(info.resourceType()), method.constData()))
|
||||
info.block(true);
|
||||
}
|
||||
|
||||
void AdBlocker::start()
|
||||
{
|
||||
auto *watcher = new QFutureWatcher<EnginePtr>(this);
|
||||
connect(watcher, &QFutureWatcher<EnginePtr>::finished, this, [this, watcher] {
|
||||
watcher->deleteLater();
|
||||
if (EnginePtr cached = watcher->result())
|
||||
setEngine(std::move(cached));
|
||||
else
|
||||
update(); // First run, or a cache from an incompatible adblock-rust.
|
||||
});
|
||||
watcher->setFuture(QtConcurrent::run(loadCache, m_cachePath));
|
||||
}
|
||||
|
||||
void AdBlocker::update()
|
||||
{
|
||||
if (m_updating) {
|
||||
emit message(QStringLiteral("Adblock update already running"), false);
|
||||
return;
|
||||
}
|
||||
m_updating = true;
|
||||
emit message(QStringLiteral("Downloading adblock lists..."), false);
|
||||
|
||||
auto download = std::make_shared<Download>();
|
||||
for (int i = 0; i < kListCount; ++i) {
|
||||
QNetworkReply *reply = m_network->get(QNetworkRequest(QUrl(QString::fromLatin1(kLists[i]))));
|
||||
connect(reply, &QNetworkReply::finished, this, [this, reply, download, i] {
|
||||
reply->deleteLater();
|
||||
if (reply->error() == QNetworkReply::NoError)
|
||||
download->lists[i] = reply->readAll();
|
||||
else
|
||||
download->failed++;
|
||||
if (--download->remaining == 0)
|
||||
compile(download);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void AdBlocker::compile(const std::shared_ptr<Download> &download)
|
||||
{
|
||||
if (download->failed == kListCount) {
|
||||
m_updating = false;
|
||||
emit message(QStringLiteral("Adblock update failed: could not download any list"), true);
|
||||
return;
|
||||
}
|
||||
|
||||
QByteArray rules;
|
||||
for (const QByteArray &list : download->lists)
|
||||
rules += list + '\n';
|
||||
|
||||
// Compiling EasyList takes a moment, so keep it off the GUI thread.
|
||||
auto *watcher = new QFutureWatcher<EnginePtr>(this);
|
||||
connect(watcher, &QFutureWatcher<EnginePtr>::finished, this, [this, watcher, download] {
|
||||
watcher->deleteLater();
|
||||
m_updating = false;
|
||||
EnginePtr compiled = watcher->result();
|
||||
if (!compiled) {
|
||||
emit message(QStringLiteral("Adblock update failed: could not compile the lists"), true);
|
||||
return;
|
||||
}
|
||||
setEngine(std::move(compiled));
|
||||
if (download->failed > 0)
|
||||
emit message(QStringLiteral("Adblock updated, but %1 of %2 lists failed to download")
|
||||
.arg(download->failed)
|
||||
.arg(kListCount),
|
||||
true);
|
||||
else
|
||||
emit message(QStringLiteral("Adblock lists updated"), false);
|
||||
});
|
||||
watcher->setFuture(QtConcurrent::run([rules, path = m_cachePath] {
|
||||
EnginePtr engine = wrap(att_adblock_engine_from_rules(
|
||||
reinterpret_cast<const uint8_t *>(rules.constData()), size_t(rules.size())));
|
||||
if (engine)
|
||||
saveCache(engine.get(), path);
|
||||
return engine;
|
||||
}));
|
||||
}
|
||||
|
||||
void AdBlocker::setEngine(EnginePtr engine)
|
||||
{
|
||||
const std::lock_guard lock(m_engineMutex);
|
||||
m_engine = std::move(engine);
|
||||
}
|
||||
|
||||
AdBlocker::EnginePtr AdBlocker::engine() const
|
||||
{
|
||||
const std::lock_guard lock(m_engineMutex);
|
||||
return m_engine;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
#pragma once
|
||||
|
||||
#include <QString>
|
||||
#include <QWebEngineUrlRequestInterceptor>
|
||||
|
||||
#include <atomic>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
|
||||
struct AttAdblockEngine;
|
||||
class QNetworkAccessManager;
|
||||
|
||||
// Network-level ad blocking with Brave's adblock-rust (the adblock/ crate),
|
||||
// wired up the way qutebrowser does it: a request interceptor on the profile
|
||||
// asks the engine about every request and blocks the matches. There is no
|
||||
// cosmetic (element hiding) filtering.
|
||||
//
|
||||
// The compiled engine is cached on disk, so startup only deserializes it;
|
||||
// update() re-downloads the filter lists and recompiles off the GUI thread.
|
||||
class AdBlocker : public QWebEngineUrlRequestInterceptor
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit AdBlocker(QObject *parent = nullptr);
|
||||
|
||||
// May run on QtWebEngine's IO thread.
|
||||
void interceptRequest(QWebEngineUrlRequestInfo &info) override;
|
||||
|
||||
// Loads the cached engine, or runs update() if there is none yet.
|
||||
void start();
|
||||
void update();
|
||||
|
||||
bool isEnabled() const { return m_enabled; }
|
||||
void setEnabled(bool enabled) { m_enabled = enabled; }
|
||||
|
||||
signals:
|
||||
void message(const QString &text, bool isError);
|
||||
|
||||
private:
|
||||
using EnginePtr = std::shared_ptr<const AttAdblockEngine>;
|
||||
|
||||
struct Download;
|
||||
void compile(const std::shared_ptr<Download> &download);
|
||||
void setEngine(EnginePtr engine);
|
||||
EnginePtr engine() const;
|
||||
|
||||
QNetworkAccessManager *m_network;
|
||||
QString m_cachePath;
|
||||
bool m_updating = false;
|
||||
std::atomic_bool m_enabled = true;
|
||||
|
||||
// interceptRequest copies the pointer under the lock, so an update can swap
|
||||
// in a new engine while a check on the old one is still running.
|
||||
mutable std::mutex m_engineMutex;
|
||||
EnginePtr m_engine;
|
||||
};
|
||||
@@ -0,0 +1,410 @@
|
||||
#include "browserwindow.h"
|
||||
|
||||
#include "adblock.h"
|
||||
#include "commandline.h"
|
||||
#include "hints.h"
|
||||
#include "statusbar.h"
|
||||
#include "urlutil.h"
|
||||
|
||||
#include <QApplication>
|
||||
#include <QClipboard>
|
||||
#include <QEvent>
|
||||
#include <QKeyEvent>
|
||||
#include <QMouseEvent>
|
||||
#include <QVBoxLayout>
|
||||
#include <QWebEnginePage>
|
||||
#include <QWebEngineScript>
|
||||
#include <QWebEngineView>
|
||||
#include <QWidget>
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr int kScrollStep = 60;
|
||||
constexpr double kZoomStep = 0.1;
|
||||
|
||||
} // namespace
|
||||
|
||||
BrowserWindow::BrowserWindow(QWebEngineProfile *profile, AdBlocker *adBlocker,
|
||||
const QUrl &startUrl, QWidget *parent)
|
||||
: QMainWindow(parent)
|
||||
, m_adBlocker(adBlocker)
|
||||
, m_view(new QWebEngineView(profile, this))
|
||||
, m_status(new StatusBar(this))
|
||||
, m_commandLine(new CommandLine(this))
|
||||
{
|
||||
setupUi();
|
||||
connectView();
|
||||
|
||||
connect(m_commandLine, &CommandLine::commandEntered, this, [this](const QString &c) {
|
||||
setMode(Mode::Normal);
|
||||
runCommand(c);
|
||||
});
|
||||
connect(m_commandLine, &CommandLine::cancelled, this, [this] {
|
||||
setMode(Mode::Normal);
|
||||
});
|
||||
connect(m_adBlocker, &AdBlocker::message, m_status, &StatusBar::showMessage);
|
||||
|
||||
qApp->installEventFilter(this);
|
||||
|
||||
setMode(Mode::Normal);
|
||||
m_view->setUrl(startUrl);
|
||||
focusView();
|
||||
}
|
||||
|
||||
void BrowserWindow::setupUi()
|
||||
{
|
||||
setWindowTitle(QStringLiteral("att"));
|
||||
resize(1280, 800);
|
||||
|
||||
auto *central = new QWidget(this);
|
||||
auto *layout = new QVBoxLayout(central);
|
||||
layout->setContentsMargins(0, 0, 0, 0);
|
||||
layout->setSpacing(0);
|
||||
layout->addWidget(m_view, 1);
|
||||
layout->addWidget(m_status);
|
||||
layout->addWidget(m_commandLine);
|
||||
setCentralWidget(central);
|
||||
|
||||
m_commandLine->hide();
|
||||
}
|
||||
|
||||
void BrowserWindow::connectView()
|
||||
{
|
||||
connect(m_view, &QWebEngineView::urlChanged, this, [this](const QUrl &url) {
|
||||
m_status->setUrl(url.toDisplayString());
|
||||
// The hints belong to the page that is going away.
|
||||
if (m_mode == Mode::Hint)
|
||||
setMode(Mode::Normal);
|
||||
});
|
||||
connect(m_view, &QWebEngineView::loadProgress, m_status, &StatusBar::setProgress);
|
||||
connect(m_view, &QWebEngineView::titleChanged, this, [this](const QString &title) {
|
||||
setWindowTitle(title.isEmpty() ? QStringLiteral("att")
|
||||
: QStringLiteral("%1 - att").arg(title));
|
||||
});
|
||||
connect(m_view, &QWebEngineView::loadFinished, this, [this](bool ok) {
|
||||
m_status->setProgress(100);
|
||||
if (!ok)
|
||||
m_status->showMessage(QStringLiteral("Failed to load page"), true);
|
||||
});
|
||||
}
|
||||
|
||||
void BrowserWindow::setMode(Mode mode)
|
||||
{
|
||||
if (m_mode == Mode::Hint && mode != Mode::Hint)
|
||||
runHintScript(hints::clearScript());
|
||||
|
||||
m_mode = mode;
|
||||
m_pendingKeys.clear();
|
||||
|
||||
switch (mode) {
|
||||
case Mode::Normal:
|
||||
m_status->setMode(QString());
|
||||
m_status->show();
|
||||
focusView();
|
||||
break;
|
||||
case Mode::Insert:
|
||||
m_status->setMode(QStringLiteral("insert"));
|
||||
m_status->show();
|
||||
focusView();
|
||||
break;
|
||||
case Mode::Command:
|
||||
m_status->hide();
|
||||
break;
|
||||
case Mode::Hint:
|
||||
m_status->setMode(QStringLiteral("hint"));
|
||||
m_status->show();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
bool BrowserWindow::eventFilter(QObject *watched, QEvent *event)
|
||||
{
|
||||
if (event->type() != QEvent::KeyPress || !isAncestorOf(qobject_cast<QWidget *>(watched)))
|
||||
return QMainWindow::eventFilter(watched, event);
|
||||
|
||||
auto *key = static_cast<QKeyEvent *>(event);
|
||||
|
||||
if (m_mode == Mode::Command)
|
||||
return QMainWindow::eventFilter(watched, event);
|
||||
|
||||
if (key->key() == Qt::Key_Escape
|
||||
|| (key->key() == Qt::Key_BracketLeft && key->modifiers() == Qt::ControlModifier)) {
|
||||
setMode(Mode::Normal);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (m_mode == Mode::Insert)
|
||||
return QMainWindow::eventFilter(watched, event);
|
||||
|
||||
if (m_mode == Mode::Hint) {
|
||||
handleHintModeKey(key);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (handleNormalModeKey(key))
|
||||
return true;
|
||||
|
||||
return QMainWindow::eventFilter(watched, event);
|
||||
}
|
||||
|
||||
bool BrowserWindow::handleNormalModeKey(QKeyEvent *event)
|
||||
{
|
||||
const QString text = event->text();
|
||||
const Qt::KeyboardModifiers mods = event->modifiers();
|
||||
|
||||
if (mods.testFlag(Qt::ControlModifier)) {
|
||||
switch (event->key()) {
|
||||
case Qt::Key_D:
|
||||
scrollPages(0.5);
|
||||
return true;
|
||||
case Qt::Key_U:
|
||||
scrollPages(-0.5);
|
||||
return true;
|
||||
case Qt::Key_F:
|
||||
scrollPages(1.0);
|
||||
return true;
|
||||
case Qt::Key_B:
|
||||
scrollPages(-1.0);
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (mods.testFlag(Qt::AltModifier) || mods.testFlag(Qt::MetaModifier))
|
||||
return false;
|
||||
|
||||
// Two-key sequences starting with "g".
|
||||
if (!m_pendingKeys.isEmpty()) {
|
||||
const QString sequence = m_pendingKeys + text;
|
||||
m_pendingKeys.clear();
|
||||
if (sequence == QLatin1String("gg")) {
|
||||
scrollToEnd(false);
|
||||
return true;
|
||||
}
|
||||
return true; // Swallow the unknown sequence rather than leaking it.
|
||||
}
|
||||
|
||||
if (text == QLatin1String("g")) {
|
||||
m_pendingKeys = text;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (text == QLatin1String("o")) {
|
||||
m_commandLine->openWith(QStringLiteral(":open "));
|
||||
setMode(Mode::Command);
|
||||
return true;
|
||||
}
|
||||
if (text == QLatin1String("O")) {
|
||||
// Edit the current URL.
|
||||
m_commandLine->openWith(QStringLiteral(":open ") + m_view->url().toString());
|
||||
setMode(Mode::Command);
|
||||
return true;
|
||||
}
|
||||
if (text == QLatin1String(":")) {
|
||||
m_commandLine->openWith(QStringLiteral(":"));
|
||||
setMode(Mode::Command);
|
||||
return true;
|
||||
}
|
||||
if (text == QLatin1String("i")) {
|
||||
setMode(Mode::Insert);
|
||||
return true;
|
||||
}
|
||||
if (text == QLatin1String("f")) {
|
||||
startHints();
|
||||
return true;
|
||||
}
|
||||
if (text == QLatin1String("r")) {
|
||||
m_view->reload();
|
||||
return true;
|
||||
}
|
||||
if (text == QLatin1String("R")) {
|
||||
m_view->triggerPageAction(QWebEnginePage::ReloadAndBypassCache);
|
||||
return true;
|
||||
}
|
||||
if (text == QLatin1String("H")) {
|
||||
m_view->back();
|
||||
return true;
|
||||
}
|
||||
if (text == QLatin1String("L")) {
|
||||
m_view->forward();
|
||||
return true;
|
||||
}
|
||||
if (text == QLatin1String("j")) {
|
||||
scrollBy(0, kScrollStep);
|
||||
return true;
|
||||
}
|
||||
if (text == QLatin1String("k")) {
|
||||
scrollBy(0, -kScrollStep);
|
||||
return true;
|
||||
}
|
||||
if (text == QLatin1String("h")) {
|
||||
scrollBy(-kScrollStep, 0);
|
||||
return true;
|
||||
}
|
||||
if (text == QLatin1String("l")) {
|
||||
scrollBy(kScrollStep, 0);
|
||||
return true;
|
||||
}
|
||||
if (text == QLatin1String("G")) {
|
||||
scrollToEnd(true);
|
||||
return true;
|
||||
}
|
||||
if (text == QLatin1String("y")) {
|
||||
const QString url = m_view->url().toString();
|
||||
qApp->clipboard()->setText(url);
|
||||
m_status->showMessage(QStringLiteral("Yanked %1").arg(url));
|
||||
return true;
|
||||
}
|
||||
if (text == QLatin1String("+")) {
|
||||
m_view->setZoomFactor(m_view->zoomFactor() + kZoomStep);
|
||||
return true;
|
||||
}
|
||||
if (text == QLatin1String("-")) {
|
||||
m_view->setZoomFactor(qMax(0.25, m_view->zoomFactor() - kZoomStep));
|
||||
return true;
|
||||
}
|
||||
if (text == QLatin1String("=")) {
|
||||
m_view->setZoomFactor(1.0);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
void BrowserWindow::handleHintModeKey(QKeyEvent *event)
|
||||
{
|
||||
if (event->key() == Qt::Key_Backspace) {
|
||||
runHintScript(hints::backspaceScript());
|
||||
return;
|
||||
}
|
||||
|
||||
const QString text = event->text().toLower();
|
||||
if (text.size() == 1 && hints::chars().contains(text))
|
||||
runHintScript(hints::keyScript(text.at(0)));
|
||||
// Anything else is swallowed, so a stray key can't trigger a page shortcut.
|
||||
}
|
||||
|
||||
void BrowserWindow::startHints()
|
||||
{
|
||||
setMode(Mode::Hint);
|
||||
runHintScript(hints::showScript());
|
||||
}
|
||||
|
||||
void BrowserWindow::runHintScript(const QString &script)
|
||||
{
|
||||
m_view->page()->runJavaScript(script, QWebEngineScript::ApplicationWorld,
|
||||
[this](const QVariant &result) {
|
||||
handleHintResult(result.toMap());
|
||||
});
|
||||
}
|
||||
|
||||
void BrowserWindow::handleHintResult(const QVariantMap &result)
|
||||
{
|
||||
// Results can arrive after the user already left hint mode, e.g. a key that
|
||||
// was still in flight when Escape was pressed.
|
||||
if (m_mode != Mode::Hint)
|
||||
return;
|
||||
|
||||
const QString state = result.value(QStringLiteral("state")).toString();
|
||||
|
||||
if (state == QLatin1String("shown")) {
|
||||
if (result.value(QStringLiteral("count")).toInt() == 0) {
|
||||
setMode(Mode::Normal);
|
||||
m_status->showMessage(QStringLiteral("No elements found"), true);
|
||||
}
|
||||
} else if (state == QLatin1String("follow")) {
|
||||
const QPointF pos(result.value(QStringLiteral("x")).toDouble(),
|
||||
result.value(QStringLiteral("y")).toDouble());
|
||||
setMode(Mode::Normal);
|
||||
clickAt(pos * m_view->zoomFactor());
|
||||
if (result.value(QStringLiteral("editable")).toBool())
|
||||
setMode(Mode::Insert);
|
||||
} else if (state == QLatin1String("gone")) {
|
||||
setMode(Mode::Normal);
|
||||
m_status->showMessage(QStringLiteral("Element went away"), true);
|
||||
} else if (state == QLatin1String("none") || state.isEmpty()) {
|
||||
// The script failed, or the page navigated and took the hints with it.
|
||||
setMode(Mode::Normal);
|
||||
}
|
||||
}
|
||||
|
||||
void BrowserWindow::clickAt(const QPointF &pos)
|
||||
{
|
||||
// A real mouse click rather than element.click(): plenty of sites only
|
||||
// listen for mousedown/pointerdown, and it gives the page a trusted event.
|
||||
QWidget *target = m_view->focusProxy();
|
||||
if (!target)
|
||||
return;
|
||||
|
||||
const QPointF global = target->mapToGlobal(pos);
|
||||
QMouseEvent press(QEvent::MouseButtonPress, pos, global, Qt::LeftButton,
|
||||
Qt::LeftButton, Qt::NoModifier);
|
||||
QMouseEvent release(QEvent::MouseButtonRelease, pos, global, Qt::LeftButton,
|
||||
Qt::NoButton, Qt::NoModifier);
|
||||
QCoreApplication::sendEvent(target, &press);
|
||||
QCoreApplication::sendEvent(target, &release);
|
||||
}
|
||||
|
||||
void BrowserWindow::runCommand(const QString &command)
|
||||
{
|
||||
const QString name = command.section(QLatin1Char(' '), 0, 0);
|
||||
const QString args = command.section(QLatin1Char(' '), 1).trimmed();
|
||||
|
||||
if (name == QLatin1String("open") || name == QLatin1String("o")) {
|
||||
openUrl(args);
|
||||
} else if (name == QLatin1String("quit") || name == QLatin1String("q")) {
|
||||
close();
|
||||
} else if (name == QLatin1String("reload")) {
|
||||
m_view->reload();
|
||||
} else if (name == QLatin1String("back")) {
|
||||
m_view->back();
|
||||
} else if (name == QLatin1String("forward")) {
|
||||
m_view->forward();
|
||||
} else if (name == QLatin1String("stop")) {
|
||||
m_view->stop();
|
||||
} else if (name == QLatin1String("adblock-update")) {
|
||||
m_adBlocker->update();
|
||||
} else if (name == QLatin1String("adblock-toggle")) {
|
||||
m_adBlocker->setEnabled(!m_adBlocker->isEnabled());
|
||||
m_status->showMessage(m_adBlocker->isEnabled() ? QStringLiteral("Adblock enabled")
|
||||
: QStringLiteral("Adblock disabled"));
|
||||
} else {
|
||||
m_status->showMessage(QStringLiteral("Unknown command: %1").arg(name), true);
|
||||
}
|
||||
}
|
||||
|
||||
void BrowserWindow::openUrl(const QString &input)
|
||||
{
|
||||
const QUrl url = urlutil::fromUserInput(input);
|
||||
if (!url.isValid() || url.isEmpty()) {
|
||||
m_status->showMessage(QStringLiteral("Invalid URL: %1").arg(input), true);
|
||||
return;
|
||||
}
|
||||
m_view->setUrl(url);
|
||||
focusView();
|
||||
}
|
||||
|
||||
void BrowserWindow::scrollBy(int dx, int dy)
|
||||
{
|
||||
m_view->page()->runJavaScript(
|
||||
QStringLiteral("window.scrollBy(%1, %2);").arg(dx).arg(dy));
|
||||
}
|
||||
|
||||
void BrowserWindow::scrollPages(double factor)
|
||||
{
|
||||
m_view->page()->runJavaScript(
|
||||
QStringLiteral("window.scrollBy(0, window.innerHeight * %1);").arg(factor));
|
||||
}
|
||||
|
||||
void BrowserWindow::scrollToEnd(bool bottom)
|
||||
{
|
||||
m_view->page()->runJavaScript(
|
||||
bottom ? QStringLiteral("window.scrollTo(0, document.body.scrollHeight);")
|
||||
: QStringLiteral("window.scrollTo(0, 0);"));
|
||||
}
|
||||
|
||||
void BrowserWindow::focusView()
|
||||
{
|
||||
m_view->setFocus(Qt::OtherFocusReason);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
#pragma once
|
||||
|
||||
#include <QMainWindow>
|
||||
#include <QString>
|
||||
#include <QUrl>
|
||||
#include <QVariantMap>
|
||||
|
||||
class AdBlocker;
|
||||
class CommandLine;
|
||||
class StatusBar;
|
||||
class QWebEngineProfile;
|
||||
class QWebEngineView;
|
||||
|
||||
class BrowserWindow : public QMainWindow
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
enum class Mode {
|
||||
Normal, // keys are browser bindings
|
||||
Insert, // keys go to the page, for typing into forms
|
||||
Command, // keys go to the command line
|
||||
Hint, // keys pick a link label
|
||||
};
|
||||
|
||||
BrowserWindow(QWebEngineProfile *profile, AdBlocker *adBlocker, const QUrl &startUrl,
|
||||
QWidget *parent = nullptr);
|
||||
|
||||
void setMode(Mode mode);
|
||||
|
||||
protected:
|
||||
// Key handling lives in an application-wide filter because QtWebEngine
|
||||
// delivers key events straight to its internal render widget, which never
|
||||
// gives the parent window a chance to see them first.
|
||||
bool eventFilter(QObject *watched, QEvent *event) override;
|
||||
|
||||
private:
|
||||
void setupUi();
|
||||
void connectView();
|
||||
|
||||
bool handleNormalModeKey(QKeyEvent *event);
|
||||
void handleHintModeKey(QKeyEvent *event);
|
||||
void startHints();
|
||||
void runHintScript(const QString &script);
|
||||
void handleHintResult(const QVariantMap &result);
|
||||
void clickAt(const QPointF &pos);
|
||||
void runCommand(const QString &command);
|
||||
void openUrl(const QString &input);
|
||||
void scrollBy(int dx, int dy);
|
||||
void scrollPages(double factor);
|
||||
void scrollToEnd(bool bottom);
|
||||
void focusView();
|
||||
|
||||
AdBlocker *m_adBlocker;
|
||||
QWebEngineView *m_view;
|
||||
StatusBar *m_status;
|
||||
CommandLine *m_commandLine;
|
||||
|
||||
Mode m_mode = Mode::Normal;
|
||||
QString m_pendingKeys;
|
||||
};
|
||||
@@ -0,0 +1,117 @@
|
||||
#include "commandline.h"
|
||||
|
||||
#include <QKeyEvent>
|
||||
#include <QSignalBlocker>
|
||||
|
||||
CommandLine::CommandLine(QWidget *parent)
|
||||
: QLineEdit(parent)
|
||||
{
|
||||
setFrame(false);
|
||||
setObjectName(QStringLiteral("commandLine"));
|
||||
connect(this, &QLineEdit::returnPressed, this, &CommandLine::onReturnPressed);
|
||||
connect(this, &QLineEdit::textChanged, this, [this](const QString &text) {
|
||||
// The user edited the leading ":" away - treat that as an abort.
|
||||
if (isVisible() && !text.startsWith(QLatin1Char(':')))
|
||||
cancel();
|
||||
});
|
||||
}
|
||||
|
||||
void CommandLine::openWith(const QString &text)
|
||||
{
|
||||
m_historyPos = m_history.size();
|
||||
setTextSilently(text.startsWith(QLatin1Char(':')) ? text
|
||||
: QLatin1Char(':') + text);
|
||||
show();
|
||||
setFocus(Qt::OtherFocusReason);
|
||||
deselect();
|
||||
end(false);
|
||||
}
|
||||
|
||||
void CommandLine::keyPressEvent(QKeyEvent *event)
|
||||
{
|
||||
switch (event->key()) {
|
||||
case Qt::Key_Escape:
|
||||
cancel();
|
||||
return;
|
||||
case Qt::Key_Up:
|
||||
recallHistory(-1);
|
||||
return;
|
||||
case Qt::Key_Down:
|
||||
recallHistory(1);
|
||||
return;
|
||||
case Qt::Key_Home:
|
||||
// Never let the cursor sit to the left of the ":".
|
||||
setCursorPosition(1);
|
||||
return;
|
||||
case Qt::Key_C:
|
||||
case Qt::Key_BracketLeft:
|
||||
if (event->modifiers().testFlag(Qt::ControlModifier)) {
|
||||
cancel();
|
||||
return;
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
QLineEdit::keyPressEvent(event);
|
||||
|
||||
if (cursorPosition() == 0)
|
||||
setCursorPosition(1);
|
||||
}
|
||||
|
||||
void CommandLine::focusOutEvent(QFocusEvent *event)
|
||||
{
|
||||
QLineEdit::focusOutEvent(event);
|
||||
if (isVisible())
|
||||
cancel();
|
||||
}
|
||||
|
||||
void CommandLine::onReturnPressed()
|
||||
{
|
||||
const QString command = text().mid(1).trimmed();
|
||||
|
||||
if (!command.isEmpty() && (m_history.isEmpty() || m_history.last() != command))
|
||||
m_history.append(command);
|
||||
m_historyPos = m_history.size();
|
||||
|
||||
dismiss();
|
||||
|
||||
if (command.isEmpty())
|
||||
emit cancelled();
|
||||
else
|
||||
emit commandEntered(command);
|
||||
}
|
||||
|
||||
void CommandLine::recallHistory(int direction)
|
||||
{
|
||||
if (m_history.isEmpty())
|
||||
return;
|
||||
|
||||
const int pos = qBound(0, m_historyPos + direction, m_history.size());
|
||||
if (pos == m_historyPos)
|
||||
return;
|
||||
m_historyPos = pos;
|
||||
|
||||
setTextSilently(pos == m_history.size() ? QStringLiteral(":")
|
||||
: QLatin1Char(':') + m_history.at(pos));
|
||||
end(false);
|
||||
}
|
||||
|
||||
void CommandLine::cancel()
|
||||
{
|
||||
dismiss();
|
||||
emit cancelled();
|
||||
}
|
||||
|
||||
void CommandLine::dismiss()
|
||||
{
|
||||
setTextSilently(QString());
|
||||
hide();
|
||||
}
|
||||
|
||||
void CommandLine::setTextSilently(const QString &text)
|
||||
{
|
||||
const QSignalBlocker blocker(this);
|
||||
setText(text);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
#pragma once
|
||||
|
||||
#include <QLineEdit>
|
||||
#include <QStringList>
|
||||
|
||||
// The bottom-of-the-window ":" prompt. Like qutebrowser's, the leading ":" is
|
||||
// part of the text: deleting it aborts the command.
|
||||
class CommandLine : public QLineEdit
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit CommandLine(QWidget *parent = nullptr);
|
||||
|
||||
// text should include the leading ":", e.g. ":open ".
|
||||
void openWith(const QString &text);
|
||||
|
||||
signals:
|
||||
// Emitted with the command only, i.e. without the leading ":".
|
||||
void commandEntered(const QString &command);
|
||||
void cancelled();
|
||||
|
||||
protected:
|
||||
void keyPressEvent(QKeyEvent *event) override;
|
||||
void focusOutEvent(QFocusEvent *event) override;
|
||||
|
||||
private:
|
||||
void onReturnPressed();
|
||||
void recallHistory(int direction);
|
||||
void cancel();
|
||||
void dismiss();
|
||||
void setTextSilently(const QString &text);
|
||||
|
||||
QStringList m_history;
|
||||
int m_historyPos = 0;
|
||||
};
|
||||
@@ -0,0 +1,75 @@
|
||||
#include "gpuworkaround.h"
|
||||
|
||||
#include <QByteArray>
|
||||
#include <QLatin1String>
|
||||
#include <QString>
|
||||
#include <QVersionNumber>
|
||||
#include <QtWebEngineCore/qtwebenginecoreglobal.h>
|
||||
|
||||
#include <cstdlib>
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr auto kChromiumFlagsVar = "QTWEBENGINE_CHROMIUM_FLAGS";
|
||||
constexpr auto kDisableVideoDecode = "--disable-accelerated-video-decode";
|
||||
|
||||
// First release carrying the multi-planar NativePixmap import fix.
|
||||
const QVersionNumber kFixedIn(6, 11, 2);
|
||||
|
||||
bool envIsSet(const char *name)
|
||||
{
|
||||
const char *value = std::getenv(name);
|
||||
return value != nullptr && *value != '\0';
|
||||
}
|
||||
|
||||
bool envIsTruthy(const char *name)
|
||||
{
|
||||
const char *value = std::getenv(name);
|
||||
if (value == nullptr || *value == '\0')
|
||||
return false;
|
||||
const QLatin1String s(value);
|
||||
return s != QLatin1String("0") && s != QLatin1String("false");
|
||||
}
|
||||
|
||||
// The runtime version, not QTWEBENGINECORE_VERSION: linking against a fixed
|
||||
// QtWebEngine should drop the workaround without rebuilding att.
|
||||
bool webEngineHasFix()
|
||||
{
|
||||
QString version = QString::fromLatin1(qWebEngineVersion());
|
||||
const QVersionNumber parsed = QVersionNumber::fromString(version);
|
||||
return !parsed.isNull() && parsed >= kFixedIn;
|
||||
}
|
||||
|
||||
// X11/GLX only ever imports BGRA8888 buffers, so it never hits the bug.
|
||||
bool onWayland()
|
||||
{
|
||||
const char *forced = std::getenv("QT_QPA_PLATFORM");
|
||||
if (forced != nullptr && *forced != '\0')
|
||||
return QLatin1String(forced).startsWith(QLatin1String("wayland"));
|
||||
return envIsSet("WAYLAND_DISPLAY");
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
namespace gpuworkaround {
|
||||
|
||||
void apply()
|
||||
{
|
||||
if (envIsTruthy("ATT_NO_GPU_WORKAROUND"))
|
||||
return;
|
||||
if (!onWayland() || webEngineHasFix())
|
||||
return;
|
||||
|
||||
QByteArray flags(qgetenv(kChromiumFlagsVar));
|
||||
|
||||
// Leave an explicit opinion about video decoding alone, whichever way it goes.
|
||||
if (flags.contains("accelerated-video-decode"))
|
||||
return;
|
||||
|
||||
if (!flags.isEmpty())
|
||||
flags.append(' ');
|
||||
flags.append(kDisableVideoDecode);
|
||||
qputenv(kChromiumFlagsVar, flags);
|
||||
}
|
||||
|
||||
} // namespace gpuworkaround
|
||||
@@ -0,0 +1,15 @@
|
||||
#pragma once
|
||||
|
||||
namespace gpuworkaround {
|
||||
|
||||
// Works around QTBUG-145344: on Wayland, QtWebEngine before 6.11.2 cannot import
|
||||
// the multi-planar (YUV420) buffers that VA-API hardware decoding hands it, so
|
||||
// video renders as black/flickering while the log fills with
|
||||
// "SharedImageManager::ProduceSkia: ... non-existent mailbox". Disables
|
||||
// accelerated video decoding only where the bug applies.
|
||||
//
|
||||
// Must be called before QApplication is constructed. Set ATT_NO_GPU_WORKAROUND=1
|
||||
// to skip it.
|
||||
void apply();
|
||||
|
||||
} // namespace gpuworkaround
|
||||
+217
@@ -0,0 +1,217 @@
|
||||
#include "hints.h"
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr auto kChars = "asdfghjkl";
|
||||
|
||||
// Installs window.__attHints and shows the labels. "@CHARS@" is substituted
|
||||
// with kChars before running.
|
||||
constexpr auto kShowScript = R"JS(
|
||||
(function () {
|
||||
'use strict';
|
||||
if (window.__attHints)
|
||||
window.__attHints.clear();
|
||||
|
||||
const CHARS = '@CHARS@';
|
||||
const SELECTOR = [
|
||||
'a[href]', 'area[href]', 'button', 'input:not([type=hidden])', 'select',
|
||||
'textarea', 'summary', 'label', '[onclick]', '[contenteditable=""]',
|
||||
'[contenteditable=true]', '[role=link]', '[role=button]',
|
||||
'[role=checkbox]', '[role=radio]', '[role=tab]', '[role=menuitem]',
|
||||
'[role=option]', '[role=switch]', '[tabindex]:not([tabindex="-1"])',
|
||||
].join(',');
|
||||
const TEXT_INPUTS = new Set(['', 'text', 'search', 'email', 'url', 'tel',
|
||||
'password', 'number', 'date', 'datetime-local',
|
||||
'month', 'time', 'week']);
|
||||
|
||||
function visibleRect(el) {
|
||||
const style = getComputedStyle(el);
|
||||
if (style.visibility !== 'visible' || parseFloat(style.opacity) === 0)
|
||||
return null;
|
||||
// getClientRects rather than getBoundingClientRect so a link wrapped
|
||||
// over two lines gets its label on the first line, not in the gap.
|
||||
for (const r of el.getClientRects()) {
|
||||
if (r.width < 1 || r.height < 1)
|
||||
continue;
|
||||
if (r.bottom <= 0 || r.right <= 0 || r.top >= innerHeight || r.left >= innerWidth)
|
||||
continue;
|
||||
return r;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isEditable(el) {
|
||||
if (el.isContentEditable || el.tagName === 'TEXTAREA')
|
||||
return true;
|
||||
return el.tagName === 'INPUT' && TEXT_INPUTS.has((el.getAttribute('type') || '').toLowerCase());
|
||||
}
|
||||
|
||||
// All labels have the same length, so none is a prefix of another.
|
||||
function makeLabels(count) {
|
||||
const n = CHARS.length;
|
||||
let len = 1;
|
||||
while (Math.pow(n, len) < count)
|
||||
len++;
|
||||
const labels = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
let label = '';
|
||||
for (let j = 0, k = i; j < len; j++, k = Math.floor(k / n))
|
||||
label = CHARS[k % n] + label;
|
||||
labels.push(label);
|
||||
}
|
||||
return labels;
|
||||
}
|
||||
|
||||
const targets = [];
|
||||
const seen = new Set();
|
||||
for (const el of document.querySelectorAll(SELECTOR)) {
|
||||
if (el.disabled || seen.has(el))
|
||||
continue;
|
||||
const rect = visibleRect(el);
|
||||
if (!rect)
|
||||
continue;
|
||||
// A <label> that wraps its input would otherwise get two hints.
|
||||
if (el.tagName === 'LABEL' && el.control && el.contains(el.control) && visibleRect(el.control))
|
||||
continue;
|
||||
seen.add(el);
|
||||
targets.push({ el, rect });
|
||||
}
|
||||
|
||||
// A shadow root keeps the page's stylesheets off the labels.
|
||||
const host = document.createElement('att-hints');
|
||||
host.style.cssText = 'all: initial; position: fixed; inset: 0; z-index: 2147483647; pointer-events: none;';
|
||||
const root = host.attachShadow({ mode: 'closed' });
|
||||
const style = document.createElement('style');
|
||||
style.textContent = `
|
||||
span.hint {
|
||||
position: absolute;
|
||||
padding: 0 2px;
|
||||
background: linear-gradient(to bottom, #fff785, #ffc542);
|
||||
border: 1px solid #c38a22;
|
||||
border-radius: 3px;
|
||||
color: #000;
|
||||
font: bold 12px monospace;
|
||||
line-height: normal;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
span.typed { color: #8a6d00; }
|
||||
`;
|
||||
root.appendChild(style);
|
||||
|
||||
const labels = makeLabels(targets.length);
|
||||
const hints = targets.map((t, i) => {
|
||||
const span = document.createElement('span');
|
||||
span.className = 'hint';
|
||||
span.style.left = Math.max(0, t.rect.left) + 'px';
|
||||
span.style.top = Math.max(0, t.rect.top) + 'px';
|
||||
span.textContent = labels[i];
|
||||
root.appendChild(span);
|
||||
return { el: t.el, label: labels[i], span };
|
||||
});
|
||||
(document.documentElement || document.body).appendChild(host);
|
||||
|
||||
let typed = '';
|
||||
|
||||
function render() {
|
||||
for (const h of hints) {
|
||||
const match = h.label.startsWith(typed);
|
||||
h.span.style.display = match ? '' : 'none';
|
||||
if (!match)
|
||||
continue;
|
||||
h.span.textContent = '';
|
||||
if (typed) {
|
||||
const t = document.createElement('span');
|
||||
t.className = 'typed';
|
||||
t.textContent = typed;
|
||||
h.span.appendChild(t);
|
||||
}
|
||||
h.span.appendChild(document.createTextNode(h.label.slice(typed.length)));
|
||||
}
|
||||
}
|
||||
|
||||
function follow(h) {
|
||||
const el = h.el;
|
||||
if (!el.isConnected)
|
||||
return { state: 'gone' };
|
||||
el.scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
||||
const r = visibleRect(el) || el.getBoundingClientRect();
|
||||
// att has no tabs, so a link meant for a new window would go nowhere.
|
||||
if ((el.tagName === 'A' || el.tagName === 'AREA') && el.target && !/^_(self|top|parent)$/i.test(el.target))
|
||||
el.target = '_self';
|
||||
const x = Math.min(Math.max(r.left + r.width / 2, 0), innerWidth - 1);
|
||||
const y = Math.min(Math.max(r.top + r.height / 2, 0), innerHeight - 1);
|
||||
return { state: 'follow', x, y, editable: isEditable(el) };
|
||||
}
|
||||
|
||||
window.__attHints = {
|
||||
key(ch) {
|
||||
const candidate = typed + ch;
|
||||
const matches = hints.filter(h => h.label.startsWith(candidate));
|
||||
if (matches.length === 0)
|
||||
return { state: 'nomatch' };
|
||||
typed = candidate;
|
||||
if (matches.length === 1) {
|
||||
this.clear();
|
||||
return follow(matches[0]);
|
||||
}
|
||||
render();
|
||||
return { state: 'partial' };
|
||||
},
|
||||
backspace() {
|
||||
typed = typed.slice(0, -1);
|
||||
render();
|
||||
return { state: 'partial' };
|
||||
},
|
||||
clear() {
|
||||
host.remove();
|
||||
if (window.__attHints === this)
|
||||
delete window.__attHints;
|
||||
return { state: 'none' };
|
||||
},
|
||||
};
|
||||
|
||||
if (hints.length === 0)
|
||||
window.__attHints.clear();
|
||||
return { state: 'shown', count: hints.length };
|
||||
})();
|
||||
)JS";
|
||||
|
||||
QString callScript(const QString &call)
|
||||
{
|
||||
return QStringLiteral("window.__attHints ? window.__attHints.%1 : { state: 'none' };")
|
||||
.arg(call);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
namespace hints {
|
||||
|
||||
QString chars()
|
||||
{
|
||||
return QString::fromLatin1(kChars);
|
||||
}
|
||||
|
||||
QString showScript()
|
||||
{
|
||||
return QString::fromUtf8(kShowScript).replace(QLatin1String("@CHARS@"), chars());
|
||||
}
|
||||
|
||||
QString keyScript(QChar key)
|
||||
{
|
||||
// Only ever called with one of chars(), so no quoting is needed.
|
||||
return callScript(QStringLiteral("key('%1')").arg(key));
|
||||
}
|
||||
|
||||
QString backspaceScript()
|
||||
{
|
||||
return callScript(QStringLiteral("backspace()"));
|
||||
}
|
||||
|
||||
QString clearScript()
|
||||
{
|
||||
return callScript(QStringLiteral("clear()"));
|
||||
}
|
||||
|
||||
} // namespace hints
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
#pragma once
|
||||
|
||||
#include <QString>
|
||||
|
||||
// Hint mode ("f"): JavaScript that labels the clickable elements in the
|
||||
// viewport and narrows them down as the user types. All of it runs in
|
||||
// QWebEngineScript::ApplicationWorld, so the page's own scripts can neither see
|
||||
// nor tamper with the hint state; only the DOM is shared.
|
||||
//
|
||||
// Every script evaluates to a result map with a "state" key:
|
||||
// "shown" - hints are on screen; "count" says how many
|
||||
// "partial" - more than one hint still matches what was typed
|
||||
// "nomatch" - the key matched nothing and was ignored
|
||||
// "follow" - one hint left; "x"/"y" is the point to click, in CSS pixels
|
||||
// relative to the viewport, and "editable" says whether it is a
|
||||
// text field
|
||||
// "gone" - the chosen element disappeared from the page
|
||||
// "none" - there is no hint session to act on
|
||||
namespace hints {
|
||||
|
||||
// The characters labels are built from - qutebrowser's home-row default.
|
||||
QString chars();
|
||||
|
||||
QString showScript();
|
||||
QString keyScript(QChar key);
|
||||
QString backspaceScript();
|
||||
QString clearScript();
|
||||
|
||||
} // namespace hints
|
||||
@@ -0,0 +1,83 @@
|
||||
#include "adblock.h"
|
||||
#include "browserwindow.h"
|
||||
#include "gpuworkaround.h"
|
||||
#include "urlutil.h"
|
||||
|
||||
#include <QApplication>
|
||||
#include <QCommandLineParser>
|
||||
#include <QWebEngineProfile>
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr auto kDefaultUrl = "https://duckduckgo.com/";
|
||||
|
||||
constexpr auto kStyleSheet = R"(
|
||||
QWidget#statusBar, QLineEdit#commandLine {
|
||||
background-color: #1c1c1c;
|
||||
color: #d8d8d8;
|
||||
font-family: monospace;
|
||||
}
|
||||
QLabel#statusMode {
|
||||
color: #7fbf7f;
|
||||
font-weight: bold;
|
||||
}
|
||||
QLabel#statusUrl[error="true"] {
|
||||
color: #ff6b6b;
|
||||
}
|
||||
QLabel#statusProgress {
|
||||
color: #8a8a8a;
|
||||
}
|
||||
QLineEdit#commandLine {
|
||||
padding: 1px 4px;
|
||||
selection-background-color: #445566;
|
||||
}
|
||||
)";
|
||||
|
||||
} // namespace
|
||||
|
||||
int main(int argc, char *argv[])
|
||||
{
|
||||
// Chromium reads its flags out of the environment, so this has to happen
|
||||
// before QtWebEngine gets a chance to initialize.
|
||||
gpuworkaround::apply();
|
||||
|
||||
QApplication app(argc, argv);
|
||||
QCoreApplication::setApplicationName(QStringLiteral("att"));
|
||||
QCoreApplication::setApplicationVersion(QStringLiteral("0.1.0"));
|
||||
QCoreApplication::setOrganizationName(QStringLiteral("att"));
|
||||
|
||||
QCommandLineParser parser;
|
||||
parser.setApplicationDescription(
|
||||
QStringLiteral("att - a minimal, keyboard-driven browser"));
|
||||
parser.addHelpOption();
|
||||
parser.addVersionOption();
|
||||
parser.addPositionalArgument(QStringLiteral("url"), QStringLiteral("URL to open."));
|
||||
parser.process(app);
|
||||
|
||||
app.setStyleSheet(QString::fromUtf8(kStyleSheet));
|
||||
|
||||
const QStringList args = parser.positionalArguments();
|
||||
const QUrl startUrl = args.isEmpty() ? QUrl(QString::fromLatin1(kDefaultUrl))
|
||||
: urlutil::fromUserInput(args.first());
|
||||
|
||||
// Qt 6's default profile is off-the-record, so nothing - cookies, logins,
|
||||
// local storage - would survive a restart. A named profile lives under
|
||||
// QStandardPaths::AppDataLocation. It is created before the window and
|
||||
// parented to the app so it outlives the window's page, as Qt requires.
|
||||
auto *profile = new QWebEngineProfile(QStringLiteral("default"), &app);
|
||||
// Like qutebrowser's content.cookies.store: keep session cookies too, so
|
||||
// sites that log in with one don't log you out on every restart.
|
||||
profile->setPersistentCookiesPolicy(QWebEngineProfile::ForcePersistentCookies);
|
||||
|
||||
// Created after the profile so it is also destroyed after it: the profile
|
||||
// does not own its interceptor and must not outlive it.
|
||||
auto *adBlocker = new AdBlocker(&app);
|
||||
profile->setUrlRequestInterceptor(adBlocker);
|
||||
|
||||
BrowserWindow window(profile, adBlocker, startUrl);
|
||||
window.show();
|
||||
// After the window exists, so its status bar shows the update messages.
|
||||
adBlocker->start();
|
||||
|
||||
return app.exec();
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
#include "statusbar.h"
|
||||
|
||||
#include <QHBoxLayout>
|
||||
#include <QLabel>
|
||||
#include <QStyle>
|
||||
#include <QTimer>
|
||||
|
||||
StatusBar::StatusBar(QWidget *parent)
|
||||
: QWidget(parent)
|
||||
, m_mode(new QLabel(this))
|
||||
, m_url(new QLabel(this))
|
||||
, m_progress(new QLabel(this))
|
||||
{
|
||||
setObjectName(QStringLiteral("statusBar"));
|
||||
|
||||
m_mode->setObjectName(QStringLiteral("statusMode"));
|
||||
m_url->setObjectName(QStringLiteral("statusUrl"));
|
||||
m_progress->setObjectName(QStringLiteral("statusProgress"));
|
||||
|
||||
m_url->setTextInteractionFlags(Qt::NoTextInteraction);
|
||||
|
||||
auto *layout = new QHBoxLayout(this);
|
||||
layout->setContentsMargins(4, 1, 4, 1);
|
||||
layout->setSpacing(8);
|
||||
layout->addWidget(m_mode);
|
||||
layout->addWidget(m_url, 1);
|
||||
layout->addWidget(m_progress);
|
||||
}
|
||||
|
||||
void StatusBar::setMode(const QString &mode)
|
||||
{
|
||||
m_mode->setText(mode.isEmpty() ? QString()
|
||||
: QStringLiteral("-- %1 --").arg(mode.toUpper()));
|
||||
m_mode->setVisible(!mode.isEmpty());
|
||||
}
|
||||
|
||||
void StatusBar::setUrl(const QString &url)
|
||||
{
|
||||
m_urlText = url;
|
||||
m_url->setText(url);
|
||||
}
|
||||
|
||||
void StatusBar::setProgress(int percent)
|
||||
{
|
||||
const bool loading = percent > 0 && percent < 100;
|
||||
m_progress->setText(loading ? QStringLiteral("[%1%]").arg(percent) : QString());
|
||||
m_progress->setVisible(loading);
|
||||
}
|
||||
|
||||
void StatusBar::showMessage(const QString &message, bool isError)
|
||||
{
|
||||
m_url->setProperty("error", isError);
|
||||
m_url->setText(message);
|
||||
|
||||
// Re-polish so the stylesheet picks up the changed property.
|
||||
m_url->style()->unpolish(m_url);
|
||||
m_url->style()->polish(m_url);
|
||||
|
||||
QTimer::singleShot(4000, this, &StatusBar::clearMessage);
|
||||
}
|
||||
|
||||
void StatusBar::clearMessage()
|
||||
{
|
||||
m_url->setProperty("error", false);
|
||||
m_url->style()->unpolish(m_url);
|
||||
m_url->style()->polish(m_url);
|
||||
m_url->setText(m_urlText);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
#pragma once
|
||||
|
||||
#include <QWidget>
|
||||
|
||||
class QLabel;
|
||||
|
||||
// The line that replaces the command line when no command is being typed:
|
||||
// current mode on the left, URL in the middle, load progress on the right.
|
||||
class StatusBar : public QWidget
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit StatusBar(QWidget *parent = nullptr);
|
||||
|
||||
void setMode(const QString &mode);
|
||||
void setUrl(const QString &url);
|
||||
void setProgress(int percent);
|
||||
// Transient feedback: errors, unknown commands, yanked URLs.
|
||||
void showMessage(const QString &message, bool isError = false);
|
||||
void clearMessage();
|
||||
|
||||
private:
|
||||
QLabel *m_mode;
|
||||
QLabel *m_url;
|
||||
QLabel *m_progress;
|
||||
QString m_urlText;
|
||||
};
|
||||
@@ -0,0 +1,59 @@
|
||||
#include "urlutil.h"
|
||||
|
||||
#include <QDir>
|
||||
#include <QFileInfo>
|
||||
#include <QRegularExpression>
|
||||
#include <QUrlQuery>
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr auto kSearchEngine = "https://duckduckgo.com/";
|
||||
|
||||
bool looksLikeHost(const QString &s)
|
||||
{
|
||||
if (s.contains(QLatin1Char(' ')))
|
||||
return false;
|
||||
if (s == QLatin1String("localhost") || s.startsWith(QLatin1String("localhost:")))
|
||||
return true;
|
||||
|
||||
// host.tld, optionally with a port and/or a path.
|
||||
static const QRegularExpression re(
|
||||
QStringLiteral(R"(^[\w-]+(\.[\w-]+)+(:\d+)?(/.*)?$)"));
|
||||
return re.match(s).hasMatch();
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
namespace urlutil {
|
||||
|
||||
QUrl fromUserInput(const QString &input)
|
||||
{
|
||||
const QString s = input.trimmed();
|
||||
if (s.isEmpty())
|
||||
return {};
|
||||
|
||||
if (s.startsWith(QLatin1Char('/')) || s.startsWith(QLatin1String("~/"))
|
||||
|| s.startsWith(QLatin1String("./"))) {
|
||||
QString path = s;
|
||||
if (path.startsWith(QLatin1Char('~')))
|
||||
path.replace(0, 1, QDir::homePath());
|
||||
const QFileInfo info(path);
|
||||
if (info.exists())
|
||||
return QUrl::fromLocalFile(info.absoluteFilePath());
|
||||
}
|
||||
|
||||
const QUrl url(s, QUrl::TolerantMode);
|
||||
if (url.isValid() && !url.scheme().isEmpty() && !url.isRelative())
|
||||
return url;
|
||||
|
||||
if (looksLikeHost(s))
|
||||
return QUrl(QStringLiteral("https://") + s, QUrl::TolerantMode);
|
||||
|
||||
QUrl search(QString::fromLatin1(kSearchEngine));
|
||||
QUrlQuery query;
|
||||
query.addQueryItem(QStringLiteral("q"), s);
|
||||
search.setQuery(query);
|
||||
return search;
|
||||
}
|
||||
|
||||
} // namespace urlutil
|
||||
@@ -0,0 +1,13 @@
|
||||
#pragma once
|
||||
|
||||
#include <QString>
|
||||
#include <QUrl>
|
||||
|
||||
namespace urlutil {
|
||||
|
||||
// Turns whatever the user typed into the command line into a URL: a real URL is
|
||||
// kept as-is, something that looks like a host gets an https:// scheme, and
|
||||
// anything else is handed to the search engine.
|
||||
QUrl fromUserInput(const QString &input);
|
||||
|
||||
} // namespace urlutil
|
||||
Reference in New Issue
Block a user