gui: RPC client, download table model, and a live main window

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 <[email protected]>
Claude-Session: https://claude.ai/code/session_016Ne28kx4VreeBWZv82Nksd
This commit is contained in:
2026-09-10 00:58:53 +04:00
co-authored by Claude Sonnet 5
parent 850e85de2a
commit 2a87abe96d
16 changed files with 1572 additions and 0 deletions
+87
View File
@@ -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 <QJsonObject>
#include <QJsonValue>
#include <QMetaType>
#include <QString>
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)
+127
View File
@@ -0,0 +1,127 @@
#include "rpc/RpcClient.hpp"
#include <utility>
#include <QLoggingCategory>
#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 &params,
std::function<void(const RpcReply &)> 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<ConnectionState>(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<long long>(items.size()));
emit taskListReset(items);
});
}
void RpcClient::onNotification(const QString &methodName, const QJsonObject &params) {
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<long long>(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
+73
View File
@@ -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 <functional>
#include <QHash>
#include <QJsonArray>
#include <QJsonObject>
#include <QObject>
#include <QString>
#include <QThread>
#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 &params,
std::function<void(const RpcReply &)> 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 &params);
void taskRemoved(const QString &taskId);
void speedGlobal(const QJsonObject &params);
void notify(const QJsonObject &params);
private slots:
void onConnectionState(int state);
void onNotification(const QString &methodName, const QJsonObject &params);
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<quint64, std::function<void(const RpcReply &)>> callbacks_;
};
} // namespace velox::gui::rpc
+205
View File
@@ -0,0 +1,205 @@
#include "rpc/RpcConnection.hpp"
#include <QJsonArray>
#include <QJsonDocument>
#include <QLocalSocket>
#include <QTimer>
#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<qsizetype>(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 &params) {
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<qint64>(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 &params) {
const QJsonObject req{{"jsonrpc", "2.0"},
{"id", static_cast<double>(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
+76
View File
@@ -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 <QByteArray>
#include <QHash>
#include <QJsonObject>
#include <QObject>
#include <QString>
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 &params);
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 &params);
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 &params);
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<qint64, quint64> 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