Continues the build order past Options/Scheduler/Speed Limiter/Batch/ Grabber/tray. - DropTargetWidget: frameless always-on-top drop target (docs/03-gui-spec.md §5), position persisted, accepts a dropped http(s) URL or link text and opens FileInfoDialog directly (skipping Add URL, since the URL is already known). Shown/hidden from general.showDropTarget, live via event.settings.changed, same pattern MainWindow already used for general.minimizeToTray. - Clipboard, explicit path #2 (docs/06-risks-and-spikes.md R2): GlobalShortcut wraps org.freedesktop.portal.GlobalShortcuts (CreateSession -> BindShortcuts -> Activated), triggering the same Add URL flow. Guarded end-to-end on `if(TARGET Qt6::DBus)` / VELOX_GUI_HAVE_DBUS so a build without the component degrades to "feature skipped," not broken (gui/docs/pkg-qa-requests-m1.md R4). Best-effort by design per the risk doc: fails silent, never advertised. Verified live against the real portal (a real Wayland session, not just offscreen): `CreateSession` refuses every caller with "An app id is required" — reproduced identically via a bare `busctl` call with no Qt involved at all, so this is the portal requiring a sandboxed caller identity, not something fixable from an unconfined process. Recorded as a partial Spike S2 answer in docs/06-risks-and-spikes.md: this explicit path likely doesn't work for Velox as a traditionally-packaged app on stock GNOME, only if/when it ships confined. Also fixed a real leak this verification caught: QDBusInterface's introspection cache reads as a LeakSanitizer leak the first time anything touches D-Bus (tst_rtl went red under ASan) — switched to QDBusMessage::createMethodCall, which needs no introspection. - Theming (docs/03-gui-spec.md §7): gui/resources/qss/{idm-like,dark}.qss, each with a documented palette block up top (QSS itself has no variable syntax), applied by ThemeManager and kept live via QStyleHints::colorSchemeChanged. util/Theme.hpp gives the handful of inline C++ styles (status dot, offline banner, the eleven identical error-label styles across dialogs) named constants instead of a twelfth copy of the same hex. - UiThreadWatchdog: the M1 DoD's 200 ms debug-build watchdog. A background std::thread pings the UI thread every 50 ms via a queued invokeMethod and warns once (not per-poll) if a ping goes unanswered past 200 ms; no QThread, no Qt event loop of its own, so the watchdog itself can never be what blocks the thread it watches. No-op in a release build. Proven both ways in tst_uithreadwatchdog: fires on a genuinely blocked UI thread (synchronous sleep, no processEvents) and stays silent on a responsive one. Full non-conformance suite (55 tests across every lane, `ctest -LE conformance`) passes clean at this point, including the whole gui label under ASan+UBSan. Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01NSCdCWFXBTSBBK3MzWtJiC
323 lines
12 KiB
C++
323 lines
12 KiB
C++
#include "dialogs/SchedulerDialog.hpp"
|
|
|
|
#include <QCheckBox>
|
|
#include <QComboBox>
|
|
#include <QDateEdit>
|
|
#include <QDialogButtonBox>
|
|
#include <QFormLayout>
|
|
#include <QHBoxLayout>
|
|
#include <QJsonArray>
|
|
#include <QLabel>
|
|
#include <QListWidget>
|
|
#include <QPointer>
|
|
#include <QPushButton>
|
|
#include <QSpinBox>
|
|
#include <QTimeEdit>
|
|
#include <QVBoxLayout>
|
|
|
|
#include "rpc/Protocol.hpp"
|
|
#include "rpc/RpcClient.hpp"
|
|
#include "util/Theme.hpp"
|
|
|
|
namespace velox::gui {
|
|
namespace {
|
|
|
|
// 0 = Sunday, matching Schedule.schema.json's daysOfWeek.
|
|
constexpr const char *kDayLabels[7] = {
|
|
QT_TR_NOOP("Sun"), QT_TR_NOOP("Mon"), QT_TR_NOOP("Tue"), QT_TR_NOOP("Wed"),
|
|
QT_TR_NOOP("Thu"), QT_TR_NOOP("Fri"), QT_TR_NOOP("Sat"),
|
|
};
|
|
|
|
} // namespace
|
|
|
|
QJsonValue SchedulerDialog::buildSchedule(bool useSchedule, const QString &mode,
|
|
const QString &startTime, bool runUntilDrains,
|
|
const QString &stopTime, const QList<int> &daysOfWeek,
|
|
const QString &onceDate) {
|
|
if (!useSchedule) {
|
|
return QJsonValue(QJsonValue::Null);
|
|
}
|
|
QJsonObject s{
|
|
{"enabled", true},
|
|
{"mode", mode},
|
|
{"startTime", startTime},
|
|
{"stopTime", runUntilDrains ? QJsonValue(QJsonValue::Null) : QJsonValue(stopTime)},
|
|
};
|
|
if (mode == QLatin1String("periodic")) {
|
|
QJsonArray days;
|
|
for (int d : daysOfWeek) {
|
|
days.append(d);
|
|
}
|
|
s["daysOfWeek"] = days;
|
|
} else {
|
|
s["onceDate"] = onceDate;
|
|
}
|
|
return s;
|
|
}
|
|
|
|
SchedulerDialog::SchedulerDialog(rpc::RpcClient *client, QWidget *parent)
|
|
: QDialog(parent),
|
|
client_(client),
|
|
queueList_(new QListWidget(this)),
|
|
useSchedule_(new QCheckBox(tr("Run this queue on a schedule"), this)),
|
|
mode_(new QComboBox(this)),
|
|
startTime_(new QTimeEdit(this)),
|
|
runUntilDrains_(new QCheckBox(tr("Run until the queue drains"), this)),
|
|
stopTime_(new QTimeEdit(this)),
|
|
onceDate_(new QDateEdit(this)),
|
|
maxConcurrent_(new QSpinBox(this)),
|
|
onComplete_(new QComboBox(this)),
|
|
pauseRunningOnStop_(new QCheckBox(tr("Pause running tasks too"), this)),
|
|
statusLabel_(new QLabel(this)) {
|
|
setWindowTitle(tr("Scheduler"));
|
|
resize(640, 420);
|
|
|
|
queueList_->setMaximumWidth(200);
|
|
|
|
mode_->addItem(tr("Once"), QStringLiteral("once"));
|
|
mode_->addItem(tr("Periodic (weekly)"), QStringLiteral("periodic"));
|
|
startTime_->setDisplayFormat(QStringLiteral("HH:mm"));
|
|
stopTime_->setDisplayFormat(QStringLiteral("HH:mm"));
|
|
onceDate_->setCalendarPopup(true);
|
|
maxConcurrent_->setRange(1, 32);
|
|
onComplete_->addItem(tr("Do nothing"), QStringLiteral("nothing"));
|
|
onComplete_->addItem(tr("Exit Velox"), QStringLiteral("exit"));
|
|
onComplete_->addItem(tr("Shut down"), QStringLiteral("shutdown"));
|
|
onComplete_->addItem(tr("Hang up (disconnect network)"), QStringLiteral("hangup"));
|
|
|
|
auto *daysRow = new QWidget(this);
|
|
auto *daysLayout = new QHBoxLayout(daysRow);
|
|
daysLayout->setContentsMargins(0, 0, 0, 0);
|
|
for (int i = 0; i < 7; ++i) {
|
|
dayChecks_[i] = new QCheckBox(tr(kDayLabels[i]), daysRow);
|
|
daysLayout->addWidget(dayChecks_[i]);
|
|
}
|
|
|
|
auto *stopRow = new QWidget(this);
|
|
auto *stopLayout = new QHBoxLayout(stopRow);
|
|
stopLayout->setContentsMargins(0, 0, 0, 0);
|
|
stopLayout->addWidget(stopTime_);
|
|
stopLayout->addWidget(runUntilDrains_);
|
|
connect(runUntilDrains_, &QCheckBox::toggled, stopTime_, &QWidget::setDisabled);
|
|
|
|
const auto updateModeVisibility = [this, daysRow] {
|
|
const bool periodic = mode_->currentData().toString() == QLatin1String("periodic");
|
|
daysRow->setVisible(periodic);
|
|
onceDate_->setVisible(!periodic);
|
|
};
|
|
connect(mode_, &QComboBox::currentIndexChanged, this, updateModeVisibility);
|
|
updateModeVisibility();
|
|
|
|
form_ = new QWidget(this);
|
|
auto *form = new QFormLayout(form_);
|
|
form->addRow(useSchedule_);
|
|
form->addRow(tr("Mode:"), mode_);
|
|
form->addRow(tr("Start time:"), startTime_);
|
|
form->addRow(tr("Stop time:"), stopRow);
|
|
form->addRow(tr("Days:"), daysRow);
|
|
form->addRow(tr("Date:"), onceDate_);
|
|
form->addRow(tr("Max concurrent:"), maxConcurrent_);
|
|
form->addRow(tr("When the queue finishes:"), onComplete_);
|
|
connect(useSchedule_, &QCheckBox::toggled, this, [this](bool on) {
|
|
for (QWidget *w :
|
|
{static_cast<QWidget *>(mode_), static_cast<QWidget *>(startTime_),
|
|
static_cast<QWidget *>(stopTime_), static_cast<QWidget *>(runUntilDrains_),
|
|
static_cast<QWidget *>(onceDate_)}) {
|
|
w->setEnabled(on);
|
|
}
|
|
});
|
|
|
|
statusLabel_->setStyleSheet(theme::errorLabelStyle());
|
|
statusLabel_->hide();
|
|
form_->setEnabled(false); // no queue selected yet
|
|
|
|
auto *startButton = new QPushButton(tr("Start Now"), this);
|
|
auto *stopButton = new QPushButton(tr("Stop"), this);
|
|
auto *saveButton = new QPushButton(tr("Save"), this);
|
|
saveButton->setDefault(true);
|
|
connect(startButton, &QPushButton::clicked, this, &SchedulerDialog::startNow);
|
|
connect(stopButton, &QPushButton::clicked, this, &SchedulerDialog::stopNow);
|
|
connect(saveButton, &QPushButton::clicked, this, &SchedulerDialog::save);
|
|
|
|
auto *actionRow = new QHBoxLayout;
|
|
actionRow->addWidget(startButton);
|
|
actionRow->addWidget(stopButton);
|
|
actionRow->addWidget(pauseRunningOnStop_);
|
|
actionRow->addStretch();
|
|
actionRow->addWidget(saveButton);
|
|
|
|
auto *closeButtons = new QDialogButtonBox(QDialogButtonBox::Close, this);
|
|
connect(closeButtons, &QDialogButtonBox::rejected, this, &QDialog::reject);
|
|
connect(closeButtons, &QDialogButtonBox::accepted, this, &QDialog::accept);
|
|
|
|
auto *rightLayout = new QVBoxLayout;
|
|
rightLayout->addWidget(form_, 1);
|
|
rightLayout->addWidget(statusLabel_);
|
|
rightLayout->addLayout(actionRow);
|
|
rightLayout->addWidget(closeButtons);
|
|
|
|
auto *split = new QHBoxLayout(this);
|
|
split->addWidget(queueList_);
|
|
split->addLayout(rightLayout, 1);
|
|
|
|
connect(queueList_, &QListWidget::currentItemChanged, this, &SchedulerDialog::onQueueSelected);
|
|
|
|
QPointer<SchedulerDialog> self(this);
|
|
client_->call(QString::fromLatin1(rpc::method::kQueueList), {},
|
|
[self](const rpc::RpcReply &reply) {
|
|
if (!self || !reply.ok()) {
|
|
return;
|
|
}
|
|
self->onQueuesLoaded(reply.result.toObject().value("items").toArray());
|
|
});
|
|
}
|
|
|
|
void SchedulerDialog::onQueuesLoaded(const QJsonArray &items) {
|
|
queues_ = items;
|
|
queueList_->clear();
|
|
for (const QJsonValue &v : items) {
|
|
auto *item = new QListWidgetItem(v.toObject().value("name").toString(), queueList_);
|
|
item->setData(Qt::UserRole, v.toObject().value("queueId").toString());
|
|
}
|
|
if (queueList_->count() > 0) {
|
|
queueList_->setCurrentRow(0);
|
|
}
|
|
}
|
|
|
|
QJsonObject SchedulerDialog::selectedQueue() const {
|
|
if (!queueList_->currentItem()) {
|
|
return {};
|
|
}
|
|
const QString id = queueList_->currentItem()->data(Qt::UserRole).toString();
|
|
for (const QJsonValue &v : queues_) {
|
|
if (v.toObject().value("queueId").toString() == id) {
|
|
return v.toObject();
|
|
}
|
|
}
|
|
return {};
|
|
}
|
|
|
|
void SchedulerDialog::onQueueSelected(QListWidgetItem *item, QListWidgetItem * /*previous*/) {
|
|
if (!item) {
|
|
form_->setEnabled(false);
|
|
return;
|
|
}
|
|
populateForm(selectedQueue());
|
|
form_->setEnabled(true);
|
|
}
|
|
|
|
void SchedulerDialog::populateForm(const QJsonObject &queue) {
|
|
maxConcurrent_->setValue(queue.value("maxConcurrent").toInt(1));
|
|
const int completeIdx = onComplete_->findData(queue.value("onComplete").toString("nothing"));
|
|
onComplete_->setCurrentIndex(completeIdx >= 0 ? completeIdx : 0);
|
|
|
|
const QJsonValue schedule = queue.value("schedule");
|
|
const bool has = schedule.isObject();
|
|
useSchedule_->setChecked(has);
|
|
for (QWidget *w : {static_cast<QWidget *>(mode_), static_cast<QWidget *>(startTime_),
|
|
static_cast<QWidget *>(stopTime_), static_cast<QWidget *>(runUntilDrains_),
|
|
static_cast<QWidget *>(onceDate_)}) {
|
|
w->setEnabled(has);
|
|
}
|
|
if (!has) {
|
|
return;
|
|
}
|
|
const QJsonObject sched = schedule.toObject();
|
|
const int modeIdx = mode_->findData(sched.value("mode").toString("once"));
|
|
mode_->setCurrentIndex(modeIdx >= 0 ? modeIdx : 0);
|
|
startTime_->setTime(QTime::fromString(sched.value("startTime").toString("00:00"), "HH:mm"));
|
|
const bool stopIsNull = sched.value("stopTime").isNull();
|
|
runUntilDrains_->setChecked(stopIsNull);
|
|
if (!stopIsNull) {
|
|
stopTime_->setTime(QTime::fromString(sched.value("stopTime").toString(), "HH:mm"));
|
|
}
|
|
for (int i = 0; i < 7; ++i) {
|
|
dayChecks_[i]->setChecked(false);
|
|
}
|
|
for (const QJsonValue &d : sched.value("daysOfWeek").toArray()) {
|
|
const int idx = d.toInt();
|
|
if (idx >= 0 && idx < 7) {
|
|
dayChecks_[idx]->setChecked(true);
|
|
}
|
|
}
|
|
if (!sched.value("onceDate").isNull()) {
|
|
onceDate_->setDate(QDate::fromString(sched.value("onceDate").toString(), Qt::ISODate));
|
|
}
|
|
}
|
|
|
|
void SchedulerDialog::save() {
|
|
const QJsonObject queue = selectedQueue();
|
|
const QString queueId = queue.value("queueId").toString();
|
|
if (queueId.isEmpty()) {
|
|
return;
|
|
}
|
|
|
|
QList<int> days;
|
|
for (int i = 0; i < 7; ++i) {
|
|
if (dayChecks_[i]->isChecked()) {
|
|
days << i;
|
|
}
|
|
}
|
|
const QJsonValue schedule = buildSchedule(
|
|
useSchedule_->isChecked(), mode_->currentData().toString(),
|
|
startTime_->time().toString(QStringLiteral("HH:mm")), runUntilDrains_->isChecked(),
|
|
stopTime_->time().toString(QStringLiteral("HH:mm")), days,
|
|
onceDate_->date().toString(Qt::ISODate));
|
|
|
|
QPointer<SchedulerDialog> self(this);
|
|
client_->call(QString::fromLatin1(rpc::method::kScheduleSet),
|
|
QJsonObject{{"queueId", queueId}, {"schedule", schedule}},
|
|
[self, queueId](const rpc::RpcReply &reply) {
|
|
if (!self) {
|
|
return;
|
|
}
|
|
if (!reply.ok()) {
|
|
self->statusLabel_->setText(
|
|
tr("Could not save the schedule: %1").arg(reply.error.message));
|
|
self->statusLabel_->show();
|
|
return;
|
|
}
|
|
self->statusLabel_->hide();
|
|
});
|
|
|
|
// Queue-level fields (maxConcurrent/onComplete) go through queue.upsert; taskIds is
|
|
// ignored by the daemon so re-sending the cached membership cannot drop a task.
|
|
QJsonObject updatedQueue = queue;
|
|
updatedQueue["maxConcurrent"] = maxConcurrent_->value();
|
|
updatedQueue["onComplete"] = onComplete_->currentData().toString();
|
|
client_->call(QString::fromLatin1(rpc::method::kQueueUpsert),
|
|
QJsonObject{{"queue", updatedQueue}}, [self](const rpc::RpcReply &reply) {
|
|
if (!self || !reply.ok()) {
|
|
return;
|
|
}
|
|
const QJsonObject updated = reply.result.toObject().value("queue").toObject();
|
|
for (int i = 0; i < self->queues_.size(); ++i) {
|
|
if (self->queues_[i].toObject().value("queueId").toString() ==
|
|
updated.value("queueId").toString()) {
|
|
self->queues_[i] = updated;
|
|
break;
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
void SchedulerDialog::startNow() {
|
|
const QString queueId = selectedQueue().value("queueId").toString();
|
|
if (queueId.isEmpty()) {
|
|
return;
|
|
}
|
|
client_->call(QString::fromLatin1(rpc::method::kQueueStart), QJsonObject{{"queueId", queueId}});
|
|
}
|
|
|
|
void SchedulerDialog::stopNow() {
|
|
const QString queueId = selectedQueue().value("queueId").toString();
|
|
if (queueId.isEmpty()) {
|
|
return;
|
|
}
|
|
client_->call(
|
|
QString::fromLatin1(rpc::method::kQueueStop),
|
|
QJsonObject{{"queueId", queueId}, {"pauseRunning", pauseRunningOnStop_->isChecked()}});
|
|
}
|
|
|
|
} // namespace velox::gui
|