diff --git a/gui/CMakeLists.txt b/gui/CMakeLists.txt
new file mode 100644
index 0000000..3200ed3
--- /dev/null
+++ b/gui/CMakeLists.txt
@@ -0,0 +1,57 @@
+# velox-gui — the Qt 6 Widgets client. Lane GUI.
+#
+# CLAUDE.md §3: zero download logic here. This target links libveloxproto (the wire
+# types, ADR 0009) and NEVER libveloxcore — a grep for curl|pwrite|sqlite under gui/
+# must come back empty.
+#
+# The root CMakeLists.txt add_subdirectory()s this unconditionally once the file exists,
+# so it must stay configurable even before CORE has landed veloxproto. Until that target
+# exists we announce and bail; the moment `core/` merges its libveloxproto, the GUI
+# lights up with no edit here (same contract as the root file's EXISTS() guards).
+
+if(NOT TARGET veloxproto)
+ message(STATUS "velox-gui: libveloxproto has not landed yet — GUI target skipped. "
+ "It builds automatically once core/ merges the veloxproto target (ADR 0009).")
+ return()
+endif()
+
+set(CMAKE_AUTOMOC ON)
+
+add_executable(velox-gui
+ src/main.cpp
+ src/rpc/RpcConnection.cpp
+ src/rpc/RpcClient.cpp
+ src/models/DownloadTableModel.cpp
+ src/widgets/ProgressDelegate.cpp
+ src/mainwindow/MainWindow.cpp
+)
+
+target_include_directories(velox-gui PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src)
+
+target_compile_features(velox-gui PRIVATE cxx_std_23)
+
+# Warnings at target scope, not via CMAKE_CXX_FLAGS — the dev/tsan presets overwrite that
+# cache variable wholesale (same rationale as core/CMakeLists.txt).
+target_compile_options(velox-gui PRIVATE -Wall -Wextra -Wpedantic -Werror)
+
+target_link_libraries(velox-gui PRIVATE
+ velox::proto
+ Qt6::Widgets
+ Qt6::Svg
+ Qt6::Network
+)
+
+set_target_properties(velox-gui PROPERTIES
+ WIN32_EXECUTABLE OFF
+ MACOSX_BUNDLE OFF
+)
+
+# --- i18n -------------------------------------------------------------------------------
+# Every string in the GUI goes through tr(); the Arabic stub exists to prove the RTL
+# layout survives (GUI DoD). lrelease/lupdate come from Qt6::LinguistTools, found by root.
+qt_add_translations(velox-gui TS_FILES i18n/velox_ar.ts)
+
+# --- tests ---------------------------------------------------------------------------
+if(VELOX_BUILD_TESTS)
+ add_subdirectory(tests)
+endif()
diff --git a/gui/i18n/velox_ar.ts b/gui/i18n/velox_ar.ts
new file mode 100644
index 0000000..15b6eab
--- /dev/null
+++ b/gui/i18n/velox_ar.ts
@@ -0,0 +1,34 @@
+
+
+
+
+
+ velox::gui::MainWindow
+
+ Velox Download Manager
+ مدير التنزيلات فيلوكس
+
+
+ Reconnecting to the Velox service…
+
+
+
+
+ velox::gui::DownloadTableModel
+
+ File Name
+ اسم الملف
+
+
+ Status
+ الحالة
+
+
+
diff --git a/gui/src/main.cpp b/gui/src/main.cpp
new file mode 100644
index 0000000..a05f0db
--- /dev/null
+++ b/gui/src/main.cpp
@@ -0,0 +1,54 @@
+// velox-gui entry point. Lane GUI.
+
+#include
+
+#include
+#include
+#include
+#include
+#include
+
+#include "mainwindow/MainWindow.hpp"
+#include "rpc/Protocol.hpp"
+#include "rpc/RpcClient.hpp"
+
+namespace {
+
+QString defaultSocketPath() {
+ const QString override = qEnvironmentVariable("VELOX_SOCK");
+ if (!override.isEmpty()) {
+ return override;
+ }
+ QString runtimeDir = qEnvironmentVariable("XDG_RUNTIME_DIR");
+ if (runtimeDir.isEmpty()) {
+ runtimeDir = QStringLiteral("/run/user/%1").arg(getuid());
+ }
+ return runtimeDir + QStringLiteral("/velox/velox.sock");
+}
+
+} // namespace
+
+int main(int argc, char **argv) {
+ QApplication app(argc, argv);
+ QApplication::setApplicationName(QStringLiteral("velox-gui"));
+ QApplication::setOrganizationName(QStringLiteral("velox"));
+ QApplication::setApplicationVersion(QStringLiteral("0.1.0"));
+
+ qRegisterMetaType();
+ qRegisterMetaType();
+
+ // i18n from the first commit: load the bundled catalogue for the system locale.
+ // The Arabic stub (i18n/velox_ar.ts) exists to prove the RTL layout survives.
+ QTranslator translator;
+ const QString locale = QLocale::system().name();
+ if (translator.load(QStringLiteral("velox_") + locale, QStringLiteral(":/i18n"))) {
+ app.installTranslator(&translator);
+ }
+
+ velox::gui::rpc::RpcClient client(defaultSocketPath());
+ velox::gui::MainWindow window(&client);
+ window.show();
+ client.start();
+
+ return QApplication::exec();
+}
diff --git a/gui/src/mainwindow/MainWindow.cpp b/gui/src/mainwindow/MainWindow.cpp
new file mode 100644
index 0000000..60de041
--- /dev/null
+++ b/gui/src/mainwindow/MainWindow.cpp
@@ -0,0 +1,227 @@
+#include "mainwindow/MainWindow.hpp"
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include "models/DownloadTableModel.hpp"
+#include "rpc/RpcClient.hpp"
+#include "widgets/ProgressDelegate.hpp"
+
+namespace velox::gui {
+namespace {
+
+QString humanRate(qint64 bps) {
+ if (bps <= 0) {
+ return QStringLiteral("0 B/s");
+ }
+ return MainWindow::tr("%1/s").arg(
+ QLocale().formattedDataSize(bps, 1, QLocale::DataSizeIecFormat));
+}
+
+QString dotStyle(const QString &colour) {
+ return QStringLiteral("color: %1; font-size: 14px;").arg(colour);
+}
+
+} // namespace
+
+MainWindow::MainWindow(rpc::RpcClient *client, QWidget *parent)
+ : QMainWindow(parent),
+ client_(client),
+ model_(new DownloadTableModel(this)),
+ proxy_(new QSortFilterProxyModel(this)),
+ view_(new QTreeView(this)),
+ connDot_(new QLabel(QStringLiteral("●"), this)),
+ connText_(new QLabel(this)),
+ countsLabel_(new QLabel(this)),
+ offlineBanner_(new QWidget(this)) {
+ setWindowTitle(tr("Velox Download Manager"));
+ resize(960, 560);
+
+ proxy_->setSourceModel(model_);
+ proxy_->setDynamicSortFilter(true);
+ proxy_->setSortCaseSensitivity(Qt::CaseInsensitive);
+ proxy_->setFilterKeyColumn(-1);
+
+ view_->setModel(proxy_);
+ view_->setRootIsDecorated(false);
+ view_->setAlternatingRowColors(true);
+ view_->setUniformRowHeights(true); // keeps 10k rows scrolling at 60 fps
+ view_->setSelectionBehavior(QAbstractItemView::SelectRows);
+ view_->setSelectionMode(QAbstractItemView::ExtendedSelection);
+ view_->setSortingEnabled(true);
+ view_->setItemDelegateForColumn(DownloadTableModel::ColStatus, new ProgressDelegate(this));
+ view_->header()->setSectionsMovable(true);
+ view_->header()->setStretchLastSection(true);
+
+ // --- offline banner (shown instead of a modal error) -----------------------------
+ auto *bannerLayout = new QHBoxLayout(offlineBanner_);
+ bannerLayout->setContentsMargins(10, 6, 10, 6);
+ auto *bannerLabel = new QLabel(offlineBanner_);
+ bannerLabel->setObjectName(QStringLiteral("offlineBannerLabel"));
+ bannerLayout->addWidget(bannerLabel);
+ bannerLayout->addStretch();
+ offlineBanner_->setStyleSheet(QStringLiteral("background: #5a3a00; color: #ffd9a0;"));
+ offlineBanner_->setVisible(false);
+
+ auto *central = new QWidget(this);
+ auto *layout = new QVBoxLayout(central);
+ layout->setContentsMargins(0, 0, 0, 0);
+ layout->setSpacing(0);
+ layout->addWidget(offlineBanner_);
+ layout->addWidget(view_);
+ setCentralWidget(central);
+
+ // --- toolbar (dialog-free actions only for the M1 slice) -------------------------
+ auto *tb = addToolBar(tr("Main"));
+ tb->setMovable(false);
+ auto *addUrl = tb->addAction(tr("Add URL"));
+ addUrl->setEnabled(false); // wired up with the Add URL dialog (build order step 5)
+ tb->addSeparator();
+ auto *resume = tb->addAction(tr("Resume"), this, &MainWindow::resumeSelection);
+ auto *pause = tb->addAction(tr("Pause"), this, &MainWindow::pauseSelection);
+ tb->addAction(tr("Stop"), this, &MainWindow::cancelSelection);
+ resume->setShortcut(QKeySequence(Qt::CTRL | Qt::Key_R));
+ pause->setShortcut(QKeySequence(Qt::CTRL | Qt::Key_P));
+
+ // --- status bar -----------------------------------------------------------------
+ connDot_->setStyleSheet(dotStyle(QStringLiteral("#c0392b")));
+ statusBar()->addPermanentWidget(countsLabel_, 1);
+ statusBar()->addPermanentWidget(connText_);
+ statusBar()->addPermanentWidget(connDot_);
+
+ // --- wiring --------------------------------------------------------------------
+ connect(client_, &rpc::RpcClient::stateChanged, this, &MainWindow::onConnectionState);
+ connect(client_, &rpc::RpcClient::taskListReset, model_, &DownloadTableModel::resetFromJson);
+ connect(client_, &rpc::RpcClient::taskProgress, model_, &DownloadTableModel::applyProgress);
+ connect(client_, &rpc::RpcClient::taskAdded, model_, &DownloadTableModel::applyTaskAdded);
+ connect(client_, &rpc::RpcClient::taskStateChanged, model_,
+ &DownloadTableModel::applyTaskState);
+ connect(client_, &rpc::RpcClient::taskRemoved, model_, &DownloadTableModel::applyTaskRemoved);
+ connect(client_, &rpc::RpcClient::speedGlobal, this, &MainWindow::onSpeedGlobal);
+
+ connect(model_, &QAbstractItemModel::modelReset, this, &MainWindow::updateCounts);
+ connect(model_, &QAbstractItemModel::rowsInserted, this, &MainWindow::updateCounts);
+ connect(model_, &QAbstractItemModel::rowsRemoved, this, &MainWindow::updateCounts);
+ connect(model_, &QAbstractItemModel::dataChanged, this, &MainWindow::updateCounts);
+
+ restoreLayout();
+ onConnectionState(client_->state());
+ updateCounts();
+}
+
+MainWindow::~MainWindow() = default;
+
+void MainWindow::closeEvent(QCloseEvent *event) {
+ saveLayout();
+ QMainWindow::closeEvent(event);
+ event->accept();
+}
+
+void MainWindow::onConnectionState(rpc::ConnectionState state) {
+ connText_->setText(tr(rpc::toString(state)));
+
+ QString colour = QStringLiteral("#c0392b"); // red
+ if (state == rpc::ConnectionState::Connected) {
+ colour = QStringLiteral("#27ae60"); // green
+ } else if (state != rpc::ConnectionState::Disconnected) {
+ colour = QStringLiteral("#e67e22"); // amber
+ }
+ connDot_->setStyleSheet(dotStyle(colour));
+
+ const bool online = state == rpc::ConnectionState::Connected;
+ offlineBanner_->setVisible(!online);
+ if (auto *label = offlineBanner_->findChild(QStringLiteral("offlineBannerLabel"))) {
+ label->setText(state == rpc::ConnectionState::Reconnecting
+ ? tr("Reconnecting to the Velox service…")
+ : tr("The Velox service is not running. Downloads are unaffected; "
+ "this list is read-only until it returns."));
+ }
+}
+
+void MainWindow::onSpeedGlobal(const QJsonObject ¶ms) {
+ globalDownBps_ = static_cast(params.value("downBps").toDouble());
+ updateCounts();
+}
+
+void MainWindow::updateCounts() {
+ const int total = model_->rowCount();
+ int active = 0;
+ for (int i = 0; i < total; ++i) {
+ if (model_->index(i, 0).data(DownloadTableModel::StateRole).toString() ==
+ QLatin1String("downloading")) {
+ ++active;
+ }
+ }
+ countsLabel_->setText(
+ tr("%1 downloads, %2 active ↓ %3").arg(total).arg(active).arg(humanRate(globalDownBps_)));
+}
+
+QStringList MainWindow::selectedTaskIds() const {
+ QStringList ids;
+ const auto rows =
+ view_->selectionModel() ? view_->selectionModel()->selectedRows() : QModelIndexList{};
+ ids.reserve(rows.size());
+ for (const QModelIndex &proxyIdx : rows) {
+ const QModelIndex src = proxy_->mapToSource(proxyIdx);
+ const QString id = model_->data(src, DownloadTableModel::TaskIdRole).toString();
+ if (!id.isEmpty()) {
+ ids << id;
+ }
+ }
+ return ids;
+}
+
+void MainWindow::actOnSelection(const char *methodName) {
+ const QStringList ids = selectedTaskIds();
+ if (ids.isEmpty()) {
+ return;
+ }
+ client_->call(QString::fromLatin1(methodName),
+ QJsonObject{{"taskIds", QJsonArray::fromStringList(ids)}});
+}
+
+void MainWindow::pauseSelection() {
+ actOnSelection(rpc::method::kDownloadPause);
+}
+void MainWindow::resumeSelection() {
+ actOnSelection(rpc::method::kDownloadResume);
+}
+void MainWindow::cancelSelection() {
+ actOnSelection(rpc::method::kDownloadCancel);
+}
+
+void MainWindow::restoreLayout() {
+ QSettings settings;
+ const QByteArray geom = settings.value(QStringLiteral("mainwindow/geometry")).toByteArray();
+ if (!geom.isEmpty()) {
+ restoreGeometry(geom);
+ }
+ const QByteArray header =
+ settings.value(QStringLiteral("mainwindow/headerState")).toByteArray();
+ if (!header.isEmpty()) {
+ view_->header()->restoreState(header);
+ }
+}
+
+void MainWindow::saveLayout() {
+ QSettings settings;
+ settings.setValue(QStringLiteral("mainwindow/geometry"), saveGeometry());
+ settings.setValue(QStringLiteral("mainwindow/headerState"), view_->header()->saveState());
+}
+
+} // namespace velox::gui
diff --git a/gui/src/mainwindow/MainWindow.hpp b/gui/src/mainwindow/MainWindow.hpp
new file mode 100644
index 0000000..3063b23
--- /dev/null
+++ b/gui/src/mainwindow/MainWindow.hpp
@@ -0,0 +1,62 @@
+// The main window. Lane GUI.
+//
+// M1 slice: category tree, dialogs and the tray come later. This is the table driven by
+// mockd, a status-bar connection dot, an offline banner instead of a modal, and the three
+// selection actions that need no dialog (pause / resume / cancel).
+
+#pragma once
+
+#include
+
+#include "rpc/Protocol.hpp"
+
+class QLabel;
+class QSortFilterProxyModel;
+class QTreeView;
+
+namespace velox::gui {
+
+class DownloadTableModel;
+namespace rpc {
+class RpcClient;
+} // namespace rpc
+
+class MainWindow : public QMainWindow {
+ Q_OBJECT
+
+ public:
+ explicit MainWindow(rpc::RpcClient *client, QWidget *parent = nullptr);
+ ~MainWindow() override;
+
+ protected:
+ void closeEvent(QCloseEvent *event) override;
+
+ private slots:
+ void onConnectionState(rpc::ConnectionState state);
+ void onSpeedGlobal(const QJsonObject ¶ms);
+ void updateCounts();
+
+ void pauseSelection();
+ void resumeSelection();
+ void cancelSelection();
+
+ private:
+ QStringList selectedTaskIds() const;
+ void actOnSelection(const char *methodName);
+ void restoreLayout();
+ void saveLayout();
+
+ rpc::RpcClient *client_;
+ DownloadTableModel *model_;
+ QSortFilterProxyModel *proxy_;
+ QTreeView *view_;
+
+ QLabel *connDot_;
+ QLabel *connText_;
+ QLabel *countsLabel_;
+ QWidget *offlineBanner_;
+
+ qint64 globalDownBps_ = 0;
+};
+
+} // namespace velox::gui
diff --git a/gui/src/models/DownloadTableModel.cpp b/gui/src/models/DownloadTableModel.cpp
new file mode 100644
index 0000000..733f8da
--- /dev/null
+++ b/gui/src/models/DownloadTableModel.cpp
@@ -0,0 +1,281 @@
+#include "models/DownloadTableModel.hpp"
+
+#include
+
+#include
+
+namespace velox::gui {
+namespace {
+
+QString humanBytes(qint64 n) {
+ if (n < 0) {
+ return QStringLiteral("—");
+ }
+ return QLocale().formattedDataSize(n, 2, QLocale::DataSizeIecFormat);
+}
+
+QString humanRate(qint64 bps) {
+ if (bps <= 0) {
+ return QString();
+ }
+ return DownloadTableModel::tr("%1/s").arg(
+ QLocale().formattedDataSize(bps, 1, QLocale::DataSizeIecFormat));
+}
+
+QString humanEta(qint64 secs) {
+ if (secs < 0) {
+ return QString();
+ }
+ const qint64 h = secs / 3600;
+ const qint64 m = (secs % 3600) / 60;
+ const qint64 s = secs % 60;
+ return QStringLiteral("%1:%2:%3")
+ .arg(h, 2, 10, QLatin1Char('0'))
+ .arg(m, 2, 10, QLatin1Char('0'))
+ .arg(s, 2, 10, QLatin1Char('0'));
+}
+
+QString stateLabel(const QString &state) {
+ static const QHash kLabels = {
+ {QStringLiteral("new"), DownloadTableModel::tr("New")},
+ {QStringLiteral("probing"), DownloadTableModel::tr("Probing")},
+ {QStringLiteral("queued"), DownloadTableModel::tr("Queued")},
+ {QStringLiteral("connecting"), DownloadTableModel::tr("Connecting")},
+ {QStringLiteral("downloading"), DownloadTableModel::tr("Downloading")},
+ {QStringLiteral("paused"), DownloadTableModel::tr("Paused")},
+ {QStringLiteral("retry_wait"), DownloadTableModel::tr("Retry wait")},
+ {QStringLiteral("assembling"), DownloadTableModel::tr("Assembling")},
+ {QStringLiteral("verifying"), DownloadTableModel::tr("Verifying")},
+ {QStringLiteral("complete"), DownloadTableModel::tr("Complete")},
+ {QStringLiteral("failed"), DownloadTableModel::tr("Failed")},
+ {QStringLiteral("cancelled"), DownloadTableModel::tr("Cancelled")},
+ };
+ return kLabels.value(state, state);
+}
+
+qint64 optInt(const QJsonObject &o, const char *key, qint64 fallback) {
+ const QJsonValue v = o.value(QLatin1String(key));
+ return (v.isDouble()) ? static_cast(v.toDouble()) : fallback;
+}
+
+} // namespace
+
+DownloadTableModel::DownloadTableModel(QObject *parent) : QAbstractTableModel(parent) {}
+
+int DownloadTableModel::rowCount(const QModelIndex &parent) const {
+ return parent.isValid() ? 0 : static_cast(rows_.size());
+}
+
+int DownloadTableModel::columnCount(const QModelIndex &parent) const {
+ return parent.isValid() ? 0 : ColumnCount;
+}
+
+double DownloadTableModel::progressOf(const Row &r) const {
+ if (r.sizeBytes <= 0) {
+ return -1.0;
+ }
+ return std::clamp(static_cast(r.downloadedBytes) / static_cast(r.sizeBytes),
+ 0.0, 1.0);
+}
+
+QVariant DownloadTableModel::data(const QModelIndex &index, int role) const {
+ if (!index.isValid() || index.row() < 0 || index.row() >= rows_.size()) {
+ return {};
+ }
+ const Row &r = rows_.at(index.row());
+
+ switch (role) {
+ case TaskIdRole:
+ return r.taskId;
+ case StateRole:
+ return r.state;
+ case ProgressRole:
+ return progressOf(r);
+ case Qt::TextAlignmentRole:
+ switch (index.column()) {
+ case ColSize:
+ case ColSpeed:
+ case ColTimeLeft:
+ case ColQueue:
+ return static_cast(Qt::AlignRight | Qt::AlignVCenter);
+ default:
+ return static_cast(Qt::AlignLeft | Qt::AlignVCenter);
+ }
+ default:
+ break;
+ }
+
+ if (role != Qt::DisplayRole && role != Qt::ToolTipRole) {
+ return {};
+ }
+
+ switch (index.column()) {
+ case ColName:
+ return r.filename;
+ case ColQueue:
+ return r.queuePosition >= 0 ? QString::number(r.queuePosition) : QString();
+ case ColSize:
+ return humanBytes(r.sizeBytes);
+ case ColStatus:
+ if (r.state == QLatin1String("downloading")) {
+ const double p = progressOf(r);
+ return p < 0 ? stateLabel(r.state)
+ : QStringLiteral("%1 %").arg(p * 100.0, 0, 'f', 1);
+ }
+ return stateLabel(r.state);
+ case ColTimeLeft:
+ return r.state == QLatin1String("downloading") ? humanEta(r.etaSeconds) : QString();
+ case ColSpeed:
+ return r.state == QLatin1String("downloading") ? humanRate(r.speedBps) : QString();
+ case ColLastTry:
+ return r.lastTryAt;
+ case ColDescription:
+ return r.description.isEmpty() ? r.url : r.description;
+ default:
+ return {};
+ }
+}
+
+QVariant DownloadTableModel::headerData(int section, Qt::Orientation orientation, int role) const {
+ if (orientation != Qt::Horizontal || role != Qt::DisplayRole) {
+ return QAbstractTableModel::headerData(section, orientation, role);
+ }
+ switch (section) {
+ case ColName:
+ return tr("File Name");
+ case ColQueue:
+ return tr("Q");
+ case ColSize:
+ return tr("Size");
+ case ColStatus:
+ return tr("Status");
+ case ColTimeLeft:
+ return tr("Time Left");
+ case ColSpeed:
+ return tr("Transfer Rate");
+ case ColLastTry:
+ return tr("Last Try Date");
+ case ColDescription:
+ return tr("Description");
+ default:
+ return {};
+ }
+}
+
+Qt::ItemFlags DownloadTableModel::flags(const QModelIndex &index) const {
+ if (!index.isValid()) {
+ return Qt::NoItemFlags;
+ }
+ return Qt::ItemIsEnabled | Qt::ItemIsSelectable;
+}
+
+DownloadTableModel::Row DownloadTableModel::rowFromSummary(const QJsonObject &s) {
+ Row r;
+ r.taskId = s.value("taskId").toString();
+ r.filename = s.value("filename").toString();
+ r.url = s.value("url").toString();
+ r.description = s.value("description").toString();
+ r.state = s.value("state").toString();
+ r.lastTryAt = s.value("lastTryAt").toString();
+ r.categoryId = s.value("categoryId").toString();
+ r.queueId = s.value("queueId").toString();
+ r.sizeBytes = optInt(s, "sizeBytes", -1);
+ r.downloadedBytes = optInt(s, "downloadedBytes", 0);
+ r.speedBps = optInt(s, "speedBps", 0);
+ r.etaSeconds = optInt(s, "etaSeconds", -1);
+ r.queuePosition = optInt(s, "queuePosition", -1);
+ r.resumable = s.value("resumable").toBool();
+ return r;
+}
+
+void DownloadTableModel::rebuildIndex() {
+ indexByTaskId_.clear();
+ indexByTaskId_.reserve(rows_.size());
+ for (int i = 0; i < rows_.size(); ++i) {
+ indexByTaskId_.insert(rows_.at(i).taskId, i);
+ }
+}
+
+void DownloadTableModel::resetFromJson(const QJsonArray &items) {
+ beginResetModel();
+ rows_.clear();
+ rows_.reserve(items.size());
+ for (const QJsonValue &v : items) {
+ rows_.push_back(rowFromSummary(v.toObject()));
+ }
+ rebuildIndex();
+ endResetModel();
+}
+
+void DownloadTableModel::applyProgress(const QJsonArray &tasks) {
+ for (const QJsonValue &v : tasks) {
+ const QJsonObject t = v.toObject();
+ const auto it = indexByTaskId_.constFind(t.value("taskId").toString());
+ if (it == indexByTaskId_.cend()) {
+ continue; // progress for a row we are not showing — ignore, do not synthesize
+ }
+ const int row = it.value();
+ Row &r = rows_[row];
+ r.downloadedBytes = optInt(t, "downloadedBytes", r.downloadedBytes);
+ r.speedBps = optInt(t, "speedBps", r.speedBps);
+ r.etaSeconds = optInt(t, "etaSeconds", -1);
+ if (r.state != QLatin1String("downloading")) {
+ r.state = QStringLiteral("downloading");
+ }
+ // Narrow patch: only the value columns move on a tick.
+ emit dataChanged(index(row, ColSize), index(row, ColSpeed),
+ {Qt::DisplayRole, ProgressRole});
+ }
+}
+
+void DownloadTableModel::applyTaskState(const QJsonObject ¶ms) {
+ const QString taskId = params.value("taskId").toString();
+ const QJsonObject summary = params.value("summary").toObject();
+ const auto it = indexByTaskId_.constFind(taskId);
+ if (it == indexByTaskId_.cend()) {
+ if (!summary.isEmpty()) {
+ applyTaskAdded(summary);
+ }
+ return;
+ }
+ const int row = it.value();
+ if (!summary.isEmpty()) {
+ rows_[row] = rowFromSummary(summary);
+ } else {
+ rows_[row].state = params.value("state").toString();
+ }
+ emit dataChanged(index(row, 0), index(row, ColumnCount - 1));
+}
+
+void DownloadTableModel::applyTaskAdded(const QJsonObject &summary) {
+ const QString taskId = summary.value("taskId").toString();
+ if (taskId.isEmpty()) {
+ return;
+ }
+ const auto it = indexByTaskId_.constFind(taskId);
+ if (it != indexByTaskId_.cend()) {
+ const int row = it.value();
+ rows_[row] = rowFromSummary(summary);
+ emit dataChanged(index(row, 0), index(row, ColumnCount - 1));
+ return;
+ }
+ const int row = static_cast(rows_.size());
+ beginInsertRows({}, row, row);
+ rows_.push_back(rowFromSummary(summary));
+ indexByTaskId_.insert(taskId, row);
+ endInsertRows();
+}
+
+void DownloadTableModel::applyTaskRemoved(const QString &taskId) {
+ const auto it = indexByTaskId_.constFind(taskId);
+ if (it == indexByTaskId_.cend()) {
+ return;
+ }
+ const int row = it.value();
+ beginRemoveRows({}, row, row);
+ rows_.remove(row);
+ endRemoveRows();
+ rebuildIndex();
+}
+
+} // namespace velox::gui
diff --git a/gui/src/models/DownloadTableModel.hpp b/gui/src/models/DownloadTableModel.hpp
new file mode 100644
index 0000000..70d97bf
--- /dev/null
+++ b/gui/src/models/DownloadTableModel.hpp
@@ -0,0 +1,88 @@
+// The main download list, as a model. Lane GUI.
+//
+// docs/03-gui-spec.md §1: fed by events, never rebuilt on a progress tick. A progress
+// batch is applied as a row patch with a narrow dataChanged over just the value columns;
+// beginResetModel() is reserved for the initial load and a reconnect resync.
+
+#pragma once
+
+#include
+#include
+#include
+#include
+#include
+#include
+
+namespace velox::gui {
+
+class DownloadTableModel : public QAbstractTableModel {
+ Q_OBJECT
+
+ public:
+ enum Column {
+ ColName = 0,
+ ColQueue,
+ ColSize,
+ ColStatus,
+ ColTimeLeft,
+ ColSpeed,
+ ColLastTry,
+ ColDescription,
+ ColumnCount,
+ };
+
+ enum Roles {
+ ProgressRole = Qt::UserRole + 1, ///< double in [0,1], -1 when the size is unknown
+ TaskIdRole,
+ StateRole, ///< the TaskState as a lowercase string
+ };
+
+ explicit DownloadTableModel(QObject *parent = nullptr);
+
+ // QAbstractItemModel
+ int rowCount(const QModelIndex &parent = {}) const override;
+ int columnCount(const QModelIndex &parent = {}) const override;
+ QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override;
+ QVariant headerData(int section, Qt::Orientation orientation,
+ int role = Qt::DisplayRole) const override;
+ Qt::ItemFlags flags(const QModelIndex &index) const override;
+
+ public slots:
+ /// Full replace (initial load / reconnect).
+ void resetFromJson(const QJsonArray &items);
+ /// event.task.progress: one entry per still-active task. Narrow patch, no reset.
+ void applyProgress(const QJsonArray &tasks);
+ /// event.task.state: carries a full TaskSummary in `summary`.
+ void applyTaskState(const QJsonObject ¶ms);
+ /// event.task.added.
+ void applyTaskAdded(const QJsonObject &summary);
+ /// event.task.removed.
+ void applyTaskRemoved(const QString &taskId);
+
+ private:
+ struct Row {
+ QString taskId;
+ QString filename;
+ QString url;
+ QString description;
+ QString state; // lowercase, as on the wire
+ QString lastTryAt;
+ QString categoryId;
+ QString queueId;
+ qint64 sizeBytes = -1; // -1 == server reported no length
+ qint64 downloadedBytes = 0;
+ qint64 speedBps = 0;
+ qint64 etaSeconds = -1; // -1 == unknown
+ qint64 queuePosition = -1; // -1 == not queued
+ bool resumable = false;
+ };
+
+ static Row rowFromSummary(const QJsonObject &s);
+ void rebuildIndex();
+ double progressOf(const Row &r) const;
+
+ QVector rows_;
+ QHash indexByTaskId_;
+};
+
+} // namespace velox::gui
diff --git a/gui/src/rpc/Protocol.hpp b/gui/src/rpc/Protocol.hpp
new file mode 100644
index 0000000..5695395
--- /dev/null
+++ b/gui/src/rpc/Protocol.hpp
@@ -0,0 +1,87 @@
+// Shared RPC vocabulary for the GUI client. Lane GUI.
+//
+// The wire is JSON-RPC 2.0 over the Unix socket, newline-delimited (one object per line) —
+// exactly what tools/mockd and the real veloxd speak on the UDS transport. This header
+// carries only the small enums and structs the rpc/ layer passes around; the payload
+// *types* live in libveloxproto and are parsed at the model boundary.
+
+#pragma once
+
+#include
+#include
+#include
+#include
+
+namespace velox::gui::rpc {
+
+/// Where the connection is, for the status-bar dot and the offline banner. Anything other
+/// than Connected means the table is stale and toolbar actions should be disabled.
+enum class ConnectionState {
+ Disconnected, ///< start() not called yet, or stop() was called
+ Connecting, ///< TCP/UDS connect in flight
+ Handshaking, ///< socket up; session.hello / session.subscribe in flight
+ Connected, ///< handshake done, events flowing
+ Reconnecting, ///< lost the socket, backing off before the next attempt
+};
+
+/// Bare label for the status bar. Not tr()'d here — callers wrap it.
+inline const char *toString(ConnectionState s) noexcept {
+ switch (s) {
+ case ConnectionState::Disconnected:
+ return "Disconnected";
+ case ConnectionState::Connecting:
+ return "Connecting";
+ case ConnectionState::Handshaking:
+ return "Connecting";
+ case ConnectionState::Connected:
+ return "Connected";
+ case ConnectionState::Reconnecting:
+ return "Reconnecting";
+ }
+ return "Unknown";
+}
+
+/// A JSON-RPC error object, or the absence of one (code == 0).
+struct RpcError {
+ int code = 0;
+ QString message;
+ QJsonValue data;
+
+ bool isError() const noexcept { return code != 0; }
+};
+
+/// The outcome of one call: exactly one of result / error is meaningful.
+struct RpcReply {
+ QJsonValue result;
+ RpcError error;
+
+ bool ok() const noexcept { return !error.isError(); }
+};
+
+// --- method names ---------------------------------------------------------------------
+namespace method {
+inline constexpr auto kSessionHello = "session.hello";
+inline constexpr auto kSessionSubscribe = "session.subscribe";
+inline constexpr auto kDownloadList = "download.list";
+inline constexpr auto kDownloadGet = "download.get";
+inline constexpr auto kDownloadStart = "download.start";
+inline constexpr auto kDownloadPause = "download.pause";
+inline constexpr auto kDownloadResume = "download.resume";
+inline constexpr auto kDownloadCancel = "download.cancel";
+inline constexpr auto kDownloadRemove = "download.remove";
+} // namespace method
+
+// --- event names ---------------------------------------------------------------------
+namespace event {
+inline constexpr auto kTaskAdded = "event.task.added";
+inline constexpr auto kTaskRemoved = "event.task.removed";
+inline constexpr auto kTaskState = "event.task.state";
+inline constexpr auto kTaskProgress = "event.task.progress";
+inline constexpr auto kSpeedGlobal = "event.speed.global";
+inline constexpr auto kNotify = "event.notify";
+} // namespace event
+
+} // namespace velox::gui::rpc
+
+Q_DECLARE_METATYPE(velox::gui::rpc::RpcReply)
+Q_DECLARE_METATYPE(velox::gui::rpc::ConnectionState)
diff --git a/gui/src/rpc/RpcClient.cpp b/gui/src/rpc/RpcClient.cpp
new file mode 100644
index 0000000..55f9b1a
--- /dev/null
+++ b/gui/src/rpc/RpcClient.cpp
@@ -0,0 +1,127 @@
+#include "rpc/RpcClient.hpp"
+
+#include
+
+#include
+
+#include "rpc/RpcConnection.hpp"
+
+namespace velox::gui::rpc {
+
+// Off by default; enable with QT_LOGGING_RULES="velox.rpc.info=true".
+Q_LOGGING_CATEGORY(lcRpc, "velox.rpc")
+
+RpcClient::RpcClient(QString socketPath, QObject *parent) : QObject(parent) {
+ conn_ = new RpcConnection(std::move(socketPath));
+ conn_->moveToThread(&thread_);
+ // QThread flushes DeferredDelete for its objects after the loop exits, so this is the
+ // safe way to destroy an object that lives on the worker thread.
+ connect(&thread_, &QThread::finished, conn_, &QObject::deleteLater);
+
+ connect(conn_, &RpcConnection::stateChanged, this, &RpcClient::onConnectionState);
+ connect(conn_, &RpcConnection::notificationReceived, this, &RpcClient::onNotification);
+ connect(conn_, &RpcConnection::callCompleted, this, &RpcClient::onCallCompleted);
+}
+
+RpcClient::~RpcClient() {
+ if (thread_.isRunning()) {
+ stop(); // joins thread_; the finished -> deleteLater above disposes of conn_
+ } else {
+ delete conn_; // start() was never called: no loop will service deleteLater
+ }
+ conn_ = nullptr;
+ // Nothing will ever complete the in-flight callbacks now.
+ const auto pending = std::move(callbacks_);
+ callbacks_.clear();
+ for (const auto &cb : pending) {
+ if (cb) {
+ RpcReply reply;
+ reply.error = {-1, QStringLiteral("client shutting down"), {}};
+ cb(reply);
+ }
+ }
+}
+
+void RpcClient::start() {
+ if (!thread_.isRunning()) {
+ thread_.start();
+ }
+ QMetaObject::invokeMethod(conn_, "start", Qt::QueuedConnection);
+}
+
+void RpcClient::stop() {
+ if (!thread_.isRunning()) {
+ return;
+ }
+ QMetaObject::invokeMethod(conn_, "stop", Qt::QueuedConnection);
+ thread_.quit();
+ thread_.wait();
+}
+
+void RpcClient::call(const QString &methodName, const QJsonObject ¶ms,
+ std::function cb) {
+ const quint64 tag = nextTag_++;
+ callbacks_.insert(tag, std::move(cb));
+ QMetaObject::invokeMethod(conn_, "sendCall", Qt::QueuedConnection, Q_ARG(quint64, tag),
+ Q_ARG(QString, methodName), Q_ARG(QJsonObject, params));
+}
+
+void RpcClient::onConnectionState(int state) {
+ state_ = static_cast(state);
+ qCInfo(lcRpc, "connection state: %s", toString(state_));
+ emit stateChanged(state_);
+ if (state_ == ConnectionState::Connected) {
+ requestInitialList();
+ }
+}
+
+void RpcClient::requestInitialList() {
+ call(QString::fromLatin1(method::kDownloadList), QJsonObject{{"limit", 1000}},
+ [this](const RpcReply &reply) {
+ if (!reply.ok()) {
+ qCWarning(lcRpc, "download.list failed: %d %s", reply.error.code,
+ qUtf8Printable(reply.error.message));
+ return;
+ }
+ const QJsonArray items = reply.result.toObject().value("items").toArray();
+ qCInfo(lcRpc, "initial download.list: %lld row(s)",
+ static_cast(items.size()));
+ emit taskListReset(items);
+ });
+}
+
+void RpcClient::onNotification(const QString &methodName, const QJsonObject ¶ms) {
+ qCDebug(lcRpc, "notification: %s", qUtf8Printable(methodName));
+ if (methodName == QLatin1String(event::kTaskProgress)) {
+ const QJsonArray tasks = params.value("tasks").toArray();
+ qCDebug(lcRpc, "progress batch: %lld task(s)", static_cast(tasks.size()));
+ emit taskProgress(tasks);
+ } else if (methodName == QLatin1String(event::kTaskAdded)) {
+ emit taskAdded(params.value("summary").toObject());
+ } else if (methodName == QLatin1String(event::kTaskState)) {
+ emit taskStateChanged(params);
+ } else if (methodName == QLatin1String(event::kTaskRemoved)) {
+ emit taskRemoved(params.value("taskId").toString());
+ } else if (methodName == QLatin1String(event::kSpeedGlobal)) {
+ emit speedGlobal(params);
+ } else if (methodName == QLatin1String(event::kNotify)) {
+ emit notify(params);
+ }
+}
+
+void RpcClient::onCallCompleted(quint64 tag, const QJsonObject &envelope) {
+ const auto cb = callbacks_.take(tag);
+ if (!cb) {
+ return;
+ }
+ RpcReply reply;
+ if (envelope.contains("error")) {
+ const QJsonObject e = envelope.value("error").toObject();
+ reply.error = {e.value("code").toInt(), e.value("message").toString(), e.value("data")};
+ } else {
+ reply.result = envelope.value("result");
+ }
+ cb(reply);
+}
+
+} // namespace velox::gui::rpc
diff --git a/gui/src/rpc/RpcClient.hpp b/gui/src/rpc/RpcClient.hpp
new file mode 100644
index 0000000..e337f75
--- /dev/null
+++ b/gui/src/rpc/RpcClient.hpp
@@ -0,0 +1,73 @@
+// The main-thread face of the RPC client. Lane GUI.
+//
+// Everything the rest of the GUI touches. It owns a worker QThread with an RpcConnection
+// on it; calls are marshalled onto that thread and their replies come back as a callback
+// invoked on the main thread. Server notifications are re-emitted as typed Qt signals.
+//
+// On reaching Connected it issues the one-shot download.list itself and emits
+// taskListReset — the table never re-fetches after that, it is maintained from events
+// (docs/03-gui-spec.md §1).
+
+#pragma once
+
+#include
+
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include "rpc/Protocol.hpp"
+
+namespace velox::gui::rpc {
+
+class RpcConnection;
+
+class RpcClient : public QObject {
+ Q_OBJECT
+
+ public:
+ explicit RpcClient(QString socketPath, QObject *parent = nullptr);
+ ~RpcClient() override;
+
+ ConnectionState state() const noexcept { return state_; }
+
+ /// Fire a JSON-RPC call. `cb` runs on the main thread; it always runs exactly once,
+ /// with reply.ok() == false if the socket was down or the daemon returned an error.
+ void call(const QString &methodName, const QJsonObject ¶ms,
+ std::function cb = {});
+
+ public slots:
+ void start();
+ void stop();
+
+ signals:
+ void stateChanged(velox::gui::rpc::ConnectionState state);
+ /// Full replacement of the table's contents (initial load / reconnect resync).
+ void taskListReset(const QJsonArray &items);
+ void taskAdded(const QJsonObject &summary);
+ void taskProgress(const QJsonArray &tasks);
+ void taskStateChanged(const QJsonObject ¶ms);
+ void taskRemoved(const QString &taskId);
+ void speedGlobal(const QJsonObject ¶ms);
+ void notify(const QJsonObject ¶ms);
+
+ private slots:
+ void onConnectionState(int state);
+ void onNotification(const QString &methodName, const QJsonObject ¶ms);
+ void onCallCompleted(quint64 tag, const QJsonObject &envelope);
+
+ private:
+ void requestInitialList();
+
+ QThread thread_;
+ RpcConnection *conn_ = nullptr; // owned by thread_ affinity, deleted on thread finish
+ ConnectionState state_ = ConnectionState::Disconnected;
+
+ quint64 nextTag_ = 1;
+ QHash> callbacks_;
+};
+
+} // namespace velox::gui::rpc
diff --git a/gui/src/rpc/RpcConnection.cpp b/gui/src/rpc/RpcConnection.cpp
new file mode 100644
index 0000000..ced660c
--- /dev/null
+++ b/gui/src/rpc/RpcConnection.cpp
@@ -0,0 +1,205 @@
+#include "rpc/RpcConnection.hpp"
+
+#include
+#include
+#include
+#include
+
+#include "rpc/Protocol.hpp"
+#include "velox_proto.hpp"
+
+namespace velox::gui::rpc {
+namespace {
+
+constexpr int kStateDisconnected = int(ConnectionState::Disconnected);
+constexpr int kStateConnecting = int(ConnectionState::Connecting);
+constexpr int kStateHandshaking = int(ConnectionState::Handshaking);
+constexpr int kStateConnected = int(ConnectionState::Connected);
+constexpr int kStateReconnecting = int(ConnectionState::Reconnecting);
+
+QJsonObject makeErrorEnvelope(int code, const QString &message) {
+ QJsonObject err{{"code", code}, {"message", message}};
+ return QJsonObject{{"jsonrpc", "2.0"}, {"error", err}};
+}
+
+QString contractProtocolVersion() {
+ const auto v = velox::proto::kProtocolVersion;
+ return QString::fromUtf8(v.data(), static_cast(v.size()));
+}
+
+} // namespace
+
+RpcConnection::RpcConnection(QString socketPath, QObject *parent)
+ : QObject(parent), socketPath_(std::move(socketPath)) {
+ socket_ = new QLocalSocket(this);
+ connect(socket_, &QLocalSocket::connected, this, &RpcConnection::onConnected);
+ connect(socket_, &QLocalSocket::disconnected, this, &RpcConnection::onDisconnected);
+ connect(socket_, &QLocalSocket::errorOccurred, this, &RpcConnection::onErrorOccurred);
+ connect(socket_, &QLocalSocket::readyRead, this, &RpcConnection::onReadyRead);
+
+ reconnectTimer_ = new QTimer(this);
+ reconnectTimer_->setSingleShot(true);
+ connect(reconnectTimer_, &QTimer::timeout, this, &RpcConnection::attemptReconnect);
+}
+
+RpcConnection::~RpcConnection() = default;
+
+void RpcConnection::start() {
+ if (wantConnected_) {
+ return;
+ }
+ wantConnected_ = true;
+ backoffMs_ = kMinBackoffMs;
+ setState(kStateConnecting);
+ socket_->connectToServer(socketPath_);
+}
+
+void RpcConnection::stop() {
+ wantConnected_ = false;
+ reconnectTimer_->stop();
+ socket_->abort();
+ rxBuffer_.clear();
+ // Fail any in-flight callbacks so RpcClient does not leak them.
+ const auto tags = pendingTags_.values();
+ pendingTags_.clear();
+ for (const quint64 tag : tags) {
+ emit callCompleted(tag, makeErrorEnvelope(-1, QStringLiteral("connection stopped")));
+ }
+ setState(kStateDisconnected);
+}
+
+void RpcConnection::sendCall(quint64 tag, const QString &methodName, const QJsonObject ¶ms) {
+ if (state_ != kStateConnected) {
+ emit callCompleted(tag, makeErrorEnvelope(-1, QStringLiteral("not connected")));
+ return;
+ }
+ const qint64 id = nextRpcId_++;
+ pendingTags_.insert(id, tag);
+ sendRaw(id, methodName, params);
+}
+
+void RpcConnection::onConnected() {
+ rxBuffer_.clear();
+ beginHandshake();
+}
+
+void RpcConnection::beginHandshake() {
+ setState(kStateHandshaking);
+ sendRaw(kHelloId, QString::fromLatin1(method::kSessionHello),
+ QJsonObject{{"clientType", "gui"},
+ {"clientName", QStringLiteral("velox-gui 0.1.0")},
+ {"protocolVersion", contractProtocolVersion()}});
+}
+
+// A dropped connection surfaces as disconnected(); a *failed* connect attempt surfaces as
+// errorOccurred() with no disconnected() to follow. Both funnel here, and the isActive()
+// guard means the two firing together for one failure only arms one timer.
+void RpcConnection::scheduleReconnect() {
+ if (!wantConnected_) {
+ setState(kStateDisconnected);
+ return;
+ }
+ if (reconnectTimer_->isActive()) {
+ return;
+ }
+ setState(kStateReconnecting);
+ reconnectTimer_->start(backoffMs_);
+ backoffMs_ = qMin(backoffMs_ * 2, kMaxBackoffMs);
+}
+
+void RpcConnection::onDisconnected() {
+ scheduleReconnect();
+}
+
+void RpcConnection::onErrorOccurred() {
+ scheduleReconnect();
+}
+
+void RpcConnection::attemptReconnect() {
+ if (!wantConnected_) {
+ return;
+ }
+ setState(kStateConnecting);
+ socket_->abort();
+ socket_->connectToServer(socketPath_);
+}
+
+void RpcConnection::onReadyRead() {
+ rxBuffer_ += socket_->readAll();
+ int nl = rxBuffer_.indexOf('\n');
+ while (nl != -1) {
+ const QByteArray line = rxBuffer_.left(nl).trimmed();
+ rxBuffer_.remove(0, nl + 1);
+ nl = rxBuffer_.indexOf('\n');
+ if (line.isEmpty()) {
+ continue;
+ }
+ QJsonParseError perr{};
+ const QJsonDocument doc = QJsonDocument::fromJson(line, &perr);
+ if (perr.error != QJsonParseError::NoError || !doc.isObject()) {
+ continue; // a malformed frame is dropped, never thrown
+ }
+ dispatchFrame(doc.object());
+ }
+}
+
+void RpcConnection::dispatchFrame(const QJsonObject &frame) {
+ const QJsonValue idVal = frame.value("id");
+ const bool isResponse = frame.contains("result") || frame.contains("error");
+
+ if (idVal.isDouble() && isResponse) {
+ const qint64 id = static_cast(idVal.toDouble());
+ if (id == kHelloId) {
+ if (frame.contains("error")) {
+ socket_->abort(); // version mismatch or refused — bounce and retry
+ return;
+ }
+ sendRaw(kSubscribeId, QString::fromLatin1(method::kSessionSubscribe),
+ QJsonObject{{"events", QJsonArray{event::kTaskAdded, event::kTaskRemoved,
+ event::kTaskState, event::kTaskProgress,
+ event::kSpeedGlobal, event::kNotify}}});
+ return;
+ }
+ if (id == kSubscribeId) {
+ if (frame.contains("error")) {
+ socket_->abort();
+ return;
+ }
+ backoffMs_ = kMinBackoffMs;
+ setState(kStateConnected);
+ return;
+ }
+ const auto it = pendingTags_.constFind(id);
+ if (it != pendingTags_.cend()) {
+ const quint64 tag = it.value();
+ pendingTags_.erase(it);
+ emit callCompleted(tag, frame);
+ }
+ return;
+ }
+
+ const QString methodName = frame.value("method").toString();
+ if (!methodName.isEmpty()) {
+ emit notificationReceived(methodName, frame.value("params").toObject());
+ }
+}
+
+void RpcConnection::sendRaw(qint64 rpcId, const QString &methodName, const QJsonObject ¶ms) {
+ const QJsonObject req{{"jsonrpc", "2.0"},
+ {"id", static_cast(rpcId)},
+ {"method", methodName},
+ {"params", params}};
+ QByteArray line = QJsonDocument(req).toJson(QJsonDocument::Compact);
+ line += '\n';
+ socket_->write(line);
+}
+
+void RpcConnection::setState(int state) {
+ if (state_ == state) {
+ return;
+ }
+ state_ = state;
+ emit stateChanged(state);
+}
+
+} // namespace velox::gui::rpc
diff --git a/gui/src/rpc/RpcConnection.hpp b/gui/src/rpc/RpcConnection.hpp
new file mode 100644
index 0000000..c46a939
--- /dev/null
+++ b/gui/src/rpc/RpcConnection.hpp
@@ -0,0 +1,76 @@
+// The socket end of the RPC client. Lane GUI.
+//
+// Affined to a worker thread (see RpcClient). Owns the QLocalSocket, does line framing,
+// drives the session.hello / session.subscribe handshake, and reconnects with exponential
+// backoff. It never touches a widget: everything out of here is a queued signal.
+
+#pragma once
+
+#include
+#include
+#include
+#include
+#include
+
+class QLocalSocket;
+class QTimer;
+
+namespace velox::gui::rpc {
+
+class RpcConnection : public QObject {
+ Q_OBJECT
+
+ public:
+ explicit RpcConnection(QString socketPath, QObject *parent = nullptr);
+ ~RpcConnection() override;
+
+ public slots:
+ /// Begin connecting and keep the socket up until stop(). Idempotent.
+ void start();
+ /// Tear the socket down and cancel any pending reconnect. Idempotent.
+ void stop();
+ /// Enqueue one JSON-RPC call. `tag` is echoed back on callCompleted so RpcClient can
+ /// match it to a callback. Calls made while disconnected are dropped (ok() == false).
+ void sendCall(quint64 tag, const QString &methodName, const QJsonObject ¶ms);
+
+ signals:
+ /// int is a ConnectionState; kept as int so the cross-thread queued connection needs
+ /// no custom metatype registration.
+ void stateChanged(int state);
+ void notificationReceived(const QString &methodName, const QJsonObject ¶ms);
+ void callCompleted(quint64 tag, const QJsonObject &envelope);
+
+ private slots:
+ void onConnected();
+ void onDisconnected();
+ void onErrorOccurred();
+ void onReadyRead();
+ void attemptReconnect();
+
+ private:
+ void setState(int state);
+ void scheduleReconnect();
+ void sendRaw(qint64 rpcId, const QString &methodName, const QJsonObject ¶ms);
+ void dispatchFrame(const QJsonObject &frame);
+ void beginHandshake();
+
+ QString socketPath_;
+ QLocalSocket *socket_ = nullptr;
+ QTimer *reconnectTimer_ = nullptr;
+ QByteArray rxBuffer_;
+
+ bool wantConnected_ = false; ///< start() was called and stop() has not
+ int state_ = 0; ///< ConnectionState
+ int backoffMs_ = kMinBackoffMs;
+
+ qint64 nextRpcId_ = 100;
+ // rpc id -> caller tag, for regular calls only. Handshake ids are handled inline.
+ QHash pendingTags_;
+
+ static constexpr qint64 kHelloId = 1;
+ static constexpr qint64 kSubscribeId = 2;
+ static constexpr int kMinBackoffMs = 250;
+ static constexpr int kMaxBackoffMs = 8000;
+};
+
+} // namespace velox::gui::rpc
diff --git a/gui/src/widgets/ProgressDelegate.cpp b/gui/src/widgets/ProgressDelegate.cpp
new file mode 100644
index 0000000..e4a9996
--- /dev/null
+++ b/gui/src/widgets/ProgressDelegate.cpp
@@ -0,0 +1,44 @@
+#include "widgets/ProgressDelegate.hpp"
+
+#include
+#include
+#include
+
+#include "models/DownloadTableModel.hpp"
+
+namespace velox::gui {
+
+void ProgressDelegate::paint(QPainter *painter, const QStyleOptionViewItem &option,
+ const QModelIndex &index) const {
+ const QVariant progressVar = index.data(DownloadTableModel::ProgressRole);
+ const double progress = progressVar.isValid() ? progressVar.toDouble() : -1.0;
+
+ if (progress < 0.0) {
+ QStyledItemDelegate::paint(painter, option, index);
+ return;
+ }
+
+ QStyleOptionViewItem opt(option);
+ initStyleOption(&opt, index);
+ // We draw the bar ourselves; suppress the default text so it is not painted twice.
+ opt.text.clear();
+ QStyle *style = opt.widget ? opt.widget->style() : QApplication::style();
+ style->drawControl(QStyle::CE_ItemViewItem, &opt, painter, opt.widget);
+
+ QStyleOptionProgressBar bar;
+ bar.state = option.state | QStyle::State_Horizontal;
+ bar.direction = option.direction;
+ bar.rect = option.rect.adjusted(3, 3, -3, -3);
+ bar.fontMetrics = option.fontMetrics;
+ bar.palette = option.palette;
+ bar.minimum = 0;
+ bar.maximum = 10000;
+ bar.progress = static_cast(progress * 10000.0);
+ bar.textVisible = true;
+ bar.textAlignment = Qt::AlignCenter;
+ bar.text = index.data(Qt::DisplayRole).toString();
+
+ style->drawControl(QStyle::CE_ProgressBar, &bar, painter, opt.widget);
+}
+
+} // namespace velox::gui
diff --git a/gui/src/widgets/ProgressDelegate.hpp b/gui/src/widgets/ProgressDelegate.hpp
new file mode 100644
index 0000000..1697f90
--- /dev/null
+++ b/gui/src/widgets/ProgressDelegate.hpp
@@ -0,0 +1,23 @@
+// In-cell progress bar for the Status column. Lane GUI.
+//
+// docs/03-gui-spec.md §1: "Progress bar drawn by a QStyledItemDelegate in the Status
+// column." Reads DownloadTableModel::ProgressRole; falls back to plain text for rows with
+// no measurable progress (unknown size, or a non-downloading state).
+
+#pragma once
+
+#include
+
+namespace velox::gui {
+
+class ProgressDelegate : public QStyledItemDelegate {
+ Q_OBJECT
+
+ public:
+ using QStyledItemDelegate::QStyledItemDelegate;
+
+ void paint(QPainter *painter, const QStyleOptionViewItem &option,
+ const QModelIndex &index) const override;
+};
+
+} // namespace velox::gui
diff --git a/gui/tests/CMakeLists.txt b/gui/tests/CMakeLists.txt
new file mode 100644
index 0000000..52f3649
--- /dev/null
+++ b/gui/tests/CMakeLists.txt
@@ -0,0 +1,22 @@
+# GUI unit tests. Lane GUI.
+#
+# CLAUDE.md §5: a feature with no test does not exist. The model is the piece with real
+# logic that can be tested headless — the progress patch must be a narrow dataChanged and
+# must never reset the model (docs/03-gui-spec.md §1).
+
+set(CMAKE_AUTOMOC ON)
+
+find_package(Qt6 REQUIRED COMPONENTS Test)
+
+add_executable(tst_downloadtablemodel
+ tst_downloadtablemodel.cpp
+ ${CMAKE_CURRENT_SOURCE_DIR}/../src/models/DownloadTableModel.cpp
+)
+
+target_include_directories(tst_downloadtablemodel PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/../src)
+target_compile_features(tst_downloadtablemodel PRIVATE cxx_std_23)
+target_compile_options(tst_downloadtablemodel PRIVATE -Wall -Wextra -Wpedantic -Werror)
+target_link_libraries(tst_downloadtablemodel PRIVATE Qt6::Core Qt6::Test)
+
+add_test(NAME gui_downloadtablemodel COMMAND tst_downloadtablemodel)
+set_tests_properties(gui_downloadtablemodel PROPERTIES LABELS "gui")
diff --git a/gui/tests/tst_downloadtablemodel.cpp b/gui/tests/tst_downloadtablemodel.cpp
new file mode 100644
index 0000000..6fca154
--- /dev/null
+++ b/gui/tests/tst_downloadtablemodel.cpp
@@ -0,0 +1,112 @@
+// DownloadTableModel unit tests. Lane GUI.
+
+#include
+#include
+#include
+#include
+
+#include "models/DownloadTableModel.hpp"
+
+using velox::gui::DownloadTableModel;
+
+namespace {
+
+QJsonObject summary(const QString &id, const QString &state, qint64 size, qint64 done) {
+ return QJsonObject{
+ {"taskId", id},
+ {"filename", id + ".iso"},
+ {"url", "https://example.test/" + id},
+ {"state", state},
+ {"sizeBytes", double(size)},
+ {"downloadedBytes", double(done)},
+ {"speedBps", 0.0},
+ {"segments", 1.0},
+ {"resumable", true},
+ {"createdAt", "2026-09-10T00:00:00Z"},
+ };
+}
+
+} // namespace
+
+class TstDownloadTableModel : public QObject {
+ Q_OBJECT
+
+ private slots:
+ void resetPopulatesRows();
+ void progressPatchIsNarrowAndDoesNotReset();
+ void progressForUnknownTaskIsIgnored();
+ void addThenRemove();
+};
+
+void TstDownloadTableModel::resetPopulatesRows() {
+ DownloadTableModel model;
+ model.resetFromJson(
+ QJsonArray{summary("a", "downloading", 1000, 500), summary("b", "paused", 2000, 0)});
+
+ QCOMPARE(model.rowCount(), 2);
+ QCOMPARE(model.index(0, DownloadTableModel::ColName).data().toString(),
+ QStringLiteral("a.iso"));
+ QCOMPARE(model.index(0, DownloadTableModel::ColStatus).data().toString(),
+ QStringLiteral("50.0 %"));
+ QCOMPARE(model.index(0, 0).data(DownloadTableModel::ProgressRole).toDouble(), 0.5);
+}
+
+void TstDownloadTableModel::progressPatchIsNarrowAndDoesNotReset() {
+ DownloadTableModel model;
+ model.resetFromJson(QJsonArray{summary("a", "downloading", 1000, 100)});
+
+ QSignalSpy dataChangedSpy(&model, &QAbstractItemModel::dataChanged);
+ QSignalSpy resetSpy(&model, &QAbstractItemModel::modelReset);
+ QSignalSpy rowsInsertedSpy(&model, &QAbstractItemModel::rowsInserted);
+
+ model.applyProgress(QJsonArray{QJsonObject{
+ {"taskId", "a"}, {"downloadedBytes", 600.0}, {"speedBps", 50.0}, {"etaSeconds", 8.0}}});
+
+ QCOMPARE(resetSpy.count(), 0);
+ QCOMPARE(rowsInsertedSpy.count(), 0);
+ QCOMPARE(dataChangedSpy.count(), 1);
+
+ const auto args = dataChangedSpy.takeFirst();
+ const auto topLeft = args.at(0).toModelIndex();
+ const auto bottomRight = args.at(1).toModelIndex();
+ QCOMPARE(topLeft.row(), 0);
+ QCOMPARE(bottomRight.row(), 0);
+ QCOMPARE(topLeft.column(), int(DownloadTableModel::ColSize));
+ QCOMPARE(bottomRight.column(), int(DownloadTableModel::ColSpeed));
+ // The whole row must NOT be in the patch — Name/Queue stay untouched on a tick.
+ QVERIFY(topLeft.column() > int(DownloadTableModel::ColName));
+
+ QCOMPARE(model.index(0, DownloadTableModel::ColStatus).data().toString(),
+ QStringLiteral("60.0 %"));
+}
+
+void TstDownloadTableModel::progressForUnknownTaskIsIgnored() {
+ DownloadTableModel model;
+ model.resetFromJson(QJsonArray{summary("a", "downloading", 1000, 100)});
+
+ QSignalSpy dataChangedSpy(&model, &QAbstractItemModel::dataChanged);
+ model.applyProgress(QJsonArray{
+ QJsonObject{{"taskId", "ghost"}, {"downloadedBytes", 999.0}, {"speedBps", 1.0}}});
+ QCOMPARE(dataChangedSpy.count(), 0);
+ QCOMPARE(model.rowCount(), 1);
+}
+
+void TstDownloadTableModel::addThenRemove() {
+ DownloadTableModel model;
+ model.resetFromJson(QJsonArray{summary("a", "downloading", 1000, 100)});
+
+ QSignalSpy insertedSpy(&model, &QAbstractItemModel::rowsInserted);
+ model.applyTaskAdded(summary("c", "connecting", 4000, 0));
+ QCOMPARE(insertedSpy.count(), 1);
+ QCOMPARE(model.rowCount(), 2);
+
+ QSignalSpy removedSpy(&model, &QAbstractItemModel::rowsRemoved);
+ model.applyTaskRemoved(QStringLiteral("a"));
+ QCOMPARE(removedSpy.count(), 1);
+ QCOMPARE(model.rowCount(), 1);
+ QCOMPARE(model.index(0, 0).data(DownloadTableModel::TaskIdRole).toString(),
+ QStringLiteral("c"));
+}
+
+QTEST_GUILESS_MAIN(TstDownloadTableModel)
+#include "tst_downloadtablemodel.moc"