diff --git a/doc/cpp/launcherpaths.md b/doc/cpp/launcherpaths.md index 3a976c7..bd0a303 100644 --- a/doc/cpp/launcherpaths.md +++ b/doc/cpp/launcherpaths.md @@ -21,6 +21,19 @@ На Linux папка лаунчера получается `~/BiLauncher`, без точки, то есть видимая. +## BILAUNCHER_HOME + +`homeOverride()` читает переменную окружения `BILAUNCHER_HOME`. Если в ней абсолютный путь, он +заменяет домашнюю папку (`~` и `%USERPROFILE%` в таблице выше) для всех путей лаунчера, включая +папку игры по умолчанию. Пустое или относительное значение не учитывается. Остальной Qt +(`QDir::homePath()`, `QStandardPaths`) эту переменную не видит. + +Переменная нужна тестам и дымовой проверке на Windows: там `QDir::homePath()` спрашивает профиль +у системы и `USERPROFILE` игнорирует, так что иначе их нельзя увести из настоящего +`%APPDATA%\BiLauncher`. Новых возможностей она не даёт: кто управляет окружением процесса, на +Linux и macOS и так подменит `HOME`. Чтобы случайно оставленную переменную было видно, `main.cpp` +при старте пишет в лог предупреждение с итоговой папкой лаунчера. + Внутри `rootDir()`: ``` diff --git a/doc/testing.md b/doc/testing.md index a55aa3c..21c5429 100644 --- a/doc/testing.md +++ b/doc/testing.md @@ -70,13 +70,14 @@ CMake, тесты появятся в панели «Тесты». ## Как тесты изолированы -**Файлы.** `LauncherPaths` строит пути от `QDir::homePath()`. Общий `main` тестов -(`tests/common/testenv.h`) до создания приложения подменяет `HOME` на временную папку, и каждый -процесс работает в своей. Если путь лаунчера всё же оказался вне песочницы, процесс сразу -завершается. На Windows `QDir::homePath()` переменные окружения не читает, поэтому там тесты идут -в настоящей `%APPDATA%\BiLauncher` учётной записи раннера, строго по одному, а за собой папку -стирают. Если папка уже есть и создана не тестами (нет метки `.bilauncher-test-sandbox`), тесты -отказываются запускаться. +**Файлы.** `LauncherPaths` строит пути от домашней папки. Общий `main` тестов +(`tests/common/testenv.h`) до создания приложения направляет `BILAUNCHER_HOME` (а заодно `HOME` +и `USERPROFILE`) на временную папку, и каждый процесс работает в своей, на всех ОС, параллельно. +`BILAUNCHER_HOME` нужна ради Windows: там `QDir::homePath()` берёт профиль у системы и `HOME` +с `USERPROFILE` не читает. Дочерние процессы наследуют переменную. Если папка лаунчера или папка +игры по умолчанию всё же оказалась вне песочницы, процесс сразу завершается с кодом 2 и ничего не +трогает. Дымовая проверка установленной сборки на Windows (`smoke_installed.ps1`) тоже запускает +лаунчер с `BILAUNCHER_HOME` на временную папку. **Сеть.** Адреса серверов в сервисах зашиты константами, а свои `QNetworkAccessManager` они создают сами. Зато прокси и SSL-настройки у Qt общие на процесс, и код лаунчера их не трогает. diff --git a/gamelauncher.cpp b/gamelauncher.cpp index b8c138e..f2d1b75 100644 --- a/gamelauncher.cpp +++ b/gamelauncher.cpp @@ -8,6 +8,7 @@ #include #include #include +#include #include namespace { @@ -46,7 +47,11 @@ bool isNativeLibraryFile(const QString &fileName) for (const QString &suffix : suffixes) if (fileName.endsWith(suffix, Qt::CaseInsensitive)) return true; - return fileName.contains(QStringLiteral(".so.")); // libfoo.so.1 + // Версионированные so (libfoo.so.1, libfoo.so.1.2.3). Хвост — строго цифры, + // иначе в натив попадают служебные файлы LWJGL вроде liblwjgl.so.sha1 / .so.git. + static const QRegularExpression versionedSo(QStringLiteral("\\.so(\\.\\d+)+$"), + QRegularExpression::CaseInsensitiveOption); + return versionedSo.match(fileName).hasMatch(); } } // namespace diff --git a/launcherpaths.cpp b/launcherpaths.cpp index e9bbd07..09a8574 100644 --- a/launcherpaths.cpp +++ b/launcherpaths.cpp @@ -3,21 +3,38 @@ #include #include +#include namespace { const QString kFolderName = QStringLiteral("BiLauncher"); +// Домашняя папка, от которой строятся все пути (см. LauncherPaths::homeOverride()). +QString homeBase() +{ + const QString overridden = LauncherPaths::homeOverride(); + return overridden.isEmpty() ? QDir::homePath() : overridden; +} + } // namespace +QString LauncherPaths::homeOverride() +{ + // Относительный путь зависел бы от текущей папки процесса — такой не принимаем. + const QString value = QDir::fromNativeSeparators(qEnvironmentVariable("BILAUNCHER_HOME")); + if (value.isEmpty() || !QDir::isAbsolutePath(value)) + return QString(); + return QDir::cleanPath(value); +} + QString LauncherPaths::containerDir() { #if defined(Q_OS_WIN) - return QDir::homePath() + QStringLiteral("/AppData/Roaming"); + return homeBase() + QStringLiteral("/AppData/Roaming"); #elif defined(Q_OS_MACOS) - return QDir::homePath() + QStringLiteral("/Library/Application Support"); + return homeBase() + QStringLiteral("/Library/Application Support"); #else // Linux и прочие Unix-системы - return QDir::homePath(); + return homeBase(); #endif } diff --git a/launcherpaths.h b/launcherpaths.h index cd8a11c..e26f4e1 100644 --- a/launcherpaths.h +++ b/launcherpaths.h @@ -4,6 +4,14 @@ namespace LauncherPaths { +// Все пути строятся от домашней папки пользователя (QDir::homePath()). Переменная +// окружения BILAUNCHER_HOME с абсолютным путём подменяет её — только для путей +// лаунчера (папка лаунчера и папка игры по умолчанию), остальной Qt её не видит. +// Нужна тестам и дымовой проверке на Windows: там QDir::homePath() спрашивает +// профиль у системы и HOME/USERPROFILE игнорирует. Пустое или относительное +// значение не учитывается. Возвращает подменённую папку или пустую строку. +QString homeOverride(); + QString containerDir(); QString rootDir(); QString defaultMinecraftDir(); diff --git a/main.cpp b/main.cpp index 33e836c..917b139 100644 --- a/main.cpp +++ b/main.cpp @@ -1,5 +1,8 @@ +#include "launcherpaths.h" #include "localization.h" +#include +#include #include #include #include @@ -33,6 +36,10 @@ int main(int argc, char *argv[]) QGuiApplication::setDesktopFileName(QStringLiteral("bilauncher")); + // Случайно оставленная BILAUNCHER_HOME увела бы данные в чужую папку — пусть это будет видно в логе. + if (!LauncherPaths::homeOverride().isEmpty()) + qWarning().noquote() << "BILAUNCHER_HOME: данные лаунчера в" << QDir::toNativeSeparators(LauncherPaths::rootDir()); + if (!Localization::instance().load()) return -1; diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 1bca796..0fc9249 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -116,8 +116,6 @@ function(bilauncher_add_test name) TIMEOUT ${ARG_TIMEOUT} ENVIRONMENT "${BILAUNCHER_TEST_ENV}") if(WIN32) - # На Windows все тесты делят настоящую %APPDATA%\BiLauncher (см. common/testenv.h). - set_tests_properties(${name} PROPERTIES RESOURCE_LOCK launcher_profile) # DLL Qt — из установки Qt, без windeployqt для каждого теста. set_property(TEST ${name} APPEND PROPERTY ENVIRONMENT_MODIFICATION "PATH=path_list_prepend:$") diff --git a/tests/common/testenv.cpp b/tests/common/testenv.cpp index cc1929b..c441827 100644 --- a/tests/common/testenv.cpp +++ b/tests/common/testenv.cpp @@ -10,16 +10,33 @@ #include #include +#if defined(Q_OS_WIN) +#include +#include // _wputenv_s +#endif + namespace { -const char kMarker[] = ".bilauncher-test-sandbox"; - QTemporaryDir *g_sandbox = nullptr; -bool g_realProfile = false; // Windows: работаем в настоящем профиле под меткой [[noreturn]] void abortSetup(const QString &message) { - std::fprintf(stderr, "TestEnv: %s\n", qPrintable(message)); + const QString line = QStringLiteral("TestEnv: ") + message + u'\n'; +#if defined(Q_OS_WIN) + // В живую консоль пишем UTF-16 напрямую: кодовая страница консоли тогда не важна. + const HANDLE handle = GetStdHandle(STD_ERROR_HANDLE); + DWORD mode = 0; + if (handle != INVALID_HANDLE_VALUE && GetConsoleMode(handle, &mode)) { + DWORD written = 0; + WriteConsoleW(handle, reinterpret_cast(line.utf16()), + DWORD(line.size()), &written, nullptr); + std::exit(2); + } +#endif + // Вывод в трубу (ctest, CI) — UTF-8, как его и читает ctest. Локальная 8-битная + // кодировка Windows (cp1252/cp1251) превращала кириллицу в «???». + std::fputs(line.toUtf8().constData(), stderr); + std::fflush(stderr); std::exit(2); } @@ -31,15 +48,16 @@ void TestEnv::prepareSandbox() if (!g_sandbox->isValid()) abortSetup(QStringLiteral("не удалось создать временную папку")); - const QByteArray home = QDir::toNativeSeparators(g_sandbox->path()).toLocal8Bit(); - qputenv("HOME", home); - qputenv("USERPROFILE", home); + const QString home = QDir::toNativeSeparators(g_sandbox->path()); + setEnvPath("HOME", home); + setEnvPath("USERPROFILE", home); + // LauncherPaths: на Windows QDir::homePath() HOME/USERPROFILE не читает, а эту + // переменную лаунчер читает везде. Дочерние процессы получают её по наследству. + setEnvPath("BILAUNCHER_HOME", home); // Чужая Java из окружения машины не должна влиять на выбор Java в тестах. qunsetenv("JAVA_HOME"); std::atexit([] { - if (g_realProfile) - QDir(LauncherPaths::rootDir()).removeRecursively(); delete g_sandbox; g_sandbox = nullptr; }); @@ -52,19 +70,13 @@ void TestEnv::finishSetup() const QString root = QDir::cleanPath(LauncherPaths::rootDir()); const QString sandbox = QDir::cleanPath(g_sandbox->path()); - if (!root.startsWith(sandbox + u'/')) { -#if defined(Q_OS_WIN) - if (QDir(root).exists() && !QFile::exists(root + u'/' + QLatin1String(kMarker))) { - abortSetup(QStringLiteral( - "%1 уже существует и создана не тестами — отказываюсь её трогать. " - "Запускайте тесты под отдельной учётной записью.") - .arg(QDir::toNativeSeparators(root))); - } - g_realProfile = true; - resetLauncherRoot(); -#else - abortSetup(QStringLiteral("папка лаунчера %1 вне песочницы %2").arg(root, sandbox)); -#endif + const QString game = QDir::cleanPath(LauncherPaths::defaultMinecraftDir()); + // Ни одна папка лаунчера не должна указывать на настоящий профиль: resetLauncherRoot() + // стирает rootDir() целиком. + for (const QString &path : {root, game}) { + if (!path.startsWith(sandbox + u'/')) + abortSetup(QStringLiteral("путь лаунчера %1 вне песочницы %2 — отказываюсь запускаться") + .arg(QDir::toNativeSeparators(path), QDir::toNativeSeparators(sandbox))); } if (!Localization::instance().load()) @@ -72,6 +84,18 @@ void TestEnv::finishSetup() Localization::instance().setLanguage(QStringLiteral("en")); } +void TestEnv::setEnvPath(const char *name, const QString &path) +{ + // На Windows qputenv() пишет в ANSI-кодировке и испортил бы, например, + // кириллическое имя пользователя во временном пути. +#if defined(Q_OS_WIN) + _wputenv_s(reinterpret_cast(QString::fromLatin1(name).utf16()), + reinterpret_cast(path.utf16())); +#else + qputenv(name, QFile::encodeName(path)); +#endif +} + QString TestEnv::homeDir() { return g_sandbox->path(); @@ -87,11 +111,5 @@ QString TestEnv::freshDir(const QString &name) void TestEnv::resetLauncherRoot() { - const QString root = LauncherPaths::rootDir(); - QDir(root).removeRecursively(); - if (g_realProfile) { - QDir().mkpath(root); - QFile marker(root + u'/' + QLatin1String(kMarker)); - marker.open(QIODevice::WriteOnly); - } + QDir(LauncherPaths::rootDir()).removeRecursively(); } diff --git a/tests/common/testenv.h b/tests/common/testenv.h index c107ca7..b631357 100644 --- a/tests/common/testenv.h +++ b/tests/common/testenv.h @@ -2,16 +2,14 @@ // Общая обвязка тестов: песочница для домашней папки и main для QtTest. // -// LauncherPaths строит все пути от QDir::homePath(). На Linux и macOS он берёт -// HOME, поэтому каждый тестовый процесс до создания QCoreApplication -// подменяет HOME на свою временную папку — реальные ~/BiLauncher и .minecraft -// машины, на которой гоняются тесты, не трогаются. -// -// На Windows QDir::homePath() спрашивает профиль у системы и переменные -// окружения игнорирует. Там тесты работают в настоящей -// %APPDATA%\BiLauncher, но только если её ещё нет либо она создана тестами -// (в ней лежит метка .bilauncher-test-sandbox). Иначе процесс отказывается -// запускаться, чтобы не испортить данные живого лаунчера. +// LauncherPaths строит все пути от домашней папки. Каждый тестовый процесс до +// создания QCoreApplication заводит свою временную папку и направляет туда +// BILAUNCHER_HOME (её LauncherPaths читает на всех ОС), а заодно HOME и +// USERPROFILE (их читает QDir::homePath() на Linux и macOS). На Windows +// QDir::homePath() переменные окружения игнорирует, поэтому без BILAUNCHER_HOME +// там не обойтись. Реальные папка лаунчера и .minecraft машины, на которой гоняются +// тесты, не трогаются: если путь лаунчера всё же оказался вне песочницы, +// процесс сразу завершается с кодом 2. #include #include @@ -19,14 +17,17 @@ namespace TestEnv { -// До QCoreApplication: временная папка и подмена HOME/USERPROFILE. +// До QCoreApplication: временная папка и подмена BILAUNCHER_HOME/HOME/USERPROFILE. void prepareSandbox(); // После QCoreApplication: проверка, что пути лаунчера ушли в песочницу, // загрузка переводов, английский язык (тексты ошибок не зависят от локали машины). void finishSetup(); -// Корень песочницы — то, что сейчас возвращает QDir::homePath() в тестах. +// Переменная окружения с путём, без потерь символов вне ANSI-кодировки на Windows. +void setEnvPath(const char *name, const QString &path); + +// Корень песочницы — домашняя папка, от которой LauncherPaths строит пути в тестах. QString homeDir(); // Свежая пустая папка внутри песочницы (для .minecraft, архивов и т. п.). diff --git a/tests/scripts/smoke_installed.ps1 b/tests/scripts/smoke_installed.ps1 index df30b05..119f4b6 100644 --- a/tests/scripts/smoke_installed.ps1 +++ b/tests/scripts/smoke_installed.ps1 @@ -6,9 +6,9 @@ # лаунчер запускается без окна и через 10 секунд ещё жив, а в логе нет ошибок # загрузки модулей. # -# На Windows лаунчер берёт папку профиля у системы, а не из переменных окружения, -# поэтому работает в настоящей %APPDATA%\BiLauncher учётной записи раннера. Если -# её до запуска не было, скрипт убирает её за собой. +# QDir::homePath() на Windows берёт профиль у системы и USERPROFILE не читает, +# поэтому лаунчер запускается с BILAUNCHER_HOME на временную папку (см. +# launcherpaths.h) — настоящая %APPDATA%\BiLauncher раннера не трогается. param( [Parameter(Mandatory = $true)][string]$Prefix, @@ -28,14 +28,17 @@ if (-not (Get-ChildItem (Join-Path $Prefix "bin\tls") -Filter "*.dll" -ErrorActi Fail "нет TLS-плагина (bin\tls) — лаунчер не сможет ходить по https" } -$profileRoot = Join-Path $env:APPDATA "BiLauncher" -$existedBefore = Test-Path $profileRoot +$realProfile = Join-Path $env:APPDATA "BiLauncher" +$realExisted = Test-Path $realProfile -$log = Join-Path $env:TEMP ("bilauncher-smoke-" + [guid]::NewGuid().ToString("N")) +$sandbox = Join-Path $env:TEMP ("bilauncher-smoke-" + [guid]::NewGuid().ToString("N")) +$log = Join-Path $sandbox "log" New-Item -ItemType Directory -Force $log | Out-Null $stdout = Join-Path $log "stdout.log" $stderr = Join-Path $log "stderr.log" +$profileRoot = Join-Path $sandbox "AppData\Roaming\BiLauncher" +$env:BILAUNCHER_HOME = $sandbox $env:QT_QPA_PLATFORM = "offscreen" $env:QT_FORCE_STDERR_LOGGING = "1" $env:QTWEBENGINE_CHROMIUM_FLAGS = "--disable-gpu" @@ -58,9 +61,9 @@ try { Fail "в логе ошибки загрузки модулей" } if (-not (Test-Path (Join-Path $profileRoot "settings.json"))) { Fail "лаунчер не создал $profileRoot\settings.json" } + if (-not $realExisted -and (Test-Path $realProfile)) { Fail "лаунчер создал настоящую $realProfile мимо BILAUNCHER_HOME" } Write-Host "SMOKE OK: $bin" } finally { - Remove-Item -Recurse -Force $log -ErrorAction SilentlyContinue - if (-not $existedBefore) { Remove-Item -Recurse -Force $profileRoot -ErrorAction SilentlyContinue } + Remove-Item -Recurse -Force $sandbox -ErrorAction SilentlyContinue } diff --git a/tests/unit/tst_gamelauncher.cpp b/tests/unit/tst_gamelauncher.cpp index 360bdb9..4e37e09 100644 --- a/tests/unit/tst_gamelauncher.cpp +++ b/tests/unit/tst_gamelauncher.cpp @@ -99,7 +99,11 @@ class TestGameLauncher : public QObject Fixtures::makeZip(m_game + "/libraries/org/lwjgl/lwjgl-3.3.1-natives.jar", {{QStringLiteral("META-INF/MANIFEST.MF"), "x"}, {QStringLiteral("linux/x64/org/lwjgl/") + kNativeName, "native-bytes"}, - {QStringLiteral("skip/") + kNativeName + ".sha1", "hash"}}); + {QStringLiteral("skip/") + kNativeName + ".sha1", "hash"}, + // Служебные файлы LWJGL рядом с .so: не должны приниматься + // за версионированную библиотеку (libfoo.so.1) ни на одной ОС. + {QStringLiteral("skip/liblwjgl.so.sha1"), "hash"}, + {QStringLiteral("skip/liblwjgl.so.git"), "git"}}); Fixtures::write(m_game + "/assets/indexes/5.json", "{}"); QDir().mkpath(m_game + "/assets/objects"); } diff --git a/tests/unit/tst_launcherpaths.cpp b/tests/unit/tst_launcherpaths.cpp index 023cbd5..84c5fa2 100644 --- a/tests/unit/tst_launcherpaths.cpp +++ b/tests/unit/tst_launcherpaths.cpp @@ -6,6 +6,7 @@ #include #include #include +#include class TestLauncherPaths : public QObject { @@ -22,7 +23,9 @@ private slots: void containerPerPlatform() { - const QString home = QDir::homePath(); + // Не QDir::homePath(): на Windows он смотрит в настоящий профиль, а пути + // лаунчера в тестах идут от BILAUNCHER_HOME (песочницы). + const QString home = QDir::cleanPath(TestEnv::homeDir()); #if defined(Q_OS_WIN) QCOMPARE(LauncherPaths::containerDir(), home + QStringLiteral("/AppData/Roaming")); QCOMPARE(LauncherPaths::defaultMinecraftDir(), LauncherPaths::containerDir() + "/.minecraft"); @@ -35,6 +38,25 @@ private slots: #endif } + void homeOverrideFromEnvironment() + { + const QString saved = qEnvironmentVariable("BILAUNCHER_HOME"); + const auto restore = qScopeGuard([&] { TestEnv::setEnvPath("BILAUNCHER_HOME", saved); }); + + QCOMPARE(LauncherPaths::homeOverride(), QDir::cleanPath(TestEnv::homeDir())); + + const QString other = TestEnv::freshDir("other-home"); + TestEnv::setEnvPath("BILAUNCHER_HOME", QDir::toNativeSeparators(other)); + QCOMPARE(LauncherPaths::homeOverride(), QDir::cleanPath(other)); + QVERIFY(LauncherPaths::rootDir().startsWith(QDir::cleanPath(other) + u'/')); + + // Относительный путь зависел бы от текущей папки — игнорируется, как и пустой. + for (const char *ignored : {"relative/home", ""}) { + qputenv("BILAUNCHER_HOME", ignored); + QVERIFY2(LauncherPaths::homeOverride().isEmpty(), ignored); + } + } + void filesLayout_data() { QTest::addColumn("actual");