This commit is contained in:
BruebachL 2026-11-04 10:30:37 -07:00 • committed by GitHub
commit 4481c0a7a0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 475 additions and 18 deletions

View file

@ -8,9 +8,12 @@
#include <QDirIterator>
#include <QFile>
#include <QFileInfo>
#include <QFileSystemWatcher>
#include <QSaveFile>
#include <QStringList>
#include <QTimer>
#include <QUuid>
#include <QtGlobal>
#include <libcockatrice/deck_list/deck_diff.h>
#include <libcockatrice/deck_list/deck_file_format.h>
#include <libcockatrice/deck_list/deck_list.h>
@ -19,12 +22,15 @@
#include <libcockatrice/protocol/pb/command_deck_download.pb.h>
#include <libcockatrice/protocol/pb/command_deck_list.pb.h>
#include <libcockatrice/protocol/pb/command_deck_new_dir.pb.h>
#include <libcockatrice/protocol/pb/command_deck_sync_check.pb.h>
#include <libcockatrice/protocol/pb/command_deck_upload.pb.h>
#include <libcockatrice/protocol/pb/response.pb.h>
#include <libcockatrice/protocol/pb/response_deck_download.pb.h>
#include <libcockatrice/protocol/pb/response_deck_list.pb.h>
#include <libcockatrice/protocol/pb/response_deck_sync_status.pb.h>
#include <libcockatrice/protocol/pb/response_deck_upload.pb.h>
#include <libcockatrice/protocol/pb/serverinfo_deckstorage.pb.h>
#include <libcockatrice/protocol/pb/serverinfo_user.pb.h>
#include <libcockatrice/protocol/pending_command.h>
#include <libcockatrice/utility/string_limits.h>
@ -32,6 +38,7 @@ namespace
{
constexpr const char *SYNC_META_DIR = ".sync";
constexpr const char *BACKUP_SUFFIX = ".sync-backup-";
constexpr int WATCH_DEBOUNCE_MS = 2000;
QString serverPathFor(const QString &relativePath)
{
@ -74,21 +81,153 @@ QString localPathFor(const QString &dir, const QString &name)
DeckSyncManager::DeckSyncManager(QObject *parent) : QObject(parent)
{
watcher = new QFileSystemWatcher(this);
debounceTimer = new QTimer(this);
debounceTimer->setSingleShot(true);
debounceTimer->setInterval(WATCH_DEBOUNCE_MS);
intervalTimer = new QTimer(this);
connect(watcher, &QFileSystemWatcher::fileChanged, this,
[this](const QString &path) { onWatchedPathChanged(path); });
connect(watcher, &QFileSystemWatcher::directoryChanged, this, [this](const QString &path) {
refreshWatcher();
onWatchedPathChanged(path);
});
connect(debounceTimer, &QTimer::timeout, this, &DeckSyncManager::onDebounceTimeout);
connect(intervalTimer, &QTimer::timeout, this, [this] { autoSyncSoon(); });
refreshInterval();
}
DeckSyncManager::DeckSyncManager(AbstractClient *_client, ColorIdentityProvider provider, QObject *parent)
: QObject(parent), client(_client), colorIdentityProvider(std::move(provider))
{
if (client == nullptr) {
return;
}
connect(
client, &AbstractClient::statusChanged, this,
[this](ClientStatus status) {
if (status == StatusDisconnected) {
offline = true;
debounceTimer->stop();
// A run cannot survive the session it was started on: the in-flight
// command comes back RespNotConnected, the remaining queue would be
// sent on a dead client, and a re-login as another account would
// receive this profile's uploads and deletes.
if (syncing) {
activeConflict = ActiveConflict();
failSync(tr("Disconnected."));
}
}
},
Qt::UniqueConnection);
connect(
client, &AbstractClient::userInfoChanged, this,
[this](const ServerInfo_User &userInfo) {
offline = false;
serverUsername = QString::fromStdString(userInfo.name());
autoSyncSoon();
},
Qt::UniqueConnection);
}
void DeckSyncManager::setProfile(const DeckSyncProfile &_profile)
void DeckSyncManager::setServerIdentity(const QString &host, int port, const QString &username)
{
profile = _profile;
serverHost = host;
serverPort = port;
serverUsername = username;
}
void DeckSyncManager::setDeckPath(const QString &path)
{
deckPath = path;
refreshWatcher();
}
void DeckSyncManager::setAutoSyncEnabled(bool enabled)
{
autoSyncEnabled = enabled;
refreshInterval();
}
void DeckSyncManager::setSyncIntervalMinutes(int minutes)
{
syncIntervalMinutes = qBound(1, minutes, 60);
refreshInterval();
}
DeckSyncProfile DeckSyncManager::buildProfile() const
{
return DeckSyncProfile(serverHost, serverPort, serverUsername);
}
void DeckSyncManager::refreshInterval()
{
intervalTimer->stop();
if (autoSyncEnabled) {
intervalTimer->start(syncIntervalMinutes * 60000);
}
}
void DeckSyncManager::refreshWatcher()
{
watcher->removePaths(watcher->files() + watcher->directories());
if (deckPath.isEmpty() || !QDir(deckPath).exists()) {
return;
}
QStringList watched;
watched.append(deckPath);
QDirIterator it(deckPath, QDir::Dirs | QDir::Files | QDir::NoDotAndDotDot, QDirIterator::Subdirectories);
while (it.hasNext()) {
const QString path = it.next();
if (!path.contains("/" + QString(SYNC_META_DIR) + "/") && !path.endsWith("/" + QString(SYNC_META_DIR))) {
watched.append(path);
}
}
watcher->addPaths(watched);
}
void DeckSyncManager::onWatchedPathChanged(const QString &path)
{
if (path.contains("/" + QString(SYNC_META_DIR) + "/")) {
return;
}
autoSyncSoon();
}
void DeckSyncManager::autoSyncSoon()
{
if (!autoSyncEnabled) {
return;
}
debounceTimer->start(WATCH_DEBOUNCE_MS);
}
void DeckSyncManager::onDebounceTimeout()
{
if (syncing) {
debounceTimer->start(WATCH_DEBOUNCE_MS);
return;
}
if (canAutoRun()) {
startSync(true);
}
}
bool DeckSyncManager::canAutoRun() const
{
if (!autoSyncEnabled || offline || client == nullptr || deckPath.isEmpty()) {
return false;
}
const DeckSyncProfile autoProfile = buildProfile();
if (!autoProfile.isValid()) {
return false;
}
const QString autoSidecarPath = DeckSyncSidecar::pathForDeckPath(deckPath, autoProfile.key());
if (!QFileInfo::exists(autoSidecarPath)) {
return false;
}
DeckSyncSidecar sidecar(autoSidecarPath);
return sidecar.load() && sidecar.mode == DeckSyncSidecar::MODE_LINKED;
}
void DeckSyncManager::setConflictDefault(int _conflictDefault)
@ -96,22 +235,24 @@ void DeckSyncManager::setConflictDefault(int _conflictDefault)
conflictDefault = _conflictDefault;
}
void DeckSyncManager::setSyncMode(DeckSyncMode mode)
{
syncMode = mode;
}
bool DeckSyncManager::isSyncing() const
{
return syncing;
}
void DeckSyncManager::syncNow()
{
startSync(false);
}
void DeckSyncManager::startSync(bool _autoRun)
{
if (syncing) {
return;
}
if (client == nullptr || !profile.isValid() || deckPath.isEmpty()) {
autoRun = _autoRun;
const DeckSyncProfile runProfile = buildProfile();
if (client == nullptr || !runProfile.isValid() || deckPath.isEmpty()) {
emit syncFinished(SyncSummary{}, tr("Not connected to a server."));
return;
}
@ -126,6 +267,7 @@ void DeckSyncManager::syncNow()
downloadedCount = 0;
unchangedCount = 0;
skippedCount = 0;
conflictCount = 0;
pendingUploads.clear();
pendingDownloads.clear();
pendingDeletes.clear();
@ -138,10 +280,11 @@ void DeckSyncManager::syncNow()
sessionDefault = -1;
syncMode = DeckSyncMode::TwoWay;
firstSyncRun = false;
pendingServerTime = -1;
emit syncStarted();
emit statusMessage(tr("Scanning local decks..."));
sidecarPath = DeckSyncSidecar::pathForDeckPath(deckPath, profile.key());
sidecarPath = DeckSyncSidecar::pathForDeckPath(deckPath, runProfile.key());
DeckSyncSidecar sidecar(sidecarPath);
if (!sidecar.load()) {
failSync(tr("Could not read sync state."));
@ -171,12 +314,81 @@ void DeckSyncManager::syncNow()
.modified = QFileInfo(absolutePath).lastModified()});
}
// Always ask the server first: its server_time becomes the sync baseline,
// so uploads racing the DeckList response cannot fall outside the window.
sendSyncCheck(sidecar.lastSyncSecs);
}
void DeckSyncManager::fetchFullList()
{
Command_DeckList cmd;
PendingCommand *pend = AbstractClient::prepareSessionCommand(cmd);
connect(pend, &PendingCommand::finished, this, &DeckSyncManager::onListFinished);
client->sendCommand(pend);
}
void DeckSyncManager::sendSyncCheck(qint64 lastSyncSecs)
{
emit statusMessage(tr("Checking for server changes..."));
Command_DeckSyncCheck cmd;
cmd.set_last_sync_time(static_cast<google::protobuf::uint64>(lastSyncSecs));
PendingCommand *pend = AbstractClient::prepareSessionCommand(cmd);
connect(pend, &PendingCommand::finished, this, &DeckSyncManager::onCheckFinished);
client->sendCommand(pend);
}
void DeckSyncManager::onCheckFinished(const Response &response, const CommandContainer & /*commandContainer*/)
{
if (!syncing) {
return;
}
if (response.response_code() != Response::RespOk) {
fetchFullList();
return;
}
const Response_DeckSyncStatus &status = response.GetExtension(Response_DeckSyncStatus::ext);
if (status.has_server_time()) {
pendingServerTime = static_cast<qint64>(status.server_time());
}
DeckSyncSidecar sidecar(sidecarPath);
if (!sidecar.load()) {
fetchFullList();
return;
}
bool localClean = status.changed_files_size() == 0;
if (localClean) {
for (auto it = localFiles.begin(); it != localFiles.end(); ++it) {
const DeckSyncRecord record = sidecar.get(it.key());
if (record.remoteId == -1 || record.baseHash != it.value().hash) {
localClean = false;
break;
}
}
}
if (localClean) {
const QMap<QString, DeckSyncRecord> knownRecords = sidecar.records();
for (auto it = knownRecords.begin(); it != knownRecords.end(); ++it) {
if (!localFiles.contains(it.key())) {
localClean = false;
break;
}
}
}
if (!localClean) {
if (status.changed_files_size() > 0) {
emit statusMessage(tr("%1 server changes, syncing...").arg(status.changed_files_size()));
}
fetchFullList();
return;
}
pendingServerTime = status.has_server_time() ? static_cast<qint64>(status.server_time()) : -1;
unchangedCount = localFiles.size();
emit statusMessage(tr("Decks are already in sync."));
finishSync();
}
void DeckSyncManager::collectServerFiles(const ServerInfo_DeckStorage_Folder &folder,
const QString &prefix,
QMap<int, ServerFile> &files,
@ -549,6 +761,16 @@ void DeckSyncManager::queueConflict(const QString &relativePath,
void DeckSyncManager::handleConflictDownload(const QString &relativePath, const QString &serverDeckString)
{
const ConflictDownload ctx = pendingConflicts.take(relativePath);
if (autoRun) {
// An auto run has no user in front of it: opening the modal conflict
// dialog from a timer or a file-watcher event would pop up mid-game.
// Leave the path untouched so the next manual sync classifies it again
// and asks.
++conflictCount;
++skippedCount;
processNext();
return;
}
if (sessionDefault == DeckSyncConflictKeepMine) {
applyUpload(relativePath, ctx.serverPath, ctx.deckId, ctx.guid);
processNext();
@ -1035,12 +1257,22 @@ void DeckSyncManager::failSync(const QString &error)
void DeckSyncManager::finishSync()
{
syncing = false;
DeckSyncSidecar sidecar(sidecarPath);
if (sidecar.load()) {
sidecar.lastSyncSecs =
pendingServerTime >= 0 ? pendingServerTime : QDateTime::currentDateTime().toSecsSinceEpoch();
sidecar.save();
}
pendingServerTime = -1;
const SyncSummary summary = currentSummary();
emit statusMessage(tr("Sync complete: %1 uploaded, %2 downloaded, %3 unchanged, %4 skipped.")
.arg(summary.uploaded)
.arg(summary.downloaded)
.arg(summary.unchanged)
.arg(summary.skipped));
if (conflictCount > 0) {
emit statusMessage(tr("%1 conflicting decks skipped. Press Sync now to resolve them.").arg(conflictCount));
}
emit syncFinished(summary, QString());
}

View file

@ -22,6 +22,8 @@
class AbstractClient;
class CommandContainer;
class DeckList;
class QFileSystemWatcher;
class QTimer;
class Response;
class ServerInfo_DeckStorage_Folder;
@ -67,10 +69,11 @@ public:
explicit DeckSyncManager(QObject *parent = nullptr);
DeckSyncManager(AbstractClient *client, ColorIdentityProvider provider, QObject *parent = nullptr);
void setProfile(const DeckSyncProfile &_profile);
void setServerIdentity(const QString &host, int port, const QString &username);
void setDeckPath(const QString &path);
void setConflictDefault(int conflictDefault);
void setSyncMode(DeckSyncMode mode);
void setAutoSyncEnabled(bool enabled);
void setSyncIntervalMinutes(int minutes);
[[nodiscard]] bool isSyncing() const;
public slots:
@ -93,6 +96,7 @@ signals:
private slots:
void onListFinished(const Response &response, const CommandContainer &commandContainer);
void onCheckFinished(const Response &response, const CommandContainer &commandContainer);
void
onEnsureDirFinished(const Response &response, const CommandContainer &commandContainer, const QVariant &extraData);
void
@ -155,9 +159,19 @@ private:
qint64 lastModifiedSecs = 0;
};
void startSync(bool autoRun);
void failSync(const QString &error);
void finishSync();
[[nodiscard]] SyncSummary currentSummary() const;
void fetchFullList();
void sendSyncCheck(qint64 lastSyncSecs);
void autoSyncSoon();
[[nodiscard]] bool canAutoRun() const;
void onDebounceTimeout();
void onWatchedPathChanged(const QString &path);
void refreshWatcher();
void refreshInterval();
[[nodiscard]] DeckSyncProfile buildProfile() const;
void collectServerFiles(const ServerInfo_DeckStorage_Folder &folder,
const QString &prefix,
QMap<int, ServerFile> &files,
@ -195,7 +209,9 @@ private:
QString uniqueLocalPath(const QString &relativePath) const;
AbstractClient *client = nullptr;
DeckSyncProfile profile;
QString serverHost;
int serverPort = 0;
QString serverUsername;
QString deckPath;
ColorIdentityProvider colorIdentityProvider;
bool syncing = false;
@ -211,6 +227,13 @@ private:
QMap<int, ServerFile> stashedServerFiles;
ActiveConflict activeConflict;
DeckSyncMode syncMode = DeckSyncMode::TwoWay;
QFileSystemWatcher *watcher = nullptr;
QTimer *debounceTimer = nullptr;
QTimer *intervalTimer = nullptr;
bool autoSyncEnabled = true;
int syncIntervalMinutes = 5;
bool offline = false;
qint64 pendingServerTime = -1;
int conflictDefault = DeckSyncConflictAsk;
int sessionDefault = -1;
bool firstSyncRun = false;
@ -218,6 +241,8 @@ private:
int downloadedCount = 0;
int unchangedCount = 0;
int skippedCount = 0;
int conflictCount = 0;
bool autoRun = false;
};
#endif // DECK_SYNC_MANAGER_H

View file

@ -25,6 +25,7 @@ bool DeckSyncSidecar::load()
{
recordMap.clear();
mode = MODE_LINKED;
lastSyncSecs = 0;
QFile file(filePath);
if (!file.exists()) {
@ -39,6 +40,7 @@ bool DeckSyncSidecar::load()
}
const QJsonValue modeValue = document.object().value("mode");
mode = modeValue.isString() ? modeValue.toString() : MODE_LINKED;
lastSyncSecs = static_cast<qint64>(document.object().value("lastSync").toDouble());
const QJsonObject records = document.object().value("records").toObject();
for (auto it = records.begin(); it != records.end(); ++it) {
if (!it.value().isObject()) {
@ -71,6 +73,7 @@ bool DeckSyncSidecar::save() const
QJsonObject root;
root.insert("version", SIDECAR_FORMAT_VERSION);
root.insert("mode", mode);
root.insert("lastSync", static_cast<double>(lastSyncSecs));
root.insert("records", records);
QDir().mkpath(QFileInfo(filePath).absolutePath());

View file

@ -35,6 +35,7 @@ public:
QString filePath; ///< Full path of the sidecar JSON file.
QString mode = MODE_LINKED; ///< Linked normally; separate keeps this profile out of sync.
qint64 lastSyncSecs = 0; ///< Last completed sync, seconds since epoch; 0 means never.
explicit DeckSyncSidecar(const QString &_filePath);

View file

@ -373,9 +373,22 @@ GeneralSettingsPage::GeneralSettingsPage()
SettingsCache::instance().deckSync().setConflictDefault(conflictDefaultSelector.itemData(index).toInt());
});
autoSyncCheckBox.setChecked(settings.deckSync().getAutoSync());
connect(&autoSyncCheckBox, &QCheckBox::toggled, &SettingsCache::instance().deckSync(),
&DeckSyncSettings::setAutoSync);
syncIntervalEdit.setMinimum(1);
syncIntervalEdit.setMaximum(60);
syncIntervalEdit.setValue(settings.deckSync().getSyncIntervalMinutes());
connect(&syncIntervalEdit, qOverload<int>(&QSpinBox::valueChanged), &SettingsCache::instance().deckSync(),
&DeckSyncSettings::setSyncIntervalMinutes);
auto *deckSyncGrid = new QGridLayout;
deckSyncGrid->addWidget(&conflictDefaultLabel, 0, 0);
deckSyncGrid->addWidget(&conflictDefaultSelector, 0, 1);
deckSyncGrid->addWidget(&autoSyncCheckBox, 1, 0, 1, 2);
deckSyncGrid->addWidget(&syncIntervalLabel, 2, 0);
deckSyncGrid->addWidget(&syncIntervalEdit, 2, 1);
deckSyncGroupBox = new QGroupBox;
deckSyncGroupBox->setLayout(deckSyncGrid);
@ -676,5 +689,8 @@ void GeneralSettingsPage::retranslateUi()
tr("Keep the server deck"));
conflictDefaultSelector.setItemText(conflictDefaultSelector.findData(DeckSyncConflictKeepNewer),
tr("Keep the newer deck"));
autoSyncCheckBox.setText(tr("Sync decks automatically"));
syncIntervalLabel.setText(tr("Check every:"));
syncIntervalEdit.setSuffix(" " + tr("min"));
resetAllPathsButton->setText(tr("Reset all paths"));
}

View file

@ -108,6 +108,9 @@ private:
QLabel conflictDefaultLabel;
QComboBox conflictDefaultSelector;
QCheckBox autoSyncCheckBox;
QLabel syncIntervalLabel;
QSpinBox syncIntervalEdit;
};
#endif // COCKATRICE_GENERAL_SETTINGS_PAGE_H

View file

@ -4,7 +4,6 @@
#include "../../deck_loader/deck_loader.h"
#include "../../deck_sync/deck_sync_conflict.h"
#include "../../deck_sync/deck_sync_manager.h"
#include "../../deck_sync/deck_sync_profile.h"
#include "../../pixel_map_generator.h"
#include "../cards/additional_info/deck_color_identity.h"
#include "../deck_share/deck_share_utils.h"
@ -261,9 +260,22 @@ TabDeckStorage::TabDeckStorage(TabSupervisor *_tabSupervisor,
syncManager = new DeckSyncManager(
client, [](const DeckList &deck) { return getDeckColorIdentity(deck, CardDatabaseManager::query()); }, this);
syncManager->setDeckPath(SettingsCache::instance().paths().getDeckPath());
connect(&SettingsCache::instance().paths(), &PathsSettings::deckPathChanged, this, [this] {
const QString deckPath = SettingsCache::instance().paths().getDeckPath();
localDirModel->setRootPath(deckPath);
localDirView->setRootIndex(localDirModel->index(localDirModel->rootPath(), 0));
syncManager->setDeckPath(deckPath);
});
syncManager->setConflictDefault(SettingsCache::instance().deckSync().getConflictDefault());
syncManager->setAutoSyncEnabled(SettingsCache::instance().deckSync().getAutoSync());
syncManager->setSyncIntervalMinutes(SettingsCache::instance().deckSync().getSyncIntervalMinutes());
connect(&SettingsCache::instance().deckSync(), &DeckSyncSettings::conflictDefaultChanged, syncManager,
&DeckSyncManager::setConflictDefault);
connect(&SettingsCache::instance().deckSync(), &DeckSyncSettings::autoSyncChanged, syncManager,
&DeckSyncManager::setAutoSyncEnabled);
connect(&SettingsCache::instance().deckSync(), &DeckSyncSettings::syncIntervalMinutesChanged, syncManager,
&DeckSyncManager::setSyncIntervalMinutes);
connect(syncManager, &DeckSyncManager::statusMessage, this, &TabDeckStorage::onSyncStatusMessage);
connect(syncManager, &DeckSyncManager::syncFinished, this, &TabDeckStorage::onSyncFinished);
connect(syncManager, &DeckSyncManager::firstSyncDetected, this, &TabDeckStorage::onFirstSyncDetected);
@ -271,6 +283,7 @@ TabDeckStorage::TabDeckStorage(TabSupervisor *_tabSupervisor,
if (currentUserInfo != nullptr) {
syncUsername = QString::fromStdString(currentUserInfo->name());
}
syncManager->setServerIdentity(client->serverName(), client->serverPort(), syncUsername);
retranslateUi();
@ -330,6 +343,7 @@ QString TabDeckStorage::getTargetPath() const
void TabDeckStorage::handleConnected(const ServerInfo_User &userInfo)
{
syncUsername = QString::fromStdString(userInfo.name());
syncManager->setServerIdentity(client->serverName(), client->serverPort(), syncUsername);
setRemoteEnabled(userInfo.user_level() & ServerInfo_User::IsRegistered);
}
@ -561,8 +575,6 @@ void TabDeckStorage::actOpenDecksFolder()
void TabDeckStorage::actSyncNow()
{
DeckSyncProfile profile(client->serverName(), client->serverPort(), syncUsername);
syncManager->setProfile(profile);
syncManager->setDeckPath(SettingsCache::instance().paths().getDeckPath());
aSyncNow->setEnabled(false);
syncManager->syncNow();

View file

@ -26,6 +26,7 @@ set(PROTO_FILES
command_deck_share_list.proto
command_deck_share_list_mine.proto
command_deck_share_remove.proto
command_deck_sync_check.proto
command_deck_upload.proto
command_del_counter.proto
command_delete_arrow.proto
@ -153,6 +154,7 @@ set(PROTO_FILES
response_deck_share_download.proto
response_deck_share_list.proto
response_deck_share_list_mine.proto
response_deck_sync_status.proto
response_deck_upload.proto
response_dump_zone.proto
response_forgotpasswordrequest.proto

View file

@ -0,0 +1,11 @@
syntax = "proto2";
import "session_commands.proto";
message Command_DeckSyncCheck {
extend SessionCommand {
optional Command_DeckSyncCheck ext = 1034;
}
// Seconds since Unix epoch of the client's last completed sync.
// 0 means "never synced": the server reports every stored row.
optional uint64 last_sync_time = 1 [default = 0];
}

View file

@ -85,6 +85,7 @@ message Response {
DECK_SHARE_LIST = 1104; // Response listing shared decks
DECK_SHARE_DOWNLOAD = 1105; // Response for shared deck download
DECK_SHARE_LIST_MINE = 1106; // Response listing the caller's own shares
DECK_SYNC_STATUS = 1107; // Response with changed decks since a timestamp
CARD_ART_RULE_LIST = 1200; // Response containing a list of card art rules
}

View file

@ -0,0 +1,21 @@
syntax = "proto2";
import "response.proto";
message DeckSyncChangedFile {
// Server-relative folder path without file extension, e.g. "Modern/Affinity".
optional string path = 1;
optional uint32 deck_id = 2;
optional string guid = 3;
optional string hash = 4;
optional uint64 last_modified = 5;
optional bool deleted = 6 [default = false];
}
message Response_DeckSyncStatus {
extend Response {
optional Response_DeckSyncStatus ext = 1107;
}
repeated DeckSyncChangedFile changed_files = 1;
// Server clock at query time; the client adopts it as its sync baseline.
optional uint64 server_time = 2;
}

View file

@ -37,6 +37,7 @@ message SessionCommand {
DECK_SHARE_LIST_MINE = 1032;
DECK_SHARE_REMOVE = 1033;
SUBMIT_PASSWORD_VERIFIER = 1034;
DECK_SYNC_CHECK = 1035;
REPLAY_LIST = 1100;
REPLAY_DOWNLOAD = 1101;
REPLAY_MODIFY_MATCH = 1102;

View file

@ -3,6 +3,7 @@
#include "libcockatrice/settings/settings_manager.h"
#include <QVariant>
#include <QtGlobal>
DeckSyncSettings::DeckSyncSettings(const QString &settingPath, QObject *parent)
: SettingsManager(settingPath + "deck_sync.ini", "decksync", QString(), parent)
@ -19,3 +20,25 @@ void DeckSyncSettings::setConflictDefault(int _conflictDefault)
setValue(_conflictDefault, "conflictDefault");
emit conflictDefaultChanged(_conflictDefault);
}
bool DeckSyncSettings::getAutoSync() const
{
return getValue("autoSync", QString(), QString(), true).toBool();
}
void DeckSyncSettings::setAutoSync(bool _autoSync)
{
setValue(_autoSync, "autoSync");
emit autoSyncChanged(_autoSync);
}
int DeckSyncSettings::getSyncIntervalMinutes() const
{
return qBound(1, getValue("syncIntervalMinutes", QString(), QString(), 5).toInt(), 60);
}
void DeckSyncSettings::setSyncIntervalMinutes(int _syncIntervalMinutes)
{
setValue(_syncIntervalMinutes, "syncIntervalMinutes");
emit syncIntervalMinutesChanged(_syncIntervalMinutes);
}

View file

@ -22,14 +22,19 @@ class DeckSyncSettings : public SettingsManager
friend class SettingsCache;
public:
explicit DeckSyncSettings(const QString &settingPath, QObject *parent = nullptr);
[[nodiscard]] int getConflictDefault() const;
void setConflictDefault(int _conflictDefault);
[[nodiscard]] bool getAutoSync() const;
void setAutoSync(bool _autoSync);
[[nodiscard]] int getSyncIntervalMinutes() const;
void setSyncIntervalMinutes(int _syncIntervalMinutes);
signals:
void conflictDefaultChanged(int conflictDefault);
public:
explicit DeckSyncSettings(const QString &settingPath, QObject *parent = nullptr);
void autoSyncChanged(bool autoSync);
void syncIntervalMinutesChanged(int syncIntervalMinutes);
private:
DeckSyncSettings(const DeckSyncSettings & /*other*/);

View file

@ -71,6 +71,7 @@ QString PathsSettings::getRedirectCachePath() const
void PathsSettings::setDeckPath(const QString &_deckPath)
{
setValue(_deckPath, "decks");
emit deckPathChanged();
}
void PathsSettings::setFiltersPath(const QString &_filtersPath)

View file

@ -59,6 +59,7 @@ public:
void setAppDirWarningAcknowledged(bool _appDirWarningAcknowledged);
signals:
void deckPathChanged();
void cardDatabasePathChanged();
void picsPathChanged();
void themeChanged();

View file

@ -16,6 +16,12 @@ ALTER TABLE `cockatrice_decklist_files`
ADD COLUMN `last_modified` datetime NULL AFTER `hash`,
ADD COLUMN `deleted` tinyint(1) NOT NULL DEFAULT 0 AFTER `last_modified`;
-- Rows created before this migration only get a last_modified when a client
-- re-uploads them, so every sync check would keep reporting them as changed.
-- Backfill from the upload time once, here, instead of carrying a
-- "last_modified is null" branch in the poll query forever.
UPDATE `cockatrice_decklist_files` SET `last_modified` = `upload_time` WHERE `last_modified` IS NULL;
CREATE TABLE IF NOT EXISTS `cockatrice_deck_versions` (
`id` int(7) unsigned zerofill NOT NULL auto_increment,
`deck_id` int(7) unsigned zerofill NOT NULL,

View file

@ -98,6 +98,7 @@
#include <libcockatrice/protocol/pb/command_deck_share_list.pb.h>
#include <libcockatrice/protocol/pb/command_deck_share_list_mine.pb.h>
#include <libcockatrice/protocol/pb/command_deck_share_remove.pb.h>
#include <libcockatrice/protocol/pb/command_deck_sync_check.pb.h>
#include <libcockatrice/protocol/pb/command_deck_upload.pb.h>
#include <libcockatrice/protocol/pb/command_get_server_stats.pb.h>
#include <libcockatrice/protocol/pb/command_replay_delete_match.pb.h>
@ -134,6 +135,7 @@
#include <libcockatrice/protocol/pb/response_deck_share_download.pb.h>
#include <libcockatrice/protocol/pb/response_deck_share_list.pb.h>
#include <libcockatrice/protocol/pb/response_deck_share_list_mine.pb.h>
#include <libcockatrice/protocol/pb/response_deck_sync_status.pb.h>
#include <libcockatrice/protocol/pb/response_deck_upload.pb.h>
#include <libcockatrice/protocol/pb/response_forgotpasswordrequest.pb.h>
#include <libcockatrice/protocol/pb/response_get_admin_notes.pb.h>
@ -372,6 +374,8 @@ Response::ResponseCode AbstractServerSocketInterface::processExtendedSessionComm
return cmdDeckUpload(cmd.GetExtension(Command_DeckUpload::ext), rc);
case SessionCommand::DECK_DOWNLOAD:
return cmdDeckDownload(cmd.GetExtension(Command_DeckDownload::ext), rc);
case SessionCommand::DECK_SYNC_CHECK:
return cmdDeckSyncCheck(cmd.GetExtension(Command_DeckSyncCheck::ext), rc);
case SessionCommand::REPLAY_LIST:
return cmdReplayList(cmd.GetExtension(Command_ReplayList::ext), rc);
case SessionCommand::REPLAY_DOWNLOAD:
@ -895,6 +899,61 @@ Response::ResponseCode AbstractServerSocketInterface::cmdDeckSetVisibility(const
return Response::RespOk;
}
bool AbstractServerSocketInterface::syncCheckHelper(int userId, qint64 sinceSecs, Response_DeckSyncStatus *status)
{
// Single query over the user's files: folder rows are hard-deleted while
// their files linger as tombstones, so walking the live folder tree would
// miss deletes pointing at nonexistent folders. The client only checks
// changed_files_size(), so folder paths are not reconstructed here.
// last_modified is backfilled by the 37 -> 38 migration, so a plain range
// check is enough.
QSqlQuery *query = sqlInterface->prepareQuery("select id, guid, hash, last_modified, deleted from "
"{prefix}_decklist_files where id_user = :id_user "
"and last_modified >= FROM_UNIXTIME(:since)");
query->bindValue(":id_user", userId);
query->bindValue(":since", sinceSecs);
if (!sqlInterface->execSqlQuery(query)) {
return false;
}
while (query->next()) {
DeckSyncChangedFile *changed = status->add_changed_files();
changed->set_deck_id(query->value(0).toUInt());
changed->set_guid(query->value(1).toString().toStdString());
changed->set_hash(query->value(2).toString().toStdString());
const QDateTime lastModified = query->value(3).toDateTime();
if (lastModified.isValid()) {
changed->set_last_modified(lastModified.toSecsSinceEpoch());
}
changed->set_deleted(query->value(4).toBool());
}
return true;
}
Response::ResponseCode AbstractServerSocketInterface::cmdDeckSyncCheck(const Command_DeckSyncCheck &cmd,
ResponseContainer &rc)
{
if (authState != PasswordRight) {
return Response::RespFunctionNotAllowed;
}
sqlInterface->checkSql();
Response_DeckSyncStatus *re = new Response_DeckSyncStatus;
if (!syncCheckHelper(userInfo->id(), static_cast<qint64>(cmd.last_sync_time()), re)) {
delete re;
return Response::RespContextError;
}
QSqlQuery *clockQuery = sqlInterface->prepareQuery("select unix_timestamp(now())");
if (!sqlInterface->execSqlQuery(clockQuery) || !clockQuery->next()) {
delete re;
return Response::RespContextError;
}
re->set_server_time(clockQuery->value(0).toULongLong());
rc.setResponseExtension(re);
return Response::RespOk;
}
Response::ResponseCode AbstractServerSocketInterface::cmdDeckDownloadPublic(const Command_DeckDownloadPublic &cmd,
ResponseContainer &rc)
{

View file

@ -56,6 +56,7 @@ class Command_DeckShareList;
class Command_DeckShareListMine;
class Command_DeckShareRemove;
class Command_DeckShareDownload;
class Command_DeckSyncCheck;
class Command_ReplayList;
class Command_ReplayDownload;
class Command_ReplayModifyMatch;
@ -111,6 +112,7 @@ class Command_ViewLogHistory;
class Command_WarnUser;
class QObject;
class ResponseContainer;
class Response_DeckSyncStatus;
class AbstractServerSocketInterface : public Server_ProtocolHandler
{
@ -163,6 +165,8 @@ private:
bool storeDeckVersion(int deckId, const QString &content, const QString &hash);
DeckList *getDeckFromDatabase(int deckId);
Response::ResponseCode cmdDeckDownload(const Command_DeckDownload &cmd, ResponseContainer &rc);
bool syncCheckHelper(int userId, qint64 sinceSecs, Response_DeckSyncStatus *status);
Response::ResponseCode cmdDeckSyncCheck(const Command_DeckSyncCheck &cmd, ResponseContainer &rc);
Response::ResponseCode cmdDeckDownloadPublic(const Command_DeckDownloadPublic &cmd, ResponseContainer &rc);
Response::ResponseCode cmdDeckShareCreate(const Command_DeckShareCreate &cmd, ResponseContainer &rc);
Response::ResponseCode cmdDeckShareList(const Command_DeckShareList &cmd, ResponseContainer &rc);

View file

@ -125,12 +125,15 @@ TEST(DeckSyncSidecarTest, ModeRoundTripsAndDefaultsToLinked)
ASSERT_TRUE(dir.isValid());
DeckSyncSidecar sidecar(sidecarPath(dir));
EXPECT_EQ(sidecar.mode, DeckSyncSidecar::MODE_LINKED);
EXPECT_EQ(sidecar.lastSyncSecs, 0);
sidecar.mode = DeckSyncSidecar::MODE_SEPARATE;
sidecar.lastSyncSecs = 1791739200;
EXPECT_TRUE(sidecar.save());
DeckSyncSidecar reloaded(sidecarPath(dir));
EXPECT_TRUE(reloaded.load());
EXPECT_EQ(reloaded.mode, DeckSyncSidecar::MODE_SEPARATE);
EXPECT_EQ(reloaded.lastSyncSecs, 1791739200);
}
TEST(DeckSyncSidecarTest, PathForDeckPathBuildsSidecarPath)

View file

@ -606,6 +606,33 @@ TEST_F(SettingsDefaultsTest, DeckSync_ConflictDefault_SetAndGet)
ASSERT_EQ(s.getConflictDefault(), DeckSyncConflictAsk);
}
TEST_F(SettingsDefaultsTest, DeckSync_AutoSync_Default)
{
DeckSyncSettings s(settingsPath, nullptr);
ASSERT_EQ(s.getAutoSync(), true);
}
TEST_F(SettingsDefaultsTest, DeckSync_AutoSync_SetAndGet)
{
DeckSyncSettings s(settingsPath, nullptr);
s.setAutoSync(false);
ASSERT_EQ(s.getAutoSync(), false);
s.setAutoSync(true);
ASSERT_EQ(s.getAutoSync(), true);
}
TEST_F(SettingsDefaultsTest, DeckSync_SyncIntervalMinutes_DefaultAndBounds)
{
DeckSyncSettings s(settingsPath, nullptr);
ASSERT_EQ(s.getSyncIntervalMinutes(), 5);
s.setSyncIntervalMinutes(15);
ASSERT_EQ(s.getSyncIntervalMinutes(), 15);
s.setSyncIntervalMinutes(0);
ASSERT_EQ(s.getSyncIntervalMinutes(), 1);
s.setSyncIntervalMinutes(600);
ASSERT_EQ(s.getSyncIntervalMinutes(), 60);
}
// --- NetworkSettings ---
TEST_F(SettingsDefaultsTest, Network_ClientID_Default)