[Client] Improve card database update visibility (#7458)

* [Client] Improve card database update visibility

Addresses Cockatrice/Cockatrice#7431.

Cockatrice previously offered no in-client feedback while the card
database updater ran: the only progress UI lived in the first-run
wizard, stderr/stdout were not parsed in interactive runs, and starting
a second update with Oracle already open or with a hidden background run
active just showed a confusing 'already running' guard - even though
Oracle is launched as a separate process with no lifecycle tracking.

- Add a status-bar widget (CardDatabaseUpdateStatusBar) that appears
  while an update runs and shows the Downloading/Parsing/Importing
  stages with percent progress; it is also used in the quit-confirm
  prompt so users see an unfinished update before cancelling it.
- Parse the updater's PROGRESS lines (new CardUpdateProgressParser,
  unit-tested) in both foreground and background runs and log stage
  changes to the new card_database.update category, throttled to at
  most one line per second; forward Oracle stderr to the logs.
- Show a tray balloon when a background update starts and on finish or
  failure instead of staying silent.
- Request a running Oracle instance to raise itself (QLocalServer
  CockatriceOracleRaise-<user>) when the user starts another update, and
  demote the 'already running' guard to an informative message only when
  no running instance can be found.
- Emit download/scan/import progress unconditionally from Oracle so
  interactive and background runs behave the same.
- Document the new log category in the troubleshooting guide.

* [Client] Address card database update review feedback

* [Client] Add setting to control card database update tray notifications

* [Client] Address remaining card database update review nits

- Rename card_update_progress_parser.{h,cpp} to card_update_progress.{h,cpp}
- Store the CardUpdateProgress struct in the status bar widget

* Lint.

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
This commit is contained in:
BruebachL 2026-10-09 11:07:48 +02:00 • committed by GitHub
parent 6bed5d6f67
commit a44757bd11
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 702 additions and 67 deletions

View file

@ -0,0 +1,117 @@
#include "card_database_update_status_bar.h"
#include <QCoreApplication>
#include <QEvent>
#include <QHBoxLayout>
#include <QLabel>
#include <QProgressBar>
#include <QtGlobal>
#include <climits>
namespace
{
/**
* @brief Translated name of one updater stage.
* @param fallback shown when the stage is unknown or not reported yet
*/
QString stageName(CardUpdateStage stage, const QString &fallback)
{
switch (stage) {
case CardUpdateStage::Download:
return QCoreApplication::translate("CardDatabaseUpdateStatusBar", "Downloading");
case CardUpdateStage::Scan:
return QCoreApplication::translate("CardDatabaseUpdateStatusBar", "Parsing");
case CardUpdateStage::Import:
return QCoreApplication::translate("CardDatabaseUpdateStatusBar", "Importing");
case CardUpdateStage::Unknown:
return fallback;
}
return fallback;
}
} // namespace
CardDatabaseUpdateStatusBar::CardDatabaseUpdateStatusBar(QWidget *parent) : QWidget(parent)
{
layout = new QHBoxLayout(this);
layout->setContentsMargins(0, 0, 0, 0);
stageLabel = new QLabel(this);
progressBar = new QProgressBar(this);
progressBar->setMinimumWidth(120);
layout->addWidget(stageLabel);
layout->addWidget(progressBar);
setLayout(layout);
refresh();
hide();
}
QString CardDatabaseUpdateStatusBar::statusText() const
{
if (!active) {
return {};
}
const QString name = stageName(progress.stage, tr("Updating the card database"));
if (progress.total <= 0) {
return name;
}
const int percent = static_cast<int>((100.0 * progress.done) / progress.total);
return tr("%1 (%2%)").arg(name).arg(percent);
}
void CardDatabaseUpdateStatusBar::updateStarted()
{
active = true;
progress = {};
refresh();
show();
}
void CardDatabaseUpdateStatusBar::updateProgress(const CardUpdateProgress &progress)
{
active = true;
this->progress = progress;
refresh();
}
void CardDatabaseUpdateStatusBar::updateFinished()
{
active = false;
progress = {};
refresh();
hide();
}
void CardDatabaseUpdateStatusBar::changeEvent(QEvent *event)
{
if (event->type() == QEvent::LanguageChange) {
refresh();
}
QWidget::changeEvent(event);
}
void CardDatabaseUpdateStatusBar::refresh()
{
const QString name = stageName(progress.stage, tr("Updating the card database"));
stageLabel->setText(name);
const qint64 cappedTotal = qMin<qint64>(progress.total, INT_MAX);
const qint64 cappedDone = qMin<qint64>(progress.done, INT_MAX);
const int minimum = 0;
const int maximum = static_cast<int>(cappedTotal);
if (progressBar->minimum() != minimum || progressBar->maximum() != maximum) {
// A zero range is the busy indicator, used until the first report arrives.
progressBar->setRange(minimum, maximum);
}
progressBar->setValue(static_cast<int>(cappedDone));
if (active && progress.total > 0) {
const int percent = static_cast<int>((100.0 * progress.done) / progress.total);
setToolTip(tr("Card database update in progress: %1 (%2%)").arg(name).arg(percent));
} else if (active) {
setToolTip(tr("Card database update in progress."));
} else {
setToolTip({});
}
}

View file

@ -0,0 +1,68 @@
#ifndef CARD_DATABASE_UPDATE_STATUS_BAR_H
#define CARD_DATABASE_UPDATE_STATUS_BAR_H
#include "card_update_progress.h"
#include <QString>
#include <QWidget>
#include <qtmetamacros.h>
class QEvent;
class QHBoxLayout;
class QLabel;
class QProgressBar;
/**
* @class CardDatabaseUpdateStatusBar
* @brief Status bar widget showing what the card database updater (Oracle) is doing.
*
* The updater runs as a separate process, so this widget is the in-client view of
* its progress: it appears when an update starts, tracks the download/scan/import
* stages reported by the updater, and disappears when the update finishes.
*/
class CardDatabaseUpdateStatusBar : public QWidget
{
Q_OBJECT
public:
/**
* @brief Constructs a hidden progress widget.
* @param parent Parent widget
*/
explicit CardDatabaseUpdateStatusBar(QWidget *parent = nullptr);
/**
* @brief Human readable description of the running stage, e.g. "Downloading (42%)".
* @return the description, or an empty string when no update is running
*/
QString statusText() const;
public slots:
/**
* @brief Shows the widget and resets it to the indeterminate state.
*/
void updateStarted();
/**
* @brief Shows the progress of one update stage.
*/
void updateProgress(const CardUpdateProgress &progress);
/**
* @brief Hides the widget and clears the reported progress.
*/
void updateFinished();
protected:
void changeEvent(QEvent *event) override;
private:
void refresh();
QHBoxLayout *layout; ///< Horizontal layout holding the stage label and the progress bar
QLabel *stageLabel; ///< Name of the stage currently running
QProgressBar *progressBar; ///< Progress within the current stage
CardUpdateProgress progress; ///< Last reported progress
bool active = false; ///< Whether an update is currently running
};
#endif // CARD_DATABASE_UPDATE_STATUS_BAR_H

View file

@ -0,0 +1,59 @@
#include "card_update_progress.h"
#include <QList>
namespace
{
CardUpdateStage parseStage(const QByteArray &token)
{
if (token == "download") {
return CardUpdateStage::Download;
}
if (token == "scan") {
return CardUpdateStage::Scan;
}
if (token == "import") {
return CardUpdateStage::Import;
}
return CardUpdateStage::Unknown;
}
} // namespace
std::optional<CardUpdateProgress> CardUpdateProgress::fromProtocolLine(const QByteArray &line)
{
const QByteArray trimmed = line.trimmed();
if (!trimmed.startsWith("PROGRESS ")) {
return std::nullopt;
}
const QList<QByteArray> parts = trimmed.split(' ');
if (parts.size() != 4) {
return std::nullopt;
}
bool doneOk = false;
bool totalOk = false;
const qint64 done = parts.at(2).toLongLong(&doneOk);
const qint64 total = parts.at(3).toLongLong(&totalOk);
if (!doneOk || !totalOk || done < 0 || total < 0) {
return std::nullopt;
}
CardUpdateProgress progress;
progress.stage = parseStage(parts.at(1));
progress.done = done;
progress.total = total;
return progress;
}
QString CardUpdateProgress::stageToken() const
{
switch (stage) {
case CardUpdateStage::Download:
return QStringLiteral("download");
case CardUpdateStage::Scan:
return QStringLiteral("scan");
case CardUpdateStage::Import:
return QStringLiteral("import");
case CardUpdateStage::Unknown:
return QStringLiteral("unknown");
}
return QStringLiteral("unknown");
}

View file

@ -0,0 +1,42 @@
#ifndef CARD_UPDATE_PROGRESS_H
#define CARD_UPDATE_PROGRESS_H
#include <QByteArray>
#include <QString>
#include <QtGlobal>
#include <optional>
/**
* @brief One stage of the card database updater's progress protocol.
*/
enum class CardUpdateStage
{
Download, ///< downloading the source data
Scan, ///< parsing the downloaded data
Import, ///< importing cards into the database
Unknown, ///< a stage the client does not recognize
};
/**
* @brief One progress line read from the card database updater's stdout.
*/
struct CardUpdateProgress
{
/**
* @brief Parses one line of the updater stdout protocol: "PROGRESS <stage> <done> <total>".
* @param line a single stdout line, with or without its trailing newline
* @return the parsed progress, or @c std::nullopt when the line is not a valid progress line
*/
static std::optional<CardUpdateProgress> fromProtocolLine(const QByteArray &line);
/**
* @brief Protocol token of the stage ("download", "scan", "import" or "unknown"), for logging.
*/
QString stageToken() const;
CardUpdateStage stage = CardUpdateStage::Unknown;
qint64 done = 0;
qint64 total = 0;
};
#endif // CARD_UPDATE_PROGRESS_H