[Installer][Client] Back up user data before old uninstallers can delete it

This commit is contained in:
Lukas Brübach 2026-10-13 07:13:59 +02:00
parent a712553c81
commit d33542db59
7 changed files with 367 additions and 12 deletions

View file

@ -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}

View file

@ -65,6 +65,7 @@
#include <QDebug>
#include <QDesktopServices>
#include <QDialog>
#include <QDir>
#include <QEvent>
#include <QFile>
#include <QFileDialog>
@ -110,6 +111,7 @@
#include <libcockatrice/settings/tabs_settings.h>
#include <libcockatrice/settings/updates_settings.h>
#include <libcockatrice/utility/local_server_name.h>
#include <libcockatrice/utility/user_data_backup.h>
#include <memory>
#include <qlogging.h>
#include <qnamespace.h>
@ -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);

View file

@ -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
)

View file

@ -0,0 +1,93 @@
#include "user_data_backup.h"
#include <QDateTime>
#include <QDir>
#include <QDirIterator>
#include <QFile>
#include <QFileInfo>
#include <QStandardPaths>
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

View file

@ -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 <QString>
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

View file

@ -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)

View file

@ -0,0 +1,91 @@
#include "gtest/gtest.h"
#include <QDir>
#include <QFile>
#include <QTemporaryDir>
#include <libcockatrice/utility/user_data_backup.h>
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("<cockatrice_deck version=\"1\"/>\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("<cockatrice_deck version=\"1\"/>\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("<cockatrice_card_database/>\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();
}