#include "user_data_backup.h" #include #include #include #include #include #include namespace UserDataBackup { namespace { const QString COMPLETE_MARKER = QStringLiteral("backup.complete"); const QString INCOMPLETE_MARKER = QStringLiteral("backup.incomplete"); bool isMarkerFile(const QString &fileName) { return fileName == COMPLETE_MARKER || fileName == INCOMPLETE_MARKER; } void writeCompletionMarker(const QString &sessionDir, bool complete) { // Best effort: a missing marker only means isBackupSessionComplete() falls // back to the legacy content check below. 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().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 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()) { if (backupLocationsOut) { backupLocationsOut->clear(); } if (sessionDirOut) { sessionDirOut->clear(); } return false; } QString sessionDir; if (!createBackupSessionDir(backupRoot, &sessionDir)) { 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 findRecentBackupSession() nor evicts a // good session in pruneOldBackupSessions(). QDir(sessionDir).removeRecursively(); if (backupLocationsOut) { backupLocationsOut->clear(); } if (sessionDirOut) { sessionDirOut->clear(); } return false; } writeCompletionMarker(sessionDir, allOk); 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 findRecentBackupSession(const QString &backupRoot, 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; } // Sessions are newest-first, so once an entry is too old every later // one is older as well and the search can stop. if (info.lastModified().secsTo(now) >= static_cast(maxAgeHours) * 3600) { break; } // Skip empty sessions from failed attempts: they must never block a retry. if (listSessionBackupLocations(session).isEmpty()) { continue; } return session; } return {}; } bool isBackupSessionComplete(const QString &sessionDir) { const QDir session(sessionDir); if (!session.exists()) { return false; } if (QFileInfo::exists(session.absoluteFilePath(COMPLETE_MARKER))) { return true; } if (QFileInfo::exists(session.absoluteFilePath(INCOMPLETE_MARKER))) { return false; } // Legacy sessions from before markers existed: content means the copy // reported success at the time, so treat them as complete. return !listSessionBackupLocations(session.absolutePath()).isEmpty(); } 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; } } // namespace UserDataBackup