From d33542db59a2ebddb4204325af2db28f6f4aaa6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Br=C3=BCbach?= Date: Tue, 13 Oct 2026 07:13:59 +0200 Subject: [PATCH] [Installer][Client] Back up user data before old uninstallers can delete it --- cmake/NSIS.template.in | 61 ++++++++++++ cockatrice/src/interface/window_main.cpp | 85 ++++++++++++++--- libcockatrice_utility/CMakeLists.txt | 2 + .../utility/user_data_backup.cpp | 93 +++++++++++++++++++ .../libcockatrice/utility/user_data_backup.h | 46 +++++++++ tests/CMakeLists.txt | 1 + tests/user_data_backup_test.cpp | 91 ++++++++++++++++++ 7 files changed, 367 insertions(+), 12 deletions(-) create mode 100644 libcockatrice_utility/libcockatrice/utility/user_data_backup.cpp create mode 100644 libcockatrice_utility/libcockatrice/utility/user_data_backup.h create mode 100644 tests/user_data_backup_test.cpp diff --git a/cmake/NSIS.template.in b/cmake/NSIS.template.in index b6125238f..baf0cfe09 100644 --- a/cmake/NSIS.template.in +++ b/cmake/NSIS.template.in @@ -15,6 +15,7 @@ Var ReinstallMode Var AllowForceClose Var GuardDir Var OldUninstallString +Var DataBackupDir Var StaleFindHandle Var StaleFindName Var IsUpgrade @@ -317,7 +318,9 @@ ReadRegStr $R0 HKLM "SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\Cockatr !insertmacro CockLog "uninstall (32-bit): '$R0 _?=$INSTDIR'" StrCmp $R0 "" done32 DetailPrint "Removing previous version (32-bit)..." +Call BackupUserDataBeforeUninstall ExecWait '$R0 _?=$INSTDIR' +Call RestoreUserDataAfterUninstall done32: @@ -329,13 +332,67 @@ ${If} ${RunningX64} !insertmacro CockLog "uninstall (64-bit): '$R0 _?=$INSTDIR'" StrCmp $R0 "" done64 DetailPrint "Removing previous version (64-bit)..." + Call BackupUserDataBeforeUninstall ExecWait '$R0 _?=$INSTDIR' + Call RestoreUserDataAfterUninstall done64: ${EndIf} FunctionEnd +; BackupUserDataBeforeUninstall / RestoreUserDataAfterUninstall +; +; The uninstallers shipped in 3.1.0/3.1.1 run a blanket "RMDir /r $INSTDIR". +; For a portable install that deletes $INSTDIR\data (decks, settings, pictures) +; along with the payload, and for a normal install it deletes anything the user +; pointed into $INSTDIR. The fixed uninstaller preserves foreign files, but the +; destructive one is already on disk on affected machines, so stash a copy +; outside $INSTDIR before invoking it and put it back afterwards. Best effort +; only: a failed backup must never abort the install. +Function BackupUserDataBeforeUninstall +StrCpy $DataBackupDir "$TEMP\CockatriceDataBackup" +${If} ${FileExists} "$INSTDIR\portable.dat" +${AndIf} ${FileExists} "$INSTDIR\data\*.*" + !insertmacro CockLog "pre-uninstall backup: copying $INSTDIR\data" + DetailPrint "Backing up portable user data before removing previous version..." + CreateDirectory "$DataBackupDir\data" + CopyFiles /SILENT "$INSTDIR\data\*.*" "$DataBackupDir\data\" + DetailPrint "Portable user data backup complete." +${EndIf} +${If} ${FileExists} "$INSTDIR\*.cod" + !insertmacro CockLog "pre-uninstall backup: copying loose deck files" + DetailPrint "Backing up deck files stored in the install directory..." + CreateDirectory "$DataBackupDir\loose_decks" + CopyFiles /SILENT "$INSTDIR\*.cod" "$DataBackupDir\loose_decks\" +${EndIf} +FunctionEnd + +Function RestoreUserDataAfterUninstall +StrCmp $DataBackupDir "" done +${If} ${FileExists} "$DataBackupDir\data\*.*" + ${If} ${FileExists} "$INSTDIR\data\*.*" + !insertmacro CockLog "post-uninstall restore: user data survived, backup kept" + DetailPrint "Previous user data survived the uninstall; backup kept at $DataBackupDir\data." + ${Else} + !insertmacro CockLog "post-uninstall restore: restoring $INSTDIR\data" + CreateDirectory "$INSTDIR\data" + CopyFiles /SILENT "$DataBackupDir\data\*.*" "$INSTDIR\data\" + DetailPrint "Restored portable user data from pre-uninstall backup." + ${EndIf} +${EndIf} +${If} ${FileExists} "$DataBackupDir\loose_decks\*.cod" + !insertmacro CockLog "post-uninstall restore: restoring loose deck files" + CreateDirectory "$INSTDIR" + CopyFiles /SILENT "$DataBackupDir\loose_decks\*.cod" "$INSTDIR\" + DetailPrint "Restored deck files stored in the install directory." + ${IfNot} ${Silent} + MessageBox MB_OK|MB_ICONINFORMATION "Your decks were stored inside the Cockatrice install folder, which the previous version's uninstaller deletes.$\n$\nThey have been restored from a backup. Please move them out of the install folder (Settings -> General -> Paths) so a future uninstall cannot delete them again." + ${EndIf} +${EndIf} +done: +FunctionEnd + ; --- Running instance handling --- ; Cockatrice, Oracle and Servatrice must not be running while files are ; replaced or deleted. A still-running process holds locks on its .exe and @@ -1041,7 +1098,9 @@ ${If} $PortableMode = 0 !insertmacro CockLog "componentsPagePre: old 32-bit uninstall '$OldUninstallString _?=$INSTDIR'" Call EnsureOldInstallNotRunning ClearErrors + Call BackupUserDataBeforeUninstall ExecWait "$OldUninstallString _?=$INSTDIR" + Call RestoreUserDataAfterUninstall done32: @@ -1064,7 +1123,9 @@ ${If} $PortableMode = 0 !insertmacro CockLog "componentsPagePre: old 64-bit uninstall '$OldUninstallString _?=$INSTDIR'" Call EnsureOldInstallNotRunning ClearErrors + Call BackupUserDataBeforeUninstall ExecWait "$OldUninstallString _?=$INSTDIR" + Call RestoreUserDataAfterUninstall done64: ${EndIf} diff --git a/cockatrice/src/interface/window_main.cpp b/cockatrice/src/interface/window_main.cpp index 815cec1d2..dfeac5793 100644 --- a/cockatrice/src/interface/window_main.cpp +++ b/cockatrice/src/interface/window_main.cpp @@ -65,6 +65,7 @@ #include #include #include +#include #include #include #include @@ -110,6 +111,7 @@ #include #include #include +#include #include #include #include @@ -609,23 +611,82 @@ MainWindow::MainWindow(QWidget *parent) void MainWindow::warnAboutDataPathsInProgramDir() { auto &paths = SettingsCache::instance().paths(); - if (SettingsCache::instance().getIsPortableBuild() || paths.getAppDirWarningAcknowledged()) { + if (paths.getAppDirWarningAcknowledged()) { return; } - const QStringList offenders = paths.pathsInsideDir(QCoreApplication::applicationDirPath()); - if (offenders.isEmpty()) { - return; + const QString appDir = QCoreApplication::applicationDirPath(); + const bool isPortable = SettingsCache::instance().getIsPortableBuild(); + + // Sources to back up outside the install directory before warning. The uninstallers + // shipped in 3.1.0/3.1.1 delete the whole install tree, so a safety copy is kept + // even though the fixed uninstaller preserves foreign files - the destructive one + // is already on disk on affected machines. + QStringList backupSources; + QStringList offenders; + if (isPortable) { + // Portable data lives inside the install directory by design, so there is + // nothing to reconfigure. Back up the small irreplaceable data (decks, custom + // sets, settings) instead. Pictures are deliberately excluded: they are large + // and can always be re-downloaded. + const QString dataDir = SettingsCache::instance().getDataPath(); + for (const QString &sub : {QStringLiteral("decks"), QStringLiteral("customsets"), QStringLiteral("settings")}) { + const QString candidate = QDir(dataDir).absoluteFilePath(sub); + if (QFileInfo::exists(candidate)) { + backupSources.append(candidate); + } + } + if (backupSources.isEmpty()) { + return; + } + } else { + offenders = paths.pathsInsideDir(appDir); + if (offenders.isEmpty()) { + return; + } + backupSources = offenders; } - QMessageBox box(QMessageBox::Warning, tr("Data paths inside the program directory"), - tr("These paths are configured inside the Cockatrice program directory:\n\n%1\n\n" - "Cockatrice keeps its own data outside the program directory by default. Files " - "stored inside it can be deleted by an update or by uninstalling Cockatrice. " - "Change them in the Settings dialog - most are on the General page - to keep " - "them safe.") - .arg(offenders.join(QLatin1Char('\n'))), - QMessageBox::Ok, this); + QString backupRoot = UserDataBackup::defaultBackupRoot(); + if (PathsSettings::isInsideDir(backupRoot, appDir)) { + backupRoot = QDir::temp().absoluteFilePath(QStringLiteral("CockatriceBackups")); + } + QStringList backupLocations; + for (const QString &source : backupSources) { + QString backupLocation; + if (UserDataBackup::backupAtRiskPath(source, backupRoot, &backupLocation)) { + qCInfo(MainWindowLog) << "Backed up at-risk user data" << source << "to" << backupLocation; + backupLocations.append(backupLocation); + } else { + qCWarning(MainWindowLog) << "Failed to back up at-risk user data:" << source; + } + } + + QString message; + if (isPortable) { + message = tr("This portable install keeps its data inside the program directory, where the " + "uninstallers shipped in Cockatrice 3.1.0/3.1.1 delete everything on uninstall or " + "update.\n\nA safety backup of your decks, custom sets and settings has been created " + "at:\n%1\n\nUninstalling with the old uninstaller still deletes the program " + "directory, so keep your own copy of that backup until you have updated once.") + .arg(backupLocations.isEmpty() ? backupRoot : backupLocations.join(QLatin1Char('\n'))); + } else { + message = tr("These paths are configured inside the Cockatrice program directory:\n\n%1\n\n" + "Cockatrice keeps its own data outside the program directory by default. Files " + "stored inside it can be deleted by an update or by uninstalling Cockatrice. " + "Change them in the Settings dialog - most are on the General page - to keep " + "them safe.") + .arg(offenders.join(QLatin1Char('\n'))); + if (!backupLocations.isEmpty()) { + message += tr("\n\nA safety backup has been created at:\n%1").arg(backupLocations.join(QLatin1Char('\n'))); + } else { + message += tr("\n\nThe automatic safety backup failed; please back up these folders manually."); + } + } + QMessageBox box(QMessageBox::Warning, + isPortable ? tr("Portable data at risk on uninstall") + : tr("Data paths inside the program directory"), + message, QMessageBox::Ok, this); box.setTextFormat(Qt::PlainText); auto *dontShowAgain = new QCheckBox(tr("Don't show this again")); box.setCheckBox(dontShowAgain); diff --git a/libcockatrice_utility/CMakeLists.txt b/libcockatrice_utility/CMakeLists.txt index 5e6fa986d..9b21a071f 100644 --- a/libcockatrice_utility/CMakeLists.txt +++ b/libcockatrice_utility/CMakeLists.txt @@ -13,6 +13,7 @@ set(UTILITY_SOURCES libcockatrice/utility/report_categories.cpp libcockatrice/utility/server_rate_limiter.cpp libcockatrice/utility/translation_loader.cpp + libcockatrice/utility/user_data_backup.cpp libcockatrice/utility/warning_categories.cpp ) @@ -35,6 +36,7 @@ set(UTILITY_HEADERS libcockatrice/utility/days_years_between.h libcockatrice/utility/server_rate_limiter.h libcockatrice/utility/translation_loader.h + libcockatrice/utility/user_data_backup.h libcockatrice/utility/warning_categories.h ) diff --git a/libcockatrice_utility/libcockatrice/utility/user_data_backup.cpp b/libcockatrice_utility/libcockatrice/utility/user_data_backup.cpp new file mode 100644 index 000000000..554872b0a --- /dev/null +++ b/libcockatrice_utility/libcockatrice/utility/user_data_backup.cpp @@ -0,0 +1,93 @@ +#include "user_data_backup.h" + +#include +#include +#include +#include +#include +#include + +namespace UserDataBackup +{ + +bool copyDirectoryRecursively(const QString &sourceDir, const QString &destDir) +{ + QDir source(sourceDir); + if (!source.exists()) { + return false; + } + if (!QDir().mkpath(destDir)) { + return false; + } + + bool ok = true; + QDirIterator it(sourceDir, QDir::Files | QDir::Dirs | QDir::NoDotAndDotDot | QDir::Hidden, + QDirIterator::Subdirectories); + while (it.hasNext()) { + it.next(); + QString entryRel = source.relativeFilePath(it.filePath()); + QString target = QDir(destDir).absoluteFilePath(entryRel); + if (it.fileInfo().isDir()) { + if (!QDir().mkpath(target)) { + ok = false; + } + } else { + if (QFile::exists(target) && !QFile::remove(target)) { + ok = false; + continue; + } + if (!QFile::copy(it.filePath(), target)) { + ok = false; + } + } + } + return ok; +} + +bool backupAtRiskPath(const QString &sourcePath, const QString &backupRoot, QString *backupLocationOut) +{ + QFileInfo source(sourcePath); + if (!source.exists()) { + return false; + } + QString timestamp = QDateTime::currentDateTime().toString("yyyyMMdd-hhmmss"); + QString backupDir = QDir(backupRoot).absoluteFilePath("CockatriceDataBackup-" + timestamp); + if (!QDir().mkpath(backupDir)) { + return false; + } + + bool ok; + QString backupLocation; + if (source.isDir()) { + backupLocation = QDir(backupDir).absoluteFilePath(source.fileName()); + ok = copyDirectoryRecursively(sourcePath, backupLocation); + } else { + backupLocation = QDir(backupDir).absoluteFilePath(source.fileName()); + if (QFile::exists(backupLocation)) { + QFile::remove(backupLocation); + } + ok = QFile::copy(sourcePath, backupLocation); + } + if (!ok) { + return false; + } + if (backupLocationOut) { + *backupLocationOut = backupLocation; + } + return true; +} + +QString defaultBackupRoot() +{ + QString documents = QStandardPaths::writableLocation(QStandardPaths::DocumentsLocation); + if (!documents.isEmpty()) { + return QDir(documents).absoluteFilePath("CockatriceBackups"); + } + QString appData = QStandardPaths::writableLocation(QStandardPaths::AppLocalDataLocation); + if (!appData.isEmpty()) { + return QDir(appData).absoluteFilePath("../CockatriceBackups"); + } + return QDir::temp().absoluteFilePath("CockatriceBackups"); +} + +} // namespace UserDataBackup diff --git a/libcockatrice_utility/libcockatrice/utility/user_data_backup.h b/libcockatrice_utility/libcockatrice/utility/user_data_backup.h new file mode 100644 index 000000000..57656f0c5 --- /dev/null +++ b/libcockatrice_utility/libcockatrice/utility/user_data_backup.h @@ -0,0 +1,46 @@ +/** + * @file user_data_backup.h + * @ingroup Utility + * @brief Best-effort safety copies of user data that lives inside the install directory. + * + * Background: Windows uninstallers shipped in 3.1.0/3.1.1 ran a blanket + * "RMDir /r $INSTDIR". For a portable install that deletes $INSTDIR\data + * (decks, settings, pictures) along with the payload, and for a normal install + * it deletes anything the user pointed into $INSTDIR. The fixed uninstaller + * preserves foreign files, but the destructive one is already on disk on + * affected machines, so the client keeps a safety copy outside the install + * directory before warning the user. Detection of at-risk paths lives in + * PathsSettings::pathsInsideDir; this helper only copies. + */ + +#ifndef USER_DATA_BACKUP_H +#define USER_DATA_BACKUP_H + +#include + +namespace UserDataBackup +{ + +/** + * @brief Recursively copies @p sourceDir into @p destDir, creating it as needed. + * @returns true when every file was copied (or there was nothing to copy). + */ +bool copyDirectoryRecursively(const QString &sourceDir, const QString &destDir); + +/** + * @brief Copies an at-risk path (file or directory) to a timestamped folder under @p backupRoot. + * @param sourcePath file or directory holding user data (must exist). + * @param backupRoot directory OUTSIDE the install tree that will hold timestamped backups. + * @param backupLocationOut receives the created backup file or directory on success. + * @returns true on success, false when the source is missing or nothing could be copied. + */ +bool backupAtRiskPath(const QString &sourcePath, const QString &backupRoot, QString *backupLocationOut = nullptr); + +/** + * @brief Default backup root guaranteed to live outside the install directory. + */ +QString defaultBackupRoot(); + +} // namespace UserDataBackup + +#endif // USER_DATA_BACKUP_H diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 22dbedb98..80aa9f9b5 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -174,6 +174,7 @@ endfunction() cockatrice_add_test(NAME expression_test SOURCES expression_test.cpp LIBS libcockatrice_utility) cockatrice_add_test(NAME clamped_arithmetic_test SOURCES clamped_arithmetic_test.cpp LIBS libcockatrice_utility) cockatrice_add_test(NAME test_age_formatting SOURCES test_age_formatting.cpp LIBS libcockatrice_utility) +cockatrice_add_test(NAME user_data_backup_test SOURCES user_data_backup_test.cpp LIBS libcockatrice_utility) cockatrice_add_test(NAME password_hash_test SOURCES password_hash_test.cpp LIBS libcockatrice_utility) cockatrice_add_test(NAME report_categories_test SOURCES report_categories_test.cpp LIBS libcockatrice_utility) cockatrice_add_test(NAME warning_categories_test SOURCES warning_categories_test.cpp LIBS libcockatrice_utility) diff --git a/tests/user_data_backup_test.cpp b/tests/user_data_backup_test.cpp new file mode 100644 index 000000000..c48b103de --- /dev/null +++ b/tests/user_data_backup_test.cpp @@ -0,0 +1,91 @@ +#include "gtest/gtest.h" +#include +#include +#include +#include + +namespace +{ + +TEST(UserDataBackup, CopyDirectoryKeepsNestedStructure) +{ + QTemporaryDir sourceDir; + QTemporaryDir destParent; + ASSERT_TRUE(sourceDir.isValid()); + ASSERT_TRUE(destParent.isValid()); + QDir source(sourceDir.path()); + ASSERT_TRUE(source.mkpath("decks/sub")); + QFile deck(source.absoluteFilePath("decks/sub/mydeck.cod")); + ASSERT_TRUE(deck.open(QIODevice::WriteOnly | QIODevice::Text)); + deck.write("\n"); + deck.close(); + + QString dest = QDir(destParent.path()).absoluteFilePath("copy"); + EXPECT_TRUE(UserDataBackup::copyDirectoryRecursively(sourceDir.path(), dest)); + EXPECT_TRUE(QFile::exists(dest + "/decks/sub/mydeck.cod")); +} + +TEST(UserDataBackup, CopyMissingDirectoryFails) +{ + QTemporaryDir destParent; + ASSERT_TRUE(destParent.isValid()); + EXPECT_FALSE( + UserDataBackup::copyDirectoryRecursively("/nonexistent-cockatrice-path-xyz", destParent.path() + "/copy")); +} + +TEST(UserDataBackup, BackupDirectoryCreatesTimestampedCopy) +{ + QTemporaryDir sourceDir; + QTemporaryDir backupRoot; + ASSERT_TRUE(sourceDir.isValid()); + ASSERT_TRUE(backupRoot.isValid()); + QFile deck(QDir(sourceDir.path()).absoluteFilePath("mydeck.cod")); + ASSERT_TRUE(deck.open(QIODevice::WriteOnly | QIODevice::Text)); + deck.write("\n"); + deck.close(); + + QString backupLocation; + EXPECT_TRUE(UserDataBackup::backupAtRiskPath(sourceDir.path(), backupRoot.path(), &backupLocation)); + EXPECT_TRUE(QFile::exists(backupLocation + "/mydeck.cod")); + EXPECT_TRUE(backupLocation.startsWith(QDir(backupRoot.path()).absolutePath())); +} + +TEST(UserDataBackup, BackupSingleFile) +{ + QTemporaryDir sourceDir; + QTemporaryDir backupRoot; + ASSERT_TRUE(sourceDir.isValid()); + ASSERT_TRUE(backupRoot.isValid()); + QString cardDb = QDir(sourceDir.path()).absoluteFilePath("cards.xml"); + QFile db(cardDb); + ASSERT_TRUE(db.open(QIODevice::WriteOnly | QIODevice::Text)); + db.write("\n"); + db.close(); + + QString backupLocation; + EXPECT_TRUE(UserDataBackup::backupAtRiskPath(cardDb, backupRoot.path(), &backupLocation)); + EXPECT_TRUE(QFile::exists(backupLocation)); + EXPECT_TRUE(backupLocation.endsWith("cards.xml")); +} + +TEST(UserDataBackup, BackupMissingSourceFails) +{ + QTemporaryDir backupRoot; + ASSERT_TRUE(backupRoot.isValid()); + EXPECT_FALSE(UserDataBackup::backupAtRiskPath("/nonexistent-cockatrice-path-xyz", backupRoot.path(), nullptr)); +} + +TEST(UserDataBackup, DefaultBackupRootIsUsable) +{ + QString root = UserDataBackup::defaultBackupRoot(); + EXPECT_FALSE(root.isEmpty()); + EXPECT_TRUE(QDir(root).isAbsolute()); +} + +} // namespace + +int main(int argc, char **argv) +{ + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +}