merge: lane/gui

This commit is contained in:
2026-09-12 11:05:07 +04:00
25 changed files with 2708 additions and 13 deletions
+6
View File
@@ -29,6 +29,12 @@ add_library(velox-gui-lib STATIC
src/dialogs/AddUrlDialog.cpp
src/dialogs/FileInfoDialog.cpp
src/dialogs/ProgressDialog.cpp
src/dialogs/OptionsDialog.cpp
src/dialogs/SchedulerDialog.cpp
src/dialogs/SpeedLimiterDialog.cpp
src/dialogs/BatchDialog.cpp
src/dialogs/GrabberWizard.cpp
src/tray/TrayIcon.cpp
src/mainwindow/CategoryPanel.cpp
src/mainwindow/MainWindow.cpp
)
+17
View File
@@ -37,3 +37,20 @@ as a reference for what "no size" currently looks like end to end.
Not urgent — mockd always supplies `sizeBytes`, so this doesn't block M1 GUI work — but
worth knowing before the M1 GUI↔real-daemon integration pass, since the percentage bar is
the headline visual of the main table.
## No method exists to stop `veloxd` itself
`docs/03-gui-spec.md` §5 describes the tray's Quit as asking "whether to also stop the
daemon". `contracts/schema/methods/` has nothing for it — no `daemon.stop`/`daemon.shutdown`,
and `Queue.onComplete`'s `"shutdown"` value is a *system* shutdown via
`org.freedesktop.login1`, a different thing entirely. This is a schema read, not something
that needed a live daemon to confirm — the method list is exhaustive and checkable
directly.
**Effect on the GUI:** `TrayIcon`'s Quit is implemented as "quit the GUI only; downloads
continue under the Velox service" — accurate today, since nothing else is possible, but it
means the spec's described behaviour (offer to also stop the service) has no RPC to build
on. Not urgent for M1 — CLI/systemd already stop `veloxd` outside the GUI — but worth a
`contracts/`-only PR (new privileged UDS-only method, `x-deadlineMs` generous enough to
cover in-flight transfers being paused first) before that tray menu item claims to do more
than it does.
+275
View File
@@ -0,0 +1,275 @@
#include "dialogs/BatchDialog.hpp"
#include <algorithm>
#include <QClipboard>
#include <QComboBox>
#include <QDialogButtonBox>
#include <QFormLayout>
#include <QGuiApplication>
#include <QLabel>
#include <QLineEdit>
#include <QListWidget>
#include <QPlainTextEdit>
#include <QPointer>
#include <QPushButton>
#include <QRegularExpression>
#include <QSet>
#include <QTabWidget>
#include <QUrl>
#include <QVBoxLayout>
#include "rpc/Protocol.hpp"
#include "rpc/RpcClient.hpp"
namespace velox::gui {
namespace {
constexpr int kMaxBatchItems = 5000; // download.addBatch's items.maxItems
bool looksLikeUrl(const QString &s) {
const QUrl u(s);
return u.isValid() && !u.host().isEmpty() &&
(u.scheme() == QLatin1String("http") || u.scheme() == QLatin1String("https"));
}
} // namespace
QStringList BatchDialog::parseUrlBlob(const QString &text) {
QStringList out;
QSet<QString> seen;
for (const QString &rawLine : text.split(QLatin1Char('\n'))) {
const QString line = rawLine.trimmed();
if (line.isEmpty() || !looksLikeUrl(line) || seen.contains(line)) {
continue;
}
seen.insert(line);
out << line;
}
return out;
}
QStringList BatchDialog::expandWildcard(const QString &pattern) {
static const QRegularExpression kRange(QStringLiteral(R"(\{(\d+)\.\.(\d+)\})"));
auto it = kRange.globalMatch(pattern);
QRegularExpressionMatch first;
int count = 0;
while (it.hasNext()) {
const QRegularExpressionMatch m = it.next();
if (count == 0) {
first = m;
}
++count;
}
if (count == 0) {
return looksLikeUrl(pattern) ? QStringList{pattern} : QStringList{};
}
if (count > 1) {
return {}; // more than one {a..b} token: ambiguous, refuse rather than guess
}
const QString startStr = first.captured(1);
const QString endStr = first.captured(2);
const int start = startStr.toInt();
const int end = endStr.toInt();
if (end < start) {
return {};
}
const bool zeroPad =
startStr.startsWith(QLatin1Char('0')) || endStr.startsWith(QLatin1Char('0'));
const int width = zeroPad ? std::max(startStr.size(), endStr.size()) : 0;
QStringList out;
for (int i = start; i <= end && out.size() < kMaxBatchItems; ++i) {
const QString num =
zeroPad ? QStringLiteral("%1").arg(i, width, 10, QLatin1Char('0')) : QString::number(i);
QString url = pattern;
url.replace(first.capturedStart(), first.capturedLength(), num);
if (looksLikeUrl(url)) {
out << url;
}
}
return out;
}
QJsonObject BatchDialog::buildAddBatchParams(const QStringList &urls, const QString &categoryId,
const QString &queueId, const QString &startMode) {
QJsonArray items;
for (const QString &url : urls) {
items.append(QJsonObject{{"url", url}});
}
QJsonObject defaults{{"startMode", startMode}};
if (!categoryId.isEmpty()) {
defaults["categoryId"] = categoryId;
}
if (startMode == QLatin1String("queue") && !queueId.isEmpty()) {
defaults["queueId"] = queueId;
}
return QJsonObject{{"items", items}, {"defaults", defaults}};
}
BatchDialog::BatchDialog(rpc::RpcClient *client, QJsonArray categories, QJsonArray queues,
QWidget *parent)
: QDialog(parent),
client_(client),
tabs_(new QTabWidget(this)),
clipboardText_(new QPlainTextEdit(this)),
clipboardPreview_(new QListWidget(this)),
wildcardPattern_(new QLineEdit(this)),
wildcardPreview_(new QListWidget(this)),
categoryCombo_(new QComboBox(this)),
startModeCombo_(new QComboBox(this)),
queueCombo_(new QComboBox(this)),
countLabel_(new QLabel(this)),
errorLabel_(new QLabel(this)),
addButton_(nullptr) {
setWindowTitle(tr("Batch Download"));
resize(560, 520);
auto *clipboardPage = new QWidget(this);
clipboardText_->setPlaceholderText(tr("Paste a list of URLs, one per line…"));
const QString clipboard = QGuiApplication::clipboard()->text();
if (!parseUrlBlob(clipboard).isEmpty()) {
clipboardText_->setPlainText(clipboard);
}
connect(clipboardText_, &QPlainTextEdit::textChanged, this,
&BatchDialog::updateClipboardPreview);
auto *clipboardLayout = new QVBoxLayout(clipboardPage);
clipboardLayout->addWidget(new QLabel(tr("URLs:"), clipboardPage));
clipboardLayout->addWidget(clipboardText_, 1);
clipboardLayout->addWidget(new QLabel(tr("Will add:"), clipboardPage));
clipboardLayout->addWidget(clipboardPreview_, 1);
tabs_->addTab(clipboardPage, tr("From Clipboard"));
auto *wildcardPage = new QWidget(this);
wildcardPattern_->setPlaceholderText(
tr("http://host/path/img{1..50}.jpg — one {start..end} range"));
connect(wildcardPattern_, &QLineEdit::textChanged, this, &BatchDialog::updateWildcardPreview);
auto *wildcardLayout = new QVBoxLayout(wildcardPage);
wildcardLayout->addWidget(new QLabel(tr("Pattern:"), wildcardPage));
wildcardLayout->addWidget(wildcardPattern_);
wildcardLayout->addWidget(new QLabel(tr("Will add:"), wildcardPage));
wildcardLayout->addWidget(wildcardPreview_, 1);
tabs_->addTab(wildcardPage, tr("With Wildcards"));
populateCategories(categories);
startModeCombo_->addItem(tr("Download Now"), QStringLiteral("now"));
startModeCombo_->addItem(tr("Download Later"), QStringLiteral("later"));
startModeCombo_->addItem(tr("Add to Queue"), QStringLiteral("queue"));
populateQueues(queues);
connect(startModeCombo_, &QComboBox::currentIndexChanged, this, [this] {
queueCombo_->setEnabled(startModeCombo_->currentData().toString() ==
QLatin1String("queue"));
});
queueCombo_->setEnabled(false);
errorLabel_->setStyleSheet(QStringLiteral("color: #c0392b;"));
errorLabel_->setWordWrap(true);
errorLabel_->hide();
auto *footer = new QFormLayout;
footer->addRow(tr("Category:"), categoryCombo_);
footer->addRow(tr("Start mode:"), startModeCombo_);
footer->addRow(tr("Queue:"), queueCombo_);
footer->addRow(countLabel_);
auto *buttons = new QDialogButtonBox(QDialogButtonBox::Cancel, this);
addButton_ = buttons->addButton(tr("Add"), QDialogButtonBox::AcceptRole);
connect(addButton_, &QPushButton::clicked, this, &BatchDialog::submit);
connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject);
auto *layout = new QVBoxLayout(this);
layout->addWidget(tabs_, 1);
layout->addLayout(footer);
layout->addWidget(errorLabel_);
layout->addWidget(buttons);
connect(tabs_, &QTabWidget::currentChanged, this, [this] {
updateClipboardPreview();
updateWildcardPreview();
});
updateClipboardPreview();
updateWildcardPreview();
}
void BatchDialog::populateCategories(const QJsonArray &categories) {
categoryCombo_->addItem(tr("(Automatic)"), QString());
for (const QJsonValue &v : categories) {
const QJsonObject c = v.toObject();
categoryCombo_->addItem(c.value("name").toString(), c.value("categoryId").toString());
}
}
void BatchDialog::populateQueues(const QJsonArray &queues) {
for (const QJsonValue &v : queues) {
const QJsonObject q = v.toObject();
queueCombo_->addItem(q.value("name").toString(), q.value("queueId").toString());
}
if (queues.isEmpty()) {
const int queueIdx = startModeCombo_->findData(QStringLiteral("queue"));
if (queueIdx >= 0) {
startModeCombo_->removeItem(queueIdx); // nothing to add it to
}
}
}
QStringList BatchDialog::activeUrls() const {
return tabs_->currentIndex() == 0 ? parseUrlBlob(clipboardText_->toPlainText())
: expandWildcard(wildcardPattern_->text().trimmed());
}
void BatchDialog::updateClipboardPreview() {
const QStringList urls = parseUrlBlob(clipboardText_->toPlainText());
clipboardPreview_->clear();
clipboardPreview_->addItems(urls);
if (tabs_->currentIndex() == 0) {
countLabel_->setText(tr("%n URL(s)", "", urls.size()));
addButton_->setEnabled(!urls.isEmpty());
}
}
void BatchDialog::updateWildcardPreview() {
const QStringList urls = expandWildcard(wildcardPattern_->text().trimmed());
wildcardPreview_->clear();
wildcardPreview_->addItems(urls);
if (tabs_->currentIndex() == 1) {
countLabel_->setText(tr("%n URL(s)", "", urls.size()));
addButton_->setEnabled(!urls.isEmpty());
}
}
void BatchDialog::submit() {
const QStringList urls = activeUrls();
if (urls.isEmpty()) {
return;
}
const QJsonObject params = buildAddBatchParams(urls, categoryCombo_->currentData().toString(),
queueCombo_->currentData().toString(),
startModeCombo_->currentData().toString());
addButton_->setEnabled(false);
QPointer<BatchDialog> self(this);
client_->call(QString::fromLatin1(rpc::method::kDownloadAddBatch), params,
[self](const rpc::RpcReply &reply) {
if (!self) {
return;
}
self->addButton_->setEnabled(true);
if (!reply.ok()) {
self->errorLabel_->setText(
tr("Could not add the batch: %1").arg(reply.error.message));
self->errorLabel_->show();
return;
}
const QJsonArray failed = reply.result.toObject().value("failed").toArray();
if (failed.isEmpty()) {
self->accept();
return;
}
self->errorLabel_->setText(
tr("%n item(s) could not be added; the rest were.", "", failed.size()));
self->errorLabel_->show();
});
}
} // namespace velox::gui
+76
View File
@@ -0,0 +1,76 @@
// Batch download dialog. Lane GUI.
//
// docs/03-gui-spec.md §5 describes this as two windows ("from clipboard" and "with
// wildcards"); they share everything but how the URL list is produced, so this is one
// dialog with a tab per source and a common category/queue/start-mode footer feeding a
// single download.addBatch call.
#pragma once
#include <QDialog>
#include <QJsonArray>
#include <QJsonObject>
class QComboBox;
class QLabel;
class QLineEdit;
class QListWidget;
class QPlainTextEdit;
class QPushButton;
class QTabWidget;
namespace velox::gui {
namespace rpc {
class RpcClient;
} // namespace rpc
class BatchDialog : public QDialog {
Q_OBJECT
public:
BatchDialog(rpc::RpcClient *client, QJsonArray categories, QJsonArray queues,
QWidget *parent = nullptr);
/// Pure: one URL per non-blank line, trimmed, deduplicated (first occurrence wins,
/// order preserved), kept only when it parses as an absolute http/https URL.
static QStringList parseUrlBlob(const QString &text);
/// Pure: expand a single "{start..end}" numeric range in `pattern` (the wildcard
/// dialog's `img{1..50}.jpg` example). Zero-padded when either bound has a leading
/// zero, so "{01..10}" produces "01".."10". No range found -> the pattern itself, if it
/// looks like an absolute URL; a malformed range (end < start, or more than one range)
/// -> empty. Capped at download.addBatch's 5000-item limit.
static QStringList expandWildcard(const QString &pattern);
/// Pure: the download.addBatch params for this URL list plus shared defaults. Only
/// non-empty optional fields are set; queueId only when startMode is "queue".
static QJsonObject buildAddBatchParams(const QStringList &urls, const QString &categoryId,
const QString &queueId, const QString &startMode);
private slots:
void updateClipboardPreview();
void updateWildcardPreview();
void submit();
private:
void populateCategories(const QJsonArray &categories);
void populateQueues(const QJsonArray &queues);
QStringList activeUrls() const;
rpc::RpcClient *client_;
QTabWidget *tabs_;
QPlainTextEdit *clipboardText_;
QListWidget *clipboardPreview_;
QLineEdit *wildcardPattern_;
QListWidget *wildcardPreview_;
QComboBox *categoryCombo_;
QComboBox *startModeCombo_;
QComboBox *queueCombo_;
QLabel *countLabel_;
QLabel *errorLabel_;
QPushButton *addButton_;
};
} // namespace velox::gui
+443
View File
@@ -0,0 +1,443 @@
#include "dialogs/GrabberWizard.hpp"
#include <utility>
#include <QAbstractItemView>
#include <QCheckBox>
#include <QComboBox>
#include <QFormLayout>
#include <QHBoxLayout>
#include <QHeaderView>
#include <QJsonArray>
#include <QLabel>
#include <QLineEdit>
#include <QLocale>
#include <QPointer>
#include <QPushButton>
#include <QRegularExpression>
#include <QSpinBox>
#include <QTableWidget>
#include <QTableWidgetItem>
#include <QTimer>
#include <QVBoxLayout>
#include "rpc/Protocol.hpp"
#include "rpc/RpcClient.hpp"
namespace velox::gui {
namespace {
QStringList splitList(const QString &text) {
QStringList out;
for (const QString &raw : text.split(QRegularExpression(QStringLiteral("[,\n]")))) {
const QString s = raw.trimmed();
if (!s.isEmpty()) {
out << s;
}
}
return out;
}
// --- Page 1: project (client-side label only; grabber.start has no template concept) ---
class GrabberProjectPage : public QWizardPage {
public:
explicit GrabberProjectPage(QWidget *parent) : QWizardPage(parent) {
setTitle(QObject::tr("Project"));
setSubTitle(QObject::tr("Give this crawl a name for your own reference."));
name_ = new QLineEdit(this);
name_->setPlaceholderText(QObject::tr("e.g. Photo gallery mirror"));
auto *layout = new QFormLayout(this);
layout->addRow(QObject::tr("Name:"), name_);
}
private:
QLineEdit *name_;
};
// --- Page 2: start URL, depth, host/pattern filters -------------------------------------
class GrabberStartPage : public QWizardPage {
public:
explicit GrabberStartPage(QWidget *parent) : QWizardPage(parent) {
setTitle(QObject::tr("Start URL"));
startUrl_ = new QLineEdit(this);
startUrl_->setPlaceholderText(QStringLiteral("https://example.com/gallery/"));
registerField(QStringLiteral("startUrl*"), startUrl_);
depth_ = new QSpinBox(this);
depth_->setRange(0, 10);
depth_->setValue(1);
sameHostOnly_ = new QCheckBox(QObject::tr("Stay on the same host"), this);
sameHostOnly_->setChecked(true);
includePatterns_ = new QLineEdit(this);
includePatterns_->setPlaceholderText(QObject::tr("comma-separated globs, optional"));
excludePatterns_ = new QLineEdit(this);
excludePatterns_->setPlaceholderText(QObject::tr("comma-separated globs, optional"));
auto *layout = new QFormLayout(this);
layout->addRow(QObject::tr("Start URL:"), startUrl_);
layout->addRow(QObject::tr("Crawl depth:"), depth_);
layout->addRow(sameHostOnly_);
layout->addRow(QObject::tr("Include only:"), includePatterns_);
layout->addRow(QObject::tr("Exclude:"), excludePatterns_);
}
QString startUrl() const { return startUrl_->text().trimmed(); }
int depth() const { return depth_->value(); }
bool sameHostOnly() const { return sameHostOnly_->isChecked(); }
QString includePatterns() const { return includePatterns_->text(); }
QString excludePatterns() const { return excludePatterns_->text(); }
private:
QLineEdit *startUrl_;
QSpinBox *depth_;
QCheckBox *sameHostOnly_;
QLineEdit *includePatterns_;
QLineEdit *excludePatterns_;
};
// --- Page 3: file-type filter ------------------------------------------------------------
class GrabberFileTypesPage : public QWizardPage {
public:
explicit GrabberFileTypesPage(QWidget *parent) : QWizardPage(parent) {
setTitle(QObject::tr("File types"));
setSubTitle(QObject::tr("Leave everything unchecked to crawl every file type."));
images_ = new QCheckBox(QObject::tr("Images (jpg, png, gif, webp)"), this);
archives_ = new QCheckBox(QObject::tr("Archives (zip, rar, 7z, tar, gz)"), this);
docs_ = new QCheckBox(QObject::tr("Documents (pdf, doc, docx, xls, xlsx)"), this);
video_ = new QCheckBox(QObject::tr("Video (mp4, mkv, avi, webm)"), this);
audio_ = new QCheckBox(QObject::tr("Audio (mp3, flac, wav, ogg)"), this);
custom_ = new QLineEdit(this);
custom_->setPlaceholderText(QObject::tr("custom extensions, comma-separated"));
auto *layout = new QVBoxLayout(this);
layout->addWidget(images_);
layout->addWidget(archives_);
layout->addWidget(docs_);
layout->addWidget(video_);
layout->addWidget(audio_);
auto *customRow = new QFormLayout;
customRow->addRow(QObject::tr("Also:"), custom_);
layout->addLayout(customRow);
}
bool images() const { return images_->isChecked(); }
bool archives() const { return archives_->isChecked(); }
bool docs() const { return docs_->isChecked(); }
bool video() const { return video_->isChecked(); }
bool audio() const { return audio_->isChecked(); }
QString customCsv() const { return custom_->text(); }
private:
QCheckBox *images_;
QCheckBox *archives_;
QCheckBox *docs_;
QCheckBox *video_;
QCheckBox *audio_;
QLineEdit *custom_;
};
// --- Page 4: crawl + review + harvest -----------------------------------------------------
class GrabberReviewPage : public QWizardPage {
Q_OBJECT
public:
GrabberReviewPage(rpc::RpcClient *client, QJsonArray categories, GrabberStartPage *startPage,
GrabberFileTypesPage *fileTypesPage, QWidget *parent)
: QWizardPage(parent),
client_(client),
startPage_(startPage),
fileTypesPage_(fileTypesPage) {
setTitle(QObject::tr("Review found files"));
setFinalPage(true);
statusLabel_ = new QLabel(this);
table_ = new QTableWidget(0, 3, this);
table_->setHorizontalHeaderLabels(
{QObject::tr("Get"), QObject::tr("File"), QObject::tr("Size")});
table_->horizontalHeader()->setSectionResizeMode(1, QHeaderView::Stretch);
table_->verticalHeader()->setVisible(false);
table_->setEditTriggers(QAbstractItemView::NoEditTriggers);
auto *selectAll = new QPushButton(QObject::tr("Select All"), this);
auto *selectNone = new QPushButton(QObject::tr("Select None"), this);
connect(selectAll, &QPushButton::clicked, this, [this] { setAllChecked(true); });
connect(selectNone, &QPushButton::clicked, this, [this] { setAllChecked(false); });
auto *selectRow = new QHBoxLayout;
selectRow->addWidget(selectAll);
selectRow->addWidget(selectNone);
selectRow->addStretch();
categoryCombo_ = new QComboBox(this);
categoryCombo_->addItem(QObject::tr("(Automatic)"), QString());
for (const QJsonValue &v : categories) {
const QJsonObject c = v.toObject();
categoryCombo_->addItem(c.value("name").toString(), c.value("categoryId").toString());
}
startModeCombo_ = new QComboBox(this);
startModeCombo_->addItem(QObject::tr("Download Now"), QStringLiteral("now"));
startModeCombo_->addItem(QObject::tr("Download Later"), QStringLiteral("later"));
errorLabel_ = new QLabel(this);
errorLabel_->setStyleSheet(QStringLiteral("color: #c0392b;"));
errorLabel_->hide();
auto *footer = new QFormLayout;
footer->addRow(QObject::tr("Category:"), categoryCombo_);
footer->addRow(QObject::tr("Start mode:"), startModeCombo_);
auto *layout = new QVBoxLayout(this);
layout->addWidget(statusLabel_);
layout->addLayout(selectRow);
layout->addWidget(table_, 1);
layout->addLayout(footer);
layout->addWidget(errorLabel_);
pollTimer_ = new QTimer(this);
pollTimer_->setInterval(5000); // catch-up fallback; live updates come from the event
connect(pollTimer_, &QTimer::timeout, this, &GrabberReviewPage::fetchStatus);
connect(client_, &rpc::RpcClient::grabberProgress, this, &GrabberReviewPage::onProgress);
}
void initializePage() override {
if (started_) {
return;
}
started_ = true;
statusLabel_->setText(QObject::tr("Starting crawl…"));
const QJsonValue fileTypes = GrabberWizard::buildFileTypes(
fileTypesPage_->images(), fileTypesPage_->archives(), fileTypesPage_->docs(),
fileTypesPage_->video(), fileTypesPage_->audio(), fileTypesPage_->customCsv());
QJsonObject params = GrabberWizard::buildStartParams(
startPage_->startUrl(), startPage_->depth(), startPage_->sameHostOnly(),
startPage_->includePatterns(), startPage_->excludePatterns(), fileTypes);
QPointer<GrabberReviewPage> self(this);
client_->call(
QString::fromLatin1(rpc::method::kGrabberStart), params,
[self](const rpc::RpcReply &reply) {
if (!self) {
return;
}
if (!reply.ok()) {
self->statusLabel_->setText(
QObject::tr("Could not start the crawl: %1").arg(reply.error.message));
return;
}
self->jobId_ = reply.result.toObject().value("jobId").toString();
self->pollTimer_->start();
self->fetchStatus();
});
}
bool isComplete() const override { return !files_.isEmpty(); }
/// QWizard calls this on Finish and only advances (closes, for the last page) when it
/// returns true. harvest is async, so this always returns false and closes the wizard
/// itself from the reply callback instead — the same submit-then-accept shape every
/// other dialog here uses, just routed through validatePage() rather than a button.
bool validatePage() override {
if (harvesting_) {
return false;
}
QStringList selected;
for (int row = 0; row < table_->rowCount(); ++row) {
if (table_->item(row, 0)->checkState() == Qt::Checked) {
selected << table_->item(row, 0)->data(Qt::UserRole).toString();
}
}
if (selected.isEmpty()) {
return false;
}
harvesting_ = true;
const QJsonObject params = GrabberWizard::buildHarvestParams(
jobId_, selected, categoryCombo_->currentData().toString(),
startModeCombo_->currentData().toString());
QPointer<GrabberReviewPage> self(this);
client_->call(
QString::fromLatin1(rpc::method::kGrabberHarvest), params,
[self](const rpc::RpcReply &reply) {
if (!self) {
return;
}
self->harvesting_ = false;
if (!reply.ok()) {
self->errorLabel_->setText(QObject::tr("Could not add the selected files: %1")
.arg(reply.error.message));
self->errorLabel_->show();
return;
}
self->wizard()->accept();
});
return false;
}
private:
void setAllChecked(bool checked) {
for (int row = 0; row < table_->rowCount(); ++row) {
table_->item(row, 0)->setCheckState(checked ? Qt::Checked : Qt::Unchecked);
}
}
void onProgress(const QJsonObject &params) {
if (params.value("jobId").toString() != jobId_ || jobId_.isEmpty()) {
return;
}
statusLabel_->setText(QObject::tr("Crawled %1, found %2…")
.arg(params.value("crawled").toInt())
.arg(params.value("found").toInt()));
if (params.value("done").toBool()) {
fetchStatus();
}
}
void fetchStatus() {
if (jobId_.isEmpty()) {
return;
}
QPointer<GrabberReviewPage> self(this);
client_->call(QString::fromLatin1(rpc::method::kGrabberStatus),
QJsonObject{{"jobId", jobId_}}, [self](const rpc::RpcReply &reply) {
if (!self || !reply.ok()) {
return;
}
self->applyStatus(reply.result.toObject());
});
}
void applyStatus(const QJsonObject &status) {
const QString state = status.value("state").toString();
files_ = status.value("files").toArray();
populateTable();
if (state == QLatin1String("crawling")) {
statusLabel_->setText(QObject::tr("Crawling… %1 found so far").arg(files_.size()));
} else if (state == QLatin1String("done")) {
pollTimer_->stop();
statusLabel_->setText(QObject::tr("Done — %1 file(s) found.").arg(files_.size()));
} else {
pollTimer_->stop();
statusLabel_->setText(
QObject::tr("Crawl %1: %2").arg(state, status.value("error").toString()));
}
emit completeChanged();
}
void populateTable() {
table_->setRowCount(files_.size());
for (int row = 0; row < files_.size(); ++row) {
const QJsonObject f = files_[row].toObject();
auto *checkItem =
new QTableWidgetItem(f.value("filename").toString(f.value("url").toString()));
checkItem->setFlags(checkItem->flags() | Qt::ItemIsUserCheckable);
checkItem->setCheckState(Qt::Checked);
checkItem->setData(Qt::UserRole, f.value("fileId").toString());
table_->setItem(row, 0, checkItem);
table_->setItem(row, 1, new QTableWidgetItem(f.value("url").toString()));
const QJsonValue size = f.value("sizeBytes");
table_->setItem(
row, 2,
new QTableWidgetItem(size.isDouble() ? QLocale().formattedDataSize(
static_cast<qint64>(size.toDouble()))
: QObject::tr("unknown")));
}
}
rpc::RpcClient *client_;
GrabberStartPage *startPage_;
GrabberFileTypesPage *fileTypesPage_;
QString jobId_;
QJsonArray files_;
bool started_ = false;
bool harvesting_ = false;
QLabel *statusLabel_;
QTableWidget *table_;
QComboBox *categoryCombo_;
QComboBox *startModeCombo_;
QLabel *errorLabel_;
QTimer *pollTimer_;
};
} // namespace
QJsonValue GrabberWizard::buildFileTypes(bool images, bool archives, bool docs, bool video,
bool audio, const QString &customCsv) {
QJsonArray types;
if (images) {
for (const char *e : {"jpg", "jpeg", "png", "gif", "webp"}) {
types.append(e);
}
}
if (archives) {
for (const char *e : {"zip", "rar", "7z", "tar", "gz"}) {
types.append(e);
}
}
if (docs) {
for (const char *e : {"pdf", "doc", "docx", "xls", "xlsx"}) {
types.append(e);
}
}
if (video) {
for (const char *e : {"mp4", "mkv", "avi", "webm"}) {
types.append(e);
}
}
if (audio) {
for (const char *e : {"mp3", "flac", "wav", "ogg"}) {
types.append(e);
}
}
for (const QString &e : splitList(customCsv)) {
types.append(e);
}
return types.isEmpty() ? QJsonValue(QJsonValue::Null) : QJsonValue(types);
}
QJsonObject GrabberWizard::buildStartParams(const QString &startUrl, int depth, bool sameHostOnly,
const QString &includePatterns,
const QString &excludePatterns,
const QJsonValue &fileTypes) {
const QStringList include = splitList(includePatterns);
const QStringList exclude = splitList(excludePatterns);
return QJsonObject{
{"startUrl", startUrl},
{"depth", depth},
{"sameHostOnly", sameHostOnly},
{"includePatterns", include.isEmpty() ? QJsonValue(QJsonValue::Null)
: QJsonValue(QJsonArray::fromStringList(include))},
{"excludePatterns", exclude.isEmpty() ? QJsonValue(QJsonValue::Null)
: QJsonValue(QJsonArray::fromStringList(exclude))},
{"fileTypes", fileTypes},
};
}
QJsonObject GrabberWizard::buildHarvestParams(const QString &jobId, const QStringList &selectedIds,
const QString &categoryId, const QString &startMode) {
QJsonObject defaults{{"url", QString()}, {"startMode", startMode}}; // url is ignored here
if (!categoryId.isEmpty()) {
defaults["categoryId"] = categoryId;
}
return QJsonObject{
{"jobId", jobId},
{"select", QJsonArray::fromStringList(selectedIds)},
{"defaults", defaults},
};
}
GrabberWizard::GrabberWizard(rpc::RpcClient *client, QJsonArray categories, QWidget *parent)
: QWizard(parent), client_(client), categories_(std::move(categories)) {
setWindowTitle(tr("Site Grabber"));
resize(640, 520);
addPage(new GrabberProjectPage(this));
auto *startPage = new GrabberStartPage(this);
addPage(startPage);
auto *fileTypesPage = new GrabberFileTypesPage(this);
addPage(fileTypesPage);
addPage(new GrabberReviewPage(client_, categories_, startPage, fileTypesPage, this));
}
} // namespace velox::gui
#include "GrabberWizard.moc"
+47
View File
@@ -0,0 +1,47 @@
// Site Grabber wizard. Lane GUI.
//
// docs/03-gui-spec.md §5: 4 steps — project (client-side only; grabber.start has no
// "template" concept) -> start URL + depth + filters -> file-type filter -> review found
// files. grabber.start only crawls; nothing downloads until grabber.harvest, which is the
// Finish button here.
#pragma once
#include <QJsonArray>
#include <QJsonObject>
#include <QWizard>
namespace velox::gui {
namespace rpc {
class RpcClient;
} // namespace rpc
class GrabberWizard : public QWizard {
Q_OBJECT
public:
GrabberWizard(rpc::RpcClient *client, QJsonArray categories, QWidget *parent = nullptr);
/// Pure: the fileTypes value for grabber.start. Empty selection (every checkbox off,
/// custom field blank) means "every type", which the schema spells as null rather than
/// an empty array.
static QJsonValue buildFileTypes(bool images, bool archives, bool docs, bool video, bool audio,
const QString &customCsv);
/// Pure: grabber.start params from already-resolved field values. include/exclude are
/// comma- or newline-separated glob lists; blank -> the schema's null ("no filter").
static QJsonObject buildStartParams(const QString &startUrl, int depth, bool sameHostOnly,
const QString &includePatterns,
const QString &excludePatterns,
const QJsonValue &fileTypes);
/// Pure: grabber.harvest params for the checked fileIds.
static QJsonObject buildHarvestParams(const QString &jobId, const QStringList &selectedIds,
const QString &categoryId, const QString &startMode);
private:
rpc::RpcClient *client_;
QJsonArray categories_;
};
} // namespace velox::gui
+518
View File
@@ -0,0 +1,518 @@
#include "dialogs/OptionsDialog.hpp"
#include <QCheckBox>
#include <QComboBox>
#include <QDialogButtonBox>
#include <QFileDialog>
#include <QFormLayout>
#include <QHBoxLayout>
#include <QJsonArray>
#include <QLabel>
#include <QLineEdit>
#include <QPointer>
#include <QPushButton>
#include <QSpinBox>
#include <QTabWidget>
#include <QVBoxLayout>
#include <QWidget>
#include "rpc/Protocol.hpp"
#include "rpc/RpcClient.hpp"
namespace velox::gui {
namespace {
struct BufferChoice {
const char *label;
qint64 bytes;
};
// 64 KiB - 16 MiB, matching Settings.schema.json's connection.bufferBytes range.
constexpr BufferChoice kBufferChoices[] = {
{"64 KiB", 64 * 1024}, {"128 KiB", 128 * 1024}, {"256 KiB", 256 * 1024},
{"512 KiB", 512 * 1024}, {"1 MiB", 1024 * 1024}, {"2 MiB", 2 * 1024 * 1024},
{"4 MiB", 4 * 1024 * 1024}, {"8 MiB", 8 * 1024 * 1024}, {"16 MiB", 16 * 1024 * 1024},
};
void setBufferCombo(QComboBox *combo, qint64 bytes) {
for (int i = 0; i < combo->count(); ++i) {
if (combo->itemData(i).toLongLong() == bytes) {
combo->setCurrentIndex(i);
return;
}
}
combo->setCurrentIndex(combo->count() / 2); // an unrecognized value: land near the middle
}
QStringList splitHosts(const QString &text) {
QStringList out;
for (const QString &h : text.split(QLatin1Char(','), Qt::SkipEmptyParts)) {
out << h.trimmed();
}
return out;
}
} // namespace
QStringList OptionsDialog::allKeys() {
return {
"general.launchOnLogin",
"general.minimizeToTray",
"general.showDropTarget",
"general.confirmOnExit",
"general.language",
"general.checkForUpdates",
"saveTo.defaultDir",
"saveTo.tempDir",
"saveTo.fileExistsPolicy",
"saveTo.createSubfolderPerSite",
"connection.preset",
"connection.maxSegmentsPerDownload",
"connection.bufferBytes",
"connection.maxConcurrentDownloads",
"connection.timeoutSec",
"connection.maxRetries",
"connection.retryBackoffSec",
"connection.maxTotalBufferBytes",
"connection.maxActiveSegments",
"downloads.speedLimitEnabled",
"downloads.speedLimitBps",
"downloads.virusScanCommand",
"downloads.postDownloadCommand",
"downloads.duplicatePolicy",
"downloads.verifyChecksums",
"proxy.mode",
"proxy.host",
"proxy.port",
"proxy.username",
"proxy.bypassHosts",
"proxy.pacUrl",
"sounds.enabled",
"sounds.onComplete",
"sounds.onQueueComplete",
"sounds.onError",
};
}
QJsonObject OptionsDialog::diffChanged(const QJsonObject &original, const QJsonObject &current) {
QJsonObject changed;
for (auto it = current.constBegin(); it != current.constEnd(); ++it) {
if (!original.contains(it.key()) || original.value(it.key()) != it.value()) {
changed.insert(it.key(), it.value());
}
}
return changed;
}
OptionsDialog::OptionsDialog(rpc::RpcClient *client, QWidget *parent)
: QDialog(parent),
client_(client),
tabs_(new QTabWidget(this)),
statusLabel_(new QLabel(this)) {
setWindowTitle(tr("Options"));
resize(560, 480);
buildGeneralTab();
buildSaveToTab();
buildConnectionTab();
buildDownloadsTab();
buildProxyTab();
buildSoundsTab();
statusLabel_->setStyleSheet(QStringLiteral("color: #c0392b;"));
statusLabel_->hide();
auto *buttons = new QDialogButtonBox(
QDialogButtonBox::Ok | QDialogButtonBox::Apply | QDialogButtonBox::Cancel, this);
connect(buttons, &QDialogButtonBox::accepted, this, [this] {
apply();
accept();
});
connect(buttons->button(QDialogButtonBox::Apply), &QPushButton::clicked, this,
&OptionsDialog::apply);
connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject);
auto *layout = new QVBoxLayout(this);
layout->addWidget(tabs_);
layout->addWidget(statusLabel_);
layout->addWidget(buttons);
tabs_->setEnabled(false); // stays disabled until settings.get answers
QPointer<OptionsDialog> self(this);
client_->call(QString::fromLatin1(rpc::method::kSettingsGet),
QJsonObject{{"keys", QJsonArray::fromStringList(allKeys())}},
[self](const rpc::RpcReply &reply) {
if (!self) {
return;
}
if (!reply.ok()) {
self->statusLabel_->setText(
tr("Could not load settings: %1").arg(reply.error.message));
self->statusLabel_->show();
return;
}
self->onSettingsLoaded(reply.result.toObject().value("values").toObject());
});
}
void OptionsDialog::onSettingsLoaded(const QJsonObject &values) {
original_ = values;
populateFrom(values);
tabs_->setEnabled(true);
}
void OptionsDialog::buildGeneralTab() {
auto *page = new QWidget(this);
launchOnLogin_ = new QCheckBox(tr("Launch on login"), page);
minimizeToTray_ = new QCheckBox(tr("Minimize to tray"), page);
showDropTarget_ = new QCheckBox(tr("Show floating drop target"), page);
confirmOnExit_ = new QCheckBox(tr("Confirm on exit"), page);
language_ = new QComboBox(page);
language_->addItem(tr("System default"), QStringLiteral("system"));
language_->addItem(tr("English"), QStringLiteral("en"));
language_->addItem(tr("العربية"), QStringLiteral("ar"));
checkForUpdates_ = new QCheckBox(tr("Check for updates"), page);
auto *form = new QFormLayout(page);
form->addRow(launchOnLogin_);
form->addRow(minimizeToTray_);
form->addRow(showDropTarget_);
form->addRow(confirmOnExit_);
form->addRow(tr("Language:"), language_);
form->addRow(checkForUpdates_);
tabs_->addTab(page, tr("General"));
}
void OptionsDialog::buildSaveToTab() {
auto *page = new QWidget(this);
defaultDir_ = new QLineEdit(page);
tempDir_ = new QLineEdit(page);
fileExistsPolicy_ = new QComboBox(page);
fileExistsPolicy_->addItem(tr("Ask"), QStringLiteral("ask"));
fileExistsPolicy_->addItem(tr("Rename"), QStringLiteral("rename"));
fileExistsPolicy_->addItem(tr("Overwrite"), QStringLiteral("overwrite"));
fileExistsPolicy_->addItem(tr("Resume"), QStringLiteral("resume"));
createSubfolderPerSite_ = new QCheckBox(tr("Create a subfolder per site"), page);
auto *defaultDirRow = new QWidget(page);
auto *defaultDirLayout = new QHBoxLayout(defaultDirRow);
defaultDirLayout->setContentsMargins(0, 0, 0, 0);
auto *browseDefault = new QPushButton(tr("Browse…"), defaultDirRow);
connect(browseDefault, &QPushButton::clicked, this, [this] { browseInto(defaultDir_, false); });
defaultDirLayout->addWidget(defaultDir_, 1);
defaultDirLayout->addWidget(browseDefault);
auto *tempDirRow = new QWidget(page);
auto *tempDirLayout = new QHBoxLayout(tempDirRow);
tempDirLayout->setContentsMargins(0, 0, 0, 0);
auto *browseTemp = new QPushButton(tr("Browse…"), tempDirRow);
connect(browseTemp, &QPushButton::clicked, this, [this] { browseInto(tempDir_, false); });
tempDirLayout->addWidget(tempDir_, 1);
tempDirLayout->addWidget(browseTemp);
auto *form = new QFormLayout(page);
form->addRow(tr("Default download directory:"), defaultDirRow);
form->addRow(tr("Temp folder:"), tempDirRow);
form->addRow(tr("If a file already exists:"), fileExistsPolicy_);
form->addRow(createSubfolderPerSite_);
tabs_->addTab(page, tr("Save To"));
}
void OptionsDialog::buildConnectionTab() {
auto *page = new QWidget(this);
connectionPreset_ = new QComboBox(page);
connectionPreset_->addItem(tr("Auto"), QStringLiteral("auto"));
connectionPreset_->addItem(tr("LAN"), QStringLiteral("lan"));
connectionPreset_->addItem(tr("Broadband"), QStringLiteral("broadband"));
connectionPreset_->addItem(tr("Slow"), QStringLiteral("slow"));
maxSegmentsPerDownload_ = new QSpinBox(page);
maxSegmentsPerDownload_->setRange(1, 32);
bufferBytes_ = new QComboBox(page);
for (const auto &choice : kBufferChoices) {
bufferBytes_->addItem(QString::fromLatin1(choice.label), QVariant::fromValue(choice.bytes));
}
maxConcurrentDownloads_ = new QSpinBox(page);
maxConcurrentDownloads_->setRange(1, 64);
timeoutSec_ = new QSpinBox(page);
timeoutSec_->setRange(1, 3600);
timeoutSec_->setSuffix(tr(" s"));
maxRetries_ = new QSpinBox(page);
maxRetries_->setRange(0, 100);
retryBackoffSec_ = new QSpinBox(page);
retryBackoffSec_->setRange(0, 3600);
retryBackoffSec_->setSuffix(tr(" s"));
maxTotalBufferMiB_ = new QSpinBox(page);
maxTotalBufferMiB_->setRange(16, 2048);
maxTotalBufferMiB_->setSuffix(tr(" MiB"));
maxActiveSegments_ = new QSpinBox(page);
maxActiveSegments_->setRange(1, 256);
auto *form = new QFormLayout(page);
form->addRow(tr("Connection type:"), connectionPreset_);
form->addRow(tr("Max connections per download:"), maxSegmentsPerDownload_);
form->addRow(tr("Write buffer per connection:"), bufferBytes_);
form->addRow(tr("Max concurrent downloads:"), maxConcurrentDownloads_);
form->addRow(tr("Timeout:"), timeoutSec_);
form->addRow(tr("Max retries:"), maxRetries_);
form->addRow(tr("Retry backoff:"), retryBackoffSec_);
form->addRow(tr("Max total buffer memory:"), maxTotalBufferMiB_);
form->addRow(tr("Max active segments (global):"), maxActiveSegments_);
tabs_->addTab(page, tr("Connection"));
}
void OptionsDialog::buildDownloadsTab() {
auto *page = new QWidget(this);
speedLimitEnabled_ = new QCheckBox(tr("Limit download speed by default"), page);
speedLimitKiBps_ = new QSpinBox(page);
speedLimitKiBps_->setRange(0, 1000000);
speedLimitKiBps_->setSuffix(tr(" KiB/s"));
connect(speedLimitEnabled_, &QCheckBox::toggled, speedLimitKiBps_, &QWidget::setEnabled);
virusScanCommand_ = new QLineEdit(page);
virusScanCommand_->setPlaceholderText(tr("e.g. clamscan %f"));
postDownloadCommand_ = new QLineEdit(page);
postDownloadCommand_->setPlaceholderText(tr("e.g. notify-send Downloaded %f"));
duplicatePolicy_ = new QComboBox(page);
duplicatePolicy_->addItem(tr("Ask"), QStringLiteral("ask"));
duplicatePolicy_->addItem(tr("Skip"), QStringLiteral("skip"));
duplicatePolicy_->addItem(tr("Rename"), QStringLiteral("rename"));
duplicatePolicy_->addItem(tr("Redownload"), QStringLiteral("redownload"));
verifyChecksums_ = new QCheckBox(tr("Verify checksums when available"), page);
auto *form = new QFormLayout(page);
form->addRow(speedLimitEnabled_);
form->addRow(tr("Speed limit:"), speedLimitKiBps_);
form->addRow(tr("Virus-scan command:"), virusScanCommand_);
form->addRow(tr("Post-download command:"), postDownloadCommand_);
form->addRow(tr("Duplicate URL:"), duplicatePolicy_);
form->addRow(verifyChecksums_);
tabs_->addTab(page, tr("Downloads"));
}
void OptionsDialog::buildProxyTab() {
auto *page = new QWidget(this);
proxyMode_ = new QComboBox(page);
proxyMode_->addItem(tr("System"), QStringLiteral("system"));
proxyMode_->addItem(tr("None"), QStringLiteral("none"));
proxyMode_->addItem(tr("HTTP"), QStringLiteral("http"));
proxyMode_->addItem(tr("HTTPS"), QStringLiteral("https"));
proxyMode_->addItem(tr("SOCKS5"), QStringLiteral("socks5"));
proxyMode_->addItem(tr("Automatic (PAC)"), QStringLiteral("pac"));
proxyHost_ = new QLineEdit(page);
proxyPort_ = new QSpinBox(page);
proxyPort_->setRange(1, 65535);
proxyUsername_ = new QLineEdit(page);
proxyBypassHosts_ = new QLineEdit(page);
proxyBypassHosts_->setPlaceholderText(tr("comma-separated, e.g. localhost, *.internal"));
proxyPacUrl_ = new QLineEdit(page);
const auto updateEnabled = [this] {
const QString mode = proxyMode_->currentData().toString();
const bool manual = mode == QLatin1String("http") || mode == QLatin1String("https") ||
mode == QLatin1String("socks5");
proxyHost_->setEnabled(manual);
proxyPort_->setEnabled(manual);
proxyUsername_->setEnabled(manual);
proxyPacUrl_->setEnabled(mode == QLatin1String("pac"));
};
connect(proxyMode_, &QComboBox::currentIndexChanged, this, updateEnabled);
updateEnabled();
auto *form = new QFormLayout(page);
form->addRow(tr("Mode:"), proxyMode_);
form->addRow(tr("Host:"), proxyHost_);
form->addRow(tr("Port:"), proxyPort_);
form->addRow(tr("Username:"), proxyUsername_);
form->addRow(
new QLabel(tr("(password is stored in the Secret Service, set separately)"), page));
form->addRow(tr("Bypass for:"), proxyBypassHosts_);
form->addRow(tr("PAC URL:"), proxyPacUrl_);
tabs_->addTab(page, tr("Proxy"));
}
void OptionsDialog::buildSoundsTab() {
auto *page = new QWidget(this);
soundsEnabled_ = new QCheckBox(tr("Play sounds"), page);
const auto soundRow = [this, page](QLineEdit *&edit) {
auto *row = new QWidget(page);
auto *layout = new QHBoxLayout(row);
layout->setContentsMargins(0, 0, 0, 0);
edit = new QLineEdit(row);
auto *browse = new QPushButton(tr("Browse…"), row);
connect(browse, &QPushButton::clicked, this, [this, edit] { browseInto(edit, true); });
layout->addWidget(edit, 1);
layout->addWidget(browse);
return row;
};
auto *completeRow = soundRow(soundOnComplete_);
auto *queueRow = soundRow(soundOnQueueComplete_);
auto *errorRow = soundRow(soundOnError_);
connect(soundsEnabled_, &QCheckBox::toggled, completeRow, &QWidget::setEnabled);
connect(soundsEnabled_, &QCheckBox::toggled, queueRow, &QWidget::setEnabled);
connect(soundsEnabled_, &QCheckBox::toggled, errorRow, &QWidget::setEnabled);
auto *form = new QFormLayout(page);
form->addRow(soundsEnabled_);
form->addRow(tr("Download complete:"), completeRow);
form->addRow(tr("Queue complete:"), queueRow);
form->addRow(tr("Error:"), errorRow);
tabs_->addTab(page, tr("Sounds"));
}
void OptionsDialog::browseInto(QLineEdit *target, bool pickFile) {
const QString picked =
pickFile ? QFileDialog::getOpenFileName(this, tr("Choose a sound file"), target->text())
: QFileDialog::getExistingDirectory(this, tr("Choose a folder"), target->text());
if (!picked.isEmpty()) {
target->setText(picked);
}
}
void OptionsDialog::populateFrom(const QJsonObject &v) {
launchOnLogin_->setChecked(v.value("general.launchOnLogin").toBool());
minimizeToTray_->setChecked(v.value("general.minimizeToTray").toBool());
showDropTarget_->setChecked(v.value("general.showDropTarget").toBool(true));
confirmOnExit_->setChecked(v.value("general.confirmOnExit").toBool());
const int langIdx = language_->findData(v.value("general.language").toString("system"));
language_->setCurrentIndex(langIdx >= 0 ? langIdx : 0);
checkForUpdates_->setChecked(v.value("general.checkForUpdates").toBool(true));
defaultDir_->setText(v.value("saveTo.defaultDir").toString());
tempDir_->setText(v.value("saveTo.tempDir").toString());
const int policyIdx =
fileExistsPolicy_->findData(v.value("saveTo.fileExistsPolicy").toString("ask"));
fileExistsPolicy_->setCurrentIndex(policyIdx >= 0 ? policyIdx : 0);
createSubfolderPerSite_->setChecked(v.value("saveTo.createSubfolderPerSite").toBool());
const int presetIdx =
connectionPreset_->findData(v.value("connection.preset").toString("auto"));
connectionPreset_->setCurrentIndex(presetIdx >= 0 ? presetIdx : 0);
maxSegmentsPerDownload_->setValue(
static_cast<int>(v.value("connection.maxSegmentsPerDownload").toInt(8)));
setBufferCombo(bufferBytes_,
static_cast<qint64>(v.value("connection.bufferBytes").toDouble(1048576)));
maxConcurrentDownloads_->setValue(
static_cast<int>(v.value("connection.maxConcurrentDownloads").toInt(5)));
timeoutSec_->setValue(static_cast<int>(v.value("connection.timeoutSec").toInt(30)));
maxRetries_->setValue(static_cast<int>(v.value("connection.maxRetries").toInt(3)));
retryBackoffSec_->setValue(static_cast<int>(v.value("connection.retryBackoffSec").toInt(5)));
maxTotalBufferMiB_->setValue(static_cast<int>(
v.value("connection.maxTotalBufferBytes").toDouble(134217728) / (1024 * 1024)));
maxActiveSegments_->setValue(
static_cast<int>(v.value("connection.maxActiveSegments").toInt(32)));
speedLimitEnabled_->setChecked(v.value("downloads.speedLimitEnabled").toBool());
speedLimitKiBps_->setValue(
static_cast<int>(v.value("downloads.speedLimitBps").toDouble() / 1024));
speedLimitKiBps_->setEnabled(speedLimitEnabled_->isChecked());
virusScanCommand_->setText(v.value("downloads.virusScanCommand").toString());
postDownloadCommand_->setText(v.value("downloads.postDownloadCommand").toString());
const int dupIdx =
duplicatePolicy_->findData(v.value("downloads.duplicatePolicy").toString("ask"));
duplicatePolicy_->setCurrentIndex(dupIdx >= 0 ? dupIdx : 0);
verifyChecksums_->setChecked(v.value("downloads.verifyChecksums").toBool());
const int modeIdx = proxyMode_->findData(v.value("proxy.mode").toString("system"));
proxyMode_->setCurrentIndex(modeIdx >= 0 ? modeIdx : 0);
proxyHost_->setText(v.value("proxy.host").toString());
proxyPort_->setValue(v.value("proxy.port").toInt(1080));
proxyUsername_->setText(v.value("proxy.username").toString());
QStringList bypass;
for (const QJsonValue &h : v.value("proxy.bypassHosts").toArray()) {
bypass << h.toString();
}
proxyBypassHosts_->setText(bypass.join(QStringLiteral(", ")));
proxyPacUrl_->setText(v.value("proxy.pacUrl").toString());
soundsEnabled_->setChecked(v.value("sounds.enabled").toBool());
soundOnComplete_->setText(v.value("sounds.onComplete").toString());
soundOnQueueComplete_->setText(v.value("sounds.onQueueComplete").toString());
soundOnError_->setText(v.value("sounds.onError").toString());
}
QJsonObject OptionsDialog::currentValues() const {
QJsonObject v;
v["general.launchOnLogin"] = launchOnLogin_->isChecked();
v["general.minimizeToTray"] = minimizeToTray_->isChecked();
v["general.showDropTarget"] = showDropTarget_->isChecked();
v["general.confirmOnExit"] = confirmOnExit_->isChecked();
v["general.language"] = language_->currentData().toString();
v["general.checkForUpdates"] = checkForUpdates_->isChecked();
v["saveTo.defaultDir"] = defaultDir_->text();
v["saveTo.tempDir"] = tempDir_->text();
v["saveTo.fileExistsPolicy"] = fileExistsPolicy_->currentData().toString();
v["saveTo.createSubfolderPerSite"] = createSubfolderPerSite_->isChecked();
v["connection.preset"] = connectionPreset_->currentData().toString();
v["connection.maxSegmentsPerDownload"] = maxSegmentsPerDownload_->value();
v["connection.bufferBytes"] = bufferBytes_->currentData().toLongLong();
v["connection.maxConcurrentDownloads"] = maxConcurrentDownloads_->value();
v["connection.timeoutSec"] = timeoutSec_->value();
v["connection.maxRetries"] = maxRetries_->value();
v["connection.retryBackoffSec"] = retryBackoffSec_->value();
v["connection.maxTotalBufferBytes"] =
static_cast<qint64>(maxTotalBufferMiB_->value()) * 1024 * 1024;
v["connection.maxActiveSegments"] = maxActiveSegments_->value();
v["downloads.speedLimitEnabled"] = speedLimitEnabled_->isChecked();
v["downloads.speedLimitBps"] = static_cast<qint64>(speedLimitKiBps_->value()) * 1024;
v["downloads.virusScanCommand"] = virusScanCommand_->text();
v["downloads.postDownloadCommand"] = postDownloadCommand_->text();
v["downloads.duplicatePolicy"] = duplicatePolicy_->currentData().toString();
v["downloads.verifyChecksums"] = verifyChecksums_->isChecked();
v["proxy.mode"] = proxyMode_->currentData().toString();
v["proxy.host"] = proxyHost_->text();
v["proxy.port"] = proxyPort_->value();
v["proxy.username"] = proxyUsername_->text();
v["proxy.bypassHosts"] = QJsonArray::fromStringList(splitHosts(proxyBypassHosts_->text()));
v["proxy.pacUrl"] = proxyPacUrl_->text();
v["sounds.enabled"] = soundsEnabled_->isChecked();
v["sounds.onComplete"] = soundOnComplete_->text();
v["sounds.onQueueComplete"] = soundOnQueueComplete_->text();
v["sounds.onError"] = soundOnError_->text();
return v;
}
void OptionsDialog::apply() {
const QJsonObject changed = diffChanged(original_, currentValues());
if (changed.isEmpty()) {
return;
}
QPointer<OptionsDialog> self(this);
client_->call(QString::fromLatin1(rpc::method::kSettingsSet), QJsonObject{{"values", changed}},
[self](const rpc::RpcReply &reply) {
if (!self) {
return;
}
if (!reply.ok()) {
self->statusLabel_->setText(
tr("Could not save settings: %1").arg(reply.error.message));
self->statusLabel_->show();
return;
}
self->statusLabel_->hide();
const QJsonObject written =
reply.result.toObject().value("values").toObject();
for (auto it = written.constBegin(); it != written.constEnd(); ++it) {
self->original_.insert(it.key(), it.value());
}
});
}
} // namespace velox::gui
+110
View File
@@ -0,0 +1,110 @@
// The Options dialog. Lane GUI.
//
// docs/03-gui-spec.md §4: every control here maps 1:1 onto a settings.* key from
// contracts/schema/types/Settings.schema.json. The spec's "File Types" and "Site Logins"
// tabs have no backing key (per-category extension lists live on Category via
// category.upsert, not settings.*; login credentials go to the Secret Service) — a real
// tab either binds to a real key or does not exist here, so those two are left out rather
// than shipped as fake affordances.
#pragma once
#include <QDialog>
#include <QJsonObject>
class QCheckBox;
class QComboBox;
class QLabel;
class QLineEdit;
class QSpinBox;
class QTabWidget;
namespace velox::gui {
namespace rpc {
class RpcClient;
} // namespace rpc
class OptionsDialog : public QDialog {
Q_OBJECT
public:
explicit OptionsDialog(rpc::RpcClient *client, QWidget *parent = nullptr);
/// The settings.* keys every tab's controls bind to, in the order settings.get is
/// called with. Exposed so a test can assert nothing here drifts from the schema.
static QStringList allKeys();
/// Pure: keys in `current` whose value differs from `original` (or is altogether
/// absent from `original`). settings.set is documented to write and broadcast exactly
/// the keys present in its params, so sending only what actually changed matters.
static QJsonObject diffChanged(const QJsonObject &original, const QJsonObject &current);
private slots:
void onSettingsLoaded(const QJsonObject &values);
void apply();
private:
void buildGeneralTab();
void buildSaveToTab();
void buildConnectionTab();
void buildDownloadsTab();
void buildProxyTab();
void buildSoundsTab();
void populateFrom(const QJsonObject &values);
QJsonObject currentValues() const;
void browseInto(QLineEdit *target, bool pickFile);
rpc::RpcClient *client_;
QJsonObject original_; // last-known-saved values, keyed by settings.* name
QTabWidget *tabs_;
QLabel *statusLabel_;
// General
QCheckBox *launchOnLogin_;
QCheckBox *minimizeToTray_;
QCheckBox *showDropTarget_;
QCheckBox *confirmOnExit_;
QComboBox *language_;
QCheckBox *checkForUpdates_;
// Save To
QLineEdit *defaultDir_;
QLineEdit *tempDir_;
QComboBox *fileExistsPolicy_;
QCheckBox *createSubfolderPerSite_;
// Connection
QComboBox *connectionPreset_;
QSpinBox *maxSegmentsPerDownload_;
QComboBox *bufferBytes_;
QSpinBox *maxConcurrentDownloads_;
QSpinBox *timeoutSec_;
QSpinBox *maxRetries_;
QSpinBox *retryBackoffSec_;
QSpinBox *maxTotalBufferMiB_;
QSpinBox *maxActiveSegments_;
// Downloads
QCheckBox *speedLimitEnabled_;
QSpinBox *speedLimitKiBps_;
QLineEdit *virusScanCommand_;
QLineEdit *postDownloadCommand_;
QComboBox *duplicatePolicy_;
QCheckBox *verifyChecksums_;
// Proxy
QComboBox *proxyMode_;
QLineEdit *proxyHost_;
QSpinBox *proxyPort_;
QLineEdit *proxyUsername_;
QLineEdit *proxyBypassHosts_;
QLineEdit *proxyPacUrl_;
// Sounds
QCheckBox *soundsEnabled_;
QLineEdit *soundOnComplete_;
QLineEdit *soundOnQueueComplete_;
QLineEdit *soundOnError_;
};
} // namespace velox::gui
+321
View File
@@ -0,0 +1,321 @@
#include "dialogs/SchedulerDialog.hpp"
#include <QCheckBox>
#include <QComboBox>
#include <QDateEdit>
#include <QDialogButtonBox>
#include <QFormLayout>
#include <QHBoxLayout>
#include <QJsonArray>
#include <QLabel>
#include <QListWidget>
#include <QPointer>
#include <QPushButton>
#include <QSpinBox>
#include <QTimeEdit>
#include <QVBoxLayout>
#include "rpc/Protocol.hpp"
#include "rpc/RpcClient.hpp"
namespace velox::gui {
namespace {
// 0 = Sunday, matching Schedule.schema.json's daysOfWeek.
constexpr const char *kDayLabels[7] = {
QT_TR_NOOP("Sun"), QT_TR_NOOP("Mon"), QT_TR_NOOP("Tue"), QT_TR_NOOP("Wed"),
QT_TR_NOOP("Thu"), QT_TR_NOOP("Fri"), QT_TR_NOOP("Sat"),
};
} // namespace
QJsonValue SchedulerDialog::buildSchedule(bool useSchedule, const QString &mode,
const QString &startTime, bool runUntilDrains,
const QString &stopTime, const QList<int> &daysOfWeek,
const QString &onceDate) {
if (!useSchedule) {
return QJsonValue(QJsonValue::Null);
}
QJsonObject s{
{"enabled", true},
{"mode", mode},
{"startTime", startTime},
{"stopTime", runUntilDrains ? QJsonValue(QJsonValue::Null) : QJsonValue(stopTime)},
};
if (mode == QLatin1String("periodic")) {
QJsonArray days;
for (int d : daysOfWeek) {
days.append(d);
}
s["daysOfWeek"] = days;
} else {
s["onceDate"] = onceDate;
}
return s;
}
SchedulerDialog::SchedulerDialog(rpc::RpcClient *client, QWidget *parent)
: QDialog(parent),
client_(client),
queueList_(new QListWidget(this)),
useSchedule_(new QCheckBox(tr("Run this queue on a schedule"), this)),
mode_(new QComboBox(this)),
startTime_(new QTimeEdit(this)),
runUntilDrains_(new QCheckBox(tr("Run until the queue drains"), this)),
stopTime_(new QTimeEdit(this)),
onceDate_(new QDateEdit(this)),
maxConcurrent_(new QSpinBox(this)),
onComplete_(new QComboBox(this)),
pauseRunningOnStop_(new QCheckBox(tr("Pause running tasks too"), this)),
statusLabel_(new QLabel(this)) {
setWindowTitle(tr("Scheduler"));
resize(640, 420);
queueList_->setMaximumWidth(200);
mode_->addItem(tr("Once"), QStringLiteral("once"));
mode_->addItem(tr("Periodic (weekly)"), QStringLiteral("periodic"));
startTime_->setDisplayFormat(QStringLiteral("HH:mm"));
stopTime_->setDisplayFormat(QStringLiteral("HH:mm"));
onceDate_->setCalendarPopup(true);
maxConcurrent_->setRange(1, 32);
onComplete_->addItem(tr("Do nothing"), QStringLiteral("nothing"));
onComplete_->addItem(tr("Exit Velox"), QStringLiteral("exit"));
onComplete_->addItem(tr("Shut down"), QStringLiteral("shutdown"));
onComplete_->addItem(tr("Hang up (disconnect network)"), QStringLiteral("hangup"));
auto *daysRow = new QWidget(this);
auto *daysLayout = new QHBoxLayout(daysRow);
daysLayout->setContentsMargins(0, 0, 0, 0);
for (int i = 0; i < 7; ++i) {
dayChecks_[i] = new QCheckBox(tr(kDayLabels[i]), daysRow);
daysLayout->addWidget(dayChecks_[i]);
}
auto *stopRow = new QWidget(this);
auto *stopLayout = new QHBoxLayout(stopRow);
stopLayout->setContentsMargins(0, 0, 0, 0);
stopLayout->addWidget(stopTime_);
stopLayout->addWidget(runUntilDrains_);
connect(runUntilDrains_, &QCheckBox::toggled, stopTime_, &QWidget::setDisabled);
const auto updateModeVisibility = [this, daysRow] {
const bool periodic = mode_->currentData().toString() == QLatin1String("periodic");
daysRow->setVisible(periodic);
onceDate_->setVisible(!periodic);
};
connect(mode_, &QComboBox::currentIndexChanged, this, updateModeVisibility);
updateModeVisibility();
form_ = new QWidget(this);
auto *form = new QFormLayout(form_);
form->addRow(useSchedule_);
form->addRow(tr("Mode:"), mode_);
form->addRow(tr("Start time:"), startTime_);
form->addRow(tr("Stop time:"), stopRow);
form->addRow(tr("Days:"), daysRow);
form->addRow(tr("Date:"), onceDate_);
form->addRow(tr("Max concurrent:"), maxConcurrent_);
form->addRow(tr("When the queue finishes:"), onComplete_);
connect(useSchedule_, &QCheckBox::toggled, this, [this](bool on) {
for (QWidget *w :
{static_cast<QWidget *>(mode_), static_cast<QWidget *>(startTime_),
static_cast<QWidget *>(stopTime_), static_cast<QWidget *>(runUntilDrains_),
static_cast<QWidget *>(onceDate_)}) {
w->setEnabled(on);
}
});
statusLabel_->setStyleSheet(QStringLiteral("color: #c0392b;"));
statusLabel_->hide();
form_->setEnabled(false); // no queue selected yet
auto *startButton = new QPushButton(tr("Start Now"), this);
auto *stopButton = new QPushButton(tr("Stop"), this);
auto *saveButton = new QPushButton(tr("Save"), this);
saveButton->setDefault(true);
connect(startButton, &QPushButton::clicked, this, &SchedulerDialog::startNow);
connect(stopButton, &QPushButton::clicked, this, &SchedulerDialog::stopNow);
connect(saveButton, &QPushButton::clicked, this, &SchedulerDialog::save);
auto *actionRow = new QHBoxLayout;
actionRow->addWidget(startButton);
actionRow->addWidget(stopButton);
actionRow->addWidget(pauseRunningOnStop_);
actionRow->addStretch();
actionRow->addWidget(saveButton);
auto *closeButtons = new QDialogButtonBox(QDialogButtonBox::Close, this);
connect(closeButtons, &QDialogButtonBox::rejected, this, &QDialog::reject);
connect(closeButtons, &QDialogButtonBox::accepted, this, &QDialog::accept);
auto *rightLayout = new QVBoxLayout;
rightLayout->addWidget(form_, 1);
rightLayout->addWidget(statusLabel_);
rightLayout->addLayout(actionRow);
rightLayout->addWidget(closeButtons);
auto *split = new QHBoxLayout(this);
split->addWidget(queueList_);
split->addLayout(rightLayout, 1);
connect(queueList_, &QListWidget::currentItemChanged, this, &SchedulerDialog::onQueueSelected);
QPointer<SchedulerDialog> self(this);
client_->call(QString::fromLatin1(rpc::method::kQueueList), {},
[self](const rpc::RpcReply &reply) {
if (!self || !reply.ok()) {
return;
}
self->onQueuesLoaded(reply.result.toObject().value("items").toArray());
});
}
void SchedulerDialog::onQueuesLoaded(const QJsonArray &items) {
queues_ = items;
queueList_->clear();
for (const QJsonValue &v : items) {
auto *item = new QListWidgetItem(v.toObject().value("name").toString(), queueList_);
item->setData(Qt::UserRole, v.toObject().value("queueId").toString());
}
if (queueList_->count() > 0) {
queueList_->setCurrentRow(0);
}
}
QJsonObject SchedulerDialog::selectedQueue() const {
if (!queueList_->currentItem()) {
return {};
}
const QString id = queueList_->currentItem()->data(Qt::UserRole).toString();
for (const QJsonValue &v : queues_) {
if (v.toObject().value("queueId").toString() == id) {
return v.toObject();
}
}
return {};
}
void SchedulerDialog::onQueueSelected(QListWidgetItem *item, QListWidgetItem * /*previous*/) {
if (!item) {
form_->setEnabled(false);
return;
}
populateForm(selectedQueue());
form_->setEnabled(true);
}
void SchedulerDialog::populateForm(const QJsonObject &queue) {
maxConcurrent_->setValue(queue.value("maxConcurrent").toInt(1));
const int completeIdx = onComplete_->findData(queue.value("onComplete").toString("nothing"));
onComplete_->setCurrentIndex(completeIdx >= 0 ? completeIdx : 0);
const QJsonValue schedule = queue.value("schedule");
const bool has = schedule.isObject();
useSchedule_->setChecked(has);
for (QWidget *w : {static_cast<QWidget *>(mode_), static_cast<QWidget *>(startTime_),
static_cast<QWidget *>(stopTime_), static_cast<QWidget *>(runUntilDrains_),
static_cast<QWidget *>(onceDate_)}) {
w->setEnabled(has);
}
if (!has) {
return;
}
const QJsonObject sched = schedule.toObject();
const int modeIdx = mode_->findData(sched.value("mode").toString("once"));
mode_->setCurrentIndex(modeIdx >= 0 ? modeIdx : 0);
startTime_->setTime(QTime::fromString(sched.value("startTime").toString("00:00"), "HH:mm"));
const bool stopIsNull = sched.value("stopTime").isNull();
runUntilDrains_->setChecked(stopIsNull);
if (!stopIsNull) {
stopTime_->setTime(QTime::fromString(sched.value("stopTime").toString(), "HH:mm"));
}
for (int i = 0; i < 7; ++i) {
dayChecks_[i]->setChecked(false);
}
for (const QJsonValue &d : sched.value("daysOfWeek").toArray()) {
const int idx = d.toInt();
if (idx >= 0 && idx < 7) {
dayChecks_[idx]->setChecked(true);
}
}
if (!sched.value("onceDate").isNull()) {
onceDate_->setDate(QDate::fromString(sched.value("onceDate").toString(), Qt::ISODate));
}
}
void SchedulerDialog::save() {
const QJsonObject queue = selectedQueue();
const QString queueId = queue.value("queueId").toString();
if (queueId.isEmpty()) {
return;
}
QList<int> days;
for (int i = 0; i < 7; ++i) {
if (dayChecks_[i]->isChecked()) {
days << i;
}
}
const QJsonValue schedule = buildSchedule(
useSchedule_->isChecked(), mode_->currentData().toString(),
startTime_->time().toString(QStringLiteral("HH:mm")), runUntilDrains_->isChecked(),
stopTime_->time().toString(QStringLiteral("HH:mm")), days,
onceDate_->date().toString(Qt::ISODate));
QPointer<SchedulerDialog> self(this);
client_->call(QString::fromLatin1(rpc::method::kScheduleSet),
QJsonObject{{"queueId", queueId}, {"schedule", schedule}},
[self, queueId](const rpc::RpcReply &reply) {
if (!self) {
return;
}
if (!reply.ok()) {
self->statusLabel_->setText(
tr("Could not save the schedule: %1").arg(reply.error.message));
self->statusLabel_->show();
return;
}
self->statusLabel_->hide();
});
// Queue-level fields (maxConcurrent/onComplete) go through queue.upsert; taskIds is
// ignored by the daemon so re-sending the cached membership cannot drop a task.
QJsonObject updatedQueue = queue;
updatedQueue["maxConcurrent"] = maxConcurrent_->value();
updatedQueue["onComplete"] = onComplete_->currentData().toString();
client_->call(QString::fromLatin1(rpc::method::kQueueUpsert),
QJsonObject{{"queue", updatedQueue}}, [self](const rpc::RpcReply &reply) {
if (!self || !reply.ok()) {
return;
}
const QJsonObject updated = reply.result.toObject().value("queue").toObject();
for (int i = 0; i < self->queues_.size(); ++i) {
if (self->queues_[i].toObject().value("queueId").toString() ==
updated.value("queueId").toString()) {
self->queues_[i] = updated;
break;
}
}
});
}
void SchedulerDialog::startNow() {
const QString queueId = selectedQueue().value("queueId").toString();
if (queueId.isEmpty()) {
return;
}
client_->call(QString::fromLatin1(rpc::method::kQueueStart), QJsonObject{{"queueId", queueId}});
}
void SchedulerDialog::stopNow() {
const QString queueId = selectedQueue().value("queueId").toString();
if (queueId.isEmpty()) {
return;
}
client_->call(
QString::fromLatin1(rpc::method::kQueueStop),
QJsonObject{{"queueId", queueId}, {"pauseRunning", pauseRunningOnStop_->isChecked()}});
}
} // namespace velox::gui
+73
View File
@@ -0,0 +1,73 @@
// The Scheduler window. Lane GUI.
//
// docs/03-gui-spec.md §5: per-queue start/stop time, days of week, one-time vs periodic,
// "hang up/exit when done", max concurrent per queue. Queue.schema.json already carries
// schedule/maxConcurrent/onComplete, so queue.list alone seeds the whole window; saving
// splits across schedule.set (the timing, its own method and event) and queue.upsert (the
// queue-level fields queue.list doesn't route through schedule.set).
#pragma once
#include <QDialog>
#include <QJsonArray>
#include <QJsonObject>
class QCheckBox;
class QComboBox;
class QDateEdit;
class QLabel;
class QListWidget;
class QListWidgetItem;
class QSpinBox;
class QTimeEdit;
class QWidget;
namespace velox::gui {
namespace rpc {
class RpcClient;
} // namespace rpc
class SchedulerDialog : public QDialog {
Q_OBJECT
public:
explicit SchedulerDialog(rpc::RpcClient *client, QWidget *parent = nullptr);
/// Pure: the Schedule value to send (JSON null clears it and leaves the queue under
/// manual control). daysOfWeek/onceDate are only meaningful for their own mode and are
/// left out of the result when the other mode is selected, matching the schema note
/// that daysOfWeek is "ignored when mode is 'once'".
static QJsonValue buildSchedule(bool useSchedule, const QString &mode, const QString &startTime,
bool runUntilDrains, const QString &stopTime,
const QList<int> &daysOfWeek, const QString &onceDate);
private slots:
void onQueuesLoaded(const QJsonArray &items);
void onQueueSelected(QListWidgetItem *item, QListWidgetItem *previous);
void save();
void startNow();
void stopNow();
private:
void populateForm(const QJsonObject &queue);
QJsonObject selectedQueue() const;
rpc::RpcClient *client_;
QJsonArray queues_; // last-known-saved Queue objects, as loaded from queue.list
QListWidget *queueList_;
QCheckBox *useSchedule_;
QComboBox *mode_;
QTimeEdit *startTime_;
QCheckBox *runUntilDrains_;
QTimeEdit *stopTime_;
QCheckBox *dayChecks_[7];
QDateEdit *onceDate_;
QSpinBox *maxConcurrent_;
QComboBox *onComplete_;
QCheckBox *pauseRunningOnStop_;
QLabel *statusLabel_;
QWidget *form_;
};
} // namespace velox::gui
+103
View File
@@ -0,0 +1,103 @@
#include "dialogs/SpeedLimiterDialog.hpp"
#include <algorithm>
#include <QCheckBox>
#include <QDialogButtonBox>
#include <QFormLayout>
#include <QLabel>
#include <QPointer>
#include <QSpinBox>
#include <QVBoxLayout>
#include "rpc/Protocol.hpp"
#include "rpc/RpcClient.hpp"
namespace velox::gui {
QJsonObject SpeedLimiterDialog::buildParams(bool enabled, int kibps, bool applyToRunning) {
const qint64 bps = enabled ? static_cast<qint64>(std::max(1, kibps)) * 1024 : 0;
return QJsonObject{
{"enabled", enabled},
{"globalBps", bps},
{"applyToRunning", applyToRunning},
};
}
SpeedLimiterDialog::SpeedLimiterDialog(rpc::RpcClient *client, QWidget *parent)
: QDialog(parent),
client_(client),
enabled_(new QCheckBox(tr("Limit download speed"), this)),
kibps_(new QSpinBox(this)),
applyToRunning_(new QCheckBox(tr("Apply to running downloads now"), this)),
statusLabel_(new QLabel(this)) {
setWindowTitle(tr("Speed Limiter"));
kibps_->setRange(1, 1000000);
kibps_->setSuffix(tr(" KiB/s"));
kibps_->setEnabled(false);
connect(enabled_, &QCheckBox::toggled, kibps_, &QWidget::setEnabled);
statusLabel_->setStyleSheet(QStringLiteral("color: #c0392b;"));
statusLabel_->hide();
auto *form = new QFormLayout;
form->addRow(enabled_);
form->addRow(tr("Limit to:"), kibps_);
form->addRow(applyToRunning_);
auto *buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this);
connect(buttons, &QDialogButtonBox::accepted, this, &SpeedLimiterDialog::save);
connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject);
auto *layout = new QVBoxLayout(this);
layout->addLayout(form);
layout->addWidget(statusLabel_);
layout->addWidget(buttons);
setEnabled(false);
QPointer<SpeedLimiterDialog> self(this);
client_->call(QString::fromLatin1(rpc::method::kLimiterGet), {},
[self](const rpc::RpcReply &reply) {
if (!self) {
return;
}
self->setEnabled(true);
if (!reply.ok()) {
self->statusLabel_->setText(
tr("Could not load the limiter: %1").arg(reply.error.message));
self->statusLabel_->show();
return;
}
self->onLoaded(reply.result.toObject());
});
}
void SpeedLimiterDialog::onLoaded(const QJsonObject &limiter) {
const bool on = limiter.value("enabled").toBool();
const qint64 bps = static_cast<qint64>(limiter.value("globalBps").toDouble());
enabled_->setChecked(on);
kibps_->setValue(std::max<qint64>(1, bps / 1024));
kibps_->setEnabled(on);
}
void SpeedLimiterDialog::save() {
const QJsonObject params =
buildParams(enabled_->isChecked(), kibps_->value(), applyToRunning_->isChecked());
QPointer<SpeedLimiterDialog> self(this);
client_->call(QString::fromLatin1(rpc::method::kLimiterSet), params,
[self](const rpc::RpcReply &reply) {
if (!self) {
return;
}
if (!reply.ok()) {
self->statusLabel_->setText(
tr("Could not apply the limit: %1").arg(reply.error.message));
self->statusLabel_->show();
return;
}
self->accept();
});
}
} // namespace velox::gui
+46
View File
@@ -0,0 +1,46 @@
// The Speed Limiter window. Lane GUI.
//
// docs/03-gui-spec.md §5: off / limit to N KB/s, with an "apply to running downloads now"
// button. Backed by limiter.get/limiter.set (types/Limiter.schema.json), not the
// settings.* bag — this is the live global token-bucket limit, separate from Options ->
// Downloads' speedLimitEnabled/speedLimitBps default.
#pragma once
#include <QDialog>
#include <QJsonObject>
class QCheckBox;
class QLabel;
class QSpinBox;
namespace velox::gui {
namespace rpc {
class RpcClient;
} // namespace rpc
class SpeedLimiterDialog : public QDialog {
Q_OBJECT
public:
explicit SpeedLimiterDialog(rpc::RpcClient *client, QWidget *parent = nullptr);
/// Pure: the limiter.set params for these form values. Limiter.schema.json says
/// "0 with enabled true means stop everything, which the GUI must not offer" — kibps
/// is clamped to at least 1 whenever enabled is true, defensively, even though the
/// spinbox's own minimum already keeps the UI from producing 0 here.
static QJsonObject buildParams(bool enabled, int kibps, bool applyToRunning);
private slots:
void onLoaded(const QJsonObject &limiter);
void save();
private:
rpc::RpcClient *client_;
QCheckBox *enabled_;
QSpinBox *kibps_;
QCheckBox *applyToRunning_;
QLabel *statusLabel_;
};
} // namespace velox::gui
+119 -9
View File
@@ -2,6 +2,7 @@
#include <QAction>
#include <QCloseEvent>
#include <QCoreApplication>
#include <QHBoxLayout>
#include <QHeaderView>
#include <QItemSelectionModel>
@@ -16,6 +17,7 @@
#include <QSettings>
#include <QSplitter>
#include <QStatusBar>
#include <QSystemTrayIcon>
#include <QTimer>
#include <QToolBar>
#include <QTreeView>
@@ -23,11 +25,17 @@
#include <QWidget>
#include "dialogs/AddUrlDialog.hpp"
#include "dialogs/BatchDialog.hpp"
#include "dialogs/FileInfoDialog.hpp"
#include "dialogs/GrabberWizard.hpp"
#include "dialogs/OptionsDialog.hpp"
#include "dialogs/ProgressDialog.hpp"
#include "dialogs/SchedulerDialog.hpp"
#include "dialogs/SpeedLimiterDialog.hpp"
#include "mainwindow/CategoryPanel.hpp"
#include "models/DownloadTableModel.hpp"
#include "rpc/RpcClient.hpp"
#include "tray/TrayIcon.hpp"
#include "util/Format.hpp"
#include "widgets/ProgressDelegate.hpp"
@@ -102,6 +110,7 @@ MainWindow::MainWindow(rpc::RpcClient *client, QWidget *parent)
buildActions();
buildMenus();
buildToolBar();
buildTray();
// --- status bar -----------------------------------------------------------------
connDot_->setStyleSheet(dotStyle(QStringLiteral("#c0392b")));
@@ -123,6 +132,7 @@ MainWindow::MainWindow(rpc::RpcClient *client, QWidget *parent)
&DownloadTableModel::applyTaskState);
connect(client_, &rpc::RpcClient::taskRemoved, model_, &DownloadTableModel::applyTaskRemoved);
connect(client_, &rpc::RpcClient::speedGlobal, this, &MainWindow::onSpeedGlobal);
connect(client_, &rpc::RpcClient::settingsChanged, this, &MainWindow::onSettingsChanged);
connect(model_, &QAbstractItemModel::modelReset, this, &MainWindow::scheduleCounts);
connect(model_, &QAbstractItemModel::rowsInserted, this, &MainWindow::scheduleCounts);
@@ -166,6 +176,24 @@ void MainWindow::buildActions() {
actProperties_ = new QAction(tr("&Properties…"), this);
connect(actProperties_, &QAction::triggered, this, &MainWindow::openPropertiesForSelection);
actOptions_ = new QAction(tr("&Options…"), this);
connect(actOptions_, &QAction::triggered, this, &MainWindow::openOptionsDialog);
actScheduler_ = new QAction(tr("&Scheduler…"), this);
connect(actScheduler_, &QAction::triggered, this, &MainWindow::openSchedulerDialog);
actSpeedLimiter_ = new QAction(tr("Speed &Limiter…"), this);
connect(actSpeedLimiter_, &QAction::triggered, this, &MainWindow::openSpeedLimiterDialog);
actBatch_ = new QAction(tr("&Batch Download…"), this);
connect(actBatch_, &QAction::triggered, this, &MainWindow::openBatchDialog);
actGrabber_ = new QAction(tr("Site &Grabber…"), this);
connect(actGrabber_, &QAction::triggered, this, &MainWindow::openGrabberWizard);
actShow_ = new QAction(tr("&Show Velox"), this);
connect(actShow_, &QAction::triggered, this, &MainWindow::showAndRaise);
}
void MainWindow::buildMenus() {
@@ -185,10 +213,14 @@ void MainWindow::buildMenus() {
downloads->addAction(actResumeAll_);
downloads->addAction(actPauseAll_);
downloads->addSeparator();
QAction *scheduler = downloads->addAction(tr("Scheduler…"));
scheduler->setEnabled(false); // build order step 5
QAction *limiter = downloads->addAction(tr("Speed Limiter…"));
limiter->setEnabled(false);
downloads->addAction(actScheduler_);
downloads->addAction(actSpeedLimiter_);
QMenu *tools = menuBar()->addMenu(tr("&Tools"));
tools->addAction(actBatch_);
tools->addAction(actGrabber_);
tools->addSeparator();
tools->addAction(actOptions_);
QMenu *view = menuBar()->addMenu(tr("&View"));
QAction *togglePanel = view->addAction(tr("&Category Panel"));
@@ -214,9 +246,27 @@ void MainWindow::buildToolBar() {
tb->addAction(actStop_);
tb->addSeparator();
tb->addAction(actRemove_);
tb->addSeparator();
tb->addAction(actScheduler_);
tb->addAction(actOptions_);
tb->addAction(actGrabber_);
}
void MainWindow::buildTray() {
if (!QSystemTrayIcon::isSystemTrayAvailable()) {
return; // headless/mockd-only test environments, some minimal WMs
}
trayIcon_ = new TrayIcon(client_, actShow_, actAddUrl_, actResumeAll_, actPauseAll_, this);
connect(trayIcon_, &TrayIcon::quitRequested, qApp, &QCoreApplication::quit);
trayIcon_->show();
}
void MainWindow::closeEvent(QCloseEvent *event) {
if (minimizeToTrayEnabled_ && trayIcon_ && trayIcon_->isVisible()) {
hide();
event->ignore();
return;
}
saveLayout();
QMainWindow::closeEvent(event);
event->accept();
@@ -241,13 +291,15 @@ void MainWindow::onConnectionState(rpc::ConnectionState state) {
: tr("The Velox service is not running. Downloads are unaffected; "
"this list is read-only until it returns."));
}
for (QAction *a : {actResume_, actPause_, actStop_, actRemove_, actResumeAll_, actPauseAll_,
actAddUrl_, actProperties_}) {
for (QAction *a :
{actResume_, actPause_, actStop_, actRemove_, actResumeAll_, actPauseAll_, actAddUrl_,
actProperties_, actScheduler_, actSpeedLimiter_, actBatch_, actGrabber_, actOptions_}) {
a->setEnabled(online);
}
if (online) {
fetchTree();
fetchMinimizeToTraySetting();
}
}
@@ -266,6 +318,29 @@ void MainWindow::fetchTree() {
});
}
void MainWindow::fetchMinimizeToTraySetting() {
client_->call(QStringLiteral("settings.get"),
QJsonObject{{"keys", QJsonArray{QStringLiteral("general.minimizeToTray")}}},
[this](const rpc::RpcReply &reply) {
if (reply.ok()) {
minimizeToTrayEnabled_ = reply.result.toObject()
.value("values")
.toObject()
.value("general.minimizeToTray")
.toBool();
}
});
}
void MainWindow::onSettingsChanged(const QJsonObject &params) {
for (const QJsonValue &key : params.value("keys").toArray()) {
if (key.toString() == QLatin1String("general.minimizeToTray")) {
fetchMinimizeToTraySetting();
break;
}
}
}
void MainWindow::openAddUrlDialog() {
AddUrlDialog dlg(this);
if (dlg.exec() != QDialog::Accepted) {
@@ -294,11 +369,43 @@ void MainWindow::openPropertiesForSelection() {
}
}
void MainWindow::openOptionsDialog() {
OptionsDialog dlg(client_, this);
dlg.exec();
}
void MainWindow::openSchedulerDialog() {
SchedulerDialog dlg(client_, this);
dlg.exec();
}
void MainWindow::openSpeedLimiterDialog() {
SpeedLimiterDialog dlg(client_, this);
dlg.exec();
}
void MainWindow::openBatchDialog() {
BatchDialog dlg(client_, categoriesCache_, queuesCache_, this);
dlg.exec();
}
void MainWindow::openGrabberWizard() {
auto *wizard = new GrabberWizard(client_, categoriesCache_, this);
wizard->setAttribute(Qt::WA_DeleteOnClose);
wizard->show();
}
void MainWindow::showAndRaise() {
show();
raise();
activateWindow();
}
// docs/03-gui-spec.md §1 lists a fuller row menu (Open · Open With · Open Folder ·
// Move/Rename · Redownload · Refresh Download Address · Add to Queue ▸ · ...). Those all
// need dialogs or state this build order hasn't reached yet (Options for file-type
// associations, Batch for Add to Queue) — offering them now would be a fake affordance,
// same reasoning as the disabled Scheduler/Speed Limiter menu entries above.
// need state this build order hasn't reached yet (Options' file-type associations aren't
// per-row actions, and Batch's "Add to Queue" is a different flow than re-filing an
// existing task) — offering them now would be a fake affordance.
void MainWindow::showTableContextMenu(const QPoint &pos) {
const QModelIndex idx = view_->indexAt(pos);
if (idx.isValid() && view_->selectionModel() && !view_->selectionModel()->isSelected(idx)) {
@@ -370,6 +477,9 @@ void MainWindow::refreshCounts() {
}
panel_->setCounts(total, unfinished, finished, byCategory, byQueue);
if (trayIcon_) {
trayIcon_->setActiveCount(active);
}
const int shown = proxy_->rowCount();
countsLabel_->setText(tr("%1 of %2 downloads, %3 active ↓ %4")
+23 -4
View File
@@ -1,9 +1,10 @@
// The main window. Lane GUI.
//
// M1 slice: the tray and the Options/Scheduler/Speed-Limiter/Batch/Grabber dialogs still
// come later. This is the menus + toolbar + splitter (category tree | table), the table
// driven by mockd, a status-bar connection dot, an offline banner instead of a modal, the
// Add URL -> File Info flow, and a per-task progress dialog.
// M1 slice: menus + toolbar + splitter (category tree | table), the table driven by
// mockd, a status-bar connection dot, an offline banner instead of a modal, the Add URL ->
// File Info flow, a per-task progress dialog, Options/Scheduler/Speed Limiter/Batch/
// Grabber, and the tray icon. The floating drop target and clipboard capture are still to
// come.
#pragma once
@@ -24,6 +25,7 @@ namespace velox::gui {
class DownloadTableModel;
class CategoryPanel;
class TrayIcon;
namespace rpc {
class RpcClient;
} // namespace rpc
@@ -55,12 +57,21 @@ class MainWindow : public QMainWindow {
void openAddUrlDialog();
void openPropertiesForSelection();
void showTableContextMenu(const QPoint &pos);
void openOptionsDialog();
void openSchedulerDialog();
void openSpeedLimiterDialog();
void openBatchDialog();
void openGrabberWizard();
void showAndRaise();
void onSettingsChanged(const QJsonObject &params);
private:
void buildActions();
void buildMenus();
void buildToolBar();
void buildTray();
void fetchTree();
void fetchMinimizeToTraySetting();
QStringList selectedTaskIds() const;
QStringList allTaskIds() const;
void actOnTasks(const char *methodName, const QStringList &ids);
@@ -81,9 +92,17 @@ class MainWindow : public QMainWindow {
QAction *actResumeAll_ = nullptr;
QAction *actPauseAll_ = nullptr;
QAction *actProperties_ = nullptr;
QAction *actOptions_ = nullptr;
QAction *actScheduler_ = nullptr;
QAction *actSpeedLimiter_ = nullptr;
QAction *actBatch_ = nullptr;
QAction *actGrabber_ = nullptr;
QAction *actShow_ = nullptr;
QJsonArray categoriesCache_;
QJsonArray queuesCache_;
TrayIcon *trayIcon_ = nullptr;
bool minimizeToTrayEnabled_ = false;
QLabel *connDot_;
QLabel *connText_;
+20
View File
@@ -69,6 +69,24 @@ inline constexpr auto kDownloadPause = "download.pause";
inline constexpr auto kDownloadResume = "download.resume";
inline constexpr auto kDownloadCancel = "download.cancel";
inline constexpr auto kDownloadRemove = "download.remove";
inline constexpr auto kDownloadProbe = "download.probe";
inline constexpr auto kDownloadAdd = "download.add";
inline constexpr auto kDownloadAddBatch = "download.addBatch";
inline constexpr auto kCategoryList = "category.list";
inline constexpr auto kQueueList = "queue.list";
inline constexpr auto kQueueUpsert = "queue.upsert";
inline constexpr auto kQueueReorder = "queue.reorder";
inline constexpr auto kQueueStart = "queue.start";
inline constexpr auto kQueueStop = "queue.stop";
inline constexpr auto kScheduleGet = "schedule.get";
inline constexpr auto kScheduleSet = "schedule.set";
inline constexpr auto kSettingsGet = "settings.get";
inline constexpr auto kSettingsSet = "settings.set";
inline constexpr auto kLimiterGet = "limiter.get";
inline constexpr auto kLimiterSet = "limiter.set";
inline constexpr auto kGrabberStart = "grabber.start";
inline constexpr auto kGrabberStatus = "grabber.status";
inline constexpr auto kGrabberHarvest = "grabber.harvest";
} // namespace method
// --- event names ---------------------------------------------------------------------
@@ -79,6 +97,8 @@ 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";
inline constexpr auto kSettingsChanged = "event.settings.changed";
inline constexpr auto kGrabberProgress = "event.grabber.progress";
} // namespace event
} // namespace velox::gui::rpc
+4
View File
@@ -106,6 +106,10 @@ void RpcClient::onNotification(const QString &methodName, const QJsonObject &par
emit speedGlobal(params);
} else if (methodName == QLatin1String(event::kNotify)) {
emit notify(params);
} else if (methodName == QLatin1String(event::kSettingsChanged)) {
emit settingsChanged(params);
} else if (methodName == QLatin1String(event::kGrabberProgress)) {
emit grabberProgress(params);
}
}
+6
View File
@@ -53,6 +53,12 @@ class RpcClient : public QObject {
void taskRemoved(const QString &taskId);
void speedGlobal(const QJsonObject &params);
void notify(const QJsonObject &params);
/// event.settings.changed: carries only the key names that changed; a client re-reads
/// whatever it cares about (OptionsDialog re-fetches if it is open and idle).
void settingsChanged(const QJsonObject &params);
/// event.grabber.progress: crawl progress for whichever GrabberWizard is watching
/// jobId; done true means grabber.status's file list is final.
void grabberProgress(const QJsonObject &params);
private slots:
void onConnectionState(int state);
+83
View File
@@ -0,0 +1,83 @@
#include "tray/TrayIcon.hpp"
#include <QAction>
#include <QApplication>
#include <QJsonObject>
#include <QMenu>
#include <QMessageBox>
#include <QStyle>
#include <QWidget>
#include "dialogs/SpeedLimiterDialog.hpp"
#include "rpc/Protocol.hpp"
#include "rpc/RpcClient.hpp"
namespace velox::gui {
TrayIcon::TrayIcon(rpc::RpcClient *client, QAction *actShow, QAction *actAddUrl,
QAction *actResumeAll, QAction *actPauseAll, QWidget *parent)
: QSystemTrayIcon(parent), client_(client) {
// No icon set has been designed yet (that's its own build-order step); a standard
// style icon is a placeholder, not a shipped asset.
if (auto *w = qobject_cast<QWidget *>(parent)) {
setIcon(w->style()->standardIcon(QStyle::SP_ArrowDown));
} else {
setIcon(qApp->style()->standardIcon(QStyle::SP_ArrowDown));
}
setToolTip(QCoreApplication::translate("velox::gui::TrayIcon", "Velox Download Manager"));
auto *menu = new QMenu(parent);
menu->addAction(actShow);
menu->addSeparator();
menu->addAction(actAddUrl);
menu->addAction(actResumeAll);
menu->addAction(actPauseAll);
auto *limiterMenu = menu->addMenu(tr("Speed Limiter"));
limiterMenu->addAction(tr("Off"), this, [this] { setLimiterPreset(0); });
limiterMenu->addAction(tr("128 KiB/s"), this, [this] { setLimiterPreset(128 * 1024); });
limiterMenu->addAction(tr("512 KiB/s"), this, [this] { setLimiterPreset(512 * 1024); });
limiterMenu->addAction(tr("2 MiB/s"), this, [this] { setLimiterPreset(2 * 1024 * 1024); });
limiterMenu->addSeparator();
limiterMenu->addAction(tr("Custom…"), this, &TrayIcon::openSpeedLimiterDialog);
menu->addSeparator();
auto *quit = menu->addAction(tr("Quit"));
connect(quit, &QAction::triggered, this, [this] {
if (QMessageBox::question(
nullptr, tr("Quit Velox"),
tr("Quit Velox? Downloads continue in the background under the Velox "
"service; only this window closes.")) == QMessageBox::Yes) {
emit quitRequested();
}
});
setContextMenu(menu);
connect(this, &QSystemTrayIcon::activated, this, [actShow](ActivationReason reason) {
if (reason == Trigger || reason == DoubleClick) {
actShow->trigger();
}
});
}
void TrayIcon::setActiveCount(int active) {
activeCount_ = active;
setToolTip(active > 0 ? tr("Velox — %n active download(s)", "", active) : tr("Velox — idle"));
}
void TrayIcon::setLimiterPreset(qint64 bps) {
client_->call(QString::fromLatin1(rpc::method::kLimiterSet),
QJsonObject{
{"enabled", bps > 0},
{"globalBps", bps > 0 ? bps : 1},
{"applyToRunning", true},
});
}
void TrayIcon::openSpeedLimiterDialog() {
auto *dlg = new SpeedLimiterDialog(client_, qobject_cast<QWidget *>(parent()));
dlg->setAttribute(Qt::WA_DeleteOnClose);
dlg->show();
}
} // namespace velox::gui
+43
View File
@@ -0,0 +1,43 @@
// System tray icon. Lane GUI.
//
// docs/03-gui-spec.md §5: active count in the tooltip, menu Show · Add URL · Pause All ·
// Resume All · Speed limiter ▸ · Quit. Quit asks for confirmation; it cannot also offer
// "stop the daemon" (the spec's wording) because no RPC exists to do that — filed in
// gui/docs/daemon-requests-m1.md. Quitting the GUI never stops veloxd; downloads continue.
#pragma once
#include <QSystemTrayIcon>
class QAction;
namespace velox::gui {
namespace rpc {
class RpcClient;
} // namespace rpc
class TrayIcon : public QSystemTrayIcon {
Q_OBJECT
public:
/// actShow is toggled/triggered to show or raise the main window; actAddUrl,
/// actResumeAll, actPauseAll are MainWindow's own actions, added to the tray menu
/// as-is (a QAction can live in more than one menu).
TrayIcon(rpc::RpcClient *client, QAction *actShow, QAction *actAddUrl, QAction *actResumeAll,
QAction *actPauseAll, QWidget *parent = nullptr);
public slots:
void setActiveCount(int active);
signals:
void quitRequested();
private:
void setLimiterPreset(qint64 bps);
void openSpeedLimiterDialog();
rpc::RpcClient *client_;
int activeCount_ = 0;
};
} // namespace velox::gui
+62
View File
@@ -82,6 +82,68 @@ set_tests_properties(gui_fileinfodialog PROPERTIES
LABELS "gui"
ENVIRONMENT "QT_QPA_PLATFORM=offscreen")
# tst_optionsdialog OptionsDialog::diffChanged, the "only send what changed" logic.
# Red when an unchanged key gets resent, or a key missing from `original` stops
# counting as changed.
add_executable(tst_optionsdialog tst_optionsdialog.cpp)
target_compile_features(tst_optionsdialog PRIVATE cxx_std_23)
target_compile_options(tst_optionsdialog PRIVATE -Wall -Wextra -Wpedantic -Werror)
target_link_libraries(tst_optionsdialog PRIVATE velox-gui-lib Qt6::Widgets Qt6::Test)
add_test(NAME gui_optionsdialog COMMAND tst_optionsdialog)
set_tests_properties(gui_optionsdialog PROPERTIES
LABELS "gui"
ENVIRONMENT "QT_QPA_PLATFORM=offscreen")
# tst_speedlimiterdialog SpeedLimiterDialog::buildParams.
# Red when disabling stops zeroing globalBps, or enabling at 0 KiB/s stops being
# clamped to 1 (Limiter.schema.json: enabled+0 "must not be offered").
add_executable(tst_speedlimiterdialog tst_speedlimiterdialog.cpp)
target_compile_features(tst_speedlimiterdialog PRIVATE cxx_std_23)
target_compile_options(tst_speedlimiterdialog PRIVATE -Wall -Wextra -Wpedantic -Werror)
target_link_libraries(tst_speedlimiterdialog PRIVATE velox-gui-lib Qt6::Widgets Qt6::Test)
add_test(NAME gui_speedlimiterdialog COMMAND tst_speedlimiterdialog)
set_tests_properties(gui_speedlimiterdialog PROPERTIES
LABELS "gui"
ENVIRONMENT "QT_QPA_PLATFORM=offscreen")
# tst_schedulerdialog SchedulerDialog::buildSchedule.
# Red when "use a schedule" unchecked stops sending JSON null, "run until it drains"
# stops nulling stopTime, or daysOfWeek/onceDate leak into the wrong mode.
add_executable(tst_schedulerdialog tst_schedulerdialog.cpp)
target_compile_features(tst_schedulerdialog PRIVATE cxx_std_23)
target_compile_options(tst_schedulerdialog PRIVATE -Wall -Wextra -Wpedantic -Werror)
target_link_libraries(tst_schedulerdialog PRIVATE velox-gui-lib Qt6::Widgets Qt6::Test)
add_test(NAME gui_schedulerdialog COMMAND tst_schedulerdialog)
set_tests_properties(gui_schedulerdialog PROPERTIES
LABELS "gui"
ENVIRONMENT "QT_QPA_PLATFORM=offscreen")
# tst_batchdialog parseUrlBlob, expandWildcard, buildAddBatchParams.
# Red when the clipboard parser stops deduping or lets a non-URL line through, the
# wildcard expander mishandles zero-padding or an ambiguous/malformed range, or
# addBatch params include an empty optional field or a stray queueId.
add_executable(tst_batchdialog tst_batchdialog.cpp)
target_compile_features(tst_batchdialog PRIVATE cxx_std_23)
target_compile_options(tst_batchdialog PRIVATE -Wall -Wextra -Wpedantic -Werror)
target_link_libraries(tst_batchdialog PRIVATE velox-gui-lib Qt6::Widgets Qt6::Test)
add_test(NAME gui_batchdialog COMMAND tst_batchdialog)
set_tests_properties(gui_batchdialog PROPERTIES
LABELS "gui"
ENVIRONMENT "QT_QPA_PLATFORM=offscreen")
# tst_grabberwizard buildFileTypes, buildStartParams, buildHarvestParams.
# Red when every file-type box unchecked stops meaning "every type" (null, not []),
# a blank filter stops becoming schema null, or a selected fileId goes missing from
# the harvest params.
add_executable(tst_grabberwizard tst_grabberwizard.cpp)
target_compile_features(tst_grabberwizard PRIVATE cxx_std_23)
target_compile_options(tst_grabberwizard PRIVATE -Wall -Wextra -Wpedantic -Werror)
target_link_libraries(tst_grabberwizard PRIVATE velox-gui-lib Qt6::Widgets Qt6::Test)
add_test(NAME gui_grabberwizard COMMAND tst_grabberwizard)
set_tests_properties(gui_grabberwizard PROPERTIES
LABELS "gui"
ENVIRONMENT "QT_QPA_PLATFORM=offscreen")
# gui_no_download_logic CLAUDE.md §3 as an executable check, not a hope.
# Red when: a download-logic token (curl, raw pwrite, sqlite, QSqlDatabase) appears
# under gui/src. grep exits 0 only when it finds a match, so a hit fails the test.
+87
View File
@@ -0,0 +1,87 @@
// BatchDialog unit tests: parseUrlBlob, expandWildcard, buildAddBatchParams. Lane GUI.
//
// Red when the clipboard parser stops deduping or starts letting non-URL lines through,
// the wildcard expander mishandles zero-padding or a malformed/ambiguous range, or the
// addBatch params start including an empty optional field or leak queueId into a
// non-"queue" startMode.
#include <QtTest>
#include "dialogs/BatchDialog.hpp"
using velox::gui::BatchDialog;
class TstBatchDialog : public QObject {
Q_OBJECT
private slots:
void parseUrlBlobDedupesAndFiltersNonUrls();
void parseUrlBlobTrimsAndSkipsBlankLines();
void expandWildcardSimpleRange();
void expandWildcardZeroPadded();
void expandWildcardNoRangeReturnsPatternIfUrl();
void expandWildcardMalformedRangeIsEmpty();
void expandWildcardAmbiguousMultiRangeIsEmpty();
void buildAddBatchParamsOmitsEmptyOptionalFields();
void buildAddBatchParamsQueueIdOnlyForQueueMode();
};
void TstBatchDialog::parseUrlBlobDedupesAndFiltersNonUrls() {
const QString text =
"https://example.test/a\nnot a url\nhttps://example.test/a\nhttps://example.test/b";
const QStringList urls = BatchDialog::parseUrlBlob(text);
QCOMPARE(urls, QStringList({"https://example.test/a", "https://example.test/b"}));
}
void TstBatchDialog::parseUrlBlobTrimsAndSkipsBlankLines() {
const QString text = " https://example.test/a \n\n\n \n";
QCOMPARE(BatchDialog::parseUrlBlob(text), QStringList({"https://example.test/a"}));
}
void TstBatchDialog::expandWildcardSimpleRange() {
const QStringList urls = BatchDialog::expandWildcard("https://example.test/img{1..3}.jpg");
QCOMPARE(urls, QStringList({"https://example.test/img1.jpg", "https://example.test/img2.jpg",
"https://example.test/img3.jpg"}));
}
void TstBatchDialog::expandWildcardZeroPadded() {
const QStringList urls = BatchDialog::expandWildcard("https://example.test/img{08..10}.jpg");
QCOMPARE(urls, QStringList({"https://example.test/img08.jpg", "https://example.test/img09.jpg",
"https://example.test/img10.jpg"}));
}
void TstBatchDialog::expandWildcardNoRangeReturnsPatternIfUrl() {
QCOMPARE(BatchDialog::expandWildcard("https://example.test/a.jpg"),
QStringList({"https://example.test/a.jpg"}));
QVERIFY(BatchDialog::expandWildcard("not a url").isEmpty());
}
void TstBatchDialog::expandWildcardMalformedRangeIsEmpty() {
QVERIFY(BatchDialog::expandWildcard("https://example.test/img{5..1}.jpg").isEmpty());
}
void TstBatchDialog::expandWildcardAmbiguousMultiRangeIsEmpty() {
QVERIFY(BatchDialog::expandWildcard("https://example.test/{1..2}/img{1..3}.jpg").isEmpty());
}
void TstBatchDialog::buildAddBatchParamsOmitsEmptyOptionalFields() {
const QJsonObject params =
BatchDialog::buildAddBatchParams({"https://example.test/a"}, QString(), QString(), "now");
const QJsonObject defaults = params.value("defaults").toObject();
QVERIFY(!defaults.contains("categoryId"));
QVERIFY(!defaults.contains("queueId"));
QCOMPARE(params.value("items").toArray().size(), 1);
}
void TstBatchDialog::buildAddBatchParamsQueueIdOnlyForQueueMode() {
const QJsonObject queued =
BatchDialog::buildAddBatchParams({"https://example.test/a"}, QString(), "q1", "queue");
QCOMPARE(queued.value("defaults").toObject().value("queueId").toString(), QStringLiteral("q1"));
const QJsonObject notQueued =
BatchDialog::buildAddBatchParams({"https://example.test/a"}, QString(), "q1", "now");
QVERIFY(!notQueued.value("defaults").toObject().contains("queueId"));
}
QTEST_MAIN(TstBatchDialog)
#include "tst_batchdialog.moc"
+64
View File
@@ -0,0 +1,64 @@
// GrabberWizard unit tests: buildFileTypes, buildStartParams, buildHarvestParams. Lane GUI.
//
// Red when every file-type checkbox unchecked stops meaning "every type" (null, not an
// empty array), a blank include/exclude field stops becoming schema null, or harvest
// params drop a selected fileId.
#include <QJsonArray>
#include <QtTest>
#include "dialogs/GrabberWizard.hpp"
using velox::gui::GrabberWizard;
class TstGrabberWizard : public QObject {
Q_OBJECT
private slots:
void noFileTypesSelectedMeansNull();
void selectedTypesAndCustomAreMerged();
void blankFiltersBecomeNull();
void filtersAreSplitOnCommaAndNewline();
void harvestParamsCarrySelection();
};
void TstGrabberWizard::noFileTypesSelectedMeansNull() {
const QJsonValue v = GrabberWizard::buildFileTypes(false, false, false, false, false, "");
QVERIFY(v.isNull());
}
void TstGrabberWizard::selectedTypesAndCustomAreMerged() {
const QJsonValue v =
GrabberWizard::buildFileTypes(true, false, false, false, false, "iso, bin");
QVERIFY(v.isArray());
const QJsonArray a = v.toArray();
QVERIFY(a.contains(QStringLiteral("jpg")));
QVERIFY(a.contains(QStringLiteral("iso")));
QVERIFY(a.contains(QStringLiteral("bin")));
}
void TstGrabberWizard::blankFiltersBecomeNull() {
const QJsonObject p =
GrabberWizard::buildStartParams("https://example.test/", 2, true, "", "", QJsonValue());
QVERIFY(p.value("includePatterns").isNull());
QVERIFY(p.value("excludePatterns").isNull());
QCOMPARE(p.value("depth").toInt(), 2);
QCOMPARE(p.value("sameHostOnly").toBool(), true);
}
void TstGrabberWizard::filtersAreSplitOnCommaAndNewline() {
const QJsonObject p = GrabberWizard::buildStartParams("https://example.test/", 1, false,
"*.jpg,*.png\n*.gif", "", QJsonValue());
QCOMPARE(p.value("includePatterns").toArray().size(), 3);
}
void TstGrabberWizard::harvestParamsCarrySelection() {
const QJsonObject p = GrabberWizard::buildHarvestParams("job1", {"f1", "f2"}, "cat1", "later");
QCOMPARE(p.value("jobId").toString(), QStringLiteral("job1"));
QCOMPARE(p.value("select").toArray().size(), 2);
QCOMPARE(p.value("defaults").toObject().value("categoryId").toString(), QStringLiteral("cat1"));
QCOMPARE(p.value("defaults").toObject().value("startMode").toString(), QStringLiteral("later"));
}
QTEST_MAIN(TstGrabberWizard)
#include "tst_grabberwizard.moc"
+54
View File
@@ -0,0 +1,54 @@
// OptionsDialog::diffChanged unit tests. Lane GUI.
//
// Red when a key that didn't actually change starts getting resent to settings.set (it
// would still work, but it defeats the fixture's "changed[] names exactly what took
// effect" contract and spams event.settings.changed with noise), or a key present in
// `current` but absent from `original` stops being treated as changed.
#include <QJsonObject>
#include <QtTest>
#include "dialogs/OptionsDialog.hpp"
using velox::gui::OptionsDialog;
class TstOptionsDialog : public QObject {
Q_OBJECT
private slots:
void identicalValuesProduceNoDiff();
void onlyChangedKeysAreReturned();
void keyAbsentFromOriginalCountsAsChanged();
void allKeysAreNonEmptyAndUnique();
};
void TstOptionsDialog::identicalValuesProduceNoDiff() {
const QJsonObject original{{"connection.timeoutSec", 30}, {"proxy.mode", "system"}};
const QJsonObject current = original;
QVERIFY(OptionsDialog::diffChanged(original, current).isEmpty());
}
void TstOptionsDialog::onlyChangedKeysAreReturned() {
const QJsonObject original{{"connection.timeoutSec", 30}, {"proxy.mode", "system"}};
const QJsonObject current{{"connection.timeoutSec", 60}, {"proxy.mode", "system"}};
const QJsonObject changed = OptionsDialog::diffChanged(original, current);
QCOMPARE(changed.size(), 1);
QCOMPARE(changed.value("connection.timeoutSec").toInt(), 60);
}
void TstOptionsDialog::keyAbsentFromOriginalCountsAsChanged() {
const QJsonObject original{{"proxy.mode", "system"}};
const QJsonObject current{{"proxy.mode", "system"}, {"proxy.port", 1080}};
const QJsonObject changed = OptionsDialog::diffChanged(original, current);
QCOMPARE(changed.size(), 1);
QVERIFY(changed.contains("proxy.port"));
}
void TstOptionsDialog::allKeysAreNonEmptyAndUnique() {
const QStringList keys = OptionsDialog::allKeys();
QVERIFY(!keys.isEmpty());
QCOMPARE(QSet<QString>(keys.begin(), keys.end()).size(), keys.size());
}
QTEST_MAIN(TstOptionsDialog)
#include "tst_optionsdialog.moc"
+62
View File
@@ -0,0 +1,62 @@
// SchedulerDialog::buildSchedule unit tests. Lane GUI.
//
// Red when unchecking "use a schedule" stops sending JSON null (Schedule.schema.json:
// null clears it and leaves the queue under manual control), "run until it drains" stops
// nulling stopTime, or daysOfWeek/onceDate leak into the mode that ignores them.
#include <QJsonArray>
#include <QtTest>
#include "dialogs/SchedulerDialog.hpp"
using velox::gui::SchedulerDialog;
class TstSchedulerDialog : public QObject {
Q_OBJECT
private slots:
void disabledIsNull();
void periodicIncludesDaysNotOnceDate();
void onceIncludesOnceDateNotDays();
void runUntilDrainsNullsStopTime();
void explicitStopTimeIsKept();
};
void TstSchedulerDialog::disabledIsNull() {
const QJsonValue v =
SchedulerDialog::buildSchedule(false, "once", "09:00", true, "17:00", {}, "2026-01-01");
QVERIFY(v.isNull());
}
void TstSchedulerDialog::periodicIncludesDaysNotOnceDate() {
const QJsonValue v = SchedulerDialog::buildSchedule(true, "periodic", "09:00", true, "17:00",
{1, 3, 5}, "2026-01-01");
QVERIFY(v.isObject());
const QJsonObject s = v.toObject();
QCOMPARE(s.value("mode").toString(), QStringLiteral("periodic"));
QCOMPARE(s.value("daysOfWeek").toArray().size(), 3);
QVERIFY(!s.contains("onceDate"));
}
void TstSchedulerDialog::onceIncludesOnceDateNotDays() {
const QJsonValue v = SchedulerDialog::buildSchedule(true, "once", "09:00", true, "17:00",
{1, 3, 5}, "2026-01-01");
const QJsonObject s = v.toObject();
QCOMPARE(s.value("onceDate").toString(), QStringLiteral("2026-01-01"));
QVERIFY(!s.contains("daysOfWeek"));
}
void TstSchedulerDialog::runUntilDrainsNullsStopTime() {
const QJsonValue v =
SchedulerDialog::buildSchedule(true, "once", "09:00", true, "17:00", {}, "2026-01-01");
QVERIFY(v.toObject().value("stopTime").isNull());
}
void TstSchedulerDialog::explicitStopTimeIsKept() {
const QJsonValue v =
SchedulerDialog::buildSchedule(true, "once", "09:00", false, "17:00", {}, "2026-01-01");
QCOMPARE(v.toObject().value("stopTime").toString(), QStringLiteral("17:00"));
}
QTEST_MAIN(TstSchedulerDialog)
#include "tst_schedulerdialog.moc"
+46
View File
@@ -0,0 +1,46 @@
// SpeedLimiterDialog::buildParams unit tests. Lane GUI.
//
// Red when disabling the limiter stops zeroing globalBps, enabling it with 0 KiB/s stops
// being clamped to 1 (Limiter.schema.json: "0 with enabled true means stop everything,
// which the GUI must not offer"), or the KiB/s -> bytes/s conversion drifts.
#include <QtTest>
#include "dialogs/SpeedLimiterDialog.hpp"
using velox::gui::SpeedLimiterDialog;
class TstSpeedLimiterDialog : public QObject {
Q_OBJECT
private slots:
void disabledSendsZero();
void enabledConvertsKibpsToBytesPerSec();
void enabledClampsZeroToOne();
void applyToRunningPassesThrough();
};
void TstSpeedLimiterDialog::disabledSendsZero() {
const QJsonObject p = SpeedLimiterDialog::buildParams(false, 512, false);
QCOMPARE(p.value("enabled").toBool(), false);
QCOMPARE(p.value("globalBps").toDouble(), 0.0);
}
void TstSpeedLimiterDialog::enabledConvertsKibpsToBytesPerSec() {
const QJsonObject p = SpeedLimiterDialog::buildParams(true, 512, false);
QCOMPARE(p.value("enabled").toBool(), true);
QCOMPARE(p.value("globalBps").toDouble(), 512.0 * 1024);
}
void TstSpeedLimiterDialog::enabledClampsZeroToOne() {
const QJsonObject p = SpeedLimiterDialog::buildParams(true, 0, false);
QCOMPARE(p.value("globalBps").toDouble(), 1024.0);
}
void TstSpeedLimiterDialog::applyToRunningPassesThrough() {
QVERIFY(SpeedLimiterDialog::buildParams(true, 1, true).value("applyToRunning").toBool());
QVERIFY(!SpeedLimiterDialog::buildParams(true, 1, false).value("applyToRunning").toBool());
}
QTEST_MAIN(TstSpeedLimiterDialog)
#include "tst_speedlimiterdialog.moc"