diff --git a/cmake/NSIS.template.in b/cmake/NSIS.template.in index b4893445f..0f040644d 100644 --- a/cmake/NSIS.template.in +++ b/cmake/NSIS.template.in @@ -15,6 +15,7 @@ Var ReinstallMode Var AllowForceClose Var GuardDir Var OldUninstallString +Var OldInstallDir Var DataBackupDir Var StaleFindHandle Var StaleFindName @@ -129,6 +130,9 @@ StrCpy $InstallLogPath "$TEMP\Cockatrice-installer.log" ; No backup has been taken yet; RestoreUserDataAfterUninstall must be a no-op ; until BackupUserDataBeforeUninstall sets this after actually copying something. StrCpy $DataBackupDir "" +; No old install location resolved yet; Backup/Restore fall back to $INSTDIR +; when this is empty (the /R path where $INSTDIR already is the old dir). +StrCpy $OldInstallDir "" ; Start each run from a clean file so the log that gets sent back is the last ; run, not every past run glued together. FileOpen $LogHandle "$InstallLogPath" w @@ -318,11 +322,14 @@ SetShellVarContext all SetRegView 32 ReadRegStr $R0 HKLM "SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\Cockatrice" "QuietUninstallString" -!insertmacro CockLog "uninstall (32-bit): '$R0 _?=$INSTDIR'" +; On the /R path the updater passes /D=, so $INSTDIR already is the old +; install dir. Publish it for the backup/restore pair below. +StrCpy $OldInstallDir $INSTDIR +!insertmacro CockLog "uninstall (32-bit): '$R0 _?=$OldInstallDir'" StrCmp $R0 "" done32 DetailPrint "Removing previous version (32-bit)..." Call BackupUserDataBeforeUninstall -ExecWait '$R0 _?=$INSTDIR' +ExecWait '$R0 _?=$OldInstallDir' Call RestoreUserDataAfterUninstall done32: @@ -332,11 +339,12 @@ ${If} ${RunningX64} SetRegView 64 ReadRegStr $R0 HKLM "SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\Cockatrice" "QuietUninstallString" - !insertmacro CockLog "uninstall (64-bit): '$R0 _?=$INSTDIR'" + StrCpy $OldInstallDir $INSTDIR + !insertmacro CockLog "uninstall (64-bit): '$R0 _?=$OldInstallDir'" StrCmp $R0 "" done64 DetailPrint "Removing previous version (64-bit)..." Call BackupUserDataBeforeUninstall - ExecWait '$R0 _?=$INSTDIR' + ExecWait '$R0 _?=$OldInstallDir' Call RestoreUserDataAfterUninstall done64: @@ -358,59 +366,72 @@ FunctionEnd ; Non-portable data paths beyond loose decks are covered by the client's ; startup safety backup (warnAboutDataPathsInProgramDir). Function BackupUserDataBeforeUninstall -; Always start from a clean slate: %TEMP% survives across installer runs, and a -; leftover backup must never be mistaken for this run's - a fresh install would -; otherwise resurrect ancient decks via RestoreUserDataAfterUninstall. -RMDir /r "$TEMP\CockatriceDataBackup" -StrCpy $DataBackupDir "" -${If} ${FileExists} "$INSTDIR\portable.dat" -${AndIf} ${FileExists} "$INSTDIR\data" +; $OldInstallDir is the directory being uninstalled: on the /R path it equals +; $INSTDIR (updater passes /D=), on the interactive path componentsPagePre +; sets it from the registry InstallLocation via $GuardDir, because $INSTDIR is +; still the default there (directory page comes later). +${If} $OldInstallDir == "" + StrCpy $OldInstallDir $INSTDIR +${EndIf} +; Start from a clean slate only on the first backup of this run: %TEMP% survives +; across installer runs, so a stale backup must never be mistaken for this run's - +; but this function runs twice in sequence (32-bit then 64-bit), and an +; unconditional RMDir here would delete the first call's safety copy before the +; second uninstall runs. +${If} $DataBackupDir == "" + RMDir /r "$TEMP\CockatriceDataBackup" +${EndIf} +${If} ${FileExists} "$OldInstallDir\portable.dat" +${AndIf} ${FileExists} "$OldInstallDir\data" StrCpy $DataBackupDir "$TEMP\CockatriceDataBackup" - !insertmacro CockLog "pre-uninstall backup: copying $INSTDIR\data (downloaded pics excluded)" + !insertmacro CockLog "pre-uninstall backup: copying $OldInstallDir\data (downloaded pics excluded)" DetailPrint "Backing up portable user data before removing previous version..." CreateDirectory "$DataBackupDir\data" - CopyFiles /SILENT "$INSTDIR\data\*.*" "$DataBackupDir\data\" + CopyFiles /SILENT "$OldInstallDir\data\*.*" "$DataBackupDir\data\" ; Downloaded pictures are large and re-downloadable, so drop them from the ; safety backup - but keep user-made custom pics (data\pics\CUSTOM). RMDir /r "$DataBackupDir\data\pics" - ${If} ${FileExists} "$INSTDIR\data\pics\CUSTOM" + ${If} ${FileExists} "$OldInstallDir\data\pics\CUSTOM" CreateDirectory "$DataBackupDir\data\pics\CUSTOM" - CopyFiles /SILENT "$INSTDIR\data\pics\CUSTOM\*.*" "$DataBackupDir\data\pics\CUSTOM\" + CopyFiles /SILENT "$OldInstallDir\data\pics\CUSTOM\*.*" "$DataBackupDir\data\pics\CUSTOM\" ${EndIf} DetailPrint "Portable user data backup complete." ${EndIf} -${If} ${FileExists} "$INSTDIR\*.cod" -${OrIf} ${FileExists} "$INSTDIR\*.dec" +${If} ${FileExists} "$OldInstallDir\*.cod" +${OrIf} ${FileExists} "$OldInstallDir\*.dec" ${If} $DataBackupDir == "" StrCpy $DataBackupDir "$TEMP\CockatriceDataBackup" ${EndIf} !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\" - CopyFiles /SILENT "$INSTDIR\*.dec" "$DataBackupDir\loose_decks\" + CopyFiles /SILENT "$OldInstallDir\*.cod" "$DataBackupDir\loose_decks\" + CopyFiles /SILENT "$OldInstallDir\*.dec" "$DataBackupDir\loose_decks\" ${EndIf} FunctionEnd Function RestoreUserDataAfterUninstall StrCmp $DataBackupDir "" done +${If} $OldInstallDir == "" + StrCpy $OldInstallDir $INSTDIR +${EndIf} ${If} ${FileExists} "$DataBackupDir\data" - ${If} ${FileExists} "$INSTDIR\data" + ${If} ${FileExists} "$OldInstallDir\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\" + !insertmacro CockLog "post-uninstall restore: restoring $OldInstallDir\data" + CreateDirectory "$OldInstallDir\data" + CopyFiles /SILENT "$DataBackupDir\data\*.*" "$OldInstallDir\data\" DetailPrint "Restored portable user data from pre-uninstall backup." ${EndIf} ${EndIf} ${If} ${FileExists} "$DataBackupDir\loose_decks\*.cod" ${OrIf} ${FileExists} "$DataBackupDir\loose_decks\*.dec" !insertmacro CockLog "post-uninstall restore: restoring loose deck files" - CreateDirectory "$INSTDIR" - CopyFiles /SILENT "$DataBackupDir\loose_decks\*.cod" "$INSTDIR\" - CopyFiles /SILENT "$DataBackupDir\loose_decks\*.dec" "$INSTDIR\" + CreateDirectory "$OldInstallDir" + CopyFiles /SILENT "$DataBackupDir\loose_decks\*.cod" "$OldInstallDir\" + CopyFiles /SILENT "$DataBackupDir\loose_decks\*.dec" "$OldInstallDir\" 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." @@ -1120,12 +1141,16 @@ ${If} $PortableMode = 0 ; instance of the application is still running out of the *old* install ; directory before letting it delete anything. The uninstall string is ; stashed away first, the check works on $R0. + ; $INSTDIR is still the default here (directory page comes later), so the + ; backup/restore dir comes from the registry InstallLocation via $GuardDir, + ; not from $INSTDIR - otherwise a custom-location install is unprotected. StrCpy $OldUninstallString $R0 - !insertmacro CockLog "componentsPagePre: old 32-bit uninstall '$OldUninstallString _?=$INSTDIR'" Call EnsureOldInstallNotRunning + StrCpy $OldInstallDir $GuardDir + !insertmacro CockLog "componentsPagePre: old 32-bit uninstall '$OldUninstallString _?=$OldInstallDir' (oldDir=$OldInstallDir InstDir=$INSTDIR)" ClearErrors Call BackupUserDataBeforeUninstall - ExecWait "$OldUninstallString _?=$INSTDIR" + ExecWait "$OldUninstallString _?=$OldInstallDir" Call RestoreUserDataAfterUninstall done32: @@ -1146,11 +1171,12 @@ ${If} $PortableMode = 0 uninst64: StrCpy $OldUninstallString $R0 - !insertmacro CockLog "componentsPagePre: old 64-bit uninstall '$OldUninstallString _?=$INSTDIR'" Call EnsureOldInstallNotRunning + StrCpy $OldInstallDir $GuardDir + !insertmacro CockLog "componentsPagePre: old 64-bit uninstall '$OldUninstallString _?=$OldInstallDir' (oldDir=$OldInstallDir InstDir=$INSTDIR)" ClearErrors Call BackupUserDataBeforeUninstall - ExecWait "$OldUninstallString _?=$INSTDIR" + ExecWait "$OldUninstallString _?=$OldInstallDir" Call RestoreUserDataAfterUninstall done64: diff --git a/cockatrice/src/interface/window_main.cpp b/cockatrice/src/interface/window_main.cpp index db54801f6..288951936 100644 --- a/cockatrice/src/interface/window_main.cpp +++ b/cockatrice/src/interface/window_main.cpp @@ -627,22 +627,34 @@ void MainWindow::warnAboutDataPathsInProgramDir() if (isPortable) { // Portable data lives inside the install directory by design, so there is // nothing to reconfigure. Back up the small irreplaceable data instead. - // Pictures are deliberately excluded: they are large and can always be - // re-downloaded (the installer's pre-uninstall backup keeps custom pics - // but drops downloaded ones for the same reason). Replays are excluded - // here for size; the installer backup covers them. + // Downloaded pictures are deliberately excluded: they are large and can + // always be re-downloaded (the installer's pre-uninstall backup drops + // them for the same reason). Replays are excluded here for size; the + // installer backup covers them during installer-driven upgrades, but a + // manual run of the old uninstall.exe is not covered - hence the note + // in the warning text below. const QString dataDir = SettingsCache::instance().getDataPath(); for (const QString &sub : QStringList{QStringLiteral("decks"), QStringLiteral("customsets"), QStringLiteral("settings"), - QStringLiteral("filters"), QStringLiteral("themes")}) { + QStringLiteral("filters"), QStringLiteral("themes"), QStringLiteral("pics/CUSTOM")}) { const QString candidate = QDir(dataDir).absoluteFilePath(sub); if (QFileInfo::exists(candidate)) { backupSources.append(candidate); } } + // A portable user may also have pointed a custom path inside the install + // dir but outside data/ (e.g. appDir/mydecks). Those offenders would be + // wiped by the old uninstaller but are not in the hardcoded list above. + offenders = paths.pathsInsideDir(appDir); + for (const QString &offender : offenders) { + if (!PathsSettings::isInsideDir(offender, dataDir) && !backupSources.contains(offender)) { + backupSources.append(offender); + } + } if (backupSources.isEmpty()) { return; } + offenders.clear(); } else { offenders = paths.pathsInsideDir(appDir); if (offenders.isEmpty()) { @@ -655,13 +667,26 @@ void MainWindow::warnAboutDataPathsInProgramDir() if (PathsSettings::isInsideDir(backupRoot, appDir)) { backupRoot = QDir::temp().absoluteFilePath(QStringLiteral("CockatriceBackups")); } - // One timestamped session directory for all sources, so a second boundary - // never splits a single startup check across two backup folders and - // same-named sources cannot overwrite each other. + // Throttle: dismissing the dialog without "Don't show again" must not create + // a full duplicate on every launch. Reuse a recent session instead, and keep + // only a handful of sessions so Documents cannot grow without bound. QStringList backupLocations; QString sessionDir; - const bool backupComplete = - UserDataBackup::backupAtRiskPaths(backupSources, backupRoot, &backupLocations, &sessionDir); + bool backupComplete = true; + bool backupReused = false; + const QString recentSession = UserDataBackup::findRecentBackupSession(backupRoot, 24); + if (!recentSession.isEmpty()) { + sessionDir = recentSession; + backupLocations = UserDataBackup::listSessionBackupLocations(sessionDir); + backupReused = true; + qCInfo(MainWindowLog) << "Reusing recent at-risk user data backup at" << sessionDir; + } else { + // One timestamped session directory for all sources, so a second boundary + // never splits a single startup check across two backup folders and + // same-named sources cannot overwrite each other. + backupComplete = UserDataBackup::backupAtRiskPaths(backupSources, backupRoot, &backupLocations, &sessionDir); + UserDataBackup::pruneOldBackupSessions(backupRoot, 5); + } for (const QString &backupLocation : backupLocations) { qCInfo(MainWindowLog) << "Backed up at-risk user data to" << backupLocation; } @@ -669,15 +694,28 @@ void MainWindow::warnAboutDataPathsInProgramDir() qCWarning(MainWindowLog) << "At-risk user data backup incomplete, session dir:" << sessionDir; } + const QString backupVerb = backupReused ? tr("is already available") : tr("has been created"); 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, settings, filters and themes 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'))); + if (!backupLocations.isEmpty()) { + 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, settings, filters, themes and custom " + "pictures %1 " + "at:\n%2\n\nReplays and downloaded pictures are not included in this backup. Uninstalling " + "with the old uninstaller still deletes the program " + "directory, so keep your own copy of that backup until you have updated once.") + .arg(backupVerb, backupLocations.join(QLatin1Char('\n'))); + if (!backupComplete) { + message += tr("\n\nThe backup may be incomplete; please back up these folders manually as well."); + } + } else { + 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\nThe automatic safety backup failed; please back up your data folder manually " + "before uninstalling or updating.\n\nReplays and downloaded pictures would not have been " + "included in that backup either."); + } } 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 " @@ -686,7 +724,7 @@ void MainWindow::warnAboutDataPathsInProgramDir() "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'))); + message += tr("\n\nA safety backup %1 at:\n%2").arg(backupVerb, backupLocations.join(QLatin1Char('\n'))); if (!backupComplete) { message += tr("\n\nThe backup may be incomplete; please back up these folders manually as well."); } diff --git a/libcockatrice_utility/libcockatrice/utility/user_data_backup.cpp b/libcockatrice_utility/libcockatrice/utility/user_data_backup.cpp index a6c287923..2de08c63b 100644 --- a/libcockatrice_utility/libcockatrice/utility/user_data_backup.cpp +++ b/libcockatrice_utility/libcockatrice/utility/user_data_backup.cpp @@ -160,9 +160,71 @@ QString defaultBackupRoot() } QString appData = QStandardPaths::writableLocation(QStandardPaths::AppLocalDataLocation); if (!appData.isEmpty()) { - return QDir(appData).absoluteFilePath("../CockatriceBackups"); + return QDir::cleanPath(QDir(appData).absoluteFilePath("../CockatriceBackups")); } return QDir::temp().absoluteFilePath("CockatriceBackups"); } +QStringList listBackupSessions(const QString &backupRoot) +{ + QDir root(backupRoot); + if (!root.exists()) { + return {}; + } + const QStringList names = root.entryList(QStringList() << QStringLiteral("CockatriceDataBackup-*"), + QDir::Dirs | QDir::NoDotAndDotDot, QDir::Name | QDir::Reversed); + QStringList sessions; + sessions.reserve(names.size()); + for (const QString &name : names) { + sessions.append(root.absoluteFilePath(name)); + } + return sessions; +} + +QString findRecentBackupSession(const QString &backupRoot, int maxAgeHours) +{ + const QStringList sessions = listBackupSessions(backupRoot); + if (sessions.isEmpty()) { + return {}; + } + const QDateTime now = QDateTime::currentDateTime(); + for (const QString &session : sessions) { + const QFileInfo info(session); + // Newest first; the first recent entry is the reuse candidate. Entries + // are timestamped, so an old newest entry means no recent backup exists. + if (info.exists() && info.lastModified().secsTo(now) < static_cast(maxAgeHours) * 3600) { + return session; + } + // Sessions are newest-first, so stop after the newest: older ones are older. + break; + } + return {}; +} + +void pruneOldBackupSessions(const QString &backupRoot, int keepCount) +{ + const QStringList sessions = listBackupSessions(backupRoot); + if (sessions.size() <= keepCount) { + return; + } + for (int i = keepCount; i < sessions.size(); ++i) { + QDir(sessions.at(i)).removeRecursively(); + } +} + +QStringList listSessionBackupLocations(const QString &sessionDir) +{ + const QDir session(sessionDir); + if (!session.exists()) { + return {}; + } + const QStringList entries = session.entryList(QDir::Files | QDir::Dirs | QDir::NoDotAndDotDot | QDir::Hidden); + QStringList locations; + locations.reserve(entries.size()); + for (const QString &entry : entries) { + locations.append(session.absoluteFilePath(entry)); + } + return locations; +} + } // namespace UserDataBackup diff --git a/libcockatrice_utility/libcockatrice/utility/user_data_backup.h b/libcockatrice_utility/libcockatrice/utility/user_data_backup.h index db391da90..2b98f8af0 100644 --- a/libcockatrice_utility/libcockatrice/utility/user_data_backup.h +++ b/libcockatrice_utility/libcockatrice/utility/user_data_backup.h @@ -69,6 +69,30 @@ bool backupAtRiskPaths(const QStringList &sourcePaths, */ QString defaultBackupRoot(); +/** + * @brief Lists existing backup session directories under @p backupRoot, newest first. + */ +QStringList listBackupSessions(const QString &backupRoot); + +/** + * @brief Returns the newest session directory younger than @p maxAgeHours, or empty. + * + * Used to throttle startup safety backups so dismissing the warning without + * "Don't show again" does not create a full duplicate on every launch. + */ +QString findRecentBackupSession(const QString &backupRoot, int maxAgeHours = 24); + +/** + * @brief Deletes oldest sessions keeping only the newest @p keepCount ones. + */ +void pruneOldBackupSessions(const QString &backupRoot, int keepCount = 5); + +/** + * @brief Lists the backup targets inside an existing session directory. + * @returns absolute paths of the entries, for reuse messages without a new copy. + */ +QStringList listSessionBackupLocations(const QString &sessionDir); + } // namespace UserDataBackup #endif // USER_DATA_BACKUP_H diff --git a/tests/user_data_backup_test.cpp b/tests/user_data_backup_test.cpp index 829ceb90c..bfccad92b 100644 --- a/tests/user_data_backup_test.cpp +++ b/tests/user_data_backup_test.cpp @@ -212,6 +212,62 @@ TEST(UserDataBackup, DefaultBackupRootIsUsable) EXPECT_TRUE(QDir(root).isAbsolute()); } +TEST(UserDataBackup, DefaultBackupRootHasNoDotDotSegments) +{ + QString root = UserDataBackup::defaultBackupRoot(); + EXPECT_FALSE(root.contains(QStringLiteral(".."))); + EXPECT_EQ(root, QDir::cleanPath(root)); +} + +TEST(UserDataBackup, ListSessionsFindsRecentAndPrunesOld) +{ + 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(); + + QStringList locations; + QString firstSession; + QString secondSession; + ASSERT_TRUE(UserDataBackup::backupAtRiskPaths({sourceDir.path()}, backupRoot.path(), &locations, &firstSession)); + ASSERT_TRUE(UserDataBackup::backupAtRiskPaths({sourceDir.path()}, backupRoot.path(), &locations, &secondSession)); + + const QStringList sessions = UserDataBackup::listBackupSessions(backupRoot.path()); + ASSERT_EQ(sessions.size(), 2); + // Newest first + EXPECT_EQ(sessions.first(), secondSession); + + const QString recent = UserDataBackup::findRecentBackupSession(backupRoot.path(), 24); + EXPECT_EQ(recent, secondSession); + + const QStringList reusedLocations = UserDataBackup::listSessionBackupLocations(recent); + EXPECT_FALSE(reusedLocations.isEmpty()); + for (const QString &location : reusedLocations) { + EXPECT_TRUE(location.startsWith(QDir(recent).absolutePath())); + EXPECT_TRUE(QFile::exists(location)); + } + + UserDataBackup::pruneOldBackupSessions(backupRoot.path(), 1); + const QStringList pruned = UserDataBackup::listBackupSessions(backupRoot.path()); + ASSERT_EQ(pruned.size(), 1); + EXPECT_EQ(pruned.first(), secondSession); +} + +TEST(UserDataBackup, FindRecentReturnsEmptyWhenNone) +{ + QTemporaryDir backupRoot; + ASSERT_TRUE(backupRoot.isValid()); + EXPECT_TRUE(UserDataBackup::listBackupSessions(backupRoot.path()).isEmpty()); + EXPECT_TRUE(UserDataBackup::findRecentBackupSession(backupRoot.path(), 24).isEmpty()); + EXPECT_TRUE(UserDataBackup::listSessionBackupLocations(backupRoot.path() + "/missing").isEmpty()); + // Pruning an empty root must not fail + UserDataBackup::pruneOldBackupSessions(backupRoot.path(), 5); +} + } // namespace int main(int argc, char **argv)