mirror of
https://github.com/Cockatrice/Cockatrice.git
synced 2026-10-26 09:30:30 -05:00
[Installer][Client] Back up user data before old uninstallers can delete it (#7503)
* [Installer][Client] Back up user data before old uninstallers can delete it * [Installer][Client] Fix stale backup resurrection, batch session backups, extend coverage NSIS: clear stale %TEMP% backup and only arm restore when something was copied, directory-existence checks, back up loose .dec decks, drop downloaded pics but keep pics\CUSTOM. Helper: single session dir per run (ms timestamps), same-basename disambiguation, partial copies still report their location. * [Installer][Client] Fix review findings: throttle backups, NSIS old dir, portable messaging * [Installer][Client] Fix review findings: backup markers, NSIS relocate, size cap * [Installer][Client] Fix review findings: sounds, session coverage, NSIS wording Client: back up data/sounds in portable mode and mention it in the warning; reuse a recent session only when complete and covering the current sources (source manifest), estimate size only for fresh copies, normalize dedup via isInsideDir. Helper: source manifest per session with sessionCoversSources(), clear out-params on all failure paths. NSIS: document defensive portable branch, merge backup over surviving data/ to repair partial deletes, copy (not move) wording in relocate, note .txt exclusion in deck notice. * [Installer][Client] Fix review findings: stale backup, empty reuse, relocate, portable warning NSIS: wipe TEMP backup once per run to avoid resurrecting deleted files, check data dir existence in relocate so dir-only trees are moved. Client: never reuse a recent session for an empty source set, only fall back to a covering session on copy failure, warn fresh portable installs instead of staying silent. * [Installer][Client] Fix review findings: usable session reuse, loose decks, NSIS pics, symlinks * [Installer][Client] Fix portable backup exclusions in offender merge * [Client] Split at-risk data warning into focused helpers Ricky's nit on the 200+ line warnAboutDataPathsInProgramDir: extract collection (portable/installed), backup, message building and dialog into named helpers. No behavior change, all tr() strings untouched. * [Client] Read path settings inside isPortableBackupExcluded * [Client] Make stateless at-risk helpers static * [Client] Extract at-risk data warning out of MainWindow * [Installer][Client] Fix review findings: subdir backup, portable warning, backup API NSIS: - Back up unknown top-level install subdirs (e.g. <installdir>\decks), not just loose deck files, so the 3.1.1 -> new upgrade no longer loses data folders the old RMDir /r deletes before the client ever runs. Payload dirs (plugins/translations/sounds/themes) stay in place to avoid restoring stale DLLs; restore + relocate handle the new backup set and the one-time notice covers both decks and folders. - Restart the %TEMP% backup when the 32/64-bit passes point at different dirs instead of merging (which restored one dir's files into the other); document that only the last pass relocates and the %TEMP% copy is not a durable second copy. Client: - Drop the portable at-risk warning: portable installs never shipped an uninstaller and portable updates preserve data/, so every portable user got a spurious dialog plus daily host-machine copies. - AtRiskDataWarning is now a plain class with Q_DECLARE_TR_FUNCTIONS instead of QObject+Q_OBJECT; logging category renamed to at_risk_data_warning; MainWindow wrapper inlined into startupConfigCheck. - Lower the synchronous startup backup cap 500 -> 200 MB. Utility/tests: - Remove test-only backupAtRiskPath()/findRecentBackupSession() and the no-marker/no-manifest legacy paths; isBackupSessionComplete() now requires the complete marker; tests updated to the plural API. * [Client] Restore collectInstalledAtRiskPaths per reviewa23e02b5removed the definition along with the portable helpers, breaking the build (undefined reference at link time, and isInsideAnyDir unused under -Werror on Arch/Debian 13). Restores the202c0d65version (pathsInsideDir plus loose top-level deck files) as suggested. --------- Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
This commit is contained 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=<appDir>, 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=<appDir>), 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
|
||||
|
||||
@@ -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
|
||||
|
||||
215
cockatrice/src/interface/at_risk_data_warning.cpp
Normal file
215
cockatrice/src/interface/at_risk_data_warning.cpp
Normal file
@@ -0,0 +1,215 @@
|
||||
#include "at_risk_data_warning.h"
|
||||
|
||||
#include "../client/settings/cache_settings.h"
|
||||
|
||||
#include <QCheckBox>
|
||||
#include <QCoreApplication>
|
||||
#include <QDir>
|
||||
#include <QFileInfo>
|
||||
#include <QLoggingCategory>
|
||||
#include <QMessageBox>
|
||||
#include <QWidget>
|
||||
#include <libcockatrice/settings/paths_settings.h>
|
||||
#include <libcockatrice/utility/user_data_backup.h>
|
||||
|
||||
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);
|
||||
}
|
||||
50
cockatrice/src/interface/at_risk_data_warning.h
Normal file
50
cockatrice/src/interface/at_risk_data_warning.h
Normal file
@@ -0,0 +1,50 @@
|
||||
#ifndef AT_RISK_DATA_WARNING_H
|
||||
#define AT_RISK_DATA_WARNING_H
|
||||
|
||||
#include <QCoreApplication>
|
||||
#include <QString>
|
||||
#include <QStringList>
|
||||
#include <QtGlobal>
|
||||
|
||||
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
|
||||
@@ -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 <QAction>
|
||||
#include <QApplication>
|
||||
#include <QButtonGroup>
|
||||
#include <QCheckBox>
|
||||
#include <QCloseEvent>
|
||||
#include <QCoreApplication>
|
||||
#include <QDateTime>
|
||||
#include <QDebug>
|
||||
#include <QDesktopServices>
|
||||
#include <QDialog>
|
||||
#include <QDir>
|
||||
#include <QEvent>
|
||||
#include <QFile>
|
||||
#include <QFileDialog>
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
405
libcockatrice_utility/libcockatrice/utility/user_data_backup.cpp
Normal file
405
libcockatrice_utility/libcockatrice/utility/user_data_backup.cpp
Normal file
@@ -0,0 +1,405 @@
|
||||
#include "user_data_backup.h"
|
||||
|
||||
#include <QDateTime>
|
||||
#include <QDir>
|
||||
#include <QDirIterator>
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
#include <QStandardPaths>
|
||||
|
||||
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<qint64>(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<qint64>(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
|
||||
132
libcockatrice_utility/libcockatrice/utility/user_data_backup.h
Normal file
132
libcockatrice_utility/libcockatrice/utility/user_data_backup.h
Normal file
@@ -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 <QString>
|
||||
#include <QStringList>
|
||||
|
||||
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
|
||||
@@ -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)
|
||||
|
||||
581
tests/user_data_backup_test.cpp
Normal file
581
tests/user_data_backup_test.cpp
Normal file
@@ -0,0 +1,581 @@
|
||||
#include "gtest/gtest.h"
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QTemporaryDir>
|
||||
#include <libcockatrice/utility/user_data_backup.h>
|
||||
|
||||
namespace
|
||||
{
|
||||
|
||||
TEST(UserDataBackup, CopyDirectoryKeepsNestedStructure)
|
||||
{
|
||||
QTemporaryDir sourceDir;
|
||||
QTemporaryDir destParent;
|
||||
ASSERT_TRUE(sourceDir.isValid());
|
||||
ASSERT_TRUE(destParent.isValid());
|
||||
QDir source(sourceDir.path());
|
||||
ASSERT_TRUE(source.mkpath("decks/sub"));
|
||||
QFile deck(source.absoluteFilePath("decks/sub/mydeck.cod"));
|
||||
ASSERT_TRUE(deck.open(QIODevice::WriteOnly | QIODevice::Text));
|
||||
deck.write("<cockatrice_deck version=\"1\"/>\n");
|
||||
deck.close();
|
||||
|
||||
QString dest = QDir(destParent.path()).absoluteFilePath("copy");
|
||||
EXPECT_TRUE(UserDataBackup::copyDirectoryRecursively(sourceDir.path(), dest));
|
||||
EXPECT_TRUE(QFile::exists(dest + "/decks/sub/mydeck.cod"));
|
||||
}
|
||||
|
||||
TEST(UserDataBackup, 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("<cockatrice_deck version=\"1\"/>\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("<cockatrice_card_database/>\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("<cockatrice_deck version=\"1\"/>\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("<cockatrice_card_database/>\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("<cockatrice_deck version=\"1\"/>\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("<cockatrice_deck version=\"1\"/>\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("<cockatrice_deck version=\"1\"/>\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("<cockatrice_deck version=\"1\"/>\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("<cockatrice_deck version=\"1\"/>\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("<cockatrice_deck version=\"1\"/>\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("<cockatrice_deck version=\"1\"/>\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("<cockatrice_deck version=\"1\"/>\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();
|
||||
}
|
||||
Reference in New Issue
Block a user