Compare commits

...

3 commits

Author SHA1 Message Date
tooomm
5dc5390880
Fix and rework client updater logic (#7038)
Some checks are pending
CodeQL / Analyze (cpp) (push) Waiting to run
CodeQL / Analyze (actions) (push) Waiting to run
Build Desktop / Configure (push) Waiting to run
Build Desktop / Debian 13 (push) Blocked by required conditions
Build Desktop / Debian 12 (push) Blocked by required conditions
Build Desktop / Fedora 44 (push) Blocked by required conditions
Build Desktop / Fedora 43 (push) Blocked by required conditions
Build Desktop / Servatrice_Debian 12 (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 (push) Blocked by required conditions
Build Desktop / Ubuntu 24.04 (push) Blocked by required conditions
Build Desktop / Arch (push) Blocked by required conditions
Build Desktop / macOS 13 Intel (push) Blocked by required conditions
Build Desktop / macOS 14 (push) Blocked by required conditions
Build Desktop / macOS 15 (push) Blocked by required conditions
Build Desktop / macOS 26 Debug (push) Blocked by required conditions
Build Desktop / Windows 10 (push) Blocked by required conditions
Build Docker / Servatrice (arm) (push) Waiting to run
Build Docker / Servatrice (x86) (push) Waiting to run
Build Docker / Publish multi-platform Servatrice image (push) Blocked by required conditions
* first version

* combined logic + better regex

* Simplify, we only support 64bit, installer prevents wrong installs

* lint + fix Linux versions (missing declaration)

* update regex

* improve/add comments

* comments

* ReleaseChannel: parse non-numeric OS versions on Windows Server

QSysInfo::productVersion() returns "Server 2022" on Windows Server, so
splitting on '.' and calling toInt() made the version unparseable and
disabled the in-client updater there. Extract the first numeric token
instead; Server 2022 becomes 2022 and matches the Win10 assets.

* ReleaseChannel: don't offer 64-bit assets on 32-bit Windows

The old downloadMatchesCurrentOS() had an explicit 32-bit branch that
only accepted "32bit" assets. Restore that guard: 32-bit Windows builds
return no match instead of being offered the 64-bit Win10 installer that
won't run.

* ReleaseChannel: only query the OS version on updater platforms

The in-client updater only ships assets for macOS and Windows, but the
host system version was still being resolved on every platform, including
Linux where it is never used. Compute it only under the Q_OS_MACOS /
Q_OS_WIN guards.

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-03 17:57:03 +02:00
BruebachL
0934380a5c
[Translations] Load Qt's own translations and build ours with the binary (#7384)
* [Translations] Load Qt's own translations and build ours with the binary

Three separate bugs kept translations from working.

installNewTranslator() loaded the application translation into
qtTranslator. QTranslator discards its previous contents on every load, so
that wiped the qt_<lang>.qm loaded one line earlier and installed an empty
translator instead. Qt's own strings were never translated on any platform.
Give each file its own translator.

QLibraryInfo::path(TranslationsPath) was the only directory searched for
Qt's translations. A relocated Qt can report a path that does not exist: the
KDE Flatpak runtime reports /usr/translations while its files live in
/usr/share/qt6/translations. Probe the usual install layouts and only hand
back the ones that exist.

The .qm files were attached to the default build target only, so building
the executable on its own left it without any translations and the language
selector empty. Attach them to the target as well.

Move the shared lookup into TranslationLoader so client and Oracle cannot
drift again, and have it report a missing translation at debug level. An
unset language is the default for English, which has no file to begin with,
and warning about it only added noise to every fresh debug log.

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>

* [Tests] Define main() in translation_loader_test

Without a GTest installation the fallback in tests/CMakeLists.txt links
only gtest, not gtest_main, so the test has to bring its own main(). Every
other test already does; only this one relied on the distribution package
and failed to link on CI.

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>

* [Tests] Put the translation loader fixture next to the test executable

A multi-config generator (MSBuild, Xcode) writes the executable to a
per-config subdirectory while the .qm stays in the current binary
directory, so applicationDirPath() never saw the fixture and
ABuildTreeFindsTheTranslationsNextToTheExecutable failed on Windows.

* [Translations] Depend on Qt's lrelease target and skip the loader test without lrelease

Attaching the .qm custom commands to a second custom target races with
Qt's own <Project>_lrelease target on MSBuild/Makefiles and is rejected
by Xcode, so depend on Qt's target instead. Only build the translation
loader test when Qt6::lrelease is available.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
Co-authored-by: ZeldaZach <zahalpern+github@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 15:04:30 +02:00
BruebachL
3aba1ef929
[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>
2026-10-03 14:59:58 +02:00
21 changed files with 1105 additions and 132 deletions

View file

@ -798,6 +798,18 @@ if(Qt6LinguistTools_FOUND)
elseif(WIN32)
install(FILES ${cockatrice_QM} DESTINATION ${COCKATRICE_WIN32_QM_INSTALL_DIR})
endif()
# Qt attaches the .qm files to the default build target only, so building the executable on its own
# (CLion, --target cockatrice) leaves it without any translations: nothing is translated and the
# language selector stays empty.
# Depend on Qt's own lrelease target instead of attaching the .qm custom commands to a second
# target: two independent targets owning the same outputs race on MSBuild/Makefiles and are an
# error on Xcode. Older Qt versions already add this dependency themselves.
foreach(_lrelease_target Cockatrice_lrelease cockatrice_lrelease)
if(TARGET ${_lrelease_target})
add_dependencies(cockatrice ${_lrelease_target})
endif()
endforeach()
endif()
qt6_finalize_target(cockatrice)

View file

@ -10,6 +10,7 @@
#include <QRegularExpression>
#include <QSysInfo>
#include <QtGlobal>
#include <optional>
#if defined(Q_OS_MACOS)
#include <sys/sysctl.h>
@ -26,6 +27,29 @@
#define GIT_SHORT_HASH_LEN 7
namespace
{
#if defined(Q_OS_MACOS) || defined(Q_OS_WIN)
// QSysInfo::productVersion() is not guaranteed to start with a number, e.g. it returns
// "Server 2022" on Windows Server. Use the first numeric token as the major host version.
std::optional<int> getProductVersionMajor()
{
const QString versionString = QSysInfo::productVersion();
bool ok = false;
static const QRegularExpression firstNumber(R"((\d+))");
const auto match = firstNumber.match(versionString);
const int version = match.hasMatch() ? match.captured(1).toInt(&ok) : 0;
if (!ok) {
qCWarning(ReleaseChannelLog) << "Unable to determine OS version from" << versionString;
return std::nullopt;
}
return version;
}
#endif
} // namespace
ReleaseChannel::ReleaseChannel() : netMan(new QNetworkAccessManager(this)), response(nullptr), lastRelease(nullptr)
{
}
@ -43,55 +67,93 @@ void ReleaseChannel::checkForUpdates()
connect(response, &QNetworkReply::finished, this, &ReleaseChannel::releaseListFinished);
}
// Different release channel checking functions for different operating systems
bool ReleaseChannel::downloadMatchesCurrentOS(const QString &fileName)
// Find assets compatible with host platform (Linux is not supported by in-client updater)
std::optional<int> ReleaseChannel::getTargetVersionForCurrentOS(const QString &fileName)
{
#if defined(Q_OS_MACOS)
static QRegularExpression version_regex("macOS(\\d+)");
auto match = version_regex.match(fileName);
if (!match.hasMatch()) {
return false;
#if defined(Q_OS_WIN) && Q_PROCESSOR_WORDSIZE == 4
// The published Windows assets are 64-bit only
Q_UNUSED(fileName);
return std::nullopt;
#endif
const QRegularExpression *regex = nullptr;
// Only platforms shipping updater assets (macOS, Windows) need the host version
static const std::optional<int> systemVersion =
#if defined(Q_OS_MACOS) || defined(Q_OS_WIN)
getProductVersionMajor();
#else
std::nullopt;
#endif
if (!systemVersion) {
return std::nullopt;
}
auto getSystemVersion = [] {
#if defined(Q_OS_MACOS)
static const bool isIntel = [] {
// QSysInfo does not go through translation layers
// We need to use sysctl to reliably detect the underlying architecture
char arch[255];
size_t len = sizeof(arch);
if (sysctlbyname("machdep.cpu.brand_string", arch, &len, nullptr, 0) == 0) {
// Intel mac is only supported on macOS 13 versions
if (QString::fromUtf8(arch).contains("Intel")) {
return 13;
}
return QString::fromUtf8(arch).contains("Intel");
}
return false;
}();
return QSysInfo::productVersion().split(".")[0].toInt();
};
// older(smaller) releases are compatible with a newer or the same system version
int sys_maj = getSystemVersion();
int rel_maj = match.captured(1).toInt();
return rel_maj == sys_maj;
// Apple (ARM) --> Cockatrice-3.0.0-macOS15.dmg
// Apple (x86) --> Cockatrice-3.0.0-macOS15_Intel.dmg
static const QRegularExpression macIntelRegex(R"(-macOS(\d+)_Intel\.[^.]+$)",
QRegularExpression::CaseInsensitiveOption);
static const QRegularExpression macRegex(R"(-macOS(\d+)\.[^.]+$)", QRegularExpression::CaseInsensitiveOption);
regex = isIntel ? &macIntelRegex : &macRegex;
#elif defined(Q_OS_WIN)
#if Q_PROCESSOR_WORDSIZE == 4
return fileName.contains("32bit");
#elif Q_PROCESSOR_WORDSIZE == 8
const QString &version = QSysInfo::productVersion();
if (version.startsWith("7") || version.startsWith("8")) {
return fileName.contains("Win7");
} else {
return fileName.contains("Win10");
}
#else
// Windows (x86) --> Cockatrice-3.0.0-Windows10.exe
static const QRegularExpression winRegex(R"(-(?:Win|Windows)(\d+)\.[^.]+$)",
QRegularExpression::CaseInsensitiveOption);
regex = &winRegex;
#else // if the OS doesn't fit one of the above #defines, then it will never match
Q_UNUSED(fileName);
return false;
return std::nullopt;
#endif
#else // If the OS doesn't fit one of the above #defines, then it will never match
Q_UNUSED(fileName);
return false;
#endif
// Any asset targeting an OS version up to the current host version works
auto match = regex->match(fileName);
if (!match.hasMatch()) {
return std::nullopt;
}
const int targetVersion = match.captured(1).toInt();
if (targetVersion > *systemVersion) {
return std::nullopt;
}
return targetVersion;
}
// Pick newest targeted version (highest number) amongst all compatible ones
QString ReleaseChannel::findBestDownloadUrl(const QVariantList &assets)
{
QString bestUrl;
int bestVersion = -1;
for (const auto &rawAsset : assets) {
QVariantMap asset = rawAsset.toMap();
QString name = asset["name"].toString();
QString url = asset["browser_download_url"].toString();
auto version = getTargetVersionForCurrentOS(name);
if (!version) {
continue;
}
if (*version > bestVersion) {
bestVersion = *version;
bestUrl = url;
}
}
if (!bestUrl.isEmpty()) {
qCInfo(ReleaseChannelLog) << "Best compatible asset=" << bestUrl;
}
return bestUrl;
}
QString StableReleaseChannel::getManualDownloadUrl() const
@ -138,16 +200,9 @@ void StableReleaseChannel::releaseListFinished()
lastRelease->setPublishDate(resultMap["published_at"].toDate());
if (resultMap.contains("assets")) {
auto rawAssets = resultMap["assets"].toList();
for (const auto &rawAsset : rawAssets) {
QVariantMap asset = rawAsset.toMap();
QString name = asset["name"].toString();
QString url = asset["browser_download_url"].toString();
if (downloadMatchesCurrentOS(name)) {
lastRelease->setDownloadUrl(url);
break;
}
auto url = findBestDownloadUrl(resultMap["assets"].toList());
if (!url.isEmpty()) {
lastRelease->setDownloadUrl(url);
}
}
@ -289,21 +344,10 @@ void BetaReleaseChannel::fileListFinished()
bool needToUpdate = (QString::compare(shortHash, myHash, Qt::CaseInsensitive) != 0);
bool compatibleVersion = false;
QStringList resultUrlList{};
for (QVariant file : resultList) {
QVariantMap map = file.toMap();
resultUrlList << map["browser_download_url"].toString();
}
resultUrlList.sort();
// iterate in reverse so the first item is the latest os version
for (auto url = resultUrlList.rbegin(); url < resultUrlList.rend(); ++url) {
if (downloadMatchesCurrentOS(*url)) {
compatibleVersion = true;
lastRelease->setDownloadUrl(*url);
qCInfo(ReleaseChannelLog) << "Found compatible version url=" << *url;
break;
}
QString downloadUrl = findBestDownloadUrl(resultList);
if (!downloadUrl.isEmpty()) {
compatibleVersion = true;
lastRelease->setDownloadUrl(downloadUrl);
}
emit finishedCheck(needToUpdate, compatibleVersion, lastRelease);

View file

@ -12,6 +12,7 @@
#include <QObject>
#include <QString>
#include <QVariantMap>
#include <optional>
#include <utility>
inline Q_LOGGING_CATEGORY(ReleaseChannelLog, "release_channel");
@ -96,7 +97,8 @@ protected:
Release *lastRelease;
protected:
static bool downloadMatchesCurrentOS(const QString &fileName);
static std::optional<int> getTargetVersionForCurrentOS(const QString &fileName);
static QString findBestDownloadUrl(const QVariantList &assets);
[[nodiscard]] virtual QString getReleaseChannelUrl() const = 0;
public:

View file

@ -110,6 +110,16 @@ SettingsCache::SettingsCache()
settings = new QSettings(settingsPath + "global.ini", QSettings::IniFormat, this);
// Both migrations below overwrite per-domain files in place, so snapshot the whole settings
// folder first: global.ini is preserved as global.ini.old, but pre-existing per-domain files
// (and a re-migration after a downgrade) would otherwise have no way back.
if (SettingsMigration::isMigrationPending(settingsPath)) {
const QString backupPath = SettingsMigration::backupSettingsDirectory(settingsPath);
if (!backupPath.isEmpty()) {
qCInfo(SettingsCacheLog) << "[SettingsCache] Settings backed up before migration:" << backupPath;
}
}
// Migrate from legacy NativeFormat settings to per-class INI files (runs at most once)
if (!isPortableBuild) {
SettingsMigration::migrateLegacySettings(settingsPath);

View file

@ -21,6 +21,7 @@
#include <libcockatrice/settings/tabs_settings.h>
#include <libcockatrice/settings/updates_settings.h>
#include <libcockatrice/utility/macros.h>
#include <libcockatrice/utility/translation_loader.h>
enum startupCardUpdateCheckBehaviorIndex
{
@ -333,22 +334,18 @@ GeneralSettingsPage::GeneralSettingsPage()
QStringList GeneralSettingsPage::findQmFiles()
{
QDir dir(translationPath);
QStringList fileNames = dir.entryList(QStringList(translationPrefix + "_*.qm"), QDir::Files, QDir::Name);
fileNames.replaceInStrings(QRegularExpression(translationPrefix + "_(.*)\\.qm"), "\\1");
return fileNames;
return TranslationLoader::availableLanguages(translationPrefix,
TranslationLoader::applicationTranslationPaths(translationPath));
}
QString GeneralSettingsPage::languageName(const QString &lang)
{
QTranslator qTranslator;
QString appNameHint = translationPrefix + "_" + lang;
bool appTranslationLoaded = qTranslator.load(appNameHint, translationPath);
if (!appTranslationLoaded) {
qCWarning(GeneralSettingsPageLog)
<< "Unable to load" << translationPrefix << "translation" << appNameHint << "at" << translationPath;
}
// Only the language name is needed here, so the file is loaded without logging a line per
// language: findQmFiles() asked for languages whose files exist in the first place.
TranslationLoader::loadFrom(qTranslator, translationPrefix + "_" + lang,
TranslationLoader::applicationTranslationPaths(translationPath));
return qTranslator.translate("i18n", DEFAULT_LANG_NAME);
}

View file

@ -39,7 +39,6 @@
#include <QCryptographicHash>
#include <QDateTime>
#include <QDebug>
#include <QLibraryInfo>
#include <QLocale>
#include <QMessageBox>
#include <QSystemTrayIcon>
@ -55,6 +54,7 @@
#include <libcockatrice/settings/network_settings.h>
#include <libcockatrice/settings/personal_settings.h>
#include <libcockatrice/utility/cryptoutil.h>
#include <libcockatrice/utility/translation_loader.h>
QTranslator *translator, *qtTranslator;
RNG_Abstract *rng;
@ -141,26 +141,13 @@ void installNewTranslator()
{
QString lang = SettingsCache::instance().personal().getLang();
QString qtNameHint = "qt_" + lang;
QString qtTranslationPath = QLibraryInfo::path(QLibraryInfo::TranslationsPath);
bool qtTranslationLoaded = qtTranslator->load(qtNameHint, qtTranslationPath);
if (!qtTranslationLoaded) {
qCWarning(QtTranslatorDebug) << "Unable to load qt translation" << qtNameHint << "at" << qtTranslationPath;
} else {
qCInfo(QtTranslatorDebug) << "Loaded qt translation" << qtNameHint << "at" << qtTranslationPath;
}
// Qt's own strings and ours need a translator each: QTranslator discards its previous contents
// on every load, so loading both files into one translator silently drops the first of them.
TranslationLoader::loadQt(*qtTranslator, lang);
qApp->installTranslator(qtTranslator);
QString appNameHint = translationPrefix + "_" + lang;
bool appTranslationLoaded = qtTranslator->load(appNameHint, translationPath);
if (!appTranslationLoaded) {
qCWarning(QtTranslatorDebug) << "Unable to load" << translationPrefix << "translation" << appNameHint << "at"
<< translationPath;
} else {
qCInfo(QtTranslatorDebug) << "Loaded" << translationPrefix << "translation" << appNameHint << "at"
<< translationPath;
}
TranslationLoader::loadApplication(*translator, translationPrefix, lang,
TranslationLoader::applicationTranslationPaths(translationPath));
qApp->installTranslator(translator);
}
@ -390,8 +377,11 @@ int main(int argc, char *argv[])
qCInfo(MainLog) << "MainWindow constructor finished";
ui.setWindowIcon(themePixmap(QStringLiteral("cockatrice")));
// Set name of the app desktop file; used by wayland to load the window icon
QGuiApplication::setDesktopFileName("Cockatrice");
// Base name of the desktop file installed next to the binary, without the ".desktop"
// extension. Wayland reads the window icon from it, and xdg-desktop-portal looks up
// org.freedesktop.host.portal.Registry by this app ID, so it has to match the file name
// exactly, down to the case.
QGuiApplication::setDesktopFileName("cockatrice");
SettingsCache::instance().network().setClientID(generateClientID());

View file

@ -10,7 +10,6 @@
#include <QLoggingCategory>
inline Q_LOGGING_CATEGORY(MainLog, "main");
inline Q_LOGGING_CATEGORY(QtTranslatorDebug, "qt_translator");
class CardDatabase;
class QString;

View file

@ -11,6 +11,10 @@
- @subpage beta_release
## Settings
- @subpage settings_folder
## Card Pictures
- @subpage custom_card_pictures

View file

@ -0,0 +1,43 @@
@page settings_folder Settings Folder and Backups
Cockatrice keeps all of its preferences in a settings folder on your computer, one `.ini` file per
group of settings (appearance, sound, decks, and so on). Open it from the main window via
Help → Open Settings Folder.
# The Per-File Migration
Older versions of Cockatrice stored every setting in a single `global.ini` file. The settings were
split into per-group `.ini` files so that unrelated groups no longer share one file. The first time
you start a version that uses the new layout, your settings are moved into the new files
automatically and nothing is lost. The old `global.ini` is kept next to the new files as
`global.ini.old`.
# Automatic Backups
Both migrations overwrite files in the settings folder in place, so before either one runs,
Cockatrice copies the whole folder to a timestamped backup folder:
```
<data folder>/settings-backup-20261002-193000/
```
The backup folder sits next to the settings folder, not inside it. Every backup is kept, so if
settings ever end up wrong you can compare against, or copy files back from, the earlier snapshot.
Backups are only created when a migration actually has work to do, so a normal start creates
nothing.
# Downgrading
Settings migrations only run forwards. If you ever go back to an older version of Cockatrice, that
version does not read the new per-group files, so it will start with default settings.
Before downgrading, copy your settings folder somewhere safe, for example:
- Linux: `~/.local/share/Cockatrice/settings/`
- macOS: `~/Library/Application Support/Cockatrice/settings/`
- Windows: `%LOCALAPPDATA%\Cockatrice\settings\`
Portable installs keep the settings folder in the `data` folder next to the executable.
Copying that folder back after downgrading restores your settings. The automatic backup folder from
the migration is there as a second copy of the same data if you need it.

View file

@ -1,12 +1,44 @@
#include "settings_migration.h"
#include <QDateTime>
#include <QDir>
#include <QDirIterator>
#include <QFile>
#include <QFileInfo>
#include <QLoggingCategory>
#include <QMap>
#include <QSettings>
#include <QStringList>
#include <algorithm>
inline Q_LOGGING_CATEGORY(SettingsMigrationLog, "settings_migration");
static const QString MIGRATION_SENTINEL_KEY = QStringLiteral("migration/perfile_complete");
/**
* True when global.ini holds keys that migrateSettingsFromGlobalIni() would still move, i.e. the
* exact condition that function uses to decide whether it has work to do. If a user runs an older
* Cockatrice build that writes to global.ini (non-sentinel keys) and then upgrades back, those new
* keys would otherwise be silently ignored, so they are re-migrated instead.
*/
static bool hasPendingGlobalIniSettings(const QString &settingsPath)
{
if (!QFile::exists(settingsPath + "global.ini")) {
return false;
}
QSettings globalIni(settingsPath + "global.ini", QSettings::IniFormat);
if (!globalIni.value(MIGRATION_SENTINEL_KEY, false).toBool()) {
return true;
}
// The sentinel is set, so any key besides it was written after the migration, i.e. by an
// older build sharing this file. Those are exactly the keys to re-migrate.
QStringList otherKeys = globalIni.allKeys();
otherKeys.removeAll(MIGRATION_SENTINEL_KEY);
return !otherKeys.isEmpty();
}
static void migrateTabsSettings(const QString &settingsPath, QSettings &globalIni)
{
if (!globalIni.contains("tabs/visualDeckStorage") && !globalIni.contains("tabs/server") &&
@ -658,21 +690,11 @@ bool SettingsMigration::migrateLegacySettings(const QString &settingsPath)
bool SettingsMigration::migrateSettingsFromGlobalIni(const QString &settingsPath)
{
if (!QFile::exists(settingsPath + "global.ini")) {
if (!hasPendingGlobalIniSettings(settingsPath)) {
return false;
}
QSettings globalIni(settingsPath + "global.ini", QSettings::IniFormat);
if (globalIni.value(MIGRATION_SENTINEL_KEY, false).toBool()) {
// If a user runs an older Cockatrice build that writes to global.ini (non-sentinel keys)
// and then upgrades back, those new keys will be silently ignored. Re-migrate them.
globalIni.sync();
auto allKeys = globalIni.allKeys();
allKeys.removeAll(MIGRATION_SENTINEL_KEY);
if (allKeys.isEmpty()) {
return false;
}
}
migrateTabsSettings(settingsPath, globalIni);
migrateSoundSettings(settingsPath, globalIni);
@ -700,3 +722,78 @@ bool SettingsMigration::migrateSettingsFromGlobalIni(const QString &settingsPath
return true;
}
bool SettingsMigration::isMigrationPending(const QString &settingsPath)
{
if (hasPendingGlobalIniSettings(settingsPath)) {
return true;
}
QSettings personalIni(settingsPath + "personal.ini", QSettings::IniFormat);
if (personalIni.value("migration/legacy_complete", false).toBool()) {
return false;
}
// migrateLegacySettings() also runs on an empty profile, where it only writes its sentinel.
// Reporting that as pending would leave a backup folder behind on every fresh install, so
// only count it when there really are legacy keys left to move.
return !QSettings().allKeys().isEmpty();
}
QString SettingsMigration::backupSettingsDirectory(const QString &settingsPath)
{
QDir settingsDir(QDir::cleanPath(settingsPath));
if (!settingsDir.exists()) {
return QString();
}
const QDir::Filters fileFilters = QDir::Files | QDir::Hidden | QDir::System | QDir::NoDotAndDotDot;
// A profile holding nothing but empty files has nothing to lose, and backing it up would only
// leave clutter next to the settings folder.
const QFileInfoList files = settingsDir.entryInfoList(fileFilters);
const bool hasContent =
std::any_of(files.cbegin(), files.cend(), [](const QFileInfo &file) { return file.size() > 0; });
if (!hasContent) {
return QString();
}
// Keep the backup a sibling of the settings folder rather than a child: a folder that is never
// copied into its own backup, and one the "Open settings folder" action does not have to skip.
QDir backupParentDir(settingsDir.absolutePath());
if (!backupParentDir.cdUp()) {
qCWarning(SettingsMigrationLog) << "[SettingsMigration] Could not resolve the parent of the settings folder:"
<< settingsDir.absolutePath();
return QString();
}
const QString backupName =
QStringLiteral("settings-backup-") + QDateTime::currentDateTime().toString("yyyyMMdd-HHmmss");
QString backupPath = backupParentDir.absoluteFilePath(backupName);
// Two migrations inside the same second must not merge into a single folder.
for (int suffix = 1; QFileInfo::exists(backupPath); ++suffix) {
backupPath = backupParentDir.absoluteFilePath(backupName + '-' + QString::number(suffix));
}
if (!QDir().mkpath(backupPath)) {
qCWarning(SettingsMigrationLog) << "[SettingsMigration] Could not create the settings backup folder:"
<< backupPath;
return QString();
}
QDir backupDir(backupPath);
QDirIterator it(settingsDir.absolutePath(), fileFilters, QDirIterator::Subdirectories);
while (it.hasNext()) {
it.next();
const QString target = backupDir.absoluteFilePath(settingsDir.relativeFilePath(it.filePath()));
QDir().mkpath(QFileInfo(target).absolutePath());
if (!QFile::copy(it.filePath(), target)) {
// A partially copied backup still beats none: report it and let the migration run.
qCWarning(SettingsMigrationLog) << "[SettingsMigration] Could not back up settings file:" << it.filePath();
}
}
qCInfo(SettingsMigrationLog) << "[SettingsMigration] Settings backed up to" << backupPath;
return backupPath;
}

View file

@ -8,6 +8,19 @@ class SettingsMigration
public:
static bool migrateSettingsFromGlobalIni(const QString &settingsPath);
static bool migrateLegacySettings(const QString &settingsPath);
/**
* Returns true when at least one of the two migrations would still do work, i.e. when a
* backup of the settings directory is worth taking before they run.
*/
static bool isMigrationPending(const QString &settingsPath);
/**
* Copies the whole settings directory to a timestamped sibling folder, so a migration that
* overwrites per-domain files can be undone by hand. Returns the backup path, or an empty
* string when there was nothing to back up or the copy failed. Never aborts the migration.
*/
static QString backupSettingsDirectory(const QString &settingsPath);
};
#endif // SETTINGS_MIGRATION_H

View file

@ -12,6 +12,7 @@ set(UTILITY_SOURCES
libcockatrice/utility/passwordhasher.cpp
libcockatrice/utility/report_categories.cpp
libcockatrice/utility/server_rate_limiter.cpp
libcockatrice/utility/translation_loader.cpp
libcockatrice/utility/warning_categories.cpp
)
@ -32,6 +33,7 @@ set(UTILITY_HEADERS
libcockatrice/utility/zone_names.h
libcockatrice/utility/days_years_between.h
libcockatrice/utility/server_rate_limiter.h
libcockatrice/utility/translation_loader.h
libcockatrice/utility/warning_categories.h
)

View file

@ -0,0 +1,166 @@
#include "translation_loader.h"
#include <QCoreApplication>
#include <QDir>
#include <QFileInfo>
#include <QLibraryInfo>
#include <QRegularExpression>
#include <QSet>
#include <QTranslator>
#include <algorithm>
// Kept under the historical category name so existing QT_LOGGING_RULES filters keep working.
Q_LOGGING_CATEGORY(TranslationLoaderLog, "qt_translator")
QList<QString> TranslationLoader::qtTranslationCandidates()
{
QStringList candidates;
#if QT_VERSION >= QT_VERSION_CHECK(6, 8, 0)
// Qt 6.8 exposes every path listed in qt.conf, path() only returns the first one.
candidates << QLibraryInfo::paths(QLibraryInfo::TranslationsPath);
#else
candidates << QLibraryInfo::path(QLibraryInfo::TranslationsPath);
#endif
// A relocated Qt can report a prefix-relative path that does not exist, so probe the usual
// install layouts as well instead of trusting that single report.
const QString prefix = QLibraryInfo::path(QLibraryInfo::PrefixPath);
const QString dataPath = QLibraryInfo::path(QLibraryInfo::DataPath);
candidates << prefix + QStringLiteral("/share/qt6/translations")
<< prefix + QStringLiteral("/share/qt/translations") << prefix + QStringLiteral("/translations")
<< dataPath + QStringLiteral("/translations");
QList<QString> uniqueCandidates;
for (const QString &candidate : candidates) {
if (!candidate.isEmpty() && !uniqueCandidates.contains(candidate)) {
uniqueCandidates << candidate;
}
}
return uniqueCandidates;
}
QList<QString> TranslationLoader::qtTranslationPaths()
{
QList<QString> existingPaths;
for (const QString &candidate : qtTranslationCandidates()) {
if (QFileInfo::exists(candidate)) {
existingPaths << candidate;
}
}
return existingPaths;
}
QList<QString> TranslationLoader::applicationTranslationPaths(const QString &_installedPath)
{
// A build tree keeps the .qm files next to the executable, the installed layout puts them in
// _installedPath. Searching both keeps a developer build translated as well.
const QString executableDirectory = QCoreApplication::applicationDirPath();
QList<QString> paths;
for (const QString &path : {_installedPath, executableDirectory}) {
if (!path.isEmpty() && !paths.contains(path)) {
paths << path;
}
}
return paths;
}
QList<QString> TranslationLoader::availableLanguages(const QString &_prefix, const QList<QString> &_directories)
{
const QRegularExpression filePattern(
QRegularExpression::anchoredPattern(QRegularExpression::escape(_prefix) + QStringLiteral("_(.*)\\.qm")));
QSet<QString> languages;
for (const QString &directory : _directories) {
if (directory.isEmpty()) {
continue;
}
const QDir dir(directory);
for (const QString &fileName : dir.entryList(QDir::Files)) {
const auto match = filePattern.match(fileName);
if (match.hasMatch()) {
languages.insert(match.captured(1));
}
}
}
QList<QString> sortedLanguages(languages.cbegin(), languages.cend());
std::sort(sortedLanguages.begin(), sortedLanguages.end());
return sortedLanguages;
}
TranslationLoadResult
TranslationLoader::loadFrom(QTranslator &_translator, const QString &_nameHint, const QList<QString> &_directories)
{
bool anyDirectoryExists = false;
for (const QString &directory : _directories) {
if (directory.isEmpty() || !QFileInfo::exists(directory)) {
continue;
}
anyDirectoryExists = true;
if (_translator.load(_nameHint, directory)) {
return TranslationLoadResult::Loaded;
}
}
return anyDirectoryExists ? TranslationLoadResult::Missing : TranslationLoadResult::NoDirectory;
}
TranslationLoadResult TranslationLoader::loadQt(QTranslator &_translator, const QString &_lang)
{
// An unset language is the default for English, which has no translation file to begin with.
if (_lang.isEmpty()) {
return TranslationLoadResult::Missing;
}
const QString nameHint = QStringLiteral("qt_") + _lang;
const auto result = loadFrom(_translator, nameHint, qtTranslationPaths());
switch (result) {
case TranslationLoadResult::Loaded:
qCInfo(TranslationLoaderLog) << "Loaded Qt translation" << nameHint << "at" << _translator.filePath();
break;
case TranslationLoadResult::Missing:
qCDebug(TranslationLoaderLog)
<< "No Qt translation" << nameHint << "in" << QStringList(qtTranslationPaths());
break;
case TranslationLoadResult::NoDirectory:
qCWarning(TranslationLoaderLog)
<< "No Qt translations directory exists, looked in" << QStringList(qtTranslationPaths());
break;
}
return result;
}
TranslationLoadResult TranslationLoader::loadApplication(QTranslator &_translator,
const QString &_prefix,
const QString &_lang,
const QList<QString> &_directories)
{
if (_lang.isEmpty()) {
return TranslationLoadResult::Missing;
}
const QString nameHint = _prefix + QLatin1Char('_') + _lang;
const auto result = loadFrom(_translator, nameHint, _directories);
switch (result) {
case TranslationLoadResult::Loaded:
qCInfo(TranslationLoaderLog) << "Loaded" << _prefix << "translation" << nameHint << "at"
<< _translator.filePath();
break;
case TranslationLoadResult::Missing:
qCDebug(TranslationLoaderLog)
<< "No" << _prefix << "translation" << nameHint << "in" << QStringList(_directories);
break;
case TranslationLoadResult::NoDirectory:
qCWarning(TranslationLoaderLog)
<< "No translation directory exists, looked in" << QStringList(_directories);
break;
}
return result;
}

View file

@ -0,0 +1,92 @@
#ifndef TRANSLATION_LOADER_H
#define TRANSLATION_LOADER_H
#include <QList>
#include <QLoggingCategory>
#include <QString>
class QTranslator;
Q_DECLARE_LOGGING_CATEGORY(TranslationLoaderLog)
/** @brief The outcome of a translation load attempt. */
enum class TranslationLoadResult
{
Loaded, /**< A translation file was found and loaded into the translator. */
Missing, /**< The search directories exist but hold no file for this language. */
NoDirectory /**< Not a single search directory exists, so no lookup could be attempted. */
};
namespace TranslationLoader
{
/**
* @brief Every directory that may hold Qt's own translations, in order of preference.
*
* More than one entry is needed because a relocated Qt (Flatpak, AppImage, a bundled SDK) can report
* a prefix-relative translations path that does not exist: the KDE runtime reports /usr/translations
* while its qt_*.qm files live in /usr/share/qt6/translations.
*/
QList<QString> qtTranslationCandidates();
/**
* @brief The candidates that actually exist, most likely first, so no caller searches a path that is
* not there.
*/
QList<QString> qtTranslationPaths();
/**
* @brief The directories to search for an application's own translation files, in order of preference.
*
* @param _installedPath The location the platform installs the translation files in, derived from the
* executable's path.
* @return The installed location, followed by the executable's own directory: a build tree keeps the
* .qm files next to the binary instead of in the installed layout, so a developer build would
* otherwise find no translations at all.
*/
QList<QString> applicationTranslationPaths(const QString &_installedPath);
/**
* @brief Loads the first _nameHint + language translation file found in _directories into _translator.
*
* QTranslator discards whatever it held before every load, so each translation file needs its own
* translator; loading a second file into the first one silently drops the first.
*
* @param _translator The translator to load into.
* @param _nameHint The translation file name without its language and suffix, e.g. "qt_de" or "cockatrice_de".
* @param _directories The directories to search, in order of preference.
* @return What the search found.
*/
TranslationLoadResult loadFrom(QTranslator &_translator, const QString &_nameHint, const QList<QString> &_directories);
/**
* @brief Loads Qt's own translation for _lang, which covers the strings of Qt's own dialogs.
*
* @param _translator The translator to load into; must not also be used for an application translation.
* @param _lang The language code, e.g. "de". An empty language loads nothing.
* @return What the search found.
*/
TranslationLoadResult loadQt(QTranslator &_translator, const QString &_lang);
/**
* @brief The language codes of the <_prefix>_<lang>.qm files in _directories, sorted by code.
*
* Lets a language selector offer exactly the languages that can be loaded.
*/
QList<QString> availableLanguages(const QString &_prefix, const QList<QString> &_directories);
/**
* @brief Loads the <_prefix>_<_lang>.qm translation file from _directories into _translator.
*
* @param _translator The translator to load into; must not also be used for Qt's own translation.
* @param _prefix The translation file prefix, e.g. "cockatrice" or "oracle".
* @param _lang The language code, e.g. "de". An empty language loads nothing.
* @param _directories The directories to search, in order of preference.
* @return What the search found.
*/
TranslationLoadResult loadApplication(QTranslator &_translator,
const QString &_prefix,
const QString &_lang,
const QList<QString> &_directories);
} // namespace TranslationLoader
#endif // TRANSLATION_LOADER_H

View file

@ -299,6 +299,18 @@ if(Qt6LinguistTools_FOUND)
elseif(WIN32)
install(FILES ${oracle_QM} DESTINATION ${ORACLE_WIN32_QM_INSTALL_DIR})
endif()
# Qt attaches the .qm files to the default build target only, so building the executable on its own
# (CLion, --target oracle) leaves it without any translations: nothing is translated and the
# language selector in the wizard stays empty.
# Depend on Qt's own lrelease target instead of attaching the .qm custom commands to a second
# target: two independent targets owning the same outputs race on MSBuild/Makefiles and are an
# error on Xcode. Older Qt versions already add this dependency themselves.
foreach(_lrelease_target Oracle_lrelease oracle_lrelease)
if(TARGET ${_lrelease_target})
add_dependencies(oracle ${_lrelease_target})
endif()
endforeach()
endif()
qt6_finalize_target(oracle)

View file

@ -7,10 +7,10 @@
#include <QApplication>
#include <QCommandLineParser>
#include <QIcon>
#include <QLibraryInfo>
#include <QTimer>
#include <QTranslator>
#include <libcockatrice/settings/personal_settings.h>
#include <libcockatrice/utility/translation_loader.h>
QTranslator *translator, *qtTranslator;
ThemeManager *themeManager;
@ -24,24 +24,13 @@ void installNewTranslator()
{
QString lang = SettingsCache::instance().personal().getLang();
QString qtNameHint = "qt_" + lang;
QString qtTranslationPath = QLibraryInfo::path(QLibraryInfo::TranslationsPath);
bool qtTranslationLoaded = qtTranslator->load(qtNameHint, qtTranslationPath);
if (!qtTranslationLoaded) {
qDebug() << "Unable to load qt translation" << qtNameHint << "at" << qtTranslationPath;
} else {
qDebug() << "Loaded qt translation" << qtNameHint << "at" << qtTranslationPath;
}
// Qt's own strings and ours need a translator each: QTranslator discards its previous contents
// on every load, so loading both files into one translator silently drops the first of them.
TranslationLoader::loadQt(*qtTranslator, lang);
qApp->installTranslator(qtTranslator);
QString appNameHint = translationPrefix + "_" + lang;
bool appTranslationLoaded = qtTranslator->load(appNameHint, translationPath);
if (!appTranslationLoaded) {
qDebug() << "Unable to load" << translationPrefix << "translation" << appNameHint << "at" << translationPath;
} else {
qDebug() << "Loaded" << translationPrefix << "translation" << appNameHint << "at" << translationPath;
}
TranslationLoader::loadApplication(*translator, translationPrefix, lang,
TranslationLoader::applicationTranslationPaths(translationPath));
qApp->installTranslator(translator);
}
@ -82,8 +71,9 @@ int main(int argc, char *argv[])
QIcon icon("theme:appicon.svg");
wizard.setWindowIcon(icon);
// set name of the app desktop file; used by wayland to load the window icon
QGuiApplication::setDesktopFileName("Oracle");
// Base name of the installed oracle.desktop; wayland reads the window icon from it and
// xdg-desktop-portal registers it as the app ID, so the case has to match the file name.
QGuiApplication::setDesktopFileName("oracle");
wizard.show();

View file

@ -33,6 +33,7 @@
#include <QtGui>
#include <cstdio>
#include <libcockatrice/settings/personal_settings.h>
#include <libcockatrice/utility/translation_loader.h>
#ifdef HAS_LZMA
#include "lzma/decompress.h"
@ -212,21 +213,18 @@ void IntroPage::initializePage()
QStringList IntroPage::findQmFiles()
{
QDir dir(translationPath);
QStringList fileNames = dir.entryList(QStringList(translationPrefix + "_*.qm"), QDir::Files, QDir::Name);
fileNames.replaceInStrings(QRegularExpression(translationPrefix + "_(.*)\\.qm"), "\\1");
return fileNames;
return TranslationLoader::availableLanguages(translationPrefix,
TranslationLoader::applicationTranslationPaths(translationPath));
}
QString IntroPage::languageName(const QString &lang)
{
QTranslator qTranslator;
QString appNameHint = translationPrefix + "_" + lang;
bool appTranslationLoaded = qTranslator.load(appNameHint, translationPath);
if (!appTranslationLoaded) {
qDebug() << "Unable to load" << translationPrefix << "translation" << appNameHint << "at" << translationPath;
}
// Only the language name is needed here, so the file is loaded without logging a line per
// language: findQmFiles() asked for languages whose files exist in the first place.
TranslationLoader::loadFrom(qTranslator, translationPrefix + "_" + lang,
TranslationLoader::applicationTranslationPaths(translationPath));
return qTranslator.translate("i18n", DEFAULT_LANG_NAME);
}

View file

@ -14,6 +14,10 @@ add_test(NAME server_rate_limiter_test COMMAND server_rate_limiter_test)
add_test(NAME server_developer_role_test COMMAND server_developer_role_test)
add_test(NAME server_game_join_test COMMAND server_game_join_test)
add_test(NAME warning_categories_test COMMAND warning_categories_test)
# The translation loader test compiles a .qm fixture, which needs lrelease from Qt Linguist.
if(TARGET Qt6::lrelease)
add_test(NAME translation_loader_test COMMAND translation_loader_test)
endif()
add_test(NAME report_categories_test COMMAND report_categories_test)
add_test(NAME lag_monitor_test COMMAND lag_monitor_test)
add_test(NAME latency_tracker_test COMMAND latency_tracker_test)
@ -38,6 +42,34 @@ add_executable(server_rate_limiter_test server_rate_limiter_test.cpp)
add_executable(server_developer_role_test server_developer_role_test.cpp)
add_executable(server_game_join_test server_game_join_test.cpp)
add_executable(warning_categories_test warning_categories_test.cpp)
if(TARGET Qt6::lrelease)
add_executable(translation_loader_test translation_loader_test.cpp)
# QTranslator only accepts real .qm files, so the translation loader test gets one compiled from its
# own .ts fixture rather than a stub.
set(TRANSLATION_LOADER_TEST_QM "${CMAKE_CURRENT_BINARY_DIR}/translation_loader_test_de.qm")
add_custom_command(
OUTPUT "${TRANSLATION_LOADER_TEST_QM}"
COMMAND Qt6::lrelease "${CMAKE_CURRENT_SOURCE_DIR}/translation_loader_test.ts" -qm "${TRANSLATION_LOADER_TEST_QM}"
DEPENDS "${CMAKE_CURRENT_SOURCE_DIR}/translation_loader_test.ts"
COMMENT "Compiling translation_loader_test.ts"
)
add_custom_target(translation_loader_test_qm DEPENDS "${TRANSLATION_LOADER_TEST_QM}")
add_dependencies(translation_loader_test translation_loader_test_qm)
target_compile_definitions(translation_loader_test PRIVATE TRANSLATION_LOADER_TEST_DIR="${CMAKE_CURRENT_BINARY_DIR}")
# A multi-config generator (MSBuild, Xcode) puts the executable in a per-config subdirectory while
# the .qm stays in the current binary directory, so copy the fixture next to the binary: that is the
# layout ABuildTreeFindsTheTranslationsNextToTheExecutable is about, and the only one it finds on
# those generators.
add_custom_command(
TARGET translation_loader_test
POST_BUILD
COMMAND ${CMAKE_COMMAND} -E copy_if_different "${TRANSLATION_LOADER_TEST_QM}"
"$<TARGET_FILE_DIR:translation_loader_test>/"
VERBATIM
)
endif()
add_executable(report_categories_test report_categories_test.cpp)
add_executable(lag_monitor_test ${CMAKE_SOURCE_DIR}/cockatrice/src/client/lag_monitor.cpp lag_monitor_test.cpp)
target_include_directories(lag_monitor_test PRIVATE ${CMAKE_SOURCE_DIR}/cockatrice/src)
@ -104,6 +136,9 @@ if(NOT GTEST_FOUND)
add_dependencies(server_developer_role_test gtest)
add_dependencies(server_game_join_test gtest)
add_dependencies(warning_categories_test gtest)
if(TARGET translation_loader_test)
add_dependencies(translation_loader_test gtest)
endif()
add_dependencies(report_categories_test gtest)
add_dependencies(lag_monitor_test gtest)
add_dependencies(latency_tracker_test gtest)
@ -152,6 +187,11 @@ target_link_libraries(
target_link_libraries(
warning_categories_test libcockatrice_utility Threads::Threads ${GTEST_BOTH_LIBRARIES} ${TEST_QT_MODULES}
)
if(TARGET translation_loader_test)
target_link_libraries(
translation_loader_test libcockatrice_utility Threads::Threads ${GTEST_BOTH_LIBRARIES} ${TEST_QT_MODULES}
)
endif()
target_link_libraries(
report_categories_test libcockatrice_utility Threads::Threads ${GTEST_BOTH_LIBRARIES} ${TEST_QT_MODULES}
)

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)

View file

@ -0,0 +1,262 @@
#include "gtest/gtest.h"
#include <QCoreApplication>
#include <QDir>
#include <QLibraryInfo>
#include <QSet>
#include <QString>
#include <QTemporaryDir>
#include <QTranslator>
#include <libcockatrice/utility/translation_loader.h>
// The directory holding translation_loader_test_de.qm, compiled from translation_loader_test.ts by the
// build. QTranslator rejects anything that is not a real .qm file, so a stub cannot stand in here.
#ifndef TRANSLATION_LOADER_TEST_DIR
#define TRANSLATION_LOADER_TEST_DIR ""
#endif
namespace
{
const QString testContext = QStringLiteral("TranslationLoaderTest");
const QString firstMessage = QStringLiteral("Load into the first translator");
const QString secondMessage = QStringLiteral("Load into the second translator");
const QString germanFirstMessage = QStringLiteral("In den ersten Übersetzer laden");
const QString germanSecondMessage = QStringLiteral("In den zweiten Übersetzer laden");
QString translate(const QTranslator &_translator, const QString &_sourceText)
{
return _translator.translate(testContext.toUtf8().constData(), _sourceText.toUtf8().constData());
}
QString testTranslationDirectory()
{
return QString(TRANSLATION_LOADER_TEST_DIR);
}
// GTest's main() creates no QCoreApplication, which the search paths are derived from.
const QCoreApplication &application()
{
static int argc = 1;
static char executable[] = "translation_loader_test";
static char *argv[] = {executable, nullptr};
static const QCoreApplication app(argc, argv);
return app;
}
} // namespace
TEST(TranslationLoaderTest, LoadsFromTheFirstDirectoryHoldingTheFile)
{
QTemporaryDir emptyDir;
ASSERT_TRUE(emptyDir.isValid());
QTranslator translator;
// The first directory is searched but has no file, the second one has.
EXPECT_EQ(TranslationLoadResult::Loaded,
TranslationLoader::loadFrom(translator, QStringLiteral("translation_loader_test_de"),
{emptyDir.path(), testTranslationDirectory()}));
EXPECT_EQ(germanFirstMessage, translate(translator, firstMessage));
}
TEST(TranslationLoaderTest, LoadedTranslatorResolvesTheTranslatedString)
{
QTranslator translator;
ASSERT_EQ(TranslationLoadResult::Loaded,
TranslationLoader::loadFrom(translator, QStringLiteral("translation_loader_test_de"),
{testTranslationDirectory()}));
EXPECT_EQ(germanFirstMessage, translate(translator, firstMessage));
EXPECT_EQ(germanSecondMessage, translate(translator, secondMessage));
}
TEST(TranslationLoaderTest, SeparateTranslatorsKeepTheirOwnFiles)
{
// QTranslator discards its previous contents on every load, so a caller reusing one translator
// for two files keeps only the last of them. That is why Qt's translation and the application
// translation are loaded into translators of their own.
QTranslator firstTranslator;
QTranslator secondTranslator;
ASSERT_EQ(TranslationLoadResult::Loaded,
TranslationLoader::loadFrom(firstTranslator, QStringLiteral("translation_loader_test_de"),
{testTranslationDirectory()}));
ASSERT_EQ(TranslationLoadResult::Loaded,
TranslationLoader::loadFrom(secondTranslator, QStringLiteral("translation_loader_test_de"),
{testTranslationDirectory()}));
EXPECT_FALSE(firstTranslator.isEmpty());
EXPECT_FALSE(secondTranslator.isEmpty());
EXPECT_EQ(germanFirstMessage, translate(firstTranslator, firstMessage));
EXPECT_EQ(germanSecondMessage, translate(secondTranslator, secondMessage));
}
TEST(TranslationLoaderTest, ExistingDirectoryWithoutTheFileIsMissing)
{
QTemporaryDir emptyDir;
ASSERT_TRUE(emptyDir.isValid());
QTranslator translator;
EXPECT_EQ(TranslationLoadResult::Missing,
TranslationLoader::loadFrom(translator, QStringLiteral("translation_loader_test_de"), {emptyDir.path()}));
EXPECT_TRUE(translator.isEmpty());
}
TEST(TranslationLoaderTest, NonexistentDirectoriesAreNotSearched)
{
QTemporaryDir emptyDir;
ASSERT_TRUE(emptyDir.isValid());
QTranslator translator;
const QString missingDirectory = emptyDir.path() + QStringLiteral("/does-not-exist");
EXPECT_EQ(
TranslationLoadResult::NoDirectory,
TranslationLoader::loadFrom(translator, QStringLiteral("translation_loader_test_de"), {missingDirectory}));
EXPECT_EQ(TranslationLoadResult::NoDirectory,
TranslationLoader::loadFrom(translator, QStringLiteral("translation_loader_test_de"), {}));
EXPECT_TRUE(translator.isEmpty());
}
TEST(TranslationLoaderTest, EmptyDirectoryEntriesAreSkipped)
{
QTranslator translator;
EXPECT_EQ(TranslationLoadResult::Loaded,
TranslationLoader::loadFrom(translator, QStringLiteral("translation_loader_test_de"),
{QString(), testTranslationDirectory()}));
}
TEST(TranslationLoaderTest, LanguageVariantsFallBackToTheBaseLanguage)
{
QTranslator translator;
// Users pick languages such as en_US or zh-Hans that need not have a translation file of their
// own; a language without one falls back to the file without its country or script part.
EXPECT_EQ(TranslationLoadResult::Loaded,
TranslationLoader::loadFrom(translator, QStringLiteral("translation_loader_test_de_CH"),
{testTranslationDirectory()}));
}
TEST(TranslationLoaderTest, QtTranslationCandidatesCoverTheUsualInstallLayouts)
{
const QList<QString> candidates = TranslationLoader::qtTranslationCandidates();
EXPECT_FALSE(candidates.isEmpty());
for (const QString &candidate : candidates) {
EXPECT_FALSE(candidate.isEmpty());
}
EXPECT_EQ(candidates.size(), QSet<QString>(candidates.cbegin(), candidates.cend()).size());
// A relocated Qt can report a prefix-relative translations path that does not exist (the KDE
// runtime reports /usr/translations while its files live in /usr/share/qt6/translations), so the
// layout Qt actually installs into has to be among the candidates.
const QString prefix = QLibraryInfo::path(QLibraryInfo::PrefixPath);
EXPECT_TRUE(candidates.contains(prefix + QStringLiteral("/share/qt6/translations")));
}
TEST(TranslationLoaderTest, QtTranslationPathsOnlyHoldExistingDirectories)
{
const QList<QString> candidates = TranslationLoader::qtTranslationCandidates();
const QList<QString> paths = TranslationLoader::qtTranslationPaths();
for (const QString &path : paths) {
EXPECT_FALSE(path.isEmpty());
EXPECT_TRUE(QDir(path).exists()) << path.toStdString();
}
EXPECT_EQ(paths.size(), QSet<QString>(paths.cbegin(), paths.cend()).size());
// Every candidate that exists is searched, and nothing else is.
for (const QString &candidate : candidates) {
if (QDir(candidate).exists()) {
EXPECT_TRUE(paths.contains(candidate)) << candidate.toStdString();
}
}
}
TEST(TranslationLoaderTest, LoadApplicationUsesThePrefixAndTheLanguage)
{
QTranslator translator;
ASSERT_EQ(TranslationLoadResult::Loaded,
TranslationLoader::loadApplication(translator, QStringLiteral("translation_loader_test"),
QStringLiteral("de"), {testTranslationDirectory()}));
EXPECT_EQ(germanSecondMessage, translate(translator, secondMessage));
}
TEST(TranslationLoaderTest, ABuildTreeFindsTheTranslationsNextToTheExecutable)
{
// The regression this guards: a build tree keeps the .qm files next to the executable rather than
// in the installed layout, so a developer build loaded no translation and offered no language.
// This test binary's .qm file sits in its own directory, just like cockatrice's ones do.
const QString executableDirectory = application().applicationDirPath();
const QList<QString> paths = TranslationLoader::applicationTranslationPaths(
executableDirectory + QStringLiteral("/../share/cockatrice/translations"));
ASSERT_EQ(2, paths.size());
EXPECT_EQ(executableDirectory + QStringLiteral("/../share/cockatrice/translations"), paths.at(0));
EXPECT_EQ(executableDirectory, paths.at(1));
const QList<QString> languages =
TranslationLoader::availableLanguages(QStringLiteral("translation_loader_test"), paths);
EXPECT_EQ(QStringList({QStringLiteral("de")}), QStringList(languages.cbegin(), languages.cend()));
}
TEST(TranslationLoaderTest, ApplicationTranslationPathsHoldNoDuplicates)
{
const QString executableDirectory = application().applicationDirPath();
EXPECT_EQ(1, TranslationLoader::applicationTranslationPaths(executableDirectory).size());
// Without an installed layout to search there is nothing left but the executable's directory.
const QList<QString> withoutInstalledPath = TranslationLoader::applicationTranslationPaths(QString());
ASSERT_EQ(1, withoutInstalledPath.size());
EXPECT_EQ(executableDirectory, withoutInstalledPath.at(0));
}
TEST(TranslationLoaderTest, AvailableLanguagesListsTheTranslationsThatCanBeLoaded)
{
const QList<QString> languages =
TranslationLoader::availableLanguages(QStringLiteral("translation_loader_test"), {testTranslationDirectory()});
EXPECT_EQ(QStringList({QStringLiteral("de")}), QStringList(languages.cbegin(), languages.cend()));
// Files of another application and files without a language code are not languages.
EXPECT_TRUE(TranslationLoader::availableLanguages(QStringLiteral("some_other_app"), {testTranslationDirectory()})
.isEmpty());
EXPECT_TRUE(TranslationLoader::availableLanguages(QStringLiteral("translation_loader_test"), {}).isEmpty());
}
TEST(TranslationLoaderTest, LoadApplicationReportsAMissingTranslationDirectory)
{
QTemporaryDir emptyDir;
ASSERT_TRUE(emptyDir.isValid());
QTranslator translator;
EXPECT_EQ(TranslationLoadResult::NoDirectory,
TranslationLoader::loadApplication(translator, QStringLiteral("translation_loader_test"),
QStringLiteral("de"),
{emptyDir.path() + QStringLiteral("/translations")}));
}
TEST(TranslationLoaderTest, UnsetLanguageLoadsNothing)
{
QTranslator translator;
// An unset language is the default for English, which has no translation file to begin with:
// nothing is searched for and the translator is left untouched.
EXPECT_EQ(TranslationLoadResult::Missing,
TranslationLoader::loadApplication(translator, QStringLiteral("translation_loader_test"), QString(),
{testTranslationDirectory()}));
EXPECT_EQ(TranslationLoadResult::Missing, TranslationLoader::loadQt(translator, QString()));
EXPECT_TRUE(translator.isEmpty());
EXPECT_TRUE(translator.filePath().isEmpty());
}
int main(int argc, char **argv)
{
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}

View file

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE TS>
<TS version="2.1" language="de">
<context>
<name>TranslationLoaderTest</name>
<message>
<source>Load into the first translator</source>
<translation>In den ersten Übersetzer laden</translation>
</message>
<message>
<source>Load into the second translator</source>
<translation>In den zweiten Übersetzer laden</translation>
</message>
</context>
</TS>