add base: browser window, hints, history, cookies, adblock

This commit is contained in:
2026-10-15 23:03:56 +02:00
commit a841178cf7
26 changed files with 2711 additions and 0 deletions
+225
View File
@@ -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;
}
+57
View File
@@ -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;
};
+410
View File
@@ -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);
}
+61
View File
@@ -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;
};
+117
View File
@@ -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);
}
+36
View File
@@ -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;
};
+75
View File
@@ -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
+15
View File
@@ -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
View File
@@ -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
View File
@@ -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
+83
View File
@@ -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();
}
+68
View File
@@ -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);
}
+28
View File
@@ -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;
};
+59
View File
@@ -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
+13
View File
@@ -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