[Settings] Back up the settings folder before migrating (#7386)

* [Settings] Back up the settings folder before migrating

The per-file settings migration only preserved global.ini, as
global.ini.old. Everything else the migrations write was overwritten in
place with no way back: the legacy migration and the 17 per-domain
migrateXxxSettings functions all write straight into the per-domain .ini
files, so any of them that got a key mapping wrong silently replaced a
working file. The re-migration path is the sharpest case, because
migrateSettingsFromGlobalIni() runs again whenever an older build wrote
non-sentinel keys back into global.ini, and those values then land on
top of per-domain files the user had already edited.

Snapshot the whole settings folder before either migration runs, into a
timestamped sibling settings-backup-<yyyyMMdd-HHmmss>. A sibling rather
than a child, so a backup is never copied into the next backup and the
"Open Settings Folder" action keeps showing only live files. Keep every
snapshot; settings files are kilobytes, so growth is not a concern.

SettingsMigration::isMigrationPending() gates the copy so a normal start
creates nothing. It reuses the same condition migrateSettingsFromGlobalIni()
uses to decide whether it has work, factored into a shared helper so the
two cannot drift, and it ignores the legacy migration on an empty profile
where that migration only writes its sentinel. The backup itself skips a
folder whose files are all empty, which covers a fresh profile whose
global.ini was created but never written.

A failed copy logs a warning and returns an empty path rather than
aborting the migration, since a full disk or an unwritable data folder
must not leave the client unable to start. global.ini.old still works as
the single-file escape hatch.

Document the folder layout, the automatic backups, and the fact that
migrations only run forwards, so users know to copy the settings folder
before downgrading to a version that cannot read the per-group files.

* [Client] Match the desktop file name case for the portal app ID

QGuiApplication::setDesktopFileName() was given "Cockatrice"/"Oracle"
while the installed desktop entries are cockatrice.desktop and
oracle.desktop. Linux paths are case-sensitive, so xdg-desktop-portal
failed the app-info lookup for "Cockatrice" and Qt warned on every
QDesktopServices call that it could not register with the host portal.
The same string becomes the xdg-shell appId, so the window icon lookup
failed on Wayland for the same reason.

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
This commit is contained in:
BruebachL 2026-10-03 14:59:58 +02:00 • committed by GitHub
parent b8b81170bf
commit 3aba1ef929
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 372 additions and 16 deletions

View file

@ -20,7 +20,11 @@ protected:
void SetUp() override
{
settingsPath = tempDir.path() + "/";
// Mirror the production layout: the settings folder lives inside the data folder, so
// migration backups (siblings of settings/) stay isolated inside tempDir instead of
// landing in the shared system temp directory.
settingsPath = tempDir.path() + "/settings/";
QDir().mkpath(settingsPath);
// Isolate the settings used by the legacy migration tests inside the temporary
// directory so the tests never read or write the real user config (registry on
@ -64,6 +68,25 @@ protected:
}
return false;
}
// The migration backups are timestamped, so tests address them by index rather than by name.
QStringList backupFolderNames() const
{
return QDir(tempDir.path())
.entryList(QDir::Dirs | QDir::NoDotAndDotDot, QDir::Name)
.filter(QStringLiteral("settings-backup-"));
}
QVariant readFromBackup(const QString &folderName, const QString &fileName, const QString &key) const
{
QSettings ini(QDir::cleanPath(tempDir.path() + '/' + folderName + '/' + fileName), QSettings::IniFormat);
return ini.value(key);
}
bool backupFileExists(const QString &folderName, const QString &fileName) const
{
return QFile::exists(QDir::cleanPath(tempDir.path() + '/' + folderName + '/' + fileName));
}
};
TEST_F(SettingsMigrationTest, NoGlobalIniDoesNothing)
@ -725,6 +748,168 @@ TEST_F(SettingsMigrationTest, RenameFailureWithExistingBackup)
ASSERT_FALSE(SettingsMigration::migrateSettingsFromGlobalIni(settingsPath));
}
// --- pre-migration backup of the settings folder ---
TEST_F(SettingsMigrationTest, BackupHoldsPreMigrationStateOfPerDomainFiles)
{
// A user already migrated once, changed a setting, then ran an older build that wrote to
// global.ini again. Re-migrating clobbers the per-domain file, so the backup has to hold the
// pre-migration value.
{
QSettings tabs(settingsPath + "tabs.ini", QSettings::IniFormat);
tabs.setValue("tabs/visualDeckStorage", false);
tabs.sync();
}
{
QSettings g(settingsPath + "global.ini", QSettings::IniFormat);
g.setValue("tabs/visualDeckStorage", true);
g.sync();
}
const QString backupPath = SettingsMigration::backupSettingsDirectory(settingsPath);
ASSERT_FALSE(backupPath.isEmpty());
ASSERT_TRUE(SettingsMigration::migrateSettingsFromGlobalIni(settingsPath));
// The migration overwrote the user's change ...
ASSERT_EQ(readFromIni("tabs.ini", "tabs/visualDeckStorage"), QVariant(true));
// ... but the backup still holds it, so it can be restored by hand.
const QStringList backups = backupFolderNames();
ASSERT_EQ(backups.size(), 1);
ASSERT_EQ(readFromBackup(backups.at(0), "tabs.ini", "tabs/visualDeckStorage"), QVariant(false));
ASSERT_EQ(readFromBackup(backups.at(0), "global.ini", "tabs/visualDeckStorage"), QVariant(true));
}
TEST_F(SettingsMigrationTest, BackupIsASiblingOfTheSettingsFolder)
{
{
QSettings g(settingsPath + "global.ini", QSettings::IniFormat);
g.setValue("sound/enabled", true);
g.sync();
}
const QString backupPath = SettingsMigration::backupSettingsDirectory(settingsPath);
ASSERT_FALSE(backupPath.isEmpty());
const QDir backupDir(backupPath);
ASSERT_TRUE(backupDir.dirName().startsWith("settings-backup-"));
// Same parent as the settings folder, and not inside it, so a backup is never copied into
// another backup and "Open settings folder" shows only live files.
ASSERT_EQ(QDir::cleanPath(backupDir.absolutePath() + "/.."), QDir::cleanPath(tempDir.path()));
ASSERT_FALSE(backupDir.absolutePath().startsWith(QDir::cleanPath(settingsPath) + '/'));
ASSERT_TRUE(backupFileExists(backupDir.dirName(), "global.ini"));
}
TEST_F(SettingsMigrationTest, BackupCopiesNestedFolders)
{
ASSERT_TRUE(QDir().mkpath(settingsPath + "nested"));
{
QFile f(settingsPath + "nested/deep.ini");
ASSERT_TRUE(f.open(QIODevice::WriteOnly));
f.write("[visualDeckStorage]\nfirstDeck=1234\n");
}
{
QSettings g(settingsPath + "global.ini", QSettings::IniFormat);
g.setValue("sound/enabled", true);
g.sync();
}
const QString backupPath = SettingsMigration::backupSettingsDirectory(settingsPath);
ASSERT_FALSE(backupPath.isEmpty());
const QStringList backups = backupFolderNames();
ASSERT_EQ(backups.size(), 1);
ASSERT_TRUE(backupFileExists(backups.at(0), "nested/deep.ini"));
ASSERT_EQ(readFromBackup(backups.at(0), "nested/deep.ini", "visualDeckStorage/firstDeck"), QVariant("1234"));
}
TEST_F(SettingsMigrationTest, NoBackupWhenSettingsFolderIsEmpty)
{
ASSERT_TRUE(SettingsMigration::backupSettingsDirectory(settingsPath).isEmpty());
ASSERT_TRUE(backupFolderNames().isEmpty());
}
TEST_F(SettingsMigrationTest, NoBackupWhenEverySettingsFileIsEmpty)
{
// A fresh profile: QSettings has created global.ini, but it holds nothing worth preserving.
{
QFile f(settingsPath + "global.ini");
ASSERT_TRUE(f.open(QIODevice::WriteOnly));
f.close();
}
ASSERT_TRUE(SettingsMigration::backupSettingsDirectory(settingsPath).isEmpty());
ASSERT_TRUE(backupFolderNames().isEmpty());
}
TEST_F(SettingsMigrationTest, BackupFailsGracefullyOnMissingSettingsFolder)
{
ASSERT_TRUE(SettingsMigration::backupSettingsDirectory(tempDir.path() + "/does-not-exist/").isEmpty());
}
TEST_F(SettingsMigrationTest, BackupKeepsEveryPreviousSnapshot)
{
{
QSettings g(settingsPath + "global.ini", QSettings::IniFormat);
g.setValue("sound/enabled", true);
g.sync();
}
ASSERT_FALSE(SettingsMigration::backupSettingsDirectory(settingsPath).isEmpty());
ASSERT_FALSE(SettingsMigration::backupSettingsDirectory(settingsPath).isEmpty());
// A second snapshot in the same second must not merge into the first one.
const QStringList backups = backupFolderNames();
ASSERT_EQ(backups.size(), 2);
ASSERT_NE(backups.at(0), backups.at(1));
}
TEST_F(SettingsMigrationTest, IsMigrationPendingWhenGlobalIniHasKeys)
{
{
QSettings g(settingsPath + "global.ini", QSettings::IniFormat);
g.setValue("sound/enabled", true);
g.sync();
}
ASSERT_TRUE(SettingsMigration::isMigrationPending(settingsPath));
}
TEST_F(SettingsMigrationTest, IsNotMigrationPendingAfterSentinelIsSet)
{
{
QSettings g(settingsPath + "global.ini", QSettings::IniFormat);
g.setValue("sound/enabled", true);
g.sync();
}
ASSERT_TRUE(SettingsMigration::migrateSettingsFromGlobalIni(settingsPath));
// The sentinel-only global.ini and the legacy sentinel leave nothing to migrate.
{
QSettings personal(settingsPath + "personal.ini", QSettings::IniFormat);
personal.setValue("migration/legacy_complete", true);
personal.sync();
}
ASSERT_FALSE(SettingsMigration::isMigrationPending(settingsPath));
}
TEST_F(SettingsMigrationTest, IsMigrationPendingWhenOnlyLegacySettingsRemain)
{
QSettings().setValue("server/host", "example.com");
QSettings().sync();
ASSERT_TRUE(SettingsMigration::isMigrationPending(settingsPath));
}
TEST_F(SettingsMigrationTest, IsNotMigrationPendingWithoutLegacyData)
{
// Nothing to migrate: a fresh profile must not leave a backup folder behind on every start.
QSettings().clear();
QSettings().sync();
ASSERT_FALSE(SettingsMigration::isMigrationPending(settingsPath));
}
} // namespace
int main(int argc, char **argv)