mirror of
https://github.com/Cockatrice/Cockatrice.git
synced 2026-11-05 09:33:25 -08: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:
parent
711386574d
commit
6c660d6b21
11 changed files with 1734 additions and 43 deletions
|
|
@ -15,8 +15,17 @@ Var ReinstallMode
|
||||||
Var AllowForceClose
|
Var AllowForceClose
|
||||||
Var GuardDir
|
Var GuardDir
|
||||||
Var OldUninstallString
|
Var OldUninstallString
|
||||||
|
Var OldInstallDir
|
||||||
|
Var DataBackupDir
|
||||||
|
Var DataBackupDone
|
||||||
|
Var DataBackupForDir
|
||||||
|
Var LooseDecksBackupDone
|
||||||
|
Var InstallSubdirsBackupDone
|
||||||
|
Var LooseDeckNoticeShown
|
||||||
Var StaleFindHandle
|
Var StaleFindHandle
|
||||||
Var StaleFindName
|
Var StaleFindName
|
||||||
|
Var DataBackupFindHandle
|
||||||
|
Var DataBackupFindName
|
||||||
Var IsUpgrade
|
Var IsUpgrade
|
||||||
Var AbortReason
|
Var AbortReason
|
||||||
Var WaitPid
|
Var WaitPid
|
||||||
|
|
@ -125,6 +134,17 @@ FunctionEnd
|
||||||
Function .onInit
|
Function .onInit
|
||||||
|
|
||||||
StrCpy $InstallLogPath "$TEMP\Cockatrice-installer.log"
|
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
|
; Start each run from a clean file so the log that gets sent back is the last
|
||||||
; run, not every past run glued together.
|
; run, not every past run glued together.
|
||||||
FileOpen $LogHandle "$InstallLogPath" w
|
FileOpen $LogHandle "$InstallLogPath" w
|
||||||
|
|
@ -314,10 +334,15 @@ SetShellVarContext all
|
||||||
SetRegView 32
|
SetRegView 32
|
||||||
ReadRegStr $R0 HKLM "SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\Cockatrice" "QuietUninstallString"
|
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
|
StrCmp $R0 "" done32
|
||||||
DetailPrint "Removing previous version (32-bit)..."
|
DetailPrint "Removing previous version (32-bit)..."
|
||||||
ExecWait '$R0 _?=$INSTDIR'
|
Call BackupUserDataBeforeUninstall
|
||||||
|
ExecWait '$R0 _?=$OldInstallDir'
|
||||||
|
Call RestoreUserDataAfterUninstall
|
||||||
|
|
||||||
done32:
|
done32:
|
||||||
|
|
||||||
|
|
@ -326,16 +351,312 @@ ${If} ${RunningX64}
|
||||||
SetRegView 64
|
SetRegView 64
|
||||||
ReadRegStr $R0 HKLM "SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\Cockatrice" "QuietUninstallString"
|
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
|
StrCmp $R0 "" done64
|
||||||
DetailPrint "Removing previous version (64-bit)..."
|
DetailPrint "Removing previous version (64-bit)..."
|
||||||
ExecWait '$R0 _?=$INSTDIR'
|
Call BackupUserDataBeforeUninstall
|
||||||
|
ExecWait '$R0 _?=$OldInstallDir'
|
||||||
|
Call RestoreUserDataAfterUninstall
|
||||||
|
|
||||||
done64:
|
done64:
|
||||||
${EndIf}
|
${EndIf}
|
||||||
|
|
||||||
FunctionEnd
|
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 ---
|
; --- Running instance handling ---
|
||||||
; Cockatrice, Oracle and Servatrice must not be running while files are
|
; Cockatrice, Oracle and Servatrice must not be running while files are
|
||||||
; replaced or deleted. A still-running process holds locks on its .exe and
|
; 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
|
; instance of the application is still running out of the *old* install
|
||||||
; directory before letting it delete anything. The uninstall string is
|
; directory before letting it delete anything. The uninstall string is
|
||||||
; stashed away first, the check works on $R0.
|
; 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
|
StrCpy $OldUninstallString $R0
|
||||||
!insertmacro CockLog "componentsPagePre: old 32-bit uninstall '$OldUninstallString _?=$INSTDIR'"
|
|
||||||
Call EnsureOldInstallNotRunning
|
Call EnsureOldInstallNotRunning
|
||||||
|
StrCpy $OldInstallDir $GuardDir
|
||||||
|
!insertmacro CockLog "componentsPagePre: old 32-bit uninstall '$OldUninstallString _?=$OldInstallDir' (oldDir=$OldInstallDir InstDir=$INSTDIR)"
|
||||||
ClearErrors
|
ClearErrors
|
||||||
ExecWait "$OldUninstallString _?=$INSTDIR"
|
Call BackupUserDataBeforeUninstall
|
||||||
|
ExecWait "$OldUninstallString _?=$OldInstallDir"
|
||||||
|
Call RestoreUserDataAfterUninstall
|
||||||
|
|
||||||
done32:
|
done32:
|
||||||
|
|
||||||
|
|
@ -1061,10 +1388,13 @@ ${If} $PortableMode = 0
|
||||||
|
|
||||||
uninst64:
|
uninst64:
|
||||||
StrCpy $OldUninstallString $R0
|
StrCpy $OldUninstallString $R0
|
||||||
!insertmacro CockLog "componentsPagePre: old 64-bit uninstall '$OldUninstallString _?=$INSTDIR'"
|
|
||||||
Call EnsureOldInstallNotRunning
|
Call EnsureOldInstallNotRunning
|
||||||
|
StrCpy $OldInstallDir $GuardDir
|
||||||
|
!insertmacro CockLog "componentsPagePre: old 64-bit uninstall '$OldUninstallString _?=$OldInstallDir' (oldDir=$OldInstallDir InstDir=$INSTDIR)"
|
||||||
ClearErrors
|
ClearErrors
|
||||||
ExecWait "$OldUninstallString _?=$INSTDIR"
|
Call BackupUserDataBeforeUninstall
|
||||||
|
ExecWait "$OldUninstallString _?=$OldInstallDir"
|
||||||
|
Call RestoreUserDataAfterUninstall
|
||||||
|
|
||||||
done64:
|
done64:
|
||||||
${EndIf}
|
${EndIf}
|
||||||
|
|
@ -1188,6 +1518,11 @@ ${EndIf}
|
||||||
!insertmacro CockLog "section: writing payload to $INSTDIR"
|
!insertmacro CockLog "section: writing payload to $INSTDIR"
|
||||||
@CPACK_NSIS_FULL_INSTALL@
|
@CPACK_NSIS_FULL_INSTALL@
|
||||||
!insertmacro CockLog "section: payload written"
|
!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"
|
!insertmacro CockLogListing "final" "Qt6*.dll"
|
||||||
|
|
||||||
${If} $PortableMode = 0
|
${If} $PortableMode = 0
|
||||||
|
|
|
||||||
|
|
@ -141,6 +141,8 @@ set(cockatrice_SOURCES
|
||||||
src/game_graphics/zones/view_zone.cpp
|
src/game_graphics/zones/view_zone.cpp
|
||||||
src/game_graphics/zones/view_zone_widget.cpp
|
src/game_graphics/zones/view_zone_widget.cpp
|
||||||
src/game_graphics/board/abstract_graphics_item.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.cpp
|
||||||
src/interface/card_database_update/card_database_update_status_bar.h
|
src/interface/card_database_update/card_database_update_status_bar.h
|
||||||
src/interface/card_database_update/card_update_progress.cpp
|
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 "../interface/widgets/tabs/tab_supervisor.h"
|
||||||
#include "../main.h"
|
#include "../main.h"
|
||||||
#include "abstract_client.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_database_update_status_bar.h"
|
||||||
#include "card_database_update/card_update_progress.h"
|
#include "card_database_update/card_update_progress.h"
|
||||||
#include "intents/contexts/context_connect_to_server.h"
|
#include "intents/contexts/context_connect_to_server.h"
|
||||||
|
|
@ -58,13 +59,12 @@
|
||||||
#include <QAction>
|
#include <QAction>
|
||||||
#include <QApplication>
|
#include <QApplication>
|
||||||
#include <QButtonGroup>
|
#include <QButtonGroup>
|
||||||
#include <QCheckBox>
|
|
||||||
#include <QCloseEvent>
|
#include <QCloseEvent>
|
||||||
#include <QCoreApplication>
|
|
||||||
#include <QDateTime>
|
#include <QDateTime>
|
||||||
#include <QDebug>
|
#include <QDebug>
|
||||||
#include <QDesktopServices>
|
#include <QDesktopServices>
|
||||||
#include <QDialog>
|
#include <QDialog>
|
||||||
|
#include <QDir>
|
||||||
#include <QEvent>
|
#include <QEvent>
|
||||||
#include <QFile>
|
#include <QFile>
|
||||||
#include <QFileDialog>
|
#include <QFileDialog>
|
||||||
|
|
@ -606,38 +606,9 @@ MainWindow::MainWindow(QWidget *parent)
|
||||||
QTimer::singleShot(0, this, &MainWindow::applyStartupDestination);
|
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()
|
void MainWindow::startupConfigCheck()
|
||||||
{
|
{
|
||||||
warnAboutDataPathsInProgramDir();
|
AtRiskDataWarning::warnAboutDataPathsInProgramDir(this);
|
||||||
|
|
||||||
const bool isCleanInstall = SettingsCache::instance().network().getClientVersion() == CLIENT_INFO_NOT_SET;
|
const bool isCleanInstall = SettingsCache::instance().network().getClientVersion() == CLIENT_INFO_NOT_SET;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -149,9 +149,6 @@ private:
|
||||||
|
|
||||||
void runFirstRunWizard(bool firstRun = false);
|
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()
|
inline QString getCardUpdaterBinaryName()
|
||||||
{
|
{
|
||||||
return "oracle";
|
return "oracle";
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ set(UTILITY_SOURCES
|
||||||
libcockatrice/utility/report_categories.cpp
|
libcockatrice/utility/report_categories.cpp
|
||||||
libcockatrice/utility/server_rate_limiter.cpp
|
libcockatrice/utility/server_rate_limiter.cpp
|
||||||
libcockatrice/utility/translation_loader.cpp
|
libcockatrice/utility/translation_loader.cpp
|
||||||
|
libcockatrice/utility/user_data_backup.cpp
|
||||||
libcockatrice/utility/warning_categories.cpp
|
libcockatrice/utility/warning_categories.cpp
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -35,6 +36,7 @@ set(UTILITY_HEADERS
|
||||||
libcockatrice/utility/days_years_between.h
|
libcockatrice/utility/days_years_between.h
|
||||||
libcockatrice/utility/server_rate_limiter.h
|
libcockatrice/utility/server_rate_limiter.h
|
||||||
libcockatrice/utility/translation_loader.h
|
libcockatrice/utility/translation_loader.h
|
||||||
|
libcockatrice/utility/user_data_backup.h
|
||||||
libcockatrice/utility/warning_categories.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 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 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 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 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 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)
|
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();
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue