[Spoilers] Preserve spoiler files through update and reload failures (#7707)

* Preserve spoiler files and serialize update reloads

* Surface spoiler removal reload failures and allow disabled-state retry

* Address review feedback on spoiler update lifecycle

Only remove the spoiler file when the user unchecks the setting; opening
Settings no longer queues an unrequested reload for a leftover file.
Remove the spoilersUpdatedSuccessfully signal, which had no receiver.

* Fix spoiler settings state after reopen and while disabled

Reopen Settings while an application-owned update is running: the new page
now observes that updater, so the button reads Updating... and re-enables
on completion instead of offering a second update that cancels the first.
Observation does not take ownership: only updaters the page creates are
retired with deleteLater, because the launch check is stack-allocated.

When spoilers are off, the hint now describes what Reload Card Database
actually does, stays readable, and wraps instead of clipping.

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
This commit is contained in:
Bryce! 2026-11-04 12:24:19 -05:00 • committed by GitHub
parent 3a656c9c4a
commit 0de92c06f8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 648 additions and 229 deletions

View file

@ -2,253 +2,305 @@
#include "../../../../main.h"
#include "../../../settings/cache_settings.h"
#include "spoiler_file.h"
#include <QChar>
#include <QCryptographicHash>
#include <QDateTime>
#include <QCoreApplication>
#include <QDebug>
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QFutureWatcher>
#include <QIODevice>
#include <QList>
#include <QLocale>
#include <QMessageLogger>
#include <QNetworkAccessManager>
#include <QNetworkReply>
#include <QNetworkRequest>
#include <QSharedPointer>
#include <QSystemTrayIcon>
#include <QTimeZone>
#include <QTimer>
#include <QUrl>
#include <QtConcurrentRun>
#include <QtGlobal>
#include <functional>
#include <exception>
#include <libcockatrice/card/database/card_database.h>
#include <libcockatrice/card/database/card_database_manager.h>
#include <libcockatrice/network/network_manager.h>
#include <libcockatrice/settings/download_settings.h>
#include <libcockatrice/settings/paths_settings.h>
#include <tuple>
#include <utility>
#include <version_string.h>
#define SPOILERS_STATUS_URL "https://raw.githubusercontent.com/Cockatrice/Magic-Spoiler/files/SpoilerSeasonEnabled"
#define SPOILERS_URL "https://raw.githubusercontent.com/Cockatrice/Magic-Spoiler/files/spoiler.xml"
SpoilerBackgroundUpdater::SpoilerBackgroundUpdater(QObject *apParent) : QObject(apParent), cardUpdateProcess(nullptr)
namespace
{
isSpoilerDownloadEnabled = SettingsCache::instance().downloads().getDownloadSpoilersStatus();
if (isSpoilerDownloadEnabled) {
// Start the process of checking if we're in spoiler season
// File exists means we're in spoiler season
startSpoilerDownloadProcess(SPOILERS_STATUS_URL, false);
const QUrl SPOILERS_STATUS_URL(
QStringLiteral("https://raw.githubusercontent.com/Cockatrice/Magic-Spoiler/files/SpoilerSeasonEnabled"));
const QUrl SPOILERS_URL(QStringLiteral("https://raw.githubusercontent.com/Cockatrice/Magic-Spoiler/files/spoiler.xml"));
// Request coordination stays on the GUI thread; reload workers never access it.
QPointer<SpoilerBackgroundUpdater> currentUpdater;
QPointer<QFutureWatcher<bool>> activeReload;
quint64 newestOperation = 0;
bool removeSpoilerFile(const QString &path)
{
QFile file(path);
if (!file.exists()) {
return false;
}
if (!file.remove()) {
qCWarning(SpoilerBackgroundUpdaterLog) << "Could not remove spoiler database" << path << file.errorString();
return false;
}
return true;
}
} // namespace
SpoilerBackgroundUpdater::SpoilerBackgroundUpdater(QObject *parent, bool forceReload)
: QObject(parent), enabledAtStart(SettingsCache::instance().downloads().getDownloadSpoilersStatus()),
forceReload(forceReload), targetPath(SettingsCache::instance().paths().getSpoilerCardDatabasePath()),
operationId(++newestOperation), manager(NetworkManagerFactory::create(this)), notificationIcon(trayIcon)
{
connect(&SettingsCache::instance().downloads(), &DownloadSettings::downloadSpoilerStatusChanged, this,
&SpoilerBackgroundUpdater::cancelIfSettingsChanged);
connect(&SettingsCache::instance().paths(), &PathsSettings::cardDatabasePathChanged, this,
&SpoilerBackgroundUpdater::cancelIfSettingsChanged);
// Even a disabled/no-op operation completes after its caller can connect signals.
QTimer::singleShot(0, this, &SpoilerBackgroundUpdater::begin);
}
void SpoilerBackgroundUpdater::begin()
{
if (completed) {
return;
}
if (cancelled || operationId != newestOperation ||
(reloadOnly ? SettingsCache::instance().downloads().getDownloadSpoilersStatus()
: (!enabledAtStart || !SettingsCache::instance().downloads().getDownloadSpoilersStatus())) ||
targetPath != SettingsCache::instance().paths().getSpoilerCardDatabasePath()) {
finish();
return;
}
const auto previous = currentUpdater;
if (activeReload) {
// The existing database API stores its final load status after its parser
// mutex is released. Do not overlap updater-owned calls to that API.
connect(activeReload, &QFutureWatcher<bool>::finished, this, &SpoilerBackgroundUpdater::begin,
static_cast<Qt::ConnectionType>(Qt::QueuedConnection | Qt::SingleShotConnection));
if (previous && previous != this) {
previous->cancel();
}
return;
}
currentUpdater = this;
QPointer<SpoilerBackgroundUpdater> guard(this);
if (previous && previous != this) {
previous->cancel();
}
if (!guard) {
return;
}
if (!isCurrent()) {
finish();
} else if (reloadOnly) {
if (QFileInfo::exists(targetPath) && !removeSpoilerFile(targetPath)) {
finish(tr("The spoiler database could not be removed. Check its file permissions and use Reload Card "
"Database to retry."));
return;
}
reloadDatabase(false);
} else {
qCInfo(SpoilerBackgroundUpdaterLog) << "Spoilers Disabled";
downloadFromURL(SPOILERS_STATUS_URL, false);
}
}
void SpoilerBackgroundUpdater::startSpoilerDownloadProcess(QString url, bool saveResults)
bool SpoilerBackgroundUpdater::isCurrent() const
{
auto spoilerURL = QUrl(url);
downloadFromURL(spoilerURL, saveResults);
return !completed && !cancelled && currentUpdater == this && operationId == newestOperation &&
(reloadOnly != SettingsCache::instance().downloads().getDownloadSpoilersStatus()) &&
targetPath == SettingsCache::instance().paths().getSpoilerCardDatabasePath();
}
void SpoilerBackgroundUpdater::downloadFromURL(QUrl url, bool saveResults)
void SpoilerBackgroundUpdater::cancelIfSettingsChanged()
{
auto *nam = NetworkManagerFactory::create(this);
auto request = QNetworkRequest(url);
request.setHeader(QNetworkRequest::UserAgentHeader, QString("Cockatrice %1").arg(VERSION_STRING));
QNetworkReply *reply = nam->get(request);
if (reloadOnly == SettingsCache::instance().downloads().getDownloadSpoilersStatus() ||
targetPath != SettingsCache::instance().paths().getSpoilerCardDatabasePath()) {
cancel();
}
}
void SpoilerBackgroundUpdater::cancel()
{
if (completed) {
return;
}
cancelled = true;
const auto reply = activeReply;
activeReply.clear();
QPointer<SpoilerBackgroundUpdater> guard(this);
if (reply) {
reply->abort();
if (reply) {
reply->deleteLater();
}
}
// A running reload owns no updater state. Allow its completion to retire its
// watcher before a heap-owned updater is released by the caller.
if (guard && !reloading) {
finish();
}
}
void SpoilerBackgroundUpdater::downloadFromURL(const QUrl &url, bool saveResults)
{
QNetworkRequest request(url);
request.setHeader(QNetworkRequest::UserAgentHeader, QString("Cockatrice %1").arg(VERSION_STRING));
request.setTransferTimeout(60000);
activeReply = manager->get(request);
if (saveResults) {
// This will write out to the file (used for spoiler.xml)
connect(reply, &QNetworkReply::finished, this, &SpoilerBackgroundUpdater::actDownloadFinishedSpoilersFile);
connect(activeReply, &QNetworkReply::finished, this,
&SpoilerBackgroundUpdater::actDownloadFinishedSpoilersFile);
} else {
// This will check the status (used to see if we're in spoiler season or not)
connect(reply, &QNetworkReply::finished, this, &SpoilerBackgroundUpdater::actCheckIfSpoilerSeasonEnabled);
connect(activeReply, &QNetworkReply::finished, this, &SpoilerBackgroundUpdater::actCheckIfSpoilerSeasonEnabled);
}
}
void SpoilerBackgroundUpdater::actCheckIfSpoilerSeasonEnabled()
{
auto *reply = qobject_cast<QNetworkReply *>(sender());
if (!reply || reply != activeReply) {
return;
}
activeReply.clear();
reply->deleteLater();
if (!isCurrent()) {
finish();
return;
}
if (reply->error() == QNetworkReply::NoError) {
downloadFromURL(SPOILERS_URL, true);
} else if (reply->error() == QNetworkReply::ContentNotFoundError) {
const bool existed = QFileInfo::exists(targetPath);
seasonEnded = existed;
if (existed && !removeSpoilerFile(targetPath)) {
finish(tr("The spoiler season has ended, but the old spoiler database could not be removed."));
return;
}
if (existed || forceReload) {
reloadDatabase(false);
} else {
finish();
}
} else {
finish(tr("Could not check spoiler availability: %1").arg(reply->errorString()));
}
}
void SpoilerBackgroundUpdater::actDownloadFinishedSpoilersFile()
{
// Check for server reply
auto *reply = dynamic_cast<QNetworkReply *>(sender());
QNetworkReply::NetworkError errorCode = reply->error();
if (errorCode == QNetworkReply::NoError) {
spoilerData = reply->readAll();
// Save the spoiler.xml file to the disk
saveDownloadedFile(spoilerData);
reply->deleteLater();
emit spoilerCheckerDone();
auto *reply = qobject_cast<QNetworkReply *>(sender());
if (!reply || reply != activeReply) {
return;
}
activeReply.clear();
reply->deleteLater();
if (!isCurrent()) {
finish();
return;
}
if (reply->error() != QNetworkReply::NoError) {
finish(tr("Could not download spoilers: %1").arg(reply->errorString()));
return;
}
spoilerData = reply->readAll();
QString error;
const auto result = SpoilerFile::save(targetPath, spoilerData, [this] { return isCurrent(); }, error);
if (result == SpoilerFile::SaveResult::Failed) {
finish(isCurrent() ? error : QString());
} else if (result == SpoilerFile::SaveResult::Written || forceReload) {
reloadDatabase(result == SpoilerFile::SaveResult::Written);
} else {
qCWarning(SpoilerBackgroundUpdaterLog) << "Error downloading spoilers file" << errorCode;
emit spoilerCheckerDone();
finish();
}
}
bool SpoilerBackgroundUpdater::deleteSpoilerFile()
void SpoilerBackgroundUpdater::reloadDatabase(bool updated)
{
QString fileName = SettingsCache::instance().paths().getSpoilerCardDatabasePath();
QFileInfo fi(fileName);
QDir fileDir(fi.path());
QFile file(fileName);
// Delete the spoiler.xml file
if (file.exists() && file.remove()) {
qCInfo(SpoilerBackgroundUpdaterLog) << "Deleting spoiler.xml";
return true;
}
qCInfo(SpoilerBackgroundUpdaterLog) << "Error: Spoiler.xml not found or not deleted";
return false;
}
void SpoilerBackgroundUpdater::actCheckIfSpoilerSeasonEnabled()
{
auto *response = dynamic_cast<QNetworkReply *>(sender());
QNetworkReply::NetworkError errorCode = response->error();
if (errorCode == QNetworkReply::ContentNotFoundError) {
// Spoiler season is offline at this point, so the spoiler.xml file can be safely deleted
// The user should run Oracle to get the latest card information
if (deleteSpoilerFile() && trayIcon) {
trayIcon->showMessage(tr("Spoilers season has ended"), tr("Deleting spoiler.xml. Please run Oracle"));
reloading = true;
auto *database = CardDatabaseManager::getInstance();
// The job outlives an individual request, so destroying that request cannot
// let a newer updater overlap the worker that is still completing.
auto *watcher = new QFutureWatcher<bool>(QCoreApplication::instance());
activeReload = watcher;
connect(watcher, &QFutureWatcher<bool>::finished, watcher, [watcher] {
if (activeReload == watcher) {
activeReload.clear();
}
qCInfo(SpoilerBackgroundUpdaterLog) << "Spoiler Season Offline";
emit spoilerCheckerDone();
} else if (errorCode == QNetworkReply::NoError) {
qCInfo(SpoilerBackgroundUpdaterLog) << "Spoiler Service Online";
startSpoilerDownloadProcess(SPOILERS_URL, true);
} else if (errorCode == QNetworkReply::HostNotFoundError) {
if (trayIcon) {
trayIcon->showMessage(tr("Spoilers download failed"), tr("No internet connection"));
}
qCWarning(SpoilerBackgroundUpdaterLog) << "Spoiler download failed due to no internet connection";
emit spoilerCheckerDone();
} else {
if (trayIcon) {
trayIcon->showMessage(tr("Spoilers download failed"), tr("Error") + " " + (short)errorCode);
}
qCWarning(SpoilerBackgroundUpdaterLog) << "Spoiler download failed with reason" << errorCode;
emit spoilerCheckerDone();
}
}
bool SpoilerBackgroundUpdater::saveDownloadedFile(QByteArray data)
{
QString fileName = SettingsCache::instance().paths().getSpoilerCardDatabasePath();
QFileInfo fi(fileName);
QDir fileDir(fi.path());
if (!fileDir.exists() && !fileDir.mkpath(fileDir.absolutePath())) {
return false;
}
// Check if the data matches. If it does, then spoilers are up to date.
if (getHash(fileName) == getHash(data)) {
if (trayIcon) {
trayIcon->showMessage(tr("Spoilers already up to date"), tr("No new spoilers added"));
}
qCInfo(SpoilerBackgroundUpdaterLog) << "Spoilers Up to Date";
return false;
}
QFile file(fileName);
if (!file.open(QIODevice::WriteOnly)) {
qCWarning(SpoilerBackgroundUpdaterLog) << "Spoiler Service Error: File open (w) failed for" << fileName;
file.close();
return false;
}
if (file.write(data) == -1) {
qCWarning(SpoilerBackgroundUpdaterLog) << "Spoiler Service Error: File write (w) failed for" << fileName;
file.close();
return false;
}
file.close();
// Data written, so reload the card database
qCInfo(SpoilerBackgroundUpdaterLog) << "Spoiler Service Data Written";
// Spoilers can introduce sets the config has never seen; the loader never
// runs checkUnknownSets() itself, so run it once the finished snapshot has
// been swapped in.
auto *watcher = new QFutureWatcher<void>(this);
connect(watcher, &QFutureWatcher<void>::finished, this, [watcher] {
watcher->deleteLater();
CardDatabaseManager::getInstance()->checkUnknownSets();
});
watcher->setFuture(QtConcurrent::run([] { CardDatabaseManager::getInstance()->loadCardDatabases(); }));
// If the user has notifications enabled, let them know
// when the database was last updated
if (trayIcon) {
QList<QByteArray> lines = data.split('\n');
for (const QByteArray &line : lines) {
if (line.contains("Created At:")) {
QString timeStamp = QString(line).replace("Created At:", "").trimmed();
timeStamp.chop(6); // Remove " (UTC)"
auto utcTime = QLocale().toDateTime(timeStamp, "ddd, MMM dd yyyy, hh:mm:ss");
#if QT_VERSION >= QT_VERSION_CHECK(6, 7, 0)
utcTime.setTimeZone(QTimeZone::UTC);
#else
utcTime.setTimeSpec(Qt::UTC);
#endif
QString localTime = utcTime.toLocalTime().toString("MMM d, hh:mm");
trayIcon->showMessage(tr("Spoilers have been updated!"), tr("Last change:") + " " + localTime);
emit spoilersUpdatedSuccessfully();
return true;
}
connect(watcher, &QFutureWatcher<bool>::finished, this, [this, watcher, database, updated] {
bool loaded = false;
try {
loaded = watcher->result() && database->getLoadStatus() == Ok;
} catch (const std::exception &error) {
qCWarning(SpoilerBackgroundUpdaterLog) << "Spoiler database reload failed:" << error.what();
}
}
return true;
reloading = false;
if (!isCurrent()) {
finish();
} else if (!loaded) {
finish(reloadOnly
? tr("Could not reload card data after disabling spoilers. Use Reload Card Database to retry.")
: tr("Could not reload card data after updating spoilers. Use Update Spoilers to retry the "
"reload."));
} else {
finish({}, updated);
}
});
watcher->setFuture(QtConcurrent::run([database] {
database->reloadCardDatabasesAndNotify();
return true;
}));
}
QByteArray SpoilerBackgroundUpdater::getHash(const QString fileName)
void SpoilerBackgroundUpdater::finish(const QString &error, bool updated)
{
QFile file(fileName);
if (file.open(QFile::ReadOnly)) {
// Only read the first 512 bytes (enough to get the "created" tag)
const QByteArray bytes = file.read(512);
QCryptographicHash hash(QCryptographicHash::Algorithm::Md5);
hash.addData(bytes);
qCInfo(SpoilerBackgroundUpdaterLog) << "File Hash =" << hash.result();
file.close();
return hash.result();
} else {
qCWarning(SpoilerBackgroundUpdaterLog) << "getHash ReadOnly failed!";
file.close();
return QByteArray();
if (completed) {
return;
}
completed = true;
spoilerData = QByteArray();
if (currentUpdater == this) {
currentUpdater.clear();
}
QPointer<SpoilerBackgroundUpdater> guard(this);
if (!error.isEmpty() && !cancelled) {
qCWarning(SpoilerBackgroundUpdaterLog) << error;
if (notificationIcon) {
notificationIcon->showMessage(tr("Spoiler update failed"), error);
}
if (!guard) {
return;
}
emit spoilerUpdateFailed(error);
} else if (updated && !cancelled) {
if (notificationIcon) {
notificationIcon->showMessage(tr("Spoilers have been updated!"),
tr("The card database now includes the downloaded spoilers."));
}
} else if (seasonEnded && !cancelled && notificationIcon) {
notificationIcon->showMessage(tr("Spoiler season has ended"),
tr("The spoiler database was removed. Please run Oracle to get released cards."));
}
if (guard) {
emit spoilerCheckerDone();
}
}
QByteArray SpoilerBackgroundUpdater::getHash(QByteArray data)
SpoilerBackgroundUpdater *SpoilerBackgroundUpdater::current()
{
// Only read the first 512 bytes (enough to get the "created" tag)
const QByteArray bytes = data.left(512);
QCryptographicHash hash(QCryptographicHash::Algorithm::Md5);
hash.addData(bytes);
qCInfo(SpoilerBackgroundUpdaterLog) << "Data Hash =" << hash.result();
return hash.result();
return currentUpdater;
}
SpoilerBackgroundUpdater *SpoilerBackgroundUpdater::deleteSpoilerFile()
{
// Deletion and the following reload share the operation lifetime. Callers can
// observe either failure before the queued operation starts, and an explicit
// retry still reloads when the file has already been removed.
auto *updater = new SpoilerBackgroundUpdater(QCoreApplication::instance(), true);
updater->reloadOnly = true;
connect(updater, &SpoilerBackgroundUpdater::spoilerCheckerDone, updater, &QObject::deleteLater);
return updater;
}

View file

@ -2,7 +2,6 @@
* @file spoiler_background_updater.h
* @ingroup Client
*/
//! \todo Document this file.
#ifndef COCKATRICE_SPOILER_DOWNLOADER_H
#define COCKATRICE_SPOILER_DOWNLOADER_H
@ -10,10 +9,13 @@
#include <QByteArray>
#include <QLoggingCategory>
#include <QObject>
#include <QPointer>
#include <QString>
#include <qtmetamacros.h>
class QProcess;
class QNetworkAccessManager;
class QNetworkReply;
class QSystemTrayIcon;
class QUrl;
inline Q_LOGGING_CATEGORY(SpoilerBackgroundUpdaterLog, "spoiler_background_updater");
@ -22,30 +24,48 @@ class SpoilerBackgroundUpdater : public QObject
{
Q_OBJECT
public:
explicit SpoilerBackgroundUpdater(QObject *apParent = nullptr);
explicit SpoilerBackgroundUpdater(QObject *parent = nullptr, bool forceReload = false);
inline QString getCardUpdaterBinaryName()
{
return "oracle";
}
QByteArray getHash(const QString fileName);
QByteArray getHash(QByteArray data);
static bool deleteSpoilerFile();
// Queues removal and reload; the application owns the returned updater and
// deletes it on completion. Callers can connect its failure/completion signals.
static SpoilerBackgroundUpdater *deleteSpoilerFile();
// The operation currently running, if any. The updater outlives the page that
// started it, so a reopened page uses this to observe the in-flight update.
static SpoilerBackgroundUpdater *current();
private slots:
void begin();
void actDownloadFinishedSpoilersFile();
void actCheckIfSpoilerSeasonEnabled();
void cancelIfSettingsChanged();
private:
bool isSpoilerDownloadEnabled;
QProcess *cardUpdateProcess;
const bool enabledAtStart;
const bool forceReload;
const QString targetPath;
const quint64 operationId;
QNetworkAccessManager *manager;
QPointer<QNetworkReply> activeReply;
QPointer<QSystemTrayIcon> notificationIcon;
QByteArray spoilerData;
void startSpoilerDownloadProcess(QString url, bool saveResults);
void downloadFromURL(QUrl url, bool saveResults);
bool saveDownloadedFile(QByteArray data);
bool completed = false;
bool cancelled = false;
bool reloading = false;
bool seasonEnded = false;
bool reloadOnly = false;
bool isCurrent() const;
void cancel();
void finish(const QString &error = {}, bool updated = false);
void downloadFromURL(const QUrl &url, bool saveResults);
void reloadDatabase(bool updated);
signals:
void spoilersUpdatedSuccessfully();
void spoilerCheckerDone();
void spoilerUpdateFailed(const QString &message);
};
#endif // COCKATRICE_SPOILER_DOWNLOADER_H
#endif // COCKATRICE_SPOILER_DOWNLOADER_H

View file

@ -0,0 +1,109 @@
#include "spoiler_file.h"
#include <QCoreApplication>
#include <QCryptographicHash>
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QSaveFile>
#include <QXmlStreamReader>
namespace
{
QByteArray hashFile(const QString &path)
{
QFile file(path);
if (!file.open(QIODevice::ReadOnly)) {
return {};
}
QCryptographicHash hash(QCryptographicHash::Sha256);
return hash.addData(&file) ? hash.result() : QByteArray();
}
} // namespace
namespace SpoilerFile
{
QString validate(const QByteArray &data)
{
QXmlStreamReader xml(data);
if (!xml.readNextStartElement() || xml.name() != QStringLiteral("cockatrice_carddatabase")) {
return QCoreApplication::translate(
"SpoilerBackgroundUpdater",
"The download is not a Cockatrice card database. The previous spoiler file was preserved.");
}
bool versionOk = false;
const int version = xml.attributes().value(QStringLiteral("version")).toInt(&versionOk);
if (!versionOk || (version != 3 && version != 4)) {
return QCoreApplication::translate(
"SpoilerBackgroundUpdater",
"The spoiler database uses an unsupported format. The previous spoiler file was preserved.");
}
bool hasCards = false;
int depth = 1;
while (!xml.atEnd()) {
const auto token = xml.readNext();
if (token == QXmlStreamReader::StartElement) {
++depth;
if (depth == 2 && xml.name() == QStringLiteral("cards")) {
hasCards = true;
}
} else if (token == QXmlStreamReader::EndElement) {
--depth;
}
}
if (xml.hasError() || !hasCards) {
return QCoreApplication::translate(
"SpoilerBackgroundUpdater",
"The spoiler database is incomplete or invalid. The previous spoiler file was preserved.");
}
return {};
}
SaveResult
save(const QString &targetPath, const QByteArray &data, const std::function<bool()> &isCurrent, QString &error)
{
error = validate(data);
if (!error.isEmpty()) {
return SaveResult::Failed;
}
if (!isCurrent()) {
error = QCoreApplication::translate("SpoilerBackgroundUpdater",
"The spoiler update was cancelled because its settings changed.");
return SaveResult::Failed;
}
if (hashFile(targetPath) == QCryptographicHash::hash(data, QCryptographicHash::Sha256)) {
return SaveResult::Unchanged;
}
const QDir directory = QFileInfo(targetPath).dir();
if (!directory.exists() && !directory.mkpath(QStringLiteral("."))) {
error = QCoreApplication::translate("SpoilerBackgroundUpdater",
"Could not create the spoiler database directory: %1")
.arg(directory.absolutePath());
return SaveResult::Failed;
}
QSaveFile file(targetPath);
file.setDirectWriteFallback(false);
if (!file.open(QIODevice::WriteOnly) || file.write(data) != data.size() || !file.flush()) {
error = QCoreApplication::translate(
"SpoilerBackgroundUpdater",
"Could not save the spoiler database to %1: %2. The previous file was preserved.")
.arg(targetPath, file.errorString());
return SaveResult::Failed;
}
if (!isCurrent()) {
file.cancelWriting();
error = QCoreApplication::translate("SpoilerBackgroundUpdater",
"The spoiler update was cancelled because its settings changed.");
return SaveResult::Failed;
}
if (!file.commit()) {
error = QCoreApplication::translate(
"SpoilerBackgroundUpdater",
"Could not replace the spoiler database at %1: %2. The previous file was preserved.")
.arg(targetPath, file.errorString());
return SaveResult::Failed;
}
return SaveResult::Written;
}
} // namespace SpoilerFile

View file

@ -0,0 +1,24 @@
#ifndef COCKATRICE_SPOILER_FILE_H
#define COCKATRICE_SPOILER_FILE_H
#include <QByteArray>
#include <QString>
#include <functional>
namespace SpoilerFile
{
enum class SaveResult
{
Failed,
Unchanged,
Written
};
QString validate(const QByteArray &data);
// Validate before opening the destination, compare complete files, and replace
// atomically. Check operation ownership both before writing and before committing.
SaveResult
save(const QString &targetPath, const QByteArray &data, const std::function<bool()> &isCurrent, QString &error);
} // namespace SpoilerFile
#endif // COCKATRICE_SPOILER_FILE_H