diff --git a/cmake/NSIS.template.in b/cmake/NSIS.template.in index b6125238f..811a3cb7f 100644 --- a/cmake/NSIS.template.in +++ b/cmake/NSIS.template.in @@ -15,8 +15,17 @@ Var ReinstallMode Var AllowForceClose Var GuardDir Var OldUninstallString +Var OldInstallDir +Var DataBackupDir +Var DataBackupDone +Var DataBackupForDir +Var LooseDecksBackupDone +Var InstallSubdirsBackupDone +Var LooseDeckNoticeShown Var StaleFindHandle Var StaleFindName +Var DataBackupFindHandle +Var DataBackupFindName Var IsUpgrade Var AbortReason Var WaitPid @@ -125,6 +134,17 @@ FunctionEnd Function .onInit 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 "" +StrCpy $DataBackupDone "" +StrCpy $DataBackupForDir "" +StrCpy $LooseDecksBackupDone "" +StrCpy $InstallSubdirsBackupDone "" +StrCpy $LooseDeckNoticeShown "" +; 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 @@ -314,10 +334,15 @@ 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)..." -ExecWait '$R0 _?=$INSTDIR' +Call BackupUserDataBeforeUninstall +ExecWait '$R0 _?=$OldInstallDir' +Call RestoreUserDataAfterUninstall done32: @@ -326,16 +351,312 @@ ${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)..." - ExecWait '$R0 _?=$INSTDIR' + Call BackupUserDataBeforeUninstall + ExecWait '$R0 _?=$OldInstallDir' + 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. +; +; The backup is left in %TEMP% afterwards on a best-effort basis only: the next +; installer run that takes a new backup wipes it first (see below), so it is not +; a durable second copy. Anything this backup does not cover - e.g. loose files +; with other extensions - relies on the client's startup safety backup +; (warnAboutDataPathsInProgramDir), which only exists after the first launch of +; the new client. +; +; The 32-bit and 64-bit uninstall blocks below share $TEMP\CockatriceDataBackup. +; When both are registered with different InstallLocations, backing up the second +; directory into the first one's backup would restore the first dir's files into +; the second one. BackupUserDataBeforeUninstall therefore starts over when +; $OldInstallDir changes: the previous pass already restored its own backup, so +; only its leftover %TEMP% copy is lost. RelocateRestoredDataToInstDir only sees +; the last pass, so with two different old dirs plus a new target dir the first +; dir's restored data stays in place instead of moving along - no loss, but the +; user has to copy it manually. +; +; The portable-data branch below is defensive: both callers (AutoUninstallIfNeeded +; on /R, componentsPagePre interactively) only run for non-portable installs, and +; portable installs never write the registry uninstall keys that trigger them. +; It fires only when $OldInstallDir nevertheless contains portable.dat - e.g. a +; stale registry InstallLocation pointing at a portable dir, or portable.dat added +; to a normal install dir. The loose-decks and install-subdirs branches are the +; live ones for normal installs. +Function BackupUserDataBeforeUninstall +; $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} +; %TEMP% survives across installer runs, so a previous run's safety copy would +; otherwise merge into this run: CopyFiles merges/overwrites, resurrecting files +; the user deleted since (e.g. deck A from run 1 reappearing alongside deck B +; from run 2) or restoring stale content after a failed copy. Wipe once per +; installer run - $DataBackupDir is empty until the first backup of this run, +; so the second call in sequence (32-bit then 64-bit uninstall) still merges. +${If} $DataBackupDir == "" + RMDir /r "$TEMP\CockatriceDataBackup" +${EndIf} +; The 32-bit and 64-bit uninstall blocks share one backup dir. When the second +; block points at a different directory, merging would restore the first dir's +; files into the second one, so start over instead. The previous pass already +; restored its own backup; only its leftover %TEMP% copy is discarded. +; StrCmp is case-sensitive while Windows paths are not, so a casing-only +; difference triggers a harmless redundant re-backup of the same content. +${If} $DataBackupDir != "" +${AndIf} $DataBackupForDir != "" +${AndIf} $OldInstallDir != $DataBackupForDir + !insertmacro CockLog "pre-uninstall backup: old dir changed ($DataBackupForDir -> $OldInstallDir), starting a fresh backup" + RMDir /r "$TEMP\CockatriceDataBackup" + StrCpy $DataBackupDir "" + StrCpy $DataBackupDone "" + StrCpy $LooseDecksBackupDone "" + StrCpy $InstallSubdirsBackupDone "" +${EndIf} +${If} ${FileExists} "$OldInstallDir\portable.dat" +${AndIf} ${FileExists} "$OldInstallDir\data" + StrCpy $DataBackupDir "$TEMP\CockatriceDataBackup" + StrCpy $DataBackupDone "1" + !insertmacro CockLog "pre-uninstall backup: copying $OldInstallDir\data (downloaded pics excluded)" + DetailPrint "Backing up portable user data before removing previous version..." + CreateDirectory "$DataBackupDir\data" + ; Copy every top-level entry except pics (downloaded pictures are large and + ; re-downloadable): copying everything first and deleting pics afterwards + ; would duplicate gigabytes into %TEMP% just to throw them away. + FindFirst $DataBackupFindHandle $DataBackupFindName "$OldInstallDir\data\*.*" + ${If} $DataBackupFindHandle != "" + backup_data_loop: + ${If} $DataBackupFindName != "." + ${AndIf} $DataBackupFindName != ".." + ${AndIf} $DataBackupFindName != "pics" + ${If} ${FileExists} "$OldInstallDir\data\$DataBackupFindName\*.*" + CreateDirectory "$DataBackupDir\data\$DataBackupFindName" + CopyFiles /SILENT "$OldInstallDir\data\$DataBackupFindName\*.*" "$DataBackupDir\data\$DataBackupFindName\" + ${Else} + CopyFiles /SILENT "$OldInstallDir\data\$DataBackupFindName" "$DataBackupDir\data\" + ${EndIf} + ${EndIf} + ClearErrors + FindNext $DataBackupFindHandle $DataBackupFindName + IfErrors 0 backup_data_loop + FindClose $DataBackupFindHandle + ${EndIf} + ; Keep user-made custom pics (data\pics\CUSTOM). + ${If} ${FileExists} "$OldInstallDir\data\pics\CUSTOM" + CreateDirectory "$DataBackupDir\data\pics\CUSTOM" + CopyFiles /SILENT "$OldInstallDir\data\pics\CUSTOM\*.*" "$DataBackupDir\data\pics\CUSTOM\" + ${EndIf} + DetailPrint "Portable user data backup complete." +${EndIf} +; Deck extensions mirror DeckLoader::ACCEPTED_FILE_EXTENSIONS, minus *.txt: a +; blanket *.txt copy would also grab payload-adjacent text files, while the +; deck-specific extensions are unambiguous user content. +${If} ${FileExists} "$OldInstallDir\*.cod" +${OrIf} ${FileExists} "$OldInstallDir\*.dec" +${OrIf} ${FileExists} "$OldInstallDir\*.dek" +${OrIf} ${FileExists} "$OldInstallDir\*.mwDeck" + ${If} $DataBackupDir == "" + StrCpy $DataBackupDir "$TEMP\CockatriceDataBackup" + ${EndIf} + StrCpy $LooseDecksBackupDone "1" + !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 "$OldInstallDir\*.cod" "$DataBackupDir\loose_decks\" + CopyFiles /SILENT "$OldInstallDir\*.dec" "$DataBackupDir\loose_decks\" + CopyFiles /SILENT "$OldInstallDir\*.dek" "$DataBackupDir\loose_decks\" + CopyFiles /SILENT "$OldInstallDir\*.mwDeck" "$DataBackupDir\loose_decks\" +${EndIf} +; Data folders below the top level (e.g. $OldInstallDir\decks) are deleted by the +; old "RMDir /r $INSTDIR" just like loose decks, and on a 3.1.1 -> new upgrade the +; client's startup backup has never run yet, so stash every top-level directory +; that is not part of the payload. Payload dirs stay in place: they are rewritten +; by the section below, and restoring them could put stale DLLs next to the new +; executables. "data" is skipped once the portable branch above already took it; +; without portable.dat it is user content like any other unknown dir. +FindFirst $DataBackupFindHandle $DataBackupFindName "$OldInstallDir\*.*" +${If} $DataBackupFindHandle != "" +backup_subdirs_loop: + ${If} $DataBackupFindName != "." + ${AndIf} $DataBackupFindName != ".." + ${AndIf} $DataBackupFindName != "plugins" + ${AndIf} $DataBackupFindName != "Plugins" + ${AndIf} $DataBackupFindName != "translations" + ${AndIf} $DataBackupFindName != "sounds" + ${AndIf} $DataBackupFindName != "themes" + ${If} $DataBackupFindName == "data" + ${AndIf} $DataBackupDone == "1" + Goto backup_subdirs_next + ${EndIf} + ${If} ${FileExists} "$OldInstallDir\$DataBackupFindName\*.*" + ${If} $DataBackupDir == "" + StrCpy $DataBackupDir "$TEMP\CockatriceDataBackup" + ${EndIf} + StrCpy $InstallSubdirsBackupDone "1" + !insertmacro CockLog "pre-uninstall backup: copying subdir $OldInstallDir\$DataBackupFindName" + DetailPrint "Backing up $DataBackupFindName stored in the install directory..." + CreateDirectory "$DataBackupDir\install_subdirs\$DataBackupFindName" + CopyFiles /SILENT "$OldInstallDir\$DataBackupFindName\*.*" "$DataBackupDir\install_subdirs\$DataBackupFindName\" + ${EndIf} + ${EndIf} +backup_subdirs_next: + ClearErrors + FindNext $DataBackupFindHandle $DataBackupFindName + IfErrors 0 backup_subdirs_loop + FindClose $DataBackupFindHandle +${EndIf} +; Remember which directory the current backup belongs to, so a later pass for a +; different directory starts over instead of merging (see above). +${If} $DataBackupDir != "" + StrCpy $DataBackupForDir $OldInstallDir +${EndIf} +FunctionEnd + +Function RestoreUserDataAfterUninstall +StrCmp $DataBackupDir "" done +${If} $OldInstallDir == "" + StrCpy $OldInstallDir $INSTDIR +${EndIf} +${If} $DataBackupDone == "1" +${AndIf} ${FileExists} "$DataBackupDir\data" + ${If} ${FileExists} "$OldInstallDir\data" + ; Existence-only: a partially-deleted data dir (locked files during the old + ; uninstall) looks the same as a survived one, so the backup is always kept. + ; Merge the backup back over it to repair partial deletes; CopyFiles only + ; adds/overwrites, so a fully-survived dir is unaffected. + !insertmacro CockLog "post-uninstall restore: $OldInstallDir\data still exists, merging backup and keeping it" + CopyFiles /SILENT "$DataBackupDir\data\*.*" "$OldInstallDir\data\" + DetailPrint "Previous user data is still in place; pre-uninstall backup merged back and kept at $DataBackupDir\data." + ${Else} + !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} $LooseDecksBackupDone == "1" +${AndIf} ${FileExists} "$DataBackupDir\loose_decks" + !insertmacro CockLog "post-uninstall restore: restoring loose deck files" + CreateDirectory "$OldInstallDir" + CopyFiles /SILENT "$DataBackupDir\loose_decks\*.cod" "$OldInstallDir\" + CopyFiles /SILENT "$DataBackupDir\loose_decks\*.dec" "$OldInstallDir\" + CopyFiles /SILENT "$DataBackupDir\loose_decks\*.dek" "$OldInstallDir\" + CopyFiles /SILENT "$DataBackupDir\loose_decks\*.mwDeck" "$OldInstallDir\" + DetailPrint "Restored deck files stored in the install directory." +${EndIf} +${If} $InstallSubdirsBackupDone == "1" +${AndIf} ${FileExists} "$DataBackupDir\install_subdirs" + !insertmacro CockLog "post-uninstall restore: restoring install subdirectories" + CreateDirectory "$OldInstallDir" + FindFirst $DataBackupFindHandle $DataBackupFindName "$DataBackupDir\install_subdirs\*.*" + ${If} $DataBackupFindHandle != "" + restore_subdirs_loop: + ${If} $DataBackupFindName != "." + ${AndIf} $DataBackupFindName != ".." + ${AndIf} ${FileExists} "$DataBackupDir\install_subdirs\$DataBackupFindName\*.*" + !insertmacro CockLog "post-uninstall restore: restoring subdir $OldInstallDir\$DataBackupFindName" + CreateDirectory "$OldInstallDir\$DataBackupFindName" + CopyFiles /SILENT "$DataBackupDir\install_subdirs\$DataBackupFindName\*.*" "$OldInstallDir\$DataBackupFindName\" + ${EndIf} + ClearErrors + FindNext $DataBackupFindHandle $DataBackupFindName + IfErrors 0 restore_subdirs_loop + FindClose $DataBackupFindHandle + ${EndIf} + DetailPrint "Restored folders stored in the install directory." +${EndIf} +; Runs once per uninstall block, and there are two (32-bit, 64-bit): show the +; notice a single time per installer run, covering both loose decks and +; restored subdirectories. +${IfNot} ${Silent} +${AndIf} $LooseDeckNoticeShown != "1" + ${If} $LooseDecksBackupDone == "1" + ${OrIf} $InstallSubdirsBackupDone == "1" + StrCpy $LooseDeckNoticeShown "1" + MessageBox MB_OK|MB_ICONINFORMATION "Your decks or data folders were stored inside the Cockatrice install folder ($OldInstallDir), which the previous version's uninstaller deletes.$\n$\nThey have been restored from a backup to that folder (loose deck files ending in .txt are not backed up - move those manually). If you install to a different folder, they are moved there automatically. Please move them out of the install folder afterwards (Settings -> General -> Paths) so a future uninstall cannot delete them again." + ${EndIf} +${EndIf} +done: +FunctionEnd + +; RelocateRestoredDataToInstDir +; +; componentsPagePre backs up and restores around the old uninstaller before the +; directory page, so $OldInstallDir (registry InstallLocation) is authoritative +; there while $INSTDIR is still the default. When the user then picks a different +; target directory, the restored user data would otherwise stay orphaned in the +; old directory while the new install starts empty. Copy it across when they differ +; (a copy, not a move: the source is user data and is deliberately left in place). +; Only the last uninstall pass's $OldInstallDir is relocated (see the shared-backup +; comment above); with two different old dirs the first one's data stays restored +; in place instead of moving along. +; StrCmp is case-sensitive while Windows paths are not; a casing-only difference +; triggers a harmless redundant copy onto itself. +Function RelocateRestoredDataToInstDir +StrCmp $DataBackupDir "" done +StrCmp $OldInstallDir "" done +StrCmp $OldInstallDir $INSTDIR done +${If} $DataBackupDone == "1" +${AndIf} ${FileExists} "$OldInstallDir\data" + !insertmacro CockLog "relocate: copying restored data $OldInstallDir\data -> $INSTDIR\data" + CreateDirectory "$INSTDIR\data" + CopyFiles /SILENT "$OldInstallDir\data\*.*" "$INSTDIR\data\" + DetailPrint "Copied restored user data to the new install folder." +${EndIf} +${If} $LooseDecksBackupDone == "1" + ${If} ${FileExists} "$OldInstallDir\*.cod" + ${OrIf} ${FileExists} "$OldInstallDir\*.dec" + ${OrIf} ${FileExists} "$OldInstallDir\*.dek" + ${OrIf} ${FileExists} "$OldInstallDir\*.mwDeck" + !insertmacro CockLog "relocate: copying restored decks $OldInstallDir -> $INSTDIR" + CreateDirectory "$INSTDIR" + CopyFiles /SILENT "$OldInstallDir\*.cod" "$INSTDIR\" + CopyFiles /SILENT "$OldInstallDir\*.dec" "$INSTDIR\" + CopyFiles /SILENT "$OldInstallDir\*.dek" "$INSTDIR\" + CopyFiles /SILENT "$OldInstallDir\*.mwDeck" "$INSTDIR\" + DetailPrint "Copied restored deck files to the new install folder." + ${EndIf} +${EndIf} +${If} $InstallSubdirsBackupDone == "1" +${AndIf} ${FileExists} "$DataBackupDir\install_subdirs" + FindFirst $DataBackupFindHandle $DataBackupFindName "$DataBackupDir\install_subdirs\*.*" + ${If} $DataBackupFindHandle != "" + relocate_subdirs_loop: + ${If} $DataBackupFindName != "." + ${AndIf} $DataBackupFindName != ".." + ${AndIf} ${FileExists} "$OldInstallDir\$DataBackupFindName\*.*" + !insertmacro CockLog "relocate: copying restored subdir $OldInstallDir\$DataBackupFindName -> $INSTDIR\$DataBackupFindName" + CreateDirectory "$INSTDIR\$DataBackupFindName" + CopyFiles /SILENT "$OldInstallDir\$DataBackupFindName\*.*" "$INSTDIR\$DataBackupFindName\" + DetailPrint "Copied restored folder $DataBackupFindName to the new install folder." + ${EndIf} + ClearErrors + FindNext $DataBackupFindHandle $DataBackupFindName + IfErrors 0 relocate_subdirs_loop + FindClose $DataBackupFindHandle + ${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 @@ -1037,11 +1358,17 @@ ${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 - ExecWait "$OldUninstallString _?=$INSTDIR" + Call BackupUserDataBeforeUninstall + ExecWait "$OldUninstallString _?=$OldInstallDir" + Call RestoreUserDataAfterUninstall done32: @@ -1061,10 +1388,13 @@ ${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 - ExecWait "$OldUninstallString _?=$INSTDIR" + Call BackupUserDataBeforeUninstall + ExecWait "$OldUninstallString _?=$OldInstallDir" + Call RestoreUserDataAfterUninstall done64: ${EndIf} @@ -1188,6 +1518,11 @@ ${EndIf} !insertmacro CockLog "section: writing payload to $INSTDIR" @CPACK_NSIS_FULL_INSTALL@ !insertmacro CockLog "section: payload written" +; The pre-uninstall backup/restore above runs before the directory page, so a +; user picking a different target folder would otherwise leave the restored data +; orphaned in the old directory. Move it across when they differ (no-op on the +; /R path where they are equal). +Call RelocateRestoredDataToInstDir !insertmacro CockLogListing "final" "Qt6*.dll" ${If} $PortableMode = 0 diff --git a/cockatrice/CMakeLists.txt b/cockatrice/CMakeLists.txt index 63ce7d1af..e59326214 100644 --- a/cockatrice/CMakeLists.txt +++ b/cockatrice/CMakeLists.txt @@ -141,6 +141,8 @@ set(cockatrice_SOURCES src/game_graphics/zones/view_zone.cpp src/game_graphics/zones/view_zone_widget.cpp src/game_graphics/board/abstract_graphics_item.cpp + src/interface/at_risk_data_warning.cpp + src/interface/at_risk_data_warning.h src/interface/card_database_update/card_database_update_status_bar.cpp src/interface/card_database_update/card_database_update_status_bar.h src/interface/card_database_update/card_update_progress.cpp diff --git a/cockatrice/src/interface/at_risk_data_warning.cpp b/cockatrice/src/interface/at_risk_data_warning.cpp new file mode 100644 index 000000000..5145d2ebb --- /dev/null +++ b/cockatrice/src/interface/at_risk_data_warning.cpp @@ -0,0 +1,215 @@ +#include "at_risk_data_warning.h" + +#include "../client/settings/cache_settings.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +Q_LOGGING_CATEGORY(AtRiskDataWarningLog, "at_risk_data_warning"); + +static bool isDeckFileSuffix(const QString &suffix) +{ + return suffix == QStringLiteral("cod") || suffix == QStringLiteral("dec") || suffix == QStringLiteral("dek") || + suffix == QStringLiteral("mwdeck"); +} + +static bool isInsideAnyDir(const QString &path, const QStringList &bases) +{ + for (const QString &base : bases) { + if (PathsSettings::isInsideDir(path, base)) { + return true; + } + } + return false; +} + +static bool hasLooseDeckFile(const QStringList &offenders) +{ + for (const QString &offender : offenders) { + if (isDeckFileSuffix(QFileInfo(offender).suffix().toLower())) { + return true; + } + } + return false; +} + +static QString resolveBackupRoot(const QString &appDir) +{ + QString backupRoot = UserDataBackup::defaultBackupRoot(); + if (PathsSettings::isInsideDir(backupRoot, appDir)) { + backupRoot = QDir::temp().absoluteFilePath(QStringLiteral("CockatriceBackups")); + } + return backupRoot; +} + +QStringList AtRiskDataWarning::collectInstalledAtRiskPaths(const QString &appDir) +{ + QStringList offenders = SettingsCache::instance().paths().pathsInsideDir(appDir); + + // Loose decks sitting directly in the program folder are wiped by the old uninstaller + // just like configured paths. Mirror the installer backup set (DeckLoader extensions + // minus *.txt) so a manual run of the old uninstall.exe is covered too. + const QDir appDirHandle(appDir); + const QStringList topLevelFiles = appDirHandle.entryList(QDir::Files | QDir::Hidden | QDir::NoDotAndDotDot); + for (const QString &fileName : topLevelFiles) { + if (!isDeckFileSuffix(QFileInfo(fileName).suffix().toLower())) { + continue; + } + const QString looseDeck = QDir::cleanPath(appDirHandle.absoluteFilePath(fileName)); + if (!isInsideAnyDir(looseDeck, offenders)) { + offenders.append(looseDeck); + } + } + return offenders; +} + +AtRiskDataWarning::AtRiskBackupOutcome AtRiskDataWarning::backUpAtRiskPaths(const QStringList &sources, + const QString &appDir) +{ + AtRiskBackupOutcome outcome; + const QString backupRoot = resolveBackupRoot(appDir); + + // Throttle: dismissing the dialog without "Don't show again" must not create + // a full duplicate on every launch. A recent session is reused only when it is + // complete and its source manifest covers the current source set; otherwise a + // fresh copy is taken so the dialog never claims an uncovered path is backed up. + // The size estimate runs only when a fresh copy is actually going to happen, + // keeping a synchronous tree walk off the reuse fast path at startup. + // Search all recent sessions for a complete, covering one: a partial failure must not + // pin the newest slot and force a full re-copy on every later launch. + const QString reusableSession = UserDataBackup::findRecentUsableBackupSession(backupRoot, sources, 24); + if (!reusableSession.isEmpty()) { + outcome.sessionDir = reusableSession; + outcome.locations = UserDataBackup::listSessionBackupLocations(reusableSession); + outcome.reused = true; + qCInfo(AtRiskDataWarningLog) << "Reusing recent at-risk user data backup at" << outcome.sessionDir; + } else if (!sources.isEmpty()) { + // The copy runs synchronously on the UI thread at startup, so cap it: a + // pictures folder pointed into the program dir can be gigabytes, and freezing + // the client to duplicate it is worse than asking for a manual copy. + outcome.bytes = UserDataBackup::estimateBackupSize(sources); + if (outcome.bytes >= UserDataBackup::MAX_AUTO_BACKUP_BYTES) { + outcome.skippedDueToSize = true; + qCWarning(AtRiskDataWarningLog) + << "At-risk user data backup skipped: estimated" << outcome.bytes << "bytes exceeds automatic cap"; + } 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. + outcome.complete = + UserDataBackup::backupAtRiskPaths(sources, backupRoot, &outcome.locations, &outcome.sessionDir); + UserDataBackup::pruneOldBackupSessions(backupRoot, 5); + if (outcome.locations.isEmpty()) { + // Fresh copy failed entirely: fall back to the newest covering session so the + // dialog still points at something, flagged incomplete below. + const QString fallbackSession = + UserDataBackup::findRecentCoveringBackupSession(backupRoot, sources, 24); + if (!fallbackSession.isEmpty()) { + outcome.sessionDir = fallbackSession; + outcome.locations = UserDataBackup::listSessionBackupLocations(fallbackSession); + outcome.complete = false; + } + } + } + } + for (const QString &backupLocation : outcome.locations) { + qCInfo(AtRiskDataWarningLog) << "Backed up at-risk user data to" << backupLocation; + } + if (!outcome.complete) { + qCWarning(AtRiskDataWarningLog) << "At-risk user data backup incomplete, session dir:" << outcome.sessionDir; + } + return outcome; +} + +QString AtRiskDataWarning::buildInstalledAtRiskMessage(const QStringList &offenders, + const QStringList &sources, + const AtRiskBackupOutcome &outcome) +{ + const QString backupVerb = outcome.reused ? tr("is already available") : tr("has been created"); + const QString sizeInfo = tr("%1 MB (automatic backups are capped at %2 MB)") + .arg(QString::number(outcome.bytes / 1048576.0, 'f', 1), + QString::number(UserDataBackup::MAX_AUTO_BACKUP_BYTES / 1048576)); + QString 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 (!outcome.locations.isEmpty()) { + message += tr("\n\nA safety backup %1 at:\n%2").arg(backupVerb, outcome.locations.join(QLatin1Char('\n'))); + if (!outcome.complete) { + message += tr("\n\nThe backup may be incomplete; please back up these folders manually as well."); + } + if (hasLooseDeckFile(offenders)) { + message += tr("\n\nDeck files ending in .txt are not backed up - move those manually."); + } + } else if (outcome.skippedDueToSize) { + message += tr("\n\nThe automatic safety backup was skipped because of its size: %1. " + "Please back up these folders manually.") + .arg(sizeInfo); + } else if (sources.isEmpty()) { + message += tr("\n\nNone of these paths exist on disk yet, so there was nothing to back up. " + "Files created there later are still at risk until the paths are moved."); + } else { + message += tr("\n\nThe automatic safety backup failed; please back up these folders manually."); + } + return message; +} + +void AtRiskDataWarning::showAtRiskDataWarning(QWidget *parent, const QString &title, const QString &message) +{ + QMessageBox box(QMessageBox::Warning, title, message, QMessageBox::Ok, parent); + box.setTextFormat(Qt::PlainText); + auto *dontShowAgain = new QCheckBox(tr("Don't show this again")); + box.setCheckBox(dontShowAgain); + box.exec(); + if (dontShowAgain->isChecked()) { + SettingsCache::instance().paths().setAppDirWarningAcknowledged(true); + } +} + +void AtRiskDataWarning::warnAboutDataPathsInProgramDir(QWidget *parent) +{ + auto &paths = SettingsCache::instance().paths(); + if (paths.getAppDirWarningAcknowledged()) { + return; + } + + // Portable installs keep their data inside the program directory by design, but they + // never shipped an uninstaller, and portable updates use the installer's payload + // purge, which preserves data/ - so there is nothing destructive to warn about. + if (SettingsCache::instance().getIsPortableBuild()) { + return; + } + + const QString appDir = QCoreApplication::applicationDirPath(); + + // 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. + const QStringList offenders = collectInstalledAtRiskPaths(appDir); + if (offenders.isEmpty()) { + return; + } + // Only existing paths can be copied; a configured-but-never-created path has + // nothing to lose yet and must not turn the warning into a bogus "failed". + QStringList sourcesToBackUp; + for (const QString &offender : offenders) { + if (QFileInfo::exists(offender)) { + sourcesToBackUp.append(offender); + } + } + + const AtRiskBackupOutcome outcome = backUpAtRiskPaths(sourcesToBackUp, appDir); + + const QString message = buildInstalledAtRiskMessage(offenders, sourcesToBackUp, outcome); + showAtRiskDataWarning(parent, tr("Data paths inside the program directory"), message); +} diff --git a/cockatrice/src/interface/at_risk_data_warning.h b/cockatrice/src/interface/at_risk_data_warning.h new file mode 100644 index 000000000..22cad86fa --- /dev/null +++ b/cockatrice/src/interface/at_risk_data_warning.h @@ -0,0 +1,50 @@ +#ifndef AT_RISK_DATA_WARNING_H +#define AT_RISK_DATA_WARNING_H + +#include +#include +#include +#include + +class QWidget; + +/** + * @brief Warns when user data lives inside the program directory and backs it up first. + * + * The uninstallers shipped in Cockatrice 3.1.0/3.1.1 delete the whole install + * tree, so data paths configured inside the program directory get a safety + * backup outside the install directory before the warning is shown. + * Portable installs keep their data inside the program directory by design but + * never shipped an uninstaller, so they are skipped. + */ +class AtRiskDataWarning +{ + Q_DECLARE_TR_FUNCTIONS(AtRiskDataWarning) + +public: + struct AtRiskBackupOutcome + { + QStringList locations; ///< backup targets created for this check + QString sessionDir; ///< session directory holding the backup + bool complete = true; ///< false when only a partial/fallback backup exists + bool reused = false; ///< true when a recent session was reused instead of copying + bool skippedDueToSize = false; ///< true when the automatic copy was skipped for size + qint64 bytes = 0; ///< estimated bytes, only set when a fresh copy was attempted + }; + + /** + * @brief Backs up at-risk data paths and warns about them. + * @param parent Parent widget for the warning dialog + */ + static void warnAboutDataPathsInProgramDir(QWidget *parent); + +private: + static QStringList collectInstalledAtRiskPaths(const QString &appDir); + static AtRiskBackupOutcome backUpAtRiskPaths(const QStringList &sources, const QString &appDir); + static QString buildInstalledAtRiskMessage(const QStringList &offenders, + const QStringList &sources, + const AtRiskBackupOutcome &outcome); + static void showAtRiskDataWarning(QWidget *parent, const QString &title, const QString &message); +}; + +#endif // AT_RISK_DATA_WARNING_H diff --git a/cockatrice/src/interface/window_main.cpp b/cockatrice/src/interface/window_main.cpp index 57d6e8b15..76092e765 100644 --- a/cockatrice/src/interface/window_main.cpp +++ b/cockatrice/src/interface/window_main.cpp @@ -37,6 +37,7 @@ #include "../interface/widgets/tabs/tab_supervisor.h" #include "../main.h" #include "abstract_client.h" +#include "at_risk_data_warning.h" #include "card_database_update/card_database_update_status_bar.h" #include "card_database_update/card_update_progress.h" #include "intents/contexts/context_connect_to_server.h" @@ -58,13 +59,12 @@ #include #include #include -#include #include -#include #include #include #include #include +#include #include #include #include @@ -606,38 +606,9 @@ MainWindow::MainWindow(QWidget *parent) QTimer::singleShot(0, this, &MainWindow::applyStartupDestination); } -void MainWindow::warnAboutDataPathsInProgramDir() -{ - auto &paths = SettingsCache::instance().paths(); - if (SettingsCache::instance().getIsPortableBuild() || paths.getAppDirWarningAcknowledged()) { - return; - } - - const QStringList offenders = paths.pathsInsideDir(QCoreApplication::applicationDirPath()); - if (offenders.isEmpty()) { - return; - } - - 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); - box.setTextFormat(Qt::PlainText); - auto *dontShowAgain = new QCheckBox(tr("Don't show this again")); - box.setCheckBox(dontShowAgain); - box.exec(); - if (dontShowAgain->isChecked()) { - paths.setAppDirWarningAcknowledged(true); - } -} - void MainWindow::startupConfigCheck() { - warnAboutDataPathsInProgramDir(); + AtRiskDataWarning::warnAboutDataPathsInProgramDir(this); const bool isCleanInstall = SettingsCache::instance().network().getClientVersion() == CLIENT_INFO_NOT_SET; diff --git a/cockatrice/src/interface/window_main.h b/cockatrice/src/interface/window_main.h index 2b6ed5633..4b3da716d 100644 --- a/cockatrice/src/interface/window_main.h +++ b/cockatrice/src/interface/window_main.h @@ -149,9 +149,6 @@ private: void runFirstRunWizard(bool firstRun = false); - /** @brief Warns once when a non-portable install keeps its data paths inside the program directory. */ - void warnAboutDataPathsInProgramDir(); - inline QString getCardUpdaterBinaryName() { return "oracle"; 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..aa7095545 --- /dev/null +++ b/libcockatrice_utility/libcockatrice/utility/user_data_backup.cpp @@ -0,0 +1,405 @@ +#include "user_data_backup.h" + +#include +#include +#include +#include +#include +#include + +namespace UserDataBackup +{ + +namespace +{ + +const QString COMPLETE_MARKER = QStringLiteral("backup.complete"); +const QString INCOMPLETE_MARKER = QStringLiteral("backup.incomplete"); +const QString SOURCES_MANIFEST = QStringLiteral("backup.sources"); + +bool isMarkerFile(const QString &fileName) +{ + return fileName == COMPLETE_MARKER || fileName == INCOMPLETE_MARKER || fileName == SOURCES_MANIFEST; +} + +bool pathsEqual(const QString &a, const QString &b) +{ +#if defined(Q_OS_WIN) || defined(Q_OS_MACOS) + return QDir::cleanPath(a).compare(QDir::cleanPath(b), Qt::CaseInsensitive) == 0; +#else + return QDir::cleanPath(a) == QDir::cleanPath(b); +#endif +} + +void writeCompletionMarker(const QString &sessionDir, bool complete) +{ + // Best effort: a missing marker only means isBackupSessionComplete() + // reports the session as incomplete. + QFile marker(QDir(sessionDir).absoluteFilePath(complete ? COMPLETE_MARKER : INCOMPLETE_MARKER)); + if (marker.open(QIODevice::WriteOnly | QIODevice::Text)) { + marker.write(complete ? "complete\n" : "incomplete\n"); + } +} + +QString uniqueTargetPath(const QString &sessionDir, const QString &fileName) +{ + QDir dir(sessionDir); + QString candidate = dir.absoluteFilePath(fileName); + if (!QFileInfo::exists(candidate)) { + return candidate; + } + const QFileInfo info(fileName); + const QString base = info.completeBaseName(); + const QString suffix = info.suffix(); + for (int i = 2;; ++i) { + QString numbered = suffix.isEmpty() ? QStringLiteral("%1 (%2)").arg(base).arg(i) + : QStringLiteral("%1 (%2).%3").arg(base).arg(i).arg(suffix); + candidate = dir.absoluteFilePath(numbered); + if (!QFileInfo::exists(candidate)) { + return candidate; + } + } +} + +} // namespace + +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().isSymLink()) { + // Copy the link itself best-effort without dereferencing it. QDirIterator does not + // follow symlinks here, so a symlink-to-directory must not take the isDir() branch + // below (which would create an empty dir and silently drop the contents). + QDir().mkpath(QFileInfo(target).absolutePath()); + if (QFile::exists(target) && !QFile::remove(target)) { + ok = false; + continue; + } + if (!QFile::copy(it.filePath(), target)) { + ok = false; + } + } else if (it.fileInfo().isDir()) { + if (!QDir().mkpath(target)) { + ok = false; + } + } else { + // Ensure the parent exists even if the iterator yields a file + // before its directory entry. + QDir().mkpath(QFileInfo(target).absolutePath()); + if (QFile::exists(target) && !QFile::remove(target)) { + ok = false; + continue; + } + if (!QFile::copy(it.filePath(), target)) { + ok = false; + } + } + } + return ok; +} + +bool createBackupSessionDir(const QString &backupRoot, QString *sessionDirOut) +{ + if (!QDir().mkpath(backupRoot)) { + return false; + } + const QString timestamp = QDateTime::currentDateTime().toString(QStringLiteral("yyyyMMdd-hhmmss-zzz")); + const QDir root(backupRoot); + QString sessionDir = root.absoluteFilePath(QStringLiteral("CockatriceDataBackup-") + timestamp); + for (int i = 1; QFileInfo::exists(sessionDir); ++i) { + sessionDir = root.absoluteFilePath(QStringLiteral("CockatriceDataBackup-%1-%2").arg(timestamp).arg(i)); + } + if (!QDir().mkpath(sessionDir)) { + return false; + } + if (sessionDirOut) { + *sessionDirOut = sessionDir; + } + return true; +} + +bool backupAtRiskPaths(const QStringList &sourcePaths, + const QString &backupRoot, + QStringList *backupLocationsOut, + QString *sessionDirOut) +{ + QStringList existing; + for (const QString &sourcePath : sourcePaths) { + if (QFileInfo::exists(sourcePath)) { + existing.append(sourcePath); + } + } + if (existing.isEmpty()) { + if (backupLocationsOut) { + backupLocationsOut->clear(); + } + if (sessionDirOut) { + sessionDirOut->clear(); + } + return false; + } + + QString sessionDir; + if (!createBackupSessionDir(backupRoot, &sessionDir)) { + if (backupLocationsOut) { + backupLocationsOut->clear(); + } + if (sessionDirOut) { + sessionDirOut->clear(); + } + return false; + } + if (sessionDirOut) { + *sessionDirOut = sessionDir; + } + + bool allOk = true; + QStringList locations; + for (const QString &sourcePath : existing) { + const QFileInfo source(sourcePath); + const QString backupLocation = uniqueTargetPath(sessionDir, source.fileName()); + bool ok; + if (source.isDir()) { + ok = copyDirectoryRecursively(sourcePath, backupLocation); + } else { + ok = QFile::copy(sourcePath, backupLocation); + } + // Report the location whenever a target was created, even for partial + // copies, so callers can point the user at what was salvaged. + if (QFileInfo::exists(backupLocation)) { + locations.append(backupLocation); + } else { + // Total failure with no target created (e.g. QFile::copy failed). + ok = false; + } + allOk = allOk && ok; + } + if (locations.isEmpty()) { + // Nothing salvaged: remove the session again so the empty directory + // neither blocks a retry via the recent-session reuse nor evicts a + // good session in pruneOldBackupSessions(). + QDir(sessionDir).removeRecursively(); + if (backupLocationsOut) { + backupLocationsOut->clear(); + } + if (sessionDirOut) { + sessionDirOut->clear(); + } + return false; + } + writeCompletionMarker(sessionDir, allOk); + // Record which sources this session covers so callers can reuse it only + // when it actually covers the current source set (see sessionCoversSources). + QFile manifest(QDir(sessionDir).absoluteFilePath(SOURCES_MANIFEST)); + if (manifest.open(QIODevice::WriteOnly | QIODevice::Text)) { + for (const QString &sourcePath : existing) { + manifest.write(QDir::cleanPath(sourcePath).toUtf8() + "\n"); + } + } + if (backupLocationsOut) { + *backupLocationsOut = locations; + } + return allOk; +} + +qint64 estimateBackupSize(const QStringList &sourcePaths, qint64 capBytes) +{ + qint64 total = 0; + for (const QString &sourcePath : sourcePaths) { + const QFileInfo source(sourcePath); + if (!source.exists()) { + continue; + } + if (source.isDir()) { + QDirIterator it(sourcePath, QDir::Files | QDir::Hidden | QDir::NoDotAndDotDot, + QDirIterator::Subdirectories); + while (it.hasNext()) { + it.next(); + total += it.fileInfo().size(); + if (total >= capBytes) { + return total; + } + } + } else { + total += source.size(); + if (total >= capBytes) { + return total; + } + } + } + return total; +} + +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::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 findRecentCoveringBackupSession(const QString &backupRoot, const QStringList &sourcePaths, int maxAgeHours) +{ + const QStringList sessions = listBackupSessions(backupRoot); + const QDateTime now = QDateTime::currentDateTime(); + for (const QString &session : sessions) { + const QFileInfo info(session); + if (!info.exists()) { + continue; + } + if (info.lastModified().secsTo(now) >= static_cast(maxAgeHours) * 3600) { + continue; + } + if (listSessionBackupLocations(session).isEmpty()) { + continue; + } + if (!sessionCoversSources(session, sourcePaths)) { + continue; + } + return session; + } + return {}; +} + +QString findRecentUsableBackupSession(const QString &backupRoot, const QStringList &sourcePaths, int maxAgeHours) +{ + if (sourcePaths.isEmpty()) { + return {}; + } + const QStringList sessions = listBackupSessions(backupRoot); + const QDateTime now = QDateTime::currentDateTime(); + for (const QString &session : sessions) { + const QFileInfo info(session); + if (!info.exists()) { + continue; + } + if (info.lastModified().secsTo(now) >= static_cast(maxAgeHours) * 3600) { + continue; + } + if (listSessionBackupLocations(session).isEmpty()) { + continue; + } + if (!isBackupSessionComplete(session)) { + continue; + } + if (!sessionCoversSources(session, sourcePaths)) { + continue; + } + return session; + } + return {}; +} + +bool isBackupSessionComplete(const QString &sessionDir) +{ + const QDir session(sessionDir); + if (!session.exists()) { + return false; + } + return QFileInfo::exists(session.absoluteFilePath(COMPLETE_MARKER)); +} + +void pruneOldBackupSessions(const QString &backupRoot, int keepCount) +{ + const QStringList sessions = listBackupSessions(backupRoot); + QStringList kept; + kept.reserve(sessions.size()); + for (const QString &session : sessions) { + // Empty sessions are junk from failed attempts: drop them outright so + // they can never evict a good session from the kept set. + if (listSessionBackupLocations(session).isEmpty()) { + QDir(session).removeRecursively(); + continue; + } + if (kept.size() < keepCount) { + kept.append(session); + continue; + } + QDir(session).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) { + if (isMarkerFile(entry)) { + continue; + } + locations.append(session.absoluteFilePath(entry)); + } + return locations; +} + +bool sessionCoversSources(const QString &sessionDir, const QStringList &sourcePaths) +{ + QFile manifest(QDir(sessionDir).absoluteFilePath(SOURCES_MANIFEST)); + if (!manifest.open(QIODevice::ReadOnly | QIODevice::Text)) { + // No readable manifest (e.g. the write failed): coverage cannot be + // verified, so report not-covered and let the caller take a fresh copy. + return false; + } + QStringList recorded; + while (!manifest.atEnd()) { + const QString line = QString::fromUtf8(manifest.readLine()).trimmed(); + if (!line.isEmpty()) { + recorded.append(line); + } + } + for (const QString &source : sourcePaths) { + bool found = false; + for (const QString &entry : recorded) { + if (pathsEqual(source, entry)) { + found = true; + break; + } + } + if (!found) { + return false; + } + } + return true; +} + +} // 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..4a4dc1d7f --- /dev/null +++ b/libcockatrice_utility/libcockatrice/utility/user_data_backup.h @@ -0,0 +1,132 @@ +/** + * @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 +#include + +namespace UserDataBackup +{ + +/** @brief Above this total, the client skips the automatic startup backup and asks for a manual copy. + * Kept small on purpose: the copy runs synchronously on the UI thread at startup. */ +constexpr qint64 MAX_AUTO_BACKUP_BYTES = 200LL * 1024 * 1024; + +/** + * @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). + * Symlinks (file or directory) are copied best-effort via QFile::copy without + * dereferencing them; a dangling link fails that entry only. + */ +bool copyDirectoryRecursively(const QString &sourceDir, const QString &destDir); + +/** + * @brief Creates a fresh timestamped session directory under @p backupRoot. + * @param backupRoot directory OUTSIDE the install tree that will hold timestamped backups. + * @param sessionDirOut receives the created directory on success. + * @returns true on success. Millisecond timestamps plus a numeric suffix keep rapid + * successive calls (e.g. one per installer phase) from sharing a directory. + */ +bool createBackupSessionDir(const QString &backupRoot, QString *sessionDirOut); + +/** + * @brief Copies several at-risk paths into a single timestamped session directory. + * @param sourcePaths files or directories holding user data; missing entries are skipped. + * @param backupRoot directory OUTSIDE the install tree that will hold timestamped backups. + * @param backupLocationsOut receives one entry per source that produced a backup target + * (including partial copies). + * @param sessionDirOut optionally receives the shared session directory; set to empty + * when nothing was backed up (all sources missing, or every copy failed outright). + * @returns true when every existing source was copied completely. + * A false return with a non-empty @p backupLocationsOut means a partial backup exists. + * When nothing at all could be salvaged the session directory is removed again, so a + * failed attempt never leaves an empty session behind that would block retries. + * Completeness is recorded with a marker file; see isBackupSessionComplete(). + */ +bool backupAtRiskPaths(const QStringList &sourcePaths, + const QString &backupRoot, + QStringList *backupLocationsOut, + QString *sessionDirOut = nullptr); + +/** + * @brief Estimates the total bytes that backing up @p sourcePaths would copy. + * @param capBytes stop counting once the total exceeds this; the returned value is then + * at least @p capBytes (used to skip the scan early on huge trees). + * Missing entries contribute 0. Best effort only. + */ +qint64 estimateBackupSize(const QStringList &sourcePaths, qint64 capBytes = MAX_AUTO_BACKUP_BYTES); + +/** + * @brief Default backup root guaranteed to live outside the install directory. + */ +QString defaultBackupRoot(); + +/** + * @brief Lists existing backup session directories under @p backupRoot, newest first. + */ +QStringList listBackupSessions(const QString &backupRoot); + +/** + * @brief Returns the newest session that is non-empty, complete and covering @p sourcePaths, or empty. + * + * An incomplete newest session (partial failure) or one covering a different + * source set does not block reuse of an older good session. + */ +QString findRecentUsableBackupSession(const QString &backupRoot, const QStringList &sourcePaths, int maxAgeHours = 24); + +/** + * @brief Returns the newest non-empty session covering @p sourcePaths, regardless of completeness. + * + * Used as a fallback pointer when a fresh copy fails entirely, so the dialog can still + * point at salvaged content. Completeness is left to the caller to report. + */ +QString +findRecentCoveringBackupSession(const QString &backupRoot, const QStringList &sourcePaths, int maxAgeHours = 24); + +/** + * @brief Reports whether @p sessionDir holds a complete backup. + * @returns true when the session carries the complete marker, false otherwise + * (partial backups and empty/missing sessions). + */ +bool isBackupSessionComplete(const QString &sessionDir); + +/** + * @brief Deletes oldest sessions keeping only the newest @p keepCount ones. + * Empty sessions are junk from failed attempts and are always removed, so they + * can never evict a good session from the kept set. + */ +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. + * Completeness marker and source manifest files are internal bookkeeping and are never listed. + */ +QStringList listSessionBackupLocations(const QString &sessionDir); + +/** + * @brief Reports whether @p sessionDir was recorded as covering all of @p sourcePaths. + * @returns true when the session's source manifest lists every entry of @p sourcePaths + * (normalized via QDir::cleanPath, case-insensitive on Windows/macOS). Sessions + * without a readable manifest return false so a changed source set always triggers + * a fresh copy instead of reusing a backup that may not cover it. + */ +bool sessionCoversSources(const QString &sessionDir, const QStringList &sourcePaths); + +} // namespace UserDataBackup + +#endif // USER_DATA_BACKUP_H diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 784aac3be..ee0fd0698 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..b74b26fa3 --- /dev/null +++ b/tests/user_data_backup_test.cpp @@ -0,0 +1,581 @@ +#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, CopyDirectoryKeepsHiddenFiles) +{ + QTemporaryDir sourceDir; + QTemporaryDir destParent; + ASSERT_TRUE(sourceDir.isValid()); + ASSERT_TRUE(destParent.isValid()); + QFile hidden(QDir(sourceDir.path()).absoluteFilePath(".hidden.ini")); + ASSERT_TRUE(hidden.open(QIODevice::WriteOnly | QIODevice::Text)); + hidden.write("[general]\n"); + hidden.close(); + + QString dest = QDir(destParent.path()).absoluteFilePath("copy"); + EXPECT_TRUE(UserDataBackup::copyDirectoryRecursively(sourceDir.path(), dest)); + EXPECT_TRUE(QFile::exists(dest + "/.hidden.ini")); +} + +TEST(UserDataBackup, CopyDirectoryOverwritesExistingTarget) +{ + QTemporaryDir sourceDir; + QTemporaryDir destParent; + ASSERT_TRUE(sourceDir.isValid()); + ASSERT_TRUE(destParent.isValid()); + const QString sourceFile = QDir(sourceDir.path()).absoluteFilePath("settings.ini"); + QFile first(sourceFile); + ASSERT_TRUE(first.open(QIODevice::WriteOnly | QIODevice::Text)); + first.write("version=1\n"); + first.close(); + + QString dest = QDir(destParent.path()).absoluteFilePath("copy"); + ASSERT_TRUE(UserDataBackup::copyDirectoryRecursively(sourceDir.path(), dest)); + + QFile second(sourceFile); + ASSERT_TRUE(second.open(QIODevice::WriteOnly | QIODevice::Text)); + second.write("version=2\n"); + second.close(); + + EXPECT_TRUE(UserDataBackup::copyDirectoryRecursively(sourceDir.path(), dest)); + QFile result(dest + "/settings.ini"); + ASSERT_TRUE(result.open(QIODevice::ReadOnly | QIODevice::Text)); + EXPECT_EQ(result.readAll(), QByteArray("version=2\n")); +} + +TEST(UserDataBackup, CopyEmptyDirectorySucceeds) +{ + QTemporaryDir sourceDir; + QTemporaryDir destParent; + ASSERT_TRUE(sourceDir.isValid()); + ASSERT_TRUE(destParent.isValid()); + ASSERT_TRUE(QDir(sourceDir.path()).mkpath("empty")); + + QString dest = QDir(destParent.path()).absoluteFilePath("copy"); + EXPECT_TRUE(UserDataBackup::copyDirectoryRecursively(sourceDir.path(), dest)); + EXPECT_TRUE(QDir(dest + "/empty").exists()); +} +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(); + + QStringList locations; + QString sessionDir; + EXPECT_TRUE(UserDataBackup::backupAtRiskPaths({sourceDir.path()}, backupRoot.path(), &locations, &sessionDir)); + ASSERT_EQ(locations.size(), 1); + EXPECT_TRUE(QFile::exists(locations.first() + "/mydeck.cod")); + EXPECT_TRUE(locations.first().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(); + + QStringList locations; + EXPECT_TRUE(UserDataBackup::backupAtRiskPaths({cardDb}, backupRoot.path(), &locations, nullptr)); + ASSERT_EQ(locations.size(), 1); + EXPECT_TRUE(QFile::exists(locations.first())); + EXPECT_TRUE(locations.first().endsWith("cards.xml")); +} + +TEST(UserDataBackup, BackupMissingSourceFails) +{ + QTemporaryDir backupRoot; + ASSERT_TRUE(backupRoot.isValid()); + QStringList locations; + QString sessionDir; + EXPECT_FALSE(UserDataBackup::backupAtRiskPaths({QDir(backupRoot.path()).absoluteFilePath("does-not-exist.cod")}, + backupRoot.path(), &locations, &sessionDir)); + EXPECT_TRUE(locations.isEmpty()); + EXPECT_TRUE(sessionDir.isEmpty()); +} + +TEST(UserDataBackup, BackupPathsShareSingleSessionDir) +{ + QTemporaryDir sourceDir; + QTemporaryDir backupRoot; + ASSERT_TRUE(sourceDir.isValid()); + ASSERT_TRUE(backupRoot.isValid()); + QDir source(sourceDir.path()); + ASSERT_TRUE(source.mkpath("decks")); + ASSERT_TRUE(source.mkpath("settings")); + QFile deck(source.absoluteFilePath("decks/mydeck.cod")); + ASSERT_TRUE(deck.open(QIODevice::WriteOnly | QIODevice::Text)); + deck.write("\n"); + deck.close(); + QFile settings(source.absoluteFilePath("settings/settings.ini")); + ASSERT_TRUE(settings.open(QIODevice::WriteOnly | QIODevice::Text)); + settings.write("[general]\n"); + settings.close(); + + QStringList locations; + QString sessionDir; + EXPECT_TRUE( + UserDataBackup::backupAtRiskPaths({source.absoluteFilePath("decks"), source.absoluteFilePath("settings")}, + backupRoot.path(), &locations, &sessionDir)); + ASSERT_EQ(locations.size(), 2); + // Both backups live in the same timestamped session, even across a second boundary. + for (const QString &location : locations) { + EXPECT_TRUE(location.startsWith(QDir(sessionDir).absolutePath())); + } + EXPECT_TRUE(QFile::exists(QDir(sessionDir).absoluteFilePath("decks/mydeck.cod"))); + EXPECT_TRUE(QFile::exists(QDir(sessionDir).absoluteFilePath("settings/settings.ini"))); +} + +TEST(UserDataBackup, BackupPathsWithSameNameDoNotCollide) +{ + QTemporaryDir firstDir; + QTemporaryDir secondDir; + QTemporaryDir backupRoot; + ASSERT_TRUE(firstDir.isValid()); + ASSERT_TRUE(secondDir.isValid()); + ASSERT_TRUE(backupRoot.isValid()); + for (const QString &dir : {firstDir.path(), secondDir.path()}) { + QFile deck(QDir(dir).absoluteFilePath("cards.xml")); + ASSERT_TRUE(deck.open(QIODevice::WriteOnly | QIODevice::Text)); + deck.write("\n"); + deck.close(); + } + + QStringList locations; + EXPECT_TRUE(UserDataBackup::backupAtRiskPaths( + {QDir(firstDir.path()).absoluteFilePath("cards.xml"), QDir(secondDir.path()).absoluteFilePath("cards.xml")}, + backupRoot.path(), &locations, nullptr)); + ASSERT_EQ(locations.size(), 2); + EXPECT_NE(locations.at(0), locations.at(1)); + EXPECT_TRUE(QFile::exists(locations.at(0))); + EXPECT_TRUE(QFile::exists(locations.at(1))); +} + +TEST(UserDataBackup, RapidBackupsGetUniqueSessionDirs) +{ + 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 firstSession; + QString secondSession; + QStringList locations; + EXPECT_TRUE(UserDataBackup::backupAtRiskPaths({sourceDir.path()}, backupRoot.path(), &locations, &firstSession)); + EXPECT_TRUE(UserDataBackup::backupAtRiskPaths({sourceDir.path()}, backupRoot.path(), &locations, &secondSession)); + EXPECT_NE(firstSession, secondSession); +} + +TEST(UserDataBackup, DefaultBackupRootIsUsable) +{ + QString root = UserDataBackup::defaultBackupRoot(); + EXPECT_FALSE(root.isEmpty()); + 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::findRecentUsableBackupSession(backupRoot.path(), {sourceDir.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::findRecentUsableBackupSession(backupRoot.path(), {backupRoot.path() + "/missing"}, 24) + .isEmpty()); + EXPECT_TRUE(UserDataBackup::findRecentCoveringBackupSession(backupRoot.path(), {backupRoot.path() + "/missing"}, 24) + .isEmpty()); + EXPECT_TRUE(UserDataBackup::listSessionBackupLocations(backupRoot.path() + "/missing").isEmpty()); + EXPECT_FALSE(UserDataBackup::isBackupSessionComplete(backupRoot.path() + "/missing")); + // Pruning an empty root must not fail + UserDataBackup::pruneOldBackupSessions(backupRoot.path(), 5); +} + +TEST(UserDataBackup, AllMissingSourcesCreateNoSession) +{ + QTemporaryDir backupRoot; + ASSERT_TRUE(backupRoot.isValid()); + QStringList locations; + QString sessionDir; + EXPECT_FALSE(UserDataBackup::backupAtRiskPaths({backupRoot.path() + "/nope1", backupRoot.path() + "/nope2"}, + backupRoot.path(), &locations, &sessionDir)); + EXPECT_TRUE(locations.isEmpty()); + EXPECT_TRUE(sessionDir.isEmpty()); + // No empty session left behind to block a retry or evict good sessions + EXPECT_TRUE(UserDataBackup::listBackupSessions(backupRoot.path()).isEmpty()); +} + +TEST(UserDataBackup, EmptySessionsAreSkippedAndPruned) +{ + QTemporaryDir backupRoot; + ASSERT_TRUE(backupRoot.isValid()); + QString emptySession; + ASSERT_TRUE(UserDataBackup::createBackupSessionDir(backupRoot.path(), &emptySession)); + // An empty session from a failed attempt must never block a retry + EXPECT_TRUE(UserDataBackup::listSessionBackupLocations(emptySession).isEmpty()); + EXPECT_FALSE(UserDataBackup::isBackupSessionComplete(emptySession)); + EXPECT_TRUE(UserDataBackup::findRecentUsableBackupSession(backupRoot.path(), {backupRoot.path() + "/missing"}, 24) + .isEmpty()); + EXPECT_TRUE(UserDataBackup::findRecentCoveringBackupSession(backupRoot.path(), {backupRoot.path() + "/missing"}, 24) + .isEmpty()); + UserDataBackup::pruneOldBackupSessions(backupRoot.path(), 5); + EXPECT_FALSE(QDir(emptySession).exists()); +} + +TEST(UserDataBackup, SuccessfulBackupIsMarkedComplete) +{ + 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 sessionDir; + EXPECT_TRUE(UserDataBackup::backupAtRiskPaths({sourceDir.path()}, backupRoot.path(), &locations, &sessionDir)); + EXPECT_TRUE(UserDataBackup::isBackupSessionComplete(sessionDir)); + // Marker files are bookkeeping and must not leak into the user-facing locations + const QStringList listed = UserDataBackup::listSessionBackupLocations(sessionDir); + EXPECT_FALSE(listed.isEmpty()); + for (const QString &location : listed) { + EXPECT_FALSE(location.endsWith(QStringLiteral("backup.complete"))); + EXPECT_FALSE(location.endsWith(QStringLiteral("backup.incomplete"))); + } +} + +TEST(UserDataBackup, IncompleteMarkerIsNotUsableButRemainsCovering) +{ + 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 sessionDir; + ASSERT_TRUE(UserDataBackup::backupAtRiskPaths({sourceDir.path()}, backupRoot.path(), &locations, &sessionDir)); + ASSERT_TRUE(QFile::remove(QDir(sessionDir).absoluteFilePath("backup.complete"))); + QFile marker(QDir(sessionDir).absoluteFilePath("backup.incomplete")); + ASSERT_TRUE(marker.open(QIODevice::WriteOnly | QIODevice::Text)); + marker.write("incomplete\n"); + marker.close(); + + EXPECT_FALSE(UserDataBackup::isBackupSessionComplete(sessionDir)); + // Incomplete sessions are skipped by the usable search but still found by the + // covering search, so a failed copy falls back to the salvaged content. + EXPECT_TRUE(UserDataBackup::findRecentUsableBackupSession(backupRoot.path(), {sourceDir.path()}, 24).isEmpty()); + EXPECT_EQ(UserDataBackup::findRecentCoveringBackupSession(backupRoot.path(), {sourceDir.path()}, 24), sessionDir); +} + +TEST(UserDataBackup, SessionWithoutMarkerCountsAsIncomplete) +{ + QTemporaryDir backupRoot; + ASSERT_TRUE(backupRoot.isValid()); + QString sessionDir; + ASSERT_TRUE(UserDataBackup::createBackupSessionDir(backupRoot.path(), &sessionDir)); + ASSERT_TRUE(QDir(sessionDir).mkpath("decks")); + QFile deck(QDir(sessionDir).absoluteFilePath("decks/mydeck.cod")); + ASSERT_TRUE(deck.open(QIODevice::WriteOnly | QIODevice::Text)); + deck.write("\n"); + deck.close(); + EXPECT_FALSE(UserDataBackup::isBackupSessionComplete(sessionDir)); + // No source manifest either, so neither search reuses it. + EXPECT_TRUE(UserDataBackup::findRecentUsableBackupSession(backupRoot.path(), {backupRoot.path() + "/missing"}, 24) + .isEmpty()); + EXPECT_TRUE(UserDataBackup::findRecentCoveringBackupSession(backupRoot.path(), {backupRoot.path() + "/missing"}, 24) + .isEmpty()); +} + +TEST(UserDataBackup, FindRecentIgnoresExpiredSessions) +{ + 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 sessionDir; + ASSERT_TRUE(UserDataBackup::backupAtRiskPaths({sourceDir.path()}, backupRoot.path(), &locations, &sessionDir)); + EXPECT_TRUE(UserDataBackup::findRecentUsableBackupSession(backupRoot.path(), {sourceDir.path()}, 0).isEmpty()); + EXPECT_TRUE(UserDataBackup::findRecentCoveringBackupSession(backupRoot.path(), {sourceDir.path()}, 0).isEmpty()); +} + +TEST(UserDataBackup, EstimateBackupSize) +{ + QTemporaryDir sourceDir; + ASSERT_TRUE(sourceDir.isValid()); + const QString missing = QDir(sourceDir.path()).absoluteFilePath("missing"); + EXPECT_EQ(UserDataBackup::estimateBackupSize({missing}), 0); + QFile deck(QDir(sourceDir.path()).absoluteFilePath("mydeck.cod")); + ASSERT_TRUE(deck.open(QIODevice::WriteOnly | QIODevice::Text)); + deck.write("123456"); + deck.close(); + EXPECT_EQ(UserDataBackup::estimateBackupSize({sourceDir.path(), missing}), 6); + // Early-out: once over the cap the exact total no longer matters + EXPECT_GE(UserDataBackup::estimateBackupSize({sourceDir.path()}, 1), 1); +} + +TEST(UserDataBackup, SessionCoversSourcesRequiresManifestMatch) +{ + QTemporaryDir sourceA; + QTemporaryDir sourceB; + QTemporaryDir backupRoot; + ASSERT_TRUE(sourceA.isValid()); + ASSERT_TRUE(sourceB.isValid()); + ASSERT_TRUE(backupRoot.isValid()); + QFile fa(QDir(sourceA.path()).absoluteFilePath("a.cod")); + ASSERT_TRUE(fa.open(QIODevice::WriteOnly | QIODevice::Text)); + fa.write("a"); + fa.close(); + + QStringList locations; + QString sessionDir; + ASSERT_TRUE(UserDataBackup::backupAtRiskPaths({sourceA.path()}, backupRoot.path(), &locations, &sessionDir)); + EXPECT_TRUE(UserDataBackup::sessionCoversSources(sessionDir, {sourceA.path()})); + EXPECT_FALSE(UserDataBackup::sessionCoversSources(sessionDir, {sourceA.path(), sourceB.path()})); + EXPECT_FALSE(UserDataBackup::sessionCoversSources(sessionDir, {sourceB.path()})); +} + +TEST(UserDataBackup, SessionWithoutManifestIsNotCovering) +{ + QTemporaryDir backupRoot; + ASSERT_TRUE(backupRoot.isValid()); + QString sessionDir; + ASSERT_TRUE(UserDataBackup::createBackupSessionDir(backupRoot.path(), &sessionDir)); + ASSERT_TRUE(QDir(sessionDir).mkpath("decks")); + QFile deck(QDir(sessionDir).absoluteFilePath("decks/mydeck.cod")); + ASSERT_TRUE(deck.open(QIODevice::WriteOnly | QIODevice::Text)); + deck.write("\n"); + deck.close(); + EXPECT_FALSE(UserDataBackup::sessionCoversSources(sessionDir, {QDir(sessionDir).absoluteFilePath("decks")})); +} + +TEST(UserDataBackup, ManifestIsHiddenFromSessionLocations) +{ + 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("x"); + deck.close(); + + QStringList locations; + QString sessionDir; + ASSERT_TRUE(UserDataBackup::backupAtRiskPaths({sourceDir.path()}, backupRoot.path(), &locations, &sessionDir)); + for (const QString &location : UserDataBackup::listSessionBackupLocations(sessionDir)) { + EXPECT_FALSE(location.endsWith(QStringLiteral("backup.sources"))); + } +} + +TEST(UserDataBackup, FindRecentUsableSkipsIncompleteNewest) +{ + 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 oldSession; + QString newSession; + ASSERT_TRUE(UserDataBackup::backupAtRiskPaths({sourceDir.path()}, backupRoot.path(), &locations, &oldSession)); + ASSERT_TRUE(UserDataBackup::backupAtRiskPaths({sourceDir.path()}, backupRoot.path(), &locations, &newSession)); + ASSERT_NE(oldSession, newSession); + + // Newest session becomes incomplete: usable search must fall back to the older complete one. + ASSERT_TRUE(QFile::remove(QDir(newSession).absoluteFilePath("backup.complete"))); + QFile marker(QDir(newSession).absoluteFilePath("backup.incomplete")); + ASSERT_TRUE(marker.open(QIODevice::WriteOnly | QIODevice::Text)); + marker.write("incomplete\n"); + marker.close(); + + EXPECT_EQ(UserDataBackup::findRecentUsableBackupSession(backupRoot.path(), {sourceDir.path()}), oldSession); + // Covering search still finds the newest session even though it is incomplete. + EXPECT_EQ(UserDataBackup::findRecentCoveringBackupSession(backupRoot.path(), {sourceDir.path()}), newSession); +} + +TEST(UserDataBackup, FindRecentUsableRequiresCoveringSet) +{ + QTemporaryDir sourceA; + QTemporaryDir sourceB; + QTemporaryDir backupRoot; + ASSERT_TRUE(sourceA.isValid()); + ASSERT_TRUE(sourceB.isValid()); + ASSERT_TRUE(backupRoot.isValid()); + QFile fa(QDir(sourceA.path()).absoluteFilePath("a.cod")); + ASSERT_TRUE(fa.open(QIODevice::WriteOnly | QIODevice::Text)); + fa.write("a"); + fa.close(); + QFile fb(QDir(sourceB.path()).absoluteFilePath("b.cod")); + ASSERT_TRUE(fb.open(QIODevice::WriteOnly | QIODevice::Text)); + fb.write("b"); + fb.close(); + + QStringList locations; + QString oldSession; + QString newSession; + ASSERT_TRUE(UserDataBackup::backupAtRiskPaths({sourceA.path()}, backupRoot.path(), &locations, &oldSession)); + ASSERT_TRUE(UserDataBackup::backupAtRiskPaths({sourceB.path()}, backupRoot.path(), &locations, &newSession)); + + // Newest covers B only, so a query for A must reuse the older session. + EXPECT_EQ(UserDataBackup::findRecentUsableBackupSession(backupRoot.path(), {sourceA.path()}), oldSession); + EXPECT_EQ(UserDataBackup::findRecentUsableBackupSession(backupRoot.path(), {sourceB.path()}), newSession); + EXPECT_TRUE( + UserDataBackup::findRecentUsableBackupSession(backupRoot.path(), {sourceA.path(), sourceB.path()}).isEmpty()); + EXPECT_TRUE(UserDataBackup::findRecentUsableBackupSession(backupRoot.path(), {}).isEmpty()); +} + +TEST(UserDataBackup, CopyDirectoryDoesNotSilentlyEmptyDirSymlink) +{ + QTemporaryDir sourceDir; + QTemporaryDir destParent; + ASSERT_TRUE(sourceDir.isValid()); + ASSERT_TRUE(destParent.isValid()); + QDir source(sourceDir.path()); + ASSERT_TRUE(source.mkpath("real")); + QFile inner(source.absoluteFilePath("real/card.cod")); + ASSERT_TRUE(inner.open(QIODevice::WriteOnly | QIODevice::Text)); + inner.write("x"); + inner.close(); + if (!QFile::link(source.absoluteFilePath("real"), source.absoluteFilePath("link"))) { + GTEST_SKIP() << "Symlinks not permitted in this environment"; + } + + QString dest = QDir(destParent.path()).absoluteFilePath("copy"); + const bool ok = UserDataBackup::copyDirectoryRecursively(sourceDir.path(), dest); + const QFileInfo linkCopy(QDir(dest).absoluteFilePath("link")); + // Old behavior created an empty plain dir and reported success, silently dropping contents. + // New behavior copies the link best-effort; if that fails it must report failure. + if (ok) { + EXPECT_TRUE(linkCopy.exists() || linkCopy.isSymLink()); + if (linkCopy.isDir() && !linkCopy.isSymLink()) { + EXPECT_FALSE(QDir(linkCopy.absoluteFilePath()).isEmpty()); + } + } else { + EXPECT_TRUE(linkCopy.isSymLink() || !linkCopy.exists() || !QDir(linkCopy.absoluteFilePath()).isEmpty()); + } +} + +TEST(UserDataBackup, PluralBackupClearsOutParamsOnTotalFailure) +{ + QTemporaryDir backupRoot; + ASSERT_TRUE(backupRoot.isValid()); + QStringList locations = {QStringLiteral("stale")}; + QString sessionDir = QStringLiteral("stale"); + const QString missing = QDir(backupRoot.path()).absoluteFilePath("does-not-exist.cod"); + EXPECT_FALSE(UserDataBackup::backupAtRiskPaths({missing}, backupRoot.path(), &locations, &sessionDir)); + EXPECT_TRUE(locations.isEmpty()); + EXPECT_TRUE(sessionDir.isEmpty()); +} + +} // namespace + +int main(int argc, char **argv) +{ + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +}