[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.
This commit is contained in:
Lukas Brübach 2026-10-13 08:36:08 +02:00
parent d33542db59
commit bb5f17b876
5 changed files with 312 additions and 42 deletions

View file

@ -10,6 +10,31 @@
namespace UserDataBackup
{
namespace
{
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);
@ -32,6 +57,9 @@ bool copyDirectoryRecursively(const QString &sourceDir, const QString &destDir)
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;
@ -44,37 +72,84 @@ bool copyDirectoryRecursively(const QString &sourceDir, const QString &destDir)
return ok;
}
bool backupAtRiskPath(const QString &sourcePath, const QString &backupRoot, QString *backupLocationOut)
bool createBackupSessionDir(const QString &backupRoot, QString *sessionDirOut)
{
QFileInfo source(sourcePath);
if (!source.exists()) {
if (!QDir().mkpath(backupRoot)) {
return false;
}
QString timestamp = QDateTime::currentDateTime().toString("yyyyMMdd-hhmmss");
QString backupDir = QDir(backupRoot).absoluteFilePath("CockatriceDataBackup-" + timestamp);
if (!QDir().mkpath(backupDir)) {
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 backupAtRiskPath(const QString &sourcePath, const QString &backupRoot, QString *backupLocationOut)
{
QStringList locations;
const bool ok = backupAtRiskPaths({sourcePath}, backupRoot, &locations, nullptr);
if (!locations.isEmpty() && backupLocationOut) {
*backupLocationOut = locations.first();
}
return ok;
}
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()) {
return false;
}
bool ok;
QString backupLocation;
if (source.isDir()) {
backupLocation = QDir(backupDir).absoluteFilePath(source.fileName());
ok = copyDirectoryRecursively(sourcePath, backupLocation);
} else {
backupLocation = QDir(backupDir).absoluteFilePath(source.fileName());
if (QFile::exists(backupLocation)) {
QFile::remove(backupLocation);
}
ok = QFile::copy(sourcePath, backupLocation);
}
if (!ok) {
QString sessionDir;
if (!createBackupSessionDir(backupRoot, &sessionDir)) {
return false;
}
if (backupLocationOut) {
*backupLocationOut = backupLocation;
if (sessionDirOut) {
*sessionDirOut = sessionDir;
}
return true;
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 (backupLocationsOut) {
*backupLocationsOut = locations;
}
return allOk && !locations.isEmpty();
}
QString defaultBackupRoot()

View file

@ -17,6 +17,7 @@
#define USER_DATA_BACKUP_H
#include <QString>
#include <QStringList>
namespace UserDataBackup
{
@ -24,18 +25,45 @@ namespace UserDataBackup
/**
* @brief Recursively copies @p sourceDir into @p destDir, creating it as needed.
* @returns true when every file was copied (or there was nothing to copy).
* Symlinks are copied best-effort via QFile::copy; 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 an at-risk path (file or directory) to a timestamped folder under @p backupRoot.
* @param sourcePath file or directory holding user data (must exist).
* @param backupRoot directory OUTSIDE the install tree that will hold timestamped backups.
* @param backupLocationOut receives the created backup file or directory on success.
* @returns true on success, false when the source is missing or nothing could be copied.
* @param backupLocationOut receives the created backup file or directory whenever a target
* was created, even when the copy was only partial (i.e. also on failure with a partial copy).
* @returns true when the whole source was copied, false when the source is missing or
* any entry failed. A false return with a non-empty @p backupLocationOut means a partial
* backup exists at that location.
*/
bool backupAtRiskPath(const QString &sourcePath, const QString &backupRoot, QString *backupLocationOut = nullptr);
/**
* @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.
* @returns true when every existing source was copied completely.
*/
bool backupAtRiskPaths(const QStringList &sourcePaths,
const QString &backupRoot,
QStringList *backupLocationsOut,
QString *sessionDirOut = nullptr);
/**
* @brief Default backup root guaranteed to live outside the install directory.
*/