Files
vdm/gui/src/dialogs/SchedulerDialog.cpp
T
samiandClaude Sonnet 5 c2eef96175 gui: floating drop target, clipboard global shortcut, theming, UI watchdog
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
2026-09-12 21:30:13 +04:00

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