From de83ee3cce291bc71d07a2faf8b61dfec154d663 Mon Sep 17 00:00:00 2001 From: sami Date: Fri, 11 Sep 2026 17:17:43 +0400 Subject: [PATCH] gui: Options, Scheduler, Speed Limiter, Batch, Grabber, and the tray icon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Continues the build order past the Add-URL/File-Info/Progress dialog flow. - OptionsDialog: General/Save To/Connection/Downloads/Proxy/Sounds tabs, every control bound to a real settings.* key (contracts/schema/types/Settings. schema.json). The spec's File Types and Site Logins tabs have no settings.* backing (categories go through category.upsert, credentials through the Secret Service) so they don't exist here — a tab either binds to a real key or isn't shipped. diffChanged() sends only what actually changed, matching settings.set's "changed[] names exactly what took effect" contract. - SchedulerDialog: per-queue schedule (schedule.get/set) plus maxConcurrent/ onComplete (queue.upsert), Start Now/Stop. Queue.schema.json already carries the schedule so queue.list alone seeds the window. - SpeedLimiterDialog: the live global limiter (limiter.get/set) — a different thing from Options' downloads.speedLimit* default. buildParams() enforces the schema's "0 with enabled true must not be offered". - BatchDialog: clipboard-blob and {start..end}-wildcard tabs sharing one category/queue/start-mode footer into download.addBatch. - GrabberWizard: 4-step QWizard (project label -> start URL/depth/filters -> file-type filter -> review), grabber.start feeding a poll+event.grabber. progress-driven review page, Finish = grabber.harvest for the checked files. - TrayIcon: active-count tooltip, Show/Add URL/Pause All/Resume All/Speed Limiter submenu/Quit. Quit only closes the GUI — there is no RPC to stop veloxd itself, filed as a new gap in daemon-requests-m1.md. MainWindow now also hides to tray instead of closing when general.minimizeToTray is set. Every dialog's non-widget logic (diffChanged, buildSchedule, buildParams, parseUrlBlob/expandWildcard/buildAddBatchParams, buildFileTypes/ buildStartParams/buildHarvestParams) is a static pure function with its own test, same shape as FileInfoDialog::buildSpec from the previous round. Verified end-to-end against a running mockd under ASan+UBSan: all five surfaces render real data (settings.get values, queue.list's two seeded queues, limiter.get, a live grabber.start/status crawl returning 3 files) with no sanitizer reports. gui-check (non-ASan) and dev (ASan+UBSan) presets both build the whole repo clean; all gui-labeled ctest targets pass. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01NSCdCWFXBTSBBK3MzWtJiC --- gui/CMakeLists.txt | 6 + gui/docs/daemon-requests-m1.md | 17 + gui/src/dialogs/BatchDialog.cpp | 275 +++++++++++++ gui/src/dialogs/BatchDialog.hpp | 76 ++++ gui/src/dialogs/GrabberWizard.cpp | 443 +++++++++++++++++++++ gui/src/dialogs/GrabberWizard.hpp | 47 +++ gui/src/dialogs/OptionsDialog.cpp | 518 +++++++++++++++++++++++++ gui/src/dialogs/OptionsDialog.hpp | 110 ++++++ gui/src/dialogs/SchedulerDialog.cpp | 321 +++++++++++++++ gui/src/dialogs/SchedulerDialog.hpp | 73 ++++ gui/src/dialogs/SpeedLimiterDialog.cpp | 103 +++++ gui/src/dialogs/SpeedLimiterDialog.hpp | 46 +++ gui/src/mainwindow/MainWindow.cpp | 128 +++++- gui/src/mainwindow/MainWindow.hpp | 27 +- gui/src/tray/TrayIcon.cpp | 83 ++++ gui/src/tray/TrayIcon.hpp | 43 ++ gui/tests/CMakeLists.txt | 62 +++ gui/tests/tst_batchdialog.cpp | 87 +++++ gui/tests/tst_grabberwizard.cpp | 64 +++ gui/tests/tst_optionsdialog.cpp | 54 +++ gui/tests/tst_schedulerdialog.cpp | 62 +++ gui/tests/tst_speedlimiterdialog.cpp | 46 +++ 22 files changed, 2678 insertions(+), 13 deletions(-) create mode 100644 gui/src/dialogs/BatchDialog.cpp create mode 100644 gui/src/dialogs/BatchDialog.hpp create mode 100644 gui/src/dialogs/GrabberWizard.cpp create mode 100644 gui/src/dialogs/GrabberWizard.hpp create mode 100644 gui/src/dialogs/OptionsDialog.cpp create mode 100644 gui/src/dialogs/OptionsDialog.hpp create mode 100644 gui/src/dialogs/SchedulerDialog.cpp create mode 100644 gui/src/dialogs/SchedulerDialog.hpp create mode 100644 gui/src/dialogs/SpeedLimiterDialog.cpp create mode 100644 gui/src/dialogs/SpeedLimiterDialog.hpp create mode 100644 gui/src/tray/TrayIcon.cpp create mode 100644 gui/src/tray/TrayIcon.hpp create mode 100644 gui/tests/tst_batchdialog.cpp create mode 100644 gui/tests/tst_grabberwizard.cpp create mode 100644 gui/tests/tst_optionsdialog.cpp create mode 100644 gui/tests/tst_schedulerdialog.cpp create mode 100644 gui/tests/tst_speedlimiterdialog.cpp diff --git a/gui/CMakeLists.txt b/gui/CMakeLists.txt index aeddb77..69dcb44 100644 --- a/gui/CMakeLists.txt +++ b/gui/CMakeLists.txt @@ -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 ) diff --git a/gui/docs/daemon-requests-m1.md b/gui/docs/daemon-requests-m1.md index 5604d23..b743c03 100644 --- a/gui/docs/daemon-requests-m1.md +++ b/gui/docs/daemon-requests-m1.md @@ -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. diff --git a/gui/src/dialogs/BatchDialog.cpp b/gui/src/dialogs/BatchDialog.cpp new file mode 100644 index 0000000..4046695 --- /dev/null +++ b/gui/src/dialogs/BatchDialog.cpp @@ -0,0 +1,275 @@ +#include "dialogs/BatchDialog.hpp" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#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 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 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 diff --git a/gui/src/dialogs/BatchDialog.hpp b/gui/src/dialogs/BatchDialog.hpp new file mode 100644 index 0000000..d59cd31 --- /dev/null +++ b/gui/src/dialogs/BatchDialog.hpp @@ -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 +#include +#include + +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 diff --git a/gui/src/dialogs/GrabberWizard.cpp b/gui/src/dialogs/GrabberWizard.cpp new file mode 100644 index 0000000..94c5ea2 --- /dev/null +++ b/gui/src/dialogs/GrabberWizard.cpp @@ -0,0 +1,443 @@ +#include "dialogs/GrabberWizard.hpp" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#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 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 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 ¶ms) { + 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 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(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" diff --git a/gui/src/dialogs/GrabberWizard.hpp b/gui/src/dialogs/GrabberWizard.hpp new file mode 100644 index 0000000..be7d555 --- /dev/null +++ b/gui/src/dialogs/GrabberWizard.hpp @@ -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 +#include +#include + +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 diff --git a/gui/src/dialogs/OptionsDialog.cpp b/gui/src/dialogs/OptionsDialog.cpp new file mode 100644 index 0000000..4240687 --- /dev/null +++ b/gui/src/dialogs/OptionsDialog.cpp @@ -0,0 +1,518 @@ +#include "dialogs/OptionsDialog.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#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 ¤t) { + 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 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(v.value("connection.maxSegmentsPerDownload").toInt(8))); + setBufferCombo(bufferBytes_, + static_cast(v.value("connection.bufferBytes").toDouble(1048576))); + maxConcurrentDownloads_->setValue( + static_cast(v.value("connection.maxConcurrentDownloads").toInt(5))); + timeoutSec_->setValue(static_cast(v.value("connection.timeoutSec").toInt(30))); + maxRetries_->setValue(static_cast(v.value("connection.maxRetries").toInt(3))); + retryBackoffSec_->setValue(static_cast(v.value("connection.retryBackoffSec").toInt(5))); + maxTotalBufferMiB_->setValue(static_cast( + v.value("connection.maxTotalBufferBytes").toDouble(134217728) / (1024 * 1024))); + maxActiveSegments_->setValue( + static_cast(v.value("connection.maxActiveSegments").toInt(32))); + + speedLimitEnabled_->setChecked(v.value("downloads.speedLimitEnabled").toBool()); + speedLimitKiBps_->setValue( + static_cast(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(maxTotalBufferMiB_->value()) * 1024 * 1024; + v["connection.maxActiveSegments"] = maxActiveSegments_->value(); + + v["downloads.speedLimitEnabled"] = speedLimitEnabled_->isChecked(); + v["downloads.speedLimitBps"] = static_cast(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 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 diff --git a/gui/src/dialogs/OptionsDialog.hpp b/gui/src/dialogs/OptionsDialog.hpp new file mode 100644 index 0000000..b0f4249 --- /dev/null +++ b/gui/src/dialogs/OptionsDialog.hpp @@ -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 +#include + +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 ¤t); + + 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 diff --git a/gui/src/dialogs/SchedulerDialog.cpp b/gui/src/dialogs/SchedulerDialog.cpp new file mode 100644 index 0000000..d6acea4 --- /dev/null +++ b/gui/src/dialogs/SchedulerDialog.cpp @@ -0,0 +1,321 @@ +#include "dialogs/SchedulerDialog.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#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 &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(mode_), static_cast(startTime_), + static_cast(stopTime_), static_cast(runUntilDrains_), + static_cast(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 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(mode_), static_cast(startTime_), + static_cast(stopTime_), static_cast(runUntilDrains_), + static_cast(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 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 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 diff --git a/gui/src/dialogs/SchedulerDialog.hpp b/gui/src/dialogs/SchedulerDialog.hpp new file mode 100644 index 0000000..36cc59d --- /dev/null +++ b/gui/src/dialogs/SchedulerDialog.hpp @@ -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 +#include +#include + +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 &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 diff --git a/gui/src/dialogs/SpeedLimiterDialog.cpp b/gui/src/dialogs/SpeedLimiterDialog.cpp new file mode 100644 index 0000000..2ee2854 --- /dev/null +++ b/gui/src/dialogs/SpeedLimiterDialog.cpp @@ -0,0 +1,103 @@ +#include "dialogs/SpeedLimiterDialog.hpp" + +#include + +#include +#include +#include +#include +#include +#include +#include + +#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(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 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(limiter.value("globalBps").toDouble()); + enabled_->setChecked(on); + kibps_->setValue(std::max(1, bps / 1024)); + kibps_->setEnabled(on); +} + +void SpeedLimiterDialog::save() { + const QJsonObject params = + buildParams(enabled_->isChecked(), kibps_->value(), applyToRunning_->isChecked()); + QPointer 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 diff --git a/gui/src/dialogs/SpeedLimiterDialog.hpp b/gui/src/dialogs/SpeedLimiterDialog.hpp new file mode 100644 index 0000000..709a906 --- /dev/null +++ b/gui/src/dialogs/SpeedLimiterDialog.hpp @@ -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 +#include + +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 diff --git a/gui/src/mainwindow/MainWindow.cpp b/gui/src/mainwindow/MainWindow.cpp index 4c3722c..c1f797e 100644 --- a/gui/src/mainwindow/MainWindow.cpp +++ b/gui/src/mainwindow/MainWindow.cpp @@ -2,6 +2,7 @@ #include #include +#include #include #include #include @@ -16,6 +17,7 @@ #include #include #include +#include #include #include #include @@ -23,11 +25,17 @@ #include #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 ¶ms) { + 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") diff --git a/gui/src/mainwindow/MainWindow.hpp b/gui/src/mainwindow/MainWindow.hpp index d175ad6..a75f856 100644 --- a/gui/src/mainwindow/MainWindow.hpp +++ b/gui/src/mainwindow/MainWindow.hpp @@ -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 ¶ms); 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_; diff --git a/gui/src/tray/TrayIcon.cpp b/gui/src/tray/TrayIcon.cpp new file mode 100644 index 0000000..74dadad --- /dev/null +++ b/gui/src/tray/TrayIcon.cpp @@ -0,0 +1,83 @@ +#include "tray/TrayIcon.hpp" + +#include +#include +#include +#include +#include +#include +#include + +#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(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(parent())); + dlg->setAttribute(Qt::WA_DeleteOnClose); + dlg->show(); +} + +} // namespace velox::gui diff --git a/gui/src/tray/TrayIcon.hpp b/gui/src/tray/TrayIcon.hpp new file mode 100644 index 0000000..9521da5 --- /dev/null +++ b/gui/src/tray/TrayIcon.hpp @@ -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 + +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 diff --git a/gui/tests/CMakeLists.txt b/gui/tests/CMakeLists.txt index ce8f654..01d36a6 100644 --- a/gui/tests/CMakeLists.txt +++ b/gui/tests/CMakeLists.txt @@ -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. diff --git a/gui/tests/tst_batchdialog.cpp b/gui/tests/tst_batchdialog.cpp new file mode 100644 index 0000000..6dbd996 --- /dev/null +++ b/gui/tests/tst_batchdialog.cpp @@ -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 + +#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" diff --git a/gui/tests/tst_grabberwizard.cpp b/gui/tests/tst_grabberwizard.cpp new file mode 100644 index 0000000..f0c7127 --- /dev/null +++ b/gui/tests/tst_grabberwizard.cpp @@ -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 +#include + +#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" diff --git a/gui/tests/tst_optionsdialog.cpp b/gui/tests/tst_optionsdialog.cpp new file mode 100644 index 0000000..c5d2684 --- /dev/null +++ b/gui/tests/tst_optionsdialog.cpp @@ -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 +#include + +#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(keys.begin(), keys.end()).size(), keys.size()); +} + +QTEST_MAIN(TstOptionsDialog) +#include "tst_optionsdialog.moc" diff --git a/gui/tests/tst_schedulerdialog.cpp b/gui/tests/tst_schedulerdialog.cpp new file mode 100644 index 0000000..07eff43 --- /dev/null +++ b/gui/tests/tst_schedulerdialog.cpp @@ -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 +#include + +#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" diff --git a/gui/tests/tst_speedlimiterdialog.cpp b/gui/tests/tst_speedlimiterdialog.cpp new file mode 100644 index 0000000..65e3b37 --- /dev/null +++ b/gui/tests/tst_speedlimiterdialog.cpp @@ -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 + +#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"