From 2a87abe96dbe2b836331cbc982c2bcdb22149184 Mon Sep 17 00:00:00 2001 From: sami Date: Thu, 10 Sep 2026 00:58:53 +0400 Subject: [PATCH] gui: RPC client, download table model, and a live main window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First vertical slice of velox-gui, built entirely against tools/mockd (no daemon dependency): - rpc/: RpcConnection runs a QLocalSocket on a worker thread with newline-delimited JSON-RPC framing, drives the session.hello / session.subscribe handshake, and reconnects with exponential backoff (250 ms -> 8 s). RpcClient is the main-thread face: marshals calls onto the worker, delivers replies as main-thread callbacks, re-emits server notifications as typed Qt signals, and issues the one-shot download.list on reaching Connected. - models/DownloadTableModel: QAbstractTableModel over TaskSummary. A progress batch is a row patch with a narrow dataChanged over the value columns only; beginResetModel() is reserved for the initial load and a reconnect resync. - widgets/ProgressDelegate: in-cell progress bar for the Status column. - mainwindow/MainWindow: the table, a status-bar connection dot, an offline banner instead of a modal, dialog-free pause/resume/stop actions, and QSettings column/geometry persistence. - gui/CMakeLists.txt links velox::proto (never velox::core, ADR 0009) and self-guards on the veloxproto target so main keeps configuring if it is ever absent again. - i18n from the first commit: every string via tr(), plus an Arabic .ts stub for the RTL check. - tests/: headless QTest for the model — proves the progress patch is a narrow dataChanged and never resets the model. Verified end-to-end against `mockd --tasks 300`: handshake, initial list, live progress batches applied to the model, and a clean Reconnecting -> Connected recovery when mockd is bounced mid-run. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_016Ne28kx4VreeBWZv82Nksd --- gui/CMakeLists.txt | 57 ++++++ gui/i18n/velox_ar.ts | 34 ++++ gui/src/main.cpp | 54 +++++ gui/src/mainwindow/MainWindow.cpp | 227 +++++++++++++++++++++ gui/src/mainwindow/MainWindow.hpp | 62 ++++++ gui/src/models/DownloadTableModel.cpp | 281 ++++++++++++++++++++++++++ gui/src/models/DownloadTableModel.hpp | 88 ++++++++ gui/src/rpc/Protocol.hpp | 87 ++++++++ gui/src/rpc/RpcClient.cpp | 127 ++++++++++++ gui/src/rpc/RpcClient.hpp | 73 +++++++ gui/src/rpc/RpcConnection.cpp | 205 +++++++++++++++++++ gui/src/rpc/RpcConnection.hpp | 76 +++++++ gui/src/widgets/ProgressDelegate.cpp | 44 ++++ gui/src/widgets/ProgressDelegate.hpp | 23 +++ gui/tests/CMakeLists.txt | 22 ++ gui/tests/tst_downloadtablemodel.cpp | 112 ++++++++++ 16 files changed, 1572 insertions(+) create mode 100644 gui/CMakeLists.txt create mode 100644 gui/i18n/velox_ar.ts create mode 100644 gui/src/main.cpp create mode 100644 gui/src/mainwindow/MainWindow.cpp create mode 100644 gui/src/mainwindow/MainWindow.hpp create mode 100644 gui/src/models/DownloadTableModel.cpp create mode 100644 gui/src/models/DownloadTableModel.hpp create mode 100644 gui/src/rpc/Protocol.hpp create mode 100644 gui/src/rpc/RpcClient.cpp create mode 100644 gui/src/rpc/RpcClient.hpp create mode 100644 gui/src/rpc/RpcConnection.cpp create mode 100644 gui/src/rpc/RpcConnection.hpp create mode 100644 gui/src/widgets/ProgressDelegate.cpp create mode 100644 gui/src/widgets/ProgressDelegate.hpp create mode 100644 gui/tests/CMakeLists.txt create mode 100644 gui/tests/tst_downloadtablemodel.cpp 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"