[Installer][Client] Fix review findings: backup markers, NSIS relocate, size cap

This commit is contained in:
Lukas Brübach 2026-10-13 09:03:48 +02:00
parent f4e3d9acba
commit d10ec8a113
5 changed files with 426 additions and 42 deletions

View file

@ -13,6 +13,24 @@ 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);
@ -114,6 +132,12 @@ bool backupAtRiskPaths(const QStringList &sourcePaths,
}
}
if (existing.isEmpty()) {
if (backupLocationsOut) {
backupLocationsOut->clear();
}
if (sessionDirOut) {
sessionDirOut->clear();
}
return false;
}
@ -146,10 +170,52 @@ bool backupAtRiskPaths(const QStringList &sourcePaths,
}
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 && !locations.isEmpty();
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()
@ -184,31 +250,60 @@ QStringList listBackupSessions(const QString &backupRoot)
QString findRecentBackupSession(const QString &backupRoot, int maxAgeHours)
{
const QStringList sessions = listBackupSessions(backupRoot);
if (sessions.isEmpty()) {
return {};
}
const QDateTime now = QDateTime::currentDateTime();
for (const QString &session : sessions) {
const QFileInfo info(session);
// Newest first; the first recent entry is the reuse candidate. Entries
// are timestamped, so an old newest entry means no recent backup exists.
if (info.exists() && info.lastModified().secsTo(now) < static_cast<qint64>(maxAgeHours) * 3600) {
return session;
if (!info.exists()) {
continue;
}
// Sessions are newest-first, so stop after the newest: older ones are older.
break;
// 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<qint64>(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);
if (sessions.size() <= keepCount) {
return;
}
for (int i = keepCount; i < sessions.size(); ++i) {
QDir(sessions.at(i)).removeRecursively();
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();
}
}
@ -222,6 +317,9 @@ QStringList listSessionBackupLocations(const QString &sessionDir)
QStringList locations;
locations.reserve(entries.size());
for (const QString &entry : entries) {
if (isMarkerFile(entry)) {
continue;
}
locations.append(session.absoluteFilePath(entry));
}
return locations;

View file

@ -22,6 +22,9 @@
namespace UserDataBackup
{
/** @brief Above this total, the client skips the automatic startup backup and asks for a manual copy. */
constexpr qint64 MAX_AUTO_BACKUP_BYTES = 500LL * 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).
@ -56,14 +59,27 @@ bool backupAtRiskPath(const QString &sourcePath, const QString &backupRoot, QStr
* @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.
* @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.
*/
@ -75,21 +91,34 @@ QString defaultBackupRoot();
QStringList listBackupSessions(const QString &backupRoot);
/**
* @brief Returns the newest session directory younger than @p maxAgeHours, or empty.
* @brief Returns the newest non-empty session directory younger than @p maxAgeHours, or empty.
*
* Used to throttle startup safety backups so dismissing the warning without
* "Don't show again" does not create a full duplicate on every launch.
* Empty sessions (failed attempts) are skipped so they never block a retry,
* and an expired newest entry stops the search since sessions are newest-first.
*/
QString findRecentBackupSession(const QString &backupRoot, int maxAgeHours = 24);
/**
* @brief Reports whether @p sessionDir holds a complete backup.
* @returns true when the session carries the complete marker, or when it predates
* markers but holds backup content (legacy sessions); false for partial backups
* and for 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 files are internal bookkeeping and are never listed.
*/
QStringList listSessionBackupLocations(const QString &sessionDir);