[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 review

a23e02b5 removed 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
the 202c0d65 version (pathsInsideDir plus loose top-level deck
files) as suggested.

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
This commit is contained in:
BruebachL 2026-10-16 22:05:42 +02:00 • committed by GitHub
parent 711386574d
commit 6c660d6b21
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 1734 additions and 43 deletions

View 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);
}

View 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

View file

@ -37,6 +37,7 @@
#include "../interface/widgets/tabs/tab_supervisor.h"
#include "../main.h"
#include "abstract_client.h"
#include "at_risk_data_warning.h"
#include "card_database_update/card_database_update_status_bar.h"
#include "card_database_update/card_update_progress.h"
#include "intents/contexts/context_connect_to_server.h"
@ -58,13 +59,12 @@
#include <QAction>
#include <QApplication>
#include <QButtonGroup>
#include <QCheckBox>
#include <QCloseEvent>
#include <QCoreApplication>
#include <QDateTime>
#include <QDebug>
#include <QDesktopServices>
#include <QDialog>
#include <QDir>
#include <QEvent>
#include <QFile>
#include <QFileDialog>
@ -606,38 +606,9 @@ MainWindow::MainWindow(QWidget *parent)
QTimer::singleShot(0, this, &MainWindow::applyStartupDestination);
}
void MainWindow::warnAboutDataPathsInProgramDir()
{
auto &paths = SettingsCache::instance().paths();
if (SettingsCache::instance().getIsPortableBuild() || paths.getAppDirWarningAcknowledged()) {
return;
}
const QStringList offenders = paths.pathsInsideDir(QCoreApplication::applicationDirPath());
if (offenders.isEmpty()) {
return;
}
QMessageBox box(QMessageBox::Warning, tr("Data paths inside the program directory"),
tr("These paths are configured inside the Cockatrice program directory:\n\n%1\n\n"
"Cockatrice keeps its own data outside the program directory by default. Files "
"stored inside it can be deleted by an update or by uninstalling Cockatrice. "
"Change them in the Settings dialog - most are on the General page - to keep "
"them safe.")
.arg(offenders.join(QLatin1Char('\n'))),
QMessageBox::Ok, this);
box.setTextFormat(Qt::PlainText);
auto *dontShowAgain = new QCheckBox(tr("Don't show this again"));
box.setCheckBox(dontShowAgain);
box.exec();
if (dontShowAgain->isChecked()) {
paths.setAppDirWarningAcknowledged(true);
}
}
void MainWindow::startupConfigCheck()
{
warnAboutDataPathsInProgramDir();
AtRiskDataWarning::warnAboutDataPathsInProgramDir(this);
const bool isCleanInstall = SettingsCache::instance().network().getClientVersion() == CLIENT_INFO_NOT_SET;

View file

@ -149,9 +149,6 @@ private:
void runFirstRunWizard(bool firstRun = false);
/** @brief Warns once when a non-portable install keeps its data paths inside the program directory. */
void warnAboutDataPathsInProgramDir();
inline QString getCardUpdaterBinaryName()
{
return "oracle";