From ff695d265deb8a2555da0c72f11cfc86ce103dd4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Br=C3=BCbach?= Date: Sun, 11 Oct 2026 17:02:30 +0200 Subject: [PATCH 1/7] [Client+Server] Auto deck sync with change polling --- .../interface/deck_sync/deck_sync_manager.cpp | 216 +++++++++++++++++- .../interface/deck_sync/deck_sync_manager.h | 27 ++- .../interface/deck_sync/deck_sync_sidecar.cpp | 3 + .../interface/deck_sync/deck_sync_sidecar.h | 1 + .../settings_page/general_settings_page.cpp | 16 ++ .../settings_page/general_settings_page.h | 3 + .../widgets/tabs/tab_deck_storage.cpp | 10 +- .../libcockatrice/protocol/pb/CMakeLists.txt | 2 + .../protocol/pb/command_deck_sync_check.proto | 11 + .../libcockatrice/protocol/pb/response.proto | 1 + .../pb/response_deck_sync_status.proto | 21 ++ .../protocol/pb/session_commands.proto | 1 + .../settings/deck_sync_settings.cpp | 23 ++ .../settings/deck_sync_settings.h | 11 +- servatrice/src/serversocketinterface.cpp | 78 +++++++ servatrice/src/serversocketinterface.h | 5 + tests/deck_sync_sidecar_test.cpp | 3 + tests/settings/settings_defaults_test.cpp | 27 +++ 18 files changed, 448 insertions(+), 11 deletions(-) create mode 100644 libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_sync_check.proto create mode 100644 libcockatrice_protocol/libcockatrice/protocol/pb/response_deck_sync_status.proto diff --git a/cockatrice/src/interface/deck_sync/deck_sync_manager.cpp b/cockatrice/src/interface/deck_sync/deck_sync_manager.cpp index cd51ca505..c76ed51fe 100644 --- a/cockatrice/src/interface/deck_sync/deck_sync_manager.cpp +++ b/cockatrice/src/interface/deck_sync/deck_sync_manager.cpp @@ -8,10 +8,13 @@ #include #include #include +#include #include #include #include +#include #include +#include #include #include #include @@ -20,12 +23,15 @@ #include #include #include +#include #include #include #include #include +#include #include #include +#include #include #include @@ -35,6 +41,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) { @@ -109,6 +116,20 @@ const char *sidecarModeFor(DeckSyncMode mode) 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) @@ -116,16 +137,126 @@ DeckSyncManager::DeckSyncManager(AbstractClient *_client, ColorIdentityProvider { 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(); + } + }, + 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()) { + syncNow(); + } +} + +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) @@ -143,7 +274,8 @@ void DeckSyncManager::syncNow() if (syncing) { return; } - if (client == nullptr || !profile.isValid() || deckPath.isEmpty()) { + const DeckSyncProfile runProfile = buildProfile(); + if (client == nullptr || !runProfile.isValid() || deckPath.isEmpty()) { emit syncFinished(SyncSummary{}, tr("Not connected to a server.")); return; } @@ -172,10 +304,11 @@ void DeckSyncManager::syncNow() localFiles.clear(); sessionDefault = -1; 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.")); @@ -208,12 +341,80 @@ void DeckSyncManager::syncNow() .modified = QFileInfo(absolutePath).lastModified()}); } + if (!firstSyncRun && sidecar.lastSyncSecs > 0) { + sendSyncCheck(sidecar.lastSyncSecs); + return; + } + fetchFullList(); +} + +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(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); + 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 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(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 &files, @@ -1173,6 +1374,13 @@ 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(); QString message = tr("Sync complete: %1 uploaded, %2 downloaded, %3 removed, %4 unchanged, %5 skipped.") .arg(summary.uploaded) diff --git a/cockatrice/src/interface/deck_sync/deck_sync_manager.h b/cockatrice/src/interface/deck_sync/deck_sync_manager.h index 61c5009c3..72c1a3f79 100644 --- a/cockatrice/src/interface/deck_sync/deck_sync_manager.h +++ b/cockatrice/src/interface/deck_sync/deck_sync_manager.h @@ -22,6 +22,8 @@ class AbstractClient; class CommandContainer; class DeckList; +class QFileSystemWatcher; +class QTimer; class Response; class ServerInfo_DeckStorage_Folder; @@ -73,9 +75,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 setAutoSyncEnabled(bool enabled); + void setSyncIntervalMinutes(int minutes); [[nodiscard]] bool isSyncing() const; public slots: @@ -98,6 +102,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 @@ -163,6 +168,15 @@ private: 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 &files, @@ -200,7 +214,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; @@ -218,6 +234,13 @@ private: QMap stashedServerFiles; ActiveConflict activeConflict; DeckSyncMode syncMode = DeckSyncMode::TwoWay; ///< This run's direction, loaded from the sidecar. + 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; diff --git a/cockatrice/src/interface/deck_sync/deck_sync_sidecar.cpp b/cockatrice/src/interface/deck_sync/deck_sync_sidecar.cpp index 7a509861c..10643bf75 100644 --- a/cockatrice/src/interface/deck_sync/deck_sync_sidecar.cpp +++ b/cockatrice/src/interface/deck_sync/deck_sync_sidecar.cpp @@ -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(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()) { @@ -70,6 +72,7 @@ bool DeckSyncSidecar::save() const QJsonObject root; root.insert("version", SIDECAR_FORMAT_VERSION); root.insert("mode", mode); + root.insert("lastSync", static_cast(lastSyncSecs)); root.insert("records", records); QDir().mkpath(QFileInfo(filePath).absolutePath()); diff --git a/cockatrice/src/interface/deck_sync/deck_sync_sidecar.h b/cockatrice/src/interface/deck_sync/deck_sync_sidecar.h index 404d08e8d..1f621b976 100644 --- a/cockatrice/src/interface/deck_sync/deck_sync_sidecar.h +++ b/cockatrice/src/interface/deck_sync/deck_sync_sidecar.h @@ -40,6 +40,7 @@ public: /// keeps it out of sync, and the first-sync verdicts narrow it to one /// direction. An unrecognised value falls back to the two-way sync. QString mode = MODE_LINKED; + qint64 lastSyncSecs = 0; ///< Last completed sync, seconds since epoch; 0 means never. explicit DeckSyncSidecar(const QString &_filePath); diff --git a/cockatrice/src/interface/widgets/settings_page/general_settings_page.cpp b/cockatrice/src/interface/widgets/settings_page/general_settings_page.cpp index 6b6678467..6a24f7db6 100644 --- a/cockatrice/src/interface/widgets/settings_page/general_settings_page.cpp +++ b/cockatrice/src/interface/widgets/settings_page/general_settings_page.cpp @@ -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(&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")); } diff --git a/cockatrice/src/interface/widgets/settings_page/general_settings_page.h b/cockatrice/src/interface/widgets/settings_page/general_settings_page.h index 92d46c265..f410abdf2 100644 --- a/cockatrice/src/interface/widgets/settings_page/general_settings_page.h +++ b/cockatrice/src/interface/widgets/settings_page/general_settings_page.h @@ -108,6 +108,9 @@ private: QLabel conflictDefaultLabel; QComboBox conflictDefaultSelector; + QCheckBox autoSyncCheckBox; + QLabel syncIntervalLabel; + QSpinBox syncIntervalEdit; }; #endif // COCKATRICE_GENERAL_SETTINGS_PAGE_H diff --git a/cockatrice/src/interface/widgets/tabs/tab_deck_storage.cpp b/cockatrice/src/interface/widgets/tabs/tab_deck_storage.cpp index 2c0fbd4f8..9c3ab42dd 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_deck_storage.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_deck_storage.cpp @@ -267,8 +267,14 @@ TabDeckStorage::TabDeckStorage(TabSupervisor *_tabSupervisor, syncManager = new DeckSyncManager( client, [](const DeckList &deck) { return getDeckColorIdentity(deck, CardDatabaseManager::query()); }, this); 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); @@ -276,6 +282,7 @@ TabDeckStorage::TabDeckStorage(TabSupervisor *_tabSupervisor, if (currentUserInfo != nullptr) { syncUsername = QString::fromStdString(currentUserInfo->name()); } + syncManager->setServerIdentity(client->serverName(), client->serverPort(), syncUsername); retranslateUi(); @@ -336,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); } @@ -568,8 +576,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); aSyncReset->setEnabled(false); diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt b/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt index 5fba2631d..57ed025c0 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt @@ -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 diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_sync_check.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_sync_check.proto new file mode 100644 index 000000000..0a349a15f --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_sync_check.proto @@ -0,0 +1,11 @@ +syntax = "proto2"; +import "session_commands.proto"; + +message Command_DeckSyncCheck { + extend SessionCommand { + optional Command_DeckSyncCheck ext = 1035; + } + // 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]; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/response.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/response.proto index caf9febde..8ec4c3554 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/response.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/response.proto @@ -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 } diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/response_deck_sync_status.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/response_deck_sync_status.proto new file mode 100644 index 000000000..6d6bd2cd1 --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/response_deck_sync_status.proto @@ -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; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/session_commands.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/session_commands.proto index 10aefe7b5..b9a80021f 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/session_commands.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/session_commands.proto @@ -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; diff --git a/libcockatrice_settings/libcockatrice/settings/deck_sync_settings.cpp b/libcockatrice_settings/libcockatrice/settings/deck_sync_settings.cpp index 0b86d3c84..caa9ce9c6 100644 --- a/libcockatrice_settings/libcockatrice/settings/deck_sync_settings.cpp +++ b/libcockatrice_settings/libcockatrice/settings/deck_sync_settings.cpp @@ -3,6 +3,7 @@ #include "libcockatrice/settings/settings_manager.h" #include +#include 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); +} diff --git a/libcockatrice_settings/libcockatrice/settings/deck_sync_settings.h b/libcockatrice_settings/libcockatrice/settings/deck_sync_settings.h index f984292f3..2552f52a9 100644 --- a/libcockatrice_settings/libcockatrice/settings/deck_sync_settings.h +++ b/libcockatrice_settings/libcockatrice/settings/deck_sync_settings.h @@ -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*/); diff --git a/servatrice/src/serversocketinterface.cpp b/servatrice/src/serversocketinterface.cpp index 38acf2942..f4b141880 100644 --- a/servatrice/src/serversocketinterface.cpp +++ b/servatrice/src/serversocketinterface.cpp @@ -98,6 +98,7 @@ #include #include #include +#include #include #include #include @@ -134,6 +135,7 @@ #include #include #include +#include #include #include #include @@ -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,80 @@ Response::ResponseCode AbstractServerSocketInterface::cmdDeckSetVisibility(const return Response::RespOk; } +bool AbstractServerSocketInterface::syncCheckHelper(int folderId, + const QString &prefix, + int userId, + qint64 sinceSecs, + Response_DeckSyncStatus *status) +{ + QSqlQuery *query = sqlInterface->prepareQuery("select id, name from {prefix}_decklist_folders where id_parent = " + ":id_parent and id_user = :id_user"); + query->bindValue(":id_parent", folderId); + query->bindValue(":id_user", userId); + if (!sqlInterface->execSqlQuery(query)) { + return false; + } + QList> subfolders; + while (query->next()) { + subfolders.append({query->value(0).toInt(), query->value(1).toString()}); + } + for (const auto &[subfolderId, subfolderName] : subfolders) { + const QString path = prefix.isEmpty() ? subfolderName : prefix + "/" + subfolderName; + if (!syncCheckHelper(subfolderId, path, userId, sinceSecs, status)) { + return false; + } + } + + query = sqlInterface->prepareQuery("select id, name, guid, hash, last_modified, deleted from " + "{prefix}_decklist_files where id_folder = :id_folder and id_user = :id_user " + "and (last_modified is null or last_modified > FROM_UNIXTIME(:since))"); + query->bindValue(":id_folder", folderId); + query->bindValue(":id_user", userId); + query->bindValue(":since", sinceSecs); + if (!sqlInterface->execSqlQuery(query)) { + return false; + } + while (query->next()) { + const QString name = query->value(1).toString(); + DeckSyncChangedFile *changed = status->add_changed_files(); + changed->set_path((prefix.isEmpty() ? name : prefix + "/" + name).toStdString()); + changed->set_deck_id(query->value(0).toUInt()); + changed->set_guid(query->value(2).toString().toStdString()); + changed->set_hash(query->value(3).toString().toStdString()); + const QDateTime lastModified = query->value(4).toDateTime(); + if (lastModified.isValid()) { + changed->set_last_modified(lastModified.toSecsSinceEpoch()); + } + changed->set_deleted(query->value(5).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(0, QString(), userInfo->id(), static_cast(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) { diff --git a/servatrice/src/serversocketinterface.h b/servatrice/src/serversocketinterface.h index f05fd5ac1..7a01f7c1c 100644 --- a/servatrice/src/serversocketinterface.h +++ b/servatrice/src/serversocketinterface.h @@ -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,9 @@ 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 folderId, const QString &prefix, 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); diff --git a/tests/deck_sync_sidecar_test.cpp b/tests/deck_sync_sidecar_test.cpp index 76e18d797..a86c8dc6d 100644 --- a/tests/deck_sync_sidecar_test.cpp +++ b/tests/deck_sync_sidecar_test.cpp @@ -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) diff --git a/tests/settings/settings_defaults_test.cpp b/tests/settings/settings_defaults_test.cpp index 0a2fb31e0..cc8b21ecd 100644 --- a/tests/settings/settings_defaults_test.cpp +++ b/tests/settings/settings_defaults_test.cpp @@ -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) From 5a550a57ba0c13c81aac15cf822f022d7e09ceac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Br=C3=BCbach?= Date: Tue, 13 Oct 2026 07:21:42 +0200 Subject: [PATCH 2/7] Fix ZeldaZach review for PR 7496: deck path in ctor, server-time baseline, single-query sync check --- .../interface/deck_sync/deck_sync_manager.cpp | 11 +++-- .../widgets/tabs/tab_deck_storage.cpp | 7 +++ .../libcockatrice/settings/paths_settings.cpp | 1 + .../libcockatrice/settings/paths_settings.h | 1 + servatrice/src/serversocketinterface.cpp | 48 ++++++------------- servatrice/src/serversocketinterface.h | 3 +- 6 files changed, 30 insertions(+), 41 deletions(-) diff --git a/cockatrice/src/interface/deck_sync/deck_sync_manager.cpp b/cockatrice/src/interface/deck_sync/deck_sync_manager.cpp index c76ed51fe..cc8bd9e0b 100644 --- a/cockatrice/src/interface/deck_sync/deck_sync_manager.cpp +++ b/cockatrice/src/interface/deck_sync/deck_sync_manager.cpp @@ -341,11 +341,9 @@ void DeckSyncManager::syncNow() .modified = QFileInfo(absolutePath).lastModified()}); } - if (!firstSyncRun && sidecar.lastSyncSecs > 0) { - sendSyncCheck(sidecar.lastSyncSecs); - return; - } - fetchFullList(); + // 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() @@ -376,6 +374,9 @@ void DeckSyncManager::onCheckFinished(const Response &response, const CommandCon return; } const Response_DeckSyncStatus &status = response.GetExtension(Response_DeckSyncStatus::ext); + if (status.has_server_time()) { + pendingServerTime = static_cast(status.server_time()); + } DeckSyncSidecar sidecar(sidecarPath); if (!sidecar.load()) { fetchFullList(); diff --git a/cockatrice/src/interface/widgets/tabs/tab_deck_storage.cpp b/cockatrice/src/interface/widgets/tabs/tab_deck_storage.cpp index 9c3ab42dd..674f560ce 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_deck_storage.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_deck_storage.cpp @@ -266,6 +266,13 @@ 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()); diff --git a/libcockatrice_settings/libcockatrice/settings/paths_settings.cpp b/libcockatrice_settings/libcockatrice/settings/paths_settings.cpp index 41ccf6460..4f92b017c 100644 --- a/libcockatrice_settings/libcockatrice/settings/paths_settings.cpp +++ b/libcockatrice_settings/libcockatrice/settings/paths_settings.cpp @@ -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) diff --git a/libcockatrice_settings/libcockatrice/settings/paths_settings.h b/libcockatrice_settings/libcockatrice/settings/paths_settings.h index fa00428e3..aef65aff5 100644 --- a/libcockatrice_settings/libcockatrice/settings/paths_settings.h +++ b/libcockatrice_settings/libcockatrice/settings/paths_settings.h @@ -59,6 +59,7 @@ public: void setAppDirWarningAcknowledged(bool _appDirWarningAcknowledged); signals: + void deckPathChanged(); void cardDatabasePathChanged(); void picsPathChanged(); void themeChanged(); diff --git a/servatrice/src/serversocketinterface.cpp b/servatrice/src/serversocketinterface.cpp index f4b141880..85654827c 100644 --- a/servatrice/src/serversocketinterface.cpp +++ b/servatrice/src/serversocketinterface.cpp @@ -899,51 +899,31 @@ Response::ResponseCode AbstractServerSocketInterface::cmdDeckSetVisibility(const return Response::RespOk; } -bool AbstractServerSocketInterface::syncCheckHelper(int folderId, - const QString &prefix, - int userId, - qint64 sinceSecs, - Response_DeckSyncStatus *status) +bool AbstractServerSocketInterface::syncCheckHelper(int userId, qint64 sinceSecs, Response_DeckSyncStatus *status) { - QSqlQuery *query = sqlInterface->prepareQuery("select id, name from {prefix}_decklist_folders where id_parent = " - ":id_parent and id_user = :id_user"); - query->bindValue(":id_parent", folderId); - query->bindValue(":id_user", userId); - if (!sqlInterface->execSqlQuery(query)) { - return false; - } - QList> subfolders; - while (query->next()) { - subfolders.append({query->value(0).toInt(), query->value(1).toString()}); - } - for (const auto &[subfolderId, subfolderName] : subfolders) { - const QString path = prefix.isEmpty() ? subfolderName : prefix + "/" + subfolderName; - if (!syncCheckHelper(subfolderId, path, userId, sinceSecs, status)) { - return false; - } - } - - query = sqlInterface->prepareQuery("select id, name, guid, hash, last_modified, deleted from " - "{prefix}_decklist_files where id_folder = :id_folder and id_user = :id_user " - "and (last_modified is null or last_modified > FROM_UNIXTIME(:since))"); - query->bindValue(":id_folder", folderId); + // 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. + QSqlQuery *query = sqlInterface->prepareQuery("select id, guid, hash, last_modified, deleted from " + "{prefix}_decklist_files where id_user = :id_user " + "and (last_modified is null or last_modified >= " + "FROM_UNIXTIME(:since))"); query->bindValue(":id_user", userId); query->bindValue(":since", sinceSecs); if (!sqlInterface->execSqlQuery(query)) { return false; } while (query->next()) { - const QString name = query->value(1).toString(); DeckSyncChangedFile *changed = status->add_changed_files(); - changed->set_path((prefix.isEmpty() ? name : prefix + "/" + name).toStdString()); changed->set_deck_id(query->value(0).toUInt()); - changed->set_guid(query->value(2).toString().toStdString()); - changed->set_hash(query->value(3).toString().toStdString()); - const QDateTime lastModified = query->value(4).toDateTime(); + 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(5).toBool()); + changed->set_deleted(query->value(4).toBool()); } return true; } @@ -958,7 +938,7 @@ Response::ResponseCode AbstractServerSocketInterface::cmdDeckSyncCheck(const Com sqlInterface->checkSql(); Response_DeckSyncStatus *re = new Response_DeckSyncStatus; - if (!syncCheckHelper(0, QString(), userInfo->id(), static_cast(cmd.last_sync_time()), re)) { + if (!syncCheckHelper(userInfo->id(), static_cast(cmd.last_sync_time()), re)) { delete re; return Response::RespContextError; } diff --git a/servatrice/src/serversocketinterface.h b/servatrice/src/serversocketinterface.h index 7a01f7c1c..a55f8df0f 100644 --- a/servatrice/src/serversocketinterface.h +++ b/servatrice/src/serversocketinterface.h @@ -165,8 +165,7 @@ 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 folderId, const QString &prefix, int userId, qint64 sinceSecs, Response_DeckSyncStatus *status); + 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); From 068c388a80fe6d487a8c0b5cbc4e2fb0ae9b4d26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Br=C3=BCbach?= Date: Sun, 1 Nov 2026 13:40:50 +0100 Subject: [PATCH 3/7] [DeckSync] Address auto-sync review: disconnect, backfill and modal conflicts - Fail the run when the connection drops while a sync is running, and clear the active conflict, so the queue is not sent on a dead client and a re-login as another account never receives this profile's uploads. - Backfill last_modified from upload_time in the 37 -> 38 migration and drop the "last_modified is null" arm from the sync-check query, so rows that predate the column stop reporting as changed on every poll. - Skip conflicts during auto runs and report how many were skipped, instead of opening the modal conflict dialog from a timer or a file-watcher event. --- .../interface/deck_sync/deck_sync_manager.cpp | 30 ++++++++++++++++++- .../interface/deck_sync/deck_sync_manager.h | 3 ++ .../migrations/servatrice_0040_to_0041.sql | 6 ++++ servatrice/src/serversocketinterface.cpp | 5 ++-- 4 files changed, 41 insertions(+), 3 deletions(-) diff --git a/cockatrice/src/interface/deck_sync/deck_sync_manager.cpp b/cockatrice/src/interface/deck_sync/deck_sync_manager.cpp index cc8bd9e0b..2615ab21e 100644 --- a/cockatrice/src/interface/deck_sync/deck_sync_manager.cpp +++ b/cockatrice/src/interface/deck_sync/deck_sync_manager.cpp @@ -146,6 +146,14 @@ DeckSyncManager::DeckSyncManager(AbstractClient *_client, ColorIdentityProvider 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); @@ -238,7 +246,7 @@ void DeckSyncManager::onDebounceTimeout() return; } if (canAutoRun()) { - syncNow(); + startSync(true); } } @@ -270,10 +278,16 @@ bool DeckSyncManager::isSyncing() const } void DeckSyncManager::syncNow() +{ + startSync(false); +} + +void DeckSyncManager::startSync(bool _autoRun) { if (syncing) { return; } + autoRun = _autoRun; const DeckSyncProfile runProfile = buildProfile(); if (client == nullptr || !runProfile.isValid() || deckPath.isEmpty()) { emit syncFinished(SyncSummary{}, tr("Not connected to a server.")); @@ -292,6 +306,7 @@ void DeckSyncManager::syncNow() unchangedCount = 0; skippedCount = 0; syncNote.clear(); + conflictCount = 0; pendingUploads.clear(); pendingDownloads.clear(); pendingDeletes.clear(); @@ -850,6 +865,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(); @@ -1393,6 +1418,9 @@ void DeckSyncManager::finishSync() message += QStringLiteral(" ") + summary.note; } emit statusMessage(message); + if (conflictCount > 0) { + emit statusMessage(tr("%1 conflicting decks skipped. Press Sync now to resolve them.").arg(conflictCount)); + } emit syncFinished(summary, QString()); } diff --git a/cockatrice/src/interface/deck_sync/deck_sync_manager.h b/cockatrice/src/interface/deck_sync/deck_sync_manager.h index 72c1a3f79..8364c999c 100644 --- a/cockatrice/src/interface/deck_sync/deck_sync_manager.h +++ b/cockatrice/src/interface/deck_sync/deck_sync_manager.h @@ -165,6 +165,7 @@ private: qint64 lastModifiedSecs = 0; }; + void startSync(bool autoRun); void failSync(const QString &error); void finishSync(); [[nodiscard]] SyncSummary currentSummary() const; @@ -249,6 +250,8 @@ private: int removedCount = 0; int unchangedCount = 0; int skippedCount = 0; + int conflictCount = 0; + bool autoRun = false; }; #endif // DECK_SYNC_MANAGER_H diff --git a/servatrice/migrations/servatrice_0040_to_0041.sql b/servatrice/migrations/servatrice_0040_to_0041.sql index 83edf871f..aed483d04 100644 --- a/servatrice/migrations/servatrice_0040_to_0041.sql +++ b/servatrice/migrations/servatrice_0040_to_0041.sql @@ -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, diff --git a/servatrice/src/serversocketinterface.cpp b/servatrice/src/serversocketinterface.cpp index 85654827c..595bd6d3d 100644 --- a/servatrice/src/serversocketinterface.cpp +++ b/servatrice/src/serversocketinterface.cpp @@ -905,10 +905,11 @@ bool AbstractServerSocketInterface::syncCheckHelper(int userId, qint64 sinceSecs // 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 is null or last_modified >= " - "FROM_UNIXTIME(:since))"); + "and last_modified >= FROM_UNIXTIME(:since)"); query->bindValue(":id_user", userId); query->bindValue(":since", sinceSecs); if (!sqlInterface->execSqlQuery(query)) { From d1d4c709994d0c938f1d7f970a57c412e8dfd9f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Br=C3=BCbach?= <44814898+BruebachL@users.noreply.github.com> Date: Fri, 6 Nov 2026 09:57:14 +0100 Subject: [PATCH 4/7] [DeckSync] Drop ineffective UniqueConnection flags on lambda connects --- .../interface/deck_sync/deck_sync_manager.cpp | 42 ++++++++----------- 1 file changed, 18 insertions(+), 24 deletions(-) diff --git a/cockatrice/src/interface/deck_sync/deck_sync_manager.cpp b/cockatrice/src/interface/deck_sync/deck_sync_manager.cpp index 2615ab21e..3354e9b07 100644 --- a/cockatrice/src/interface/deck_sync/deck_sync_manager.cpp +++ b/cockatrice/src/interface/deck_sync/deck_sync_manager.cpp @@ -140,31 +140,25 @@ DeckSyncManager::DeckSyncManager(AbstractClient *_client, ColorIdentityProvider 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.")); - } + 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); + } + }); + connect(client, &AbstractClient::userInfoChanged, this, [this](const ServerInfo_User &userInfo) { + offline = false; + serverUsername = QString::fromStdString(userInfo.name()); + autoSyncSoon(); + }); } void DeckSyncManager::setServerIdentity(const QString &host, int port, const QString &username) From 80ca738541ed89553dd694ed9939cbe9642394c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Br=C3=BCbach?= <44814898+BruebachL@users.noreply.github.com> Date: Fri, 6 Nov 2026 12:04:17 +0100 Subject: [PATCH 5/7] [DeckSync] Move the client signal handlers out of the constructor lambdas --- .../interface/deck_sync/deck_sync_manager.cpp | 45 +++++++++++-------- .../interface/deck_sync/deck_sync_manager.h | 5 ++- 2 files changed, 30 insertions(+), 20 deletions(-) diff --git a/cockatrice/src/interface/deck_sync/deck_sync_manager.cpp b/cockatrice/src/interface/deck_sync/deck_sync_manager.cpp index 3354e9b07..215936fe5 100644 --- a/cockatrice/src/interface/deck_sync/deck_sync_manager.cpp +++ b/cockatrice/src/interface/deck_sync/deck_sync_manager.cpp @@ -140,25 +140,32 @@ DeckSyncManager::DeckSyncManager(AbstractClient *_client, ColorIdentityProvider 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.")); - } - } - }); - connect(client, &AbstractClient::userInfoChanged, this, [this](const ServerInfo_User &userInfo) { - offline = false; - serverUsername = QString::fromStdString(userInfo.name()); - autoSyncSoon(); - }); + connect(client, &AbstractClient::statusChanged, this, &DeckSyncManager::onClientStatusChanged); + connect(client, &AbstractClient::userInfoChanged, this, &DeckSyncManager::onUserInfoChanged); +} + +void DeckSyncManager::onClientStatusChanged(ClientStatus status) +{ + if (status != StatusDisconnected) { + return; + } + 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.")); + } +} + +void DeckSyncManager::onUserInfoChanged(const ServerInfo_User &userInfo) +{ + offline = false; + serverUsername = QString::fromStdString(userInfo.name()); + autoSyncSoon(); } void DeckSyncManager::setServerIdentity(const QString &host, int port, const QString &username) diff --git a/cockatrice/src/interface/deck_sync/deck_sync_manager.h b/cockatrice/src/interface/deck_sync/deck_sync_manager.h index 8364c999c..8c20582fe 100644 --- a/cockatrice/src/interface/deck_sync/deck_sync_manager.h +++ b/cockatrice/src/interface/deck_sync/deck_sync_manager.h @@ -17,15 +17,16 @@ #include #include #include +#include #include -class AbstractClient; class CommandContainer; class DeckList; class QFileSystemWatcher; class QTimer; class Response; class ServerInfo_DeckStorage_Folder; +class ServerInfo_User; /** * @class DeckSyncManager @@ -175,6 +176,8 @@ private: [[nodiscard]] bool canAutoRun() const; void onDebounceTimeout(); void onWatchedPathChanged(const QString &path); + void onClientStatusChanged(ClientStatus status); + void onUserInfoChanged(const ServerInfo_User &userInfo); void refreshWatcher(); void refreshInterval(); [[nodiscard]] DeckSyncProfile buildProfile() const; From 2992bfdac1a8e0cfee37de6f7be2bc02426f22d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Br=C3=BCbach?= <44814898+BruebachL@users.noreply.github.com> Date: Sun, 8 Nov 2026 14:41:31 +0100 Subject: [PATCH 6/7] Address review findings --- .../interface/deck_sync/deck_sync_manager.cpp | 32 +++++++++++-------- .../interface/deck_sync/deck_sync_manager.h | 2 ++ .../interface/deck_sync/deck_sync_sidecar.h | 2 +- .../widgets/tabs/tab_deck_storage.cpp | 8 ++++- servatrice/src/serversocketinterface.cpp | 2 +- 5 files changed, 29 insertions(+), 17 deletions(-) diff --git a/cockatrice/src/interface/deck_sync/deck_sync_manager.cpp b/cockatrice/src/interface/deck_sync/deck_sync_manager.cpp index 215936fe5..80ef22687 100644 --- a/cockatrice/src/interface/deck_sync/deck_sync_manager.cpp +++ b/cockatrice/src/interface/deck_sync/deck_sync_manager.cpp @@ -858,6 +858,16 @@ void DeckSyncManager::queueConflict(const QString &relativePath, .guid = serverGuid, .hash = serverHash, .lastModifiedSecs = serverLastModifiedSecs}; + 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 — and skip the server fetch, which would only be discarded + // and repeated on every poll. + ++conflictCount; + ++skippedCount; + return; + } pendingConflicts.insert(relativePath, ctx); conflictPaths.insert(relativePath); applyDownload(relativePath, deckId, serverGuid, serverHash, serverLastModifiedSecs); @@ -865,17 +875,10 @@ void DeckSyncManager::queueConflict(const QString &relativePath, void DeckSyncManager::handleConflictDownload(const QString &relativePath, const QString &serverDeckString) { + // Only a manual run fetches a conflicting server copy, so this point is + // never reached with autoRun set: queueConflict() records the conflict and + // leaves the path alone instead. 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(); @@ -1349,7 +1352,8 @@ void DeckSyncManager::onDeleteFinished(const Response &response, } } // Both outcomes converge the record: pushed deletes and server-side - // deletes (already tombstoned, answering NameNotFound) are done. + // deletes (already tombstoned, answering NameNotFound) are done. Either + // way the server tree the client is showing is stale, so both count. if (response.response_code() == Response::RespOk || response.response_code() == Response::RespNameNotFound) { DeckSyncSidecar sidecar(sidecarPath); sidecar.load(); @@ -1418,10 +1422,9 @@ void DeckSyncManager::finishSync() if (!summary.note.isEmpty()) { message += QStringLiteral(" ") + summary.note; } + // The conflict count travels in the summary: a statusMessage here would be + // overwritten by the syncFinished() summary the tab renders next. emit statusMessage(message); - if (conflictCount > 0) { - emit statusMessage(tr("%1 conflicting decks skipped. Press Sync now to resolve them.").arg(conflictCount)); - } emit syncFinished(summary, QString()); } @@ -1433,6 +1436,7 @@ DeckSyncManager::SyncSummary DeckSyncManager::currentSummary() const .removed = removedCount, .unchanged = unchangedCount, .skipped = skippedCount, + .conflicts = conflictCount, .note = syncNote, }; } diff --git a/cockatrice/src/interface/deck_sync/deck_sync_manager.h b/cockatrice/src/interface/deck_sync/deck_sync_manager.h index 8c20582fe..e3d748996 100644 --- a/cockatrice/src/interface/deck_sync/deck_sync_manager.h +++ b/cockatrice/src/interface/deck_sync/deck_sync_manager.h @@ -70,6 +70,8 @@ public: int removed = 0; ///< Decks deleted on the server, or locally to mirror a server delete. int unchanged = 0; int skipped = 0; + /** @brief Both-sides-changed decks left untouched for a manual verdict. */ + int conflicts = 0; QString note; ///< Non-error caveat about this run, empty when there is none. }; diff --git a/cockatrice/src/interface/deck_sync/deck_sync_sidecar.h b/cockatrice/src/interface/deck_sync/deck_sync_sidecar.h index 1f621b976..013500a4e 100644 --- a/cockatrice/src/interface/deck_sync/deck_sync_sidecar.h +++ b/cockatrice/src/interface/deck_sync/deck_sync_sidecar.h @@ -40,7 +40,7 @@ public: /// keeps it out of sync, and the first-sync verdicts narrow it to one /// direction. An unrecognised value falls back to the two-way sync. QString mode = MODE_LINKED; - qint64 lastSyncSecs = 0; ///< Last completed sync, seconds since epoch; 0 means never. + qint64 lastSyncSecs = 0; ///< Last completed sync, seconds since epoch; 0 means never. explicit DeckSyncSidecar(const QString &_filePath); diff --git a/cockatrice/src/interface/widgets/tabs/tab_deck_storage.cpp b/cockatrice/src/interface/widgets/tabs/tab_deck_storage.cpp index 674f560ce..25a183c12 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_deck_storage.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_deck_storage.cpp @@ -668,7 +668,9 @@ void TabDeckStorage::onSyncFinished(const SyncSummary &summary, const QString &e lastSyncSummary = summary; } renderSyncStatus(); - if (error.isEmpty()) { + // An idle poll changes nothing: refreshing the tree there would send a + // fresh Command_DeckList every interval for every connected client. + if (error.isEmpty() && summary.uploaded + summary.downloaded + summary.removed > 0) { serverDirView->refreshTree(); } } @@ -689,6 +691,10 @@ void TabDeckStorage::renderSyncStatus() .arg(lastSyncSummary.removed) .arg(lastSyncSummary.unchanged) .arg(lastSyncSummary.skipped); + if (lastSyncSummary.conflicts > 0) { + text += QStringLiteral(" ") + tr("%n conflicting deck(s) skipped. Press Sync now to resolve them.", "", + lastSyncSummary.conflicts); + } if (!lastSyncSummary.note.isEmpty()) { text += QStringLiteral("\n") + lastSyncSummary.note; } diff --git a/servatrice/src/serversocketinterface.cpp b/servatrice/src/serversocketinterface.cpp index 595bd6d3d..1af6fc709 100644 --- a/servatrice/src/serversocketinterface.cpp +++ b/servatrice/src/serversocketinterface.cpp @@ -905,7 +905,7 @@ bool AbstractServerSocketInterface::syncCheckHelper(int userId, qint64 sinceSecs // 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 + // last_modified is backfilled by the 0040 -> 0041 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 " From fa6c2d75d61e58c214f30a7394b974e8bae87679 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Br=C3=BCbach?= <44814898+BruebachL@users.noreply.github.com> Date: Sun, 8 Nov 2026 19:37:34 +0100 Subject: [PATCH 7/7] [DeckSync] Keep the sync baseline when a server change is skipped The check's server_time became the new baseline even when the run could not apply a server change (a failed download, or a mirrored delete whose local copy could not be removed), and manual Sync now goes through the same fast path, so the change never came back. Track those skips and keep the previous baseline so the next check reports them again. Also disable Sync now while any run is in progress, drop the redundant baseline assignment in onCheckFinished, and tell the user that only two-way sync checks for changes automatically. --- .../interface/deck_sync/deck_sync_manager.cpp | 16 ++++++++++++++-- .../src/interface/deck_sync/deck_sync_manager.h | 1 + .../widgets/dialogs/dlg_sync_onboarding.cpp | 3 ++- .../interface/widgets/tabs/tab_deck_storage.cpp | 11 +++++++++++ .../interface/widgets/tabs/tab_deck_storage.h | 1 + 5 files changed, 29 insertions(+), 3 deletions(-) diff --git a/cockatrice/src/interface/deck_sync/deck_sync_manager.cpp b/cockatrice/src/interface/deck_sync/deck_sync_manager.cpp index 80ef22687..34b71a452 100644 --- a/cockatrice/src/interface/deck_sync/deck_sync_manager.cpp +++ b/cockatrice/src/interface/deck_sync/deck_sync_manager.cpp @@ -321,6 +321,7 @@ void DeckSyncManager::startSync(bool _autoRun) sessionDefault = -1; firstSyncRun = false; pendingServerTime = -1; + serverChangeSkipped = false; emit syncStarted(); emit statusMessage(tr("Scanning local decks...")); @@ -426,7 +427,6 @@ void DeckSyncManager::onCheckFinished(const Response &response, const CommandCon return; } - pendingServerTime = status.has_server_time() ? static_cast(status.server_time()) : -1; unchangedCount = localFiles.size(); emit statusMessage(tr("Decks are already in sync.")); finishSync(); @@ -641,6 +641,10 @@ void DeckSyncManager::classify(const QMap &serverFiles) const QString backupPath = absolutePath + BACKUP_SUFFIX + QString::number(QDateTime::currentDateTime().toSecsSinceEpoch()); if (!QFile::copy(absolutePath, backupPath) || !QFile::remove(absolutePath)) { + // The server row is gone but the local file could not be + // removed. Keep the baseline so the next check still reports + // the delete instead of hiding it behind a newer sync time. + serverChangeSkipped = true; ++skippedCount; continue; } @@ -1249,6 +1253,7 @@ void DeckSyncManager::onDownloadFinished(const Response &response, return; } if (response.response_code() != Response::RespOk) { + serverChangeSkipped = true; ++skippedCount; processNext(); return; @@ -1275,6 +1280,7 @@ void DeckSyncManager::applyDownloadedDeck(const QString &relativePath, { DeckList deck; if (!deck.loadFromString_Native(deckString)) { + serverChangeSkipped = true; ++skippedCount; processNext(); return; @@ -1283,6 +1289,7 @@ void DeckSyncManager::applyDownloadedDeck(const QString &relativePath, const QString cleanDeckPath = QDir::cleanPath(deckPath); const QString absolutePath = QDir::cleanPath(deckPath + "/" + relativePath); if (absolutePath != cleanDeckPath && !absolutePath.startsWith(cleanDeckPath + "/")) { + serverChangeSkipped = true; ++skippedCount; processNext(); return; @@ -1291,6 +1298,7 @@ void DeckSyncManager::applyDownloadedDeck(const QString &relativePath, const QString backupPath = absolutePath + BACKUP_SUFFIX + QString::number(QDateTime::currentDateTime().toSecsSinceEpoch()); if (!QFile::copy(absolutePath, backupPath)) { + serverChangeSkipped = true; ++skippedCount; processNext(); return; @@ -1298,6 +1306,7 @@ void DeckSyncManager::applyDownloadedDeck(const QString &relativePath, } QDir().mkpath(QFileInfo(absolutePath).absolutePath()); if (!DeckLoader::saveToFile(deck, absolutePath, DeckFileFormat::Cockatrice)) { + serverChangeSkipped = true; ++skippedCount; processNext(); return; @@ -1406,7 +1415,10 @@ void DeckSyncManager::finishSync() { syncing = false; DeckSyncSidecar sidecar(sidecarPath); - if (sidecar.load()) { + if (sidecar.load() && !serverChangeSkipped) { + // Advance the baseline only when every server change was applied. If one + // was skipped, keep the previous baseline so the next check reports it + // again instead of hiding it behind a newer sync time. sidecar.lastSyncSecs = pendingServerTime >= 0 ? pendingServerTime : QDateTime::currentDateTime().toSecsSinceEpoch(); sidecar.save(); diff --git a/cockatrice/src/interface/deck_sync/deck_sync_manager.h b/cockatrice/src/interface/deck_sync/deck_sync_manager.h index e3d748996..220f28039 100644 --- a/cockatrice/src/interface/deck_sync/deck_sync_manager.h +++ b/cockatrice/src/interface/deck_sync/deck_sync_manager.h @@ -255,6 +255,7 @@ private: int removedCount = 0; int unchangedCount = 0; int skippedCount = 0; + bool serverChangeSkipped = false; ///< A server change could not be applied; keep the sync baseline. int conflictCount = 0; bool autoRun = false; }; diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_sync_onboarding.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_sync_onboarding.cpp index 23bf663d5..32504fa75 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_sync_onboarding.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_sync_onboarding.cpp @@ -37,7 +37,8 @@ void DlgSyncOnboarding::retranslateUi() setWindowTitle(tr("Sync decks to this server")); detailLabel->setText( tr("You have %1 local decks and %2 decks stored on this server. This choice sets how decks sync with this " - "server from now on; you can reset it to two-way sync from the deck storage tab.") + "server from now on; you can reset it to two-way sync from the deck storage tab. Only two-way sync keeps " + "checking for changes automatically.") .arg(localCount) .arg(serverCount)); mergeRadio->setText(tr("Merge: keep everything, sync both ways (recommended)")); diff --git a/cockatrice/src/interface/widgets/tabs/tab_deck_storage.cpp b/cockatrice/src/interface/widgets/tabs/tab_deck_storage.cpp index 25a183c12..e38d72fef 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_deck_storage.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_deck_storage.cpp @@ -283,6 +283,7 @@ TabDeckStorage::TabDeckStorage(TabSupervisor *_tabSupervisor, connect(&SettingsCache::instance().deckSync(), &DeckSyncSettings::syncIntervalMinutesChanged, syncManager, &DeckSyncManager::setSyncIntervalMinutes); connect(syncManager, &DeckSyncManager::statusMessage, this, &TabDeckStorage::onSyncStatusMessage); + connect(syncManager, &DeckSyncManager::syncStarted, this, &TabDeckStorage::onSyncStarted); connect(syncManager, &DeckSyncManager::syncFinished, this, &TabDeckStorage::onSyncFinished); connect(syncManager, &DeckSyncManager::firstSyncDetected, this, &TabDeckStorage::onFirstSyncDetected); connect(syncManager, &DeckSyncManager::conflictRequest, this, &TabDeckStorage::onConflictRequest); @@ -656,9 +657,19 @@ void TabDeckStorage::onSyncStatusMessage(const QString &message) renderSyncStatus(); } +void TabDeckStorage::onSyncStarted() +{ + // An auto run starts from the watcher or the interval timer, so the button + // has to follow the manager: a click during a run would be dropped by + // syncNow() and leave the user pressing a button that looks live. + aSyncNow->setEnabled(false); + aSyncReset->setEnabled(false); +} + void TabDeckStorage::onSyncFinished(const SyncSummary &summary, const QString &error) { aSyncNow->setEnabled(remoteEnabled); + aSyncReset->setEnabled(remoteEnabled); if (!error.isEmpty()) { syncStatusType = SyncStatusType::Failed; syncStatusDetail = error; diff --git a/cockatrice/src/interface/widgets/tabs/tab_deck_storage.h b/cockatrice/src/interface/widgets/tabs/tab_deck_storage.h index 9889e9a51..20fdab0fb 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_deck_storage.h +++ b/cockatrice/src/interface/widgets/tabs/tab_deck_storage.h @@ -135,6 +135,7 @@ private slots: void onFirstSyncDetected(int localCount, int serverCount); void onConflictRequest(const DeckSyncConflict &conflict); void onSyncStatusMessage(const QString &message); + void onSyncStarted(); void onSyncFinished(const SyncSummary &summary, const QString &error); public: