From 1d781b5324faffa0775acea62d3b9a2d8633a074 Mon Sep 17 00:00:00 2001 From: galeon Date: Fri, 23 Oct 2026 09:55:15 +0300 Subject: [PATCH] fix #23 by adding validating on first startup and close #30 by adding version number Signed-off-by: galeon --- .gitignore | 1 + CMakeLists.txt | 8 +- Main.qml | 12 ++ doc/build-and-release.md | 11 +- doc/cpp/LauncherBackend.md | 24 ++- doc/cpp/gamedirimport.md | 57 ++++++ doc/cpp/main.md | 3 +- doc/index.md | 1 + doc/qml/Main.md | 4 + doc/testing.md | 1 + gamedirimport.cpp | 244 ++++++++++++++++++++++++++ gamedirimport.h | 38 ++++ i18n/translations.json | 12 ++ launcherbackend.cpp | 44 +++++ launcherbackend.h | 6 + tests/CMakeLists.txt | 2 +- tests/backend/tst_launcherbackend.cpp | 101 +++++++++++ tests/unit/CMakeLists.txt | 3 +- tests/unit/tst_gamedirimport.cpp | 243 +++++++++++++++++++++++++ 19 files changed, 805 insertions(+), 10 deletions(-) create mode 100644 doc/cpp/gamedirimport.md create mode 100644 gamedirimport.cpp create mode 100644 gamedirimport.h create mode 100644 tests/unit/tst_gamedirimport.cpp diff --git a/.gitignore b/.gitignore index b7e02fc..edf8ce6 100644 --- a/.gitignore +++ b/.gitignore @@ -61,6 +61,7 @@ replay_pid* build/ .qtcreator/ .DS_Store +/doc/adr/ CMakeLists.txt.user* stage/ dist/ diff --git a/CMakeLists.txt b/CMakeLists.txt index 977a388..eda5294 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,6 +1,8 @@ cmake_minimum_required(VERSION 3.20) -project(BiLauncher VERSION 1.0.0 LANGUAGES CXX C) +project(BiLauncher VERSION 1.0.1 LANGUAGES CXX C) +set(LAUNCHER_VERSION_SUFFIX "-dev") +set(LAUNCHER_VERSION "${PROJECT_VERSION}${LAUNCHER_VERSION_SUFFIX}") set(CMAKE_CXX_STANDARD_REQUIRED ON) set(QT_NO_PRIVATE_MODULE_WARNING ON) @@ -103,6 +105,8 @@ qt_add_qml_module(BiLauncher buildarchiveworker.cpp buildswitcher.h buildswitcher.cpp + gamedirimport.h + gamedirimport.cpp seasonalbuildservice.h seasonalbuildservice.cpp seasonalpackdownloader.h @@ -143,7 +147,7 @@ target_link_libraries(BiLauncher ) target_compile_definitions(BiLauncher PRIVATE - LAUNCHER_VERSION="${PROJECT_VERSION}" + LAUNCHER_VERSION="${LAUNCHER_VERSION}" ) if(Qt6WebEngineQuick_FOUND) diff --git a/Main.qml b/Main.qml index a37b96e..8464393 100644 --- a/Main.qml +++ b/Main.qml @@ -39,6 +39,9 @@ Window { onMicrosoftLoginFailed: (message) => window.showToast(message, "#cc3333", 8000) onMicrosoftReloginRequired: (profileIndex) => backend.startMicrosoftLogin(profileIndex) onGameOutput: (line) => console.log(line) + onGameDirImported: (buildName) => { + window.showToast(Loc.t.build.status.imported.arg(buildName), "#4b7a1f", 10000) + } onSeasonalInstallFinished: (seasonalId, buildName) => { window.showToast(Loc.t.seasonal.status.readyToLaunch.arg(buildName), "#4b7a1f", 8000) @@ -89,6 +92,15 @@ Window { id: image anchors.fill: parent source: "images/launcherBackground.png" + + Text { + id: launcherVersion + x: 8 + y: 8 + color: "#ffffff" + text: Loc.t.app.label.version.arg(Qt.application.version) + font.pixelSize: 12 + } } // ── Status toast ─────────────────────────────────────────────────────── diff --git a/doc/build-and-release.md b/doc/build-and-release.md index 8d11fa8..3277e0b 100644 --- a/doc/build-and-release.md +++ b/doc/build-and-release.md @@ -40,8 +40,15 @@ cmake --build build Из Qt Creator проект открывается как обычный CMake-проект. -Версия приложения задаётся в одном месте, в `project(BiLauncher VERSION x.y.z)`. Оттуда она идёт в -`LAUNCHER_VERSION`, в ресурс версии `.exe`, в `Info.plist` и в имена файлов релиза. +Версия приложения задаётся в начале `CMakeLists.txt` двумя значениями: + +- `project(BiLauncher VERSION x.y.z)` — только числа. Отсюда `PROJECT_VERSION` идёт в ресурс версии + `.exe`, в `Info.plist` и в установщик: все три принимают только числовую версию. +- `LAUNCHER_VERSION_SUFFIX` — метка пре-релиза, например `-dev` или `-rc1`. Для релиза пустая. + +Полная строка `LAUNCHER_VERSION` = `PROJECT_VERSION` + суффикс (`1.0.1-dev`) уходит дефайном в код: +в `QCoreApplication::applicationVersion`, в аргументы запуска игры и в подпись «Версия: …» в левом +верхнем углу окна. Почему так, см. [ADR-0002](adr/0002-launcher-version-display.md). ## Что собирается diff --git a/doc/cpp/LauncherBackend.md b/doc/cpp/LauncherBackend.md index 1d9a019..6f7e1b7 100644 --- a/doc/cpp/LauncherBackend.md +++ b/doc/cpp/LauncherBackend.md @@ -46,16 +46,34 @@ 3. `migrateLegacyBuildsFile()` переносит старый `versions.json` в `customBuilds.json`. Это должно случиться до следующего шага, иначе поверх старых данных появится пустой файл. 4. `loadData()` читает профили и сборки и сортирует их по `id`. -5. `ensureDataFiles()` создаёт недостающие файлы. -6. Если `BuildSwitcher` нашёл журнал оборванного переключения, бэкенд сразу продолжает его через +5. Если это первый запуск, `importGameDir()` заводит сборку под то, что уже лежит в папке игры + (см. ниже). +6. `ensureDataFiles()` создаёт недостающие файлы. +7. Если `BuildSwitcher` нашёл журнал оборванного переключения, бэкенд сразу продолжает его через `resumeInterrupted()`. Пользователю ничего не предлагается. Если начать переключение заново, оно сначала заархивирует полуразобранную `.minecraft` поверх целого архива прежней сборки. -7. Подписка на сигналы сервисов. +8. Подписка на сигналы сервисов. Все проблемы, найденные по пути (битый JSON, папка без права записи, пропущенные записи сборок), копятся в `m_storageIssues` и уходят одним `launchError` через `QTimer::singleShot(0)`: к этому моменту QML уже подписан на сигнал. +### Сборка из папки игры + +Первым запуском считается старт, когда нет ни `settings.json`, ни `customBuilds.json`, ни +старого `versions.json`. Проверка делается до `loadData()`: битый файл она отводит в `.bak`, а +`ensureDataFiles()` потом создаёт пустой. Существующий пустой `customBuilds.json` — это решение +пользователя «сборок нет», и импорта тогда нет. + +Если [GameDirImport::detect()](gamedirimport.md) что-то нашёл, запись сборки добавляется сразу, +минуя `addCustomBuild()` (тот начал бы установку), становится активной и сохраняется вместе с +`activeBuildId`. `BuildSwitcher` не вызывается: сборка описывает то, что уже разложено в папке +игры, а активная сборка без архива — штатное состояние. Сама папка игры только читается. + +Имя: ` <версия>` или `Minecraft <версия>`, а без версии — `build.title.fromGameDir`. +Через `QTimer::singleShot(0)` приходит `gameDirImported(buildName)`, раньше сообщения об ошибках +хранилища, чтобы оно их не перекрыло. Main.qml показывает по нему зелёную плашку. + ## Файлы на диске Все пути берутся из [launcherpaths.h](launcherpaths.md). diff --git a/doc/cpp/gamedirimport.md b/doc/cpp/gamedirimport.md new file mode 100644 index 0000000..05d7d2e --- /dev/null +++ b/doc/cpp/gamedirimport.md @@ -0,0 +1,57 @@ +# gamedirimport.h + +`gamedirimport.h`, `gamedirimport.cpp` + +Пространство имён `GameDirImport`: что уже лежит в папке игры, когда лаунчер запускают впервые. +Состояния нет, сеть не нужна, файлы только читаются. Вызывает его +[LauncherBackend](LauncherBackend.md) при первом запуске, решение описано в +[ADR-0001](../adr/0001-import-existing-minecraft.md). + +## detect(gameDir) + +Возвращает `std::optional`: `minecraftVersion`, `resolvedVersionId`, `loader`, +`loaderVersion` и `source` — откуда взята версия (`launcher_profiles`, `versions` или `content`). + +`nullopt` — импортировать нечего: в `versions/` нет ни одной установленной версии и нет признаков +того, что в папке играли. Признаки — непустые `mods`, `saves`, `resourcepacks`, `shaderpacks`, +`config` или файлы `options.txt`, `servers.dat`. Служебные файлы других лаунчеров +(`launcher_log.txt`, `webcache2`) не считаются: иначе пустая папка официального лаунчера давала +бы сборку без версии. + +Версия выбирается так: + +1. профили из `launcher_profiles.json`, у которых `lastVersionId` установлен, по убыванию + `lastUsed` (профиль без даты считается самым старым). `latest-release` и `latest-snapshot` без + `lastVersionId` пропускаются: без манифеста не узнать, во что они разворачивались; +2. установленные версии по убыванию времени правки `/.json`. + +Версия с нечитаемым описанием пропускается. Если в `mods/` есть хотя бы один `.jar`, сначала +ищется профиль с `inheritsFrom` (сперва в первом списке, потом во втором), и только потом берётся +что есть. Не нашлось ничего — `source` равен `content`, поля версии пустые. + +У профиля с `inheritsFrom` версия игры — это `inheritsFrom`, а `resolvedVersionId` — его `id`, +поэтому запустится ровно найденный профиль, даже если модлоадер не опознан (OptiFine, LabyMod). +У профиля без `inheritsFrom` версия — сам `id`. + +## loaderOf(versionJson) + +Модлоадер по `libraries[].name` описания профиля: + +| Библиотека | `key` | `version` | +|------------|-------|-----------| +| `net.fabricmc:fabric-loader:` | `fabric` | `` | +| `org.quiltmc:quilt-loader:` | `quilt` | `` | +| `net.neoforged:neoforge:` | `neoforge` | `` | +| `net.neoforged:forge:-` | `neoforge` | `` (NeoForge для 1.20.1) | +| `net.minecraftforge:forge:-` | `forge` | `` | + +Префикс `-` срезается, хвост `-1.7.10` у старых Forge остаётся: в каталоге +[ModLoaderVersionService](ModLoaderVersionService.md) версии записаны так же, и карточка сборки +найдёт выбранную строку. + +Если таких библиотек нет, смотрит на `id`, который дают установщики: `fabric-loader--`, +`quilt-loader--`, `neoforge-`, `-forge-`. Не опознано — пустой `key`. + +--- + +При создании этого документа использовался ИИ. diff --git a/doc/cpp/main.md b/doc/cpp/main.md index 2daccd5..4ccd05b 100644 --- a/doc/cpp/main.md +++ b/doc/cpp/main.md @@ -10,7 +10,8 @@ приложения общий контекст OpenGL уже не выставить. 2. **`QGuiApplication`**, а не `QApplication`: виджетов в проекте нет. 3. **Имя и версия.** `applicationName` = `BiLauncher`, `applicationVersion` = `LAUNCHER_VERSION` - (берётся из `project(... VERSION)` в CMake), организация `GaleonDev`, домен `galeonworx.com`. На + (в CMake это `PROJECT_VERSION` плюс `LAUNCHER_VERSION_SUFFIX`, например `1.0.1-dev`; QML читает её + через `Qt.application.version`), организация `GaleonDev`, домен `galeonworx.com`. На пути к данным лаунчера это не влияет, см. [launcherpaths.h](launcherpaths.md). 4. **Иконка окна.** На Windows и Linux в `QIcon` складываются все PNG из ресурса `:/icons/` (16–512 и `@2x`). На macOS этот шаг пропущен: там иконку берут из `icon.icns` внутри бандла. diff --git a/doc/index.md b/doc/index.md index 50bf00b..35fd235 100644 --- a/doc/index.md +++ b/doc/index.md @@ -122,6 +122,7 @@ LauncherBackend ──────────────── Loc (Localizati |---|---| | [BuildSwitcher](cpp/BuildSwitcher.md) | смена активной сборки, раскатка пака, продолжение после падения | | [BuildArchiveWorker](cpp/BuildArchiveWorker.md) | файловые операции в отдельном потоке | +| [gamedirimport.h](cpp/gamedirimport.md) | сборка по содержимому папки игры при первом запуске | | [SeasonalBuildService](cpp/SeasonalBuildService.md) | каталог сезонных сборок и его формат | | [SeasonalPackDownloader](cpp/SeasonalPackDownloader.md) | загрузка архива сезонной сборки | | [launcherpaths.h](cpp/launcherpaths.md) | все пути к данным лаунчера | diff --git a/doc/qml/Main.md b/doc/qml/Main.md index ccc2816..3c47296 100644 --- a/doc/qml/Main.md +++ b/doc/qml/Main.md @@ -25,6 +25,10 @@ сборки справа сверху, кнопки папки и настроек под списком профилей. Кнопка сезонных сборок стоит в правом нижнем углу, потому что остальные углы заняты. +В левом верхнем углу фона стоит подпись `launcherVersion` — «Версия: 1.0.1-dev». Текст из +`app.label.version`, номер из `Qt.application.version`, то есть из `LAUNCHER_VERSION` +(см. [main.cpp](../cpp/main.md)). + Обе панели прогресса (загрузки и смены сборки) стоят в левом нижнем углу на одних и тех же якорях. Одновременно они не появляются: пока лаунчер занят загрузкой, бэкенд не начнёт смену сборки, и наоборот. Панель смены сборки отменить нельзя. diff --git a/doc/testing.md b/doc/testing.md index 21c5429..1127a9d 100644 --- a/doc/testing.md +++ b/doc/testing.md @@ -49,6 +49,7 @@ CMake, тесты появятся в панели «Тесты». | unit | `tst_zlibreference` | `LD_PRELOAD` с эталонным zlib на Linux | | unit | `tst_buildarchiveworker` | архив, очистка, восстановление, раскатка пака, zip-slip, отмена | | unit | `tst_buildswitcher` | переключение сборок туда и обратно, продолжение после обрыва, пак | +| unit | `tst_gamedirimport` | выбор версии по `launcher_profiles.json` и `versions/`, модлоадер по библиотекам и id, папка не меняется | | net | `tst_fakeinternet` | самопроверка перехвата HTTPS (см. ниже) | | net | `tst_versionmanifestservice` | каталог Mojang: разбор, кеш, офлайн, общие запросы | | net | `tst_seasonalbuildservice` | каталог сезонных сборок: проверки полей, формат, кеш по адресу | diff --git a/gamedirimport.cpp b/gamedirimport.cpp new file mode 100644 index 0000000..103100f --- /dev/null +++ b/gamedirimport.cpp @@ -0,0 +1,244 @@ +#include "gamedirimport.h" + +#include "minecraftversion.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +// Признаки того, что в папке играли: это принадлежит сборке. Служебные файлы +// чужих лаунчеров (launcher_log.txt, webcache2 и т. п.) сюда не входят, иначе +// пустая папка официального лаунчера давала бы сборку без версии. +const QStringList kContentMarkers = {QStringLiteral("mods"), + QStringLiteral("saves"), + QStringLiteral("resourcepacks"), + QStringLiteral("shaderpacks"), + QStringLiteral("config"), + QStringLiteral("options.txt"), + QStringLiteral("servers.dat")}; + +struct Candidate +{ + QString id; + QJsonObject json; + bool inherits = false; +}; + +QJsonObject readObject(const QString &path) +{ + QFile file(path); + if (!file.open(QIODevice::ReadOnly)) + return {}; + return QJsonDocument::fromJson(file.readAll()).object(); +} + +QString versionJsonPath(const QString &gameDir, const QString &id) +{ + return gameDir + QStringLiteral("/versions/") + id + u'/' + id + QStringLiteral(".json"); +} + +bool hasContent(const QString &gameDir) +{ + for (const QString &name : kContentMarkers) { + const QFileInfo info(gameDir + u'/' + name); + if (info.isFile()) + return true; + if (info.isDir() && !QDir(info.filePath()).isEmpty()) + return true; + } + return false; +} + +bool hasMods(const QString &gameDir) +{ + return !QDir(gameDir + QStringLiteral("/mods")) + .entryList({QStringLiteral("*.jar")}, QDir::Files) + .isEmpty(); +} + +QDateTime parseLastUsed(const QString &text) +{ + QDateTime when = QDateTime::fromString(text, Qt::ISODateWithMs); + if (!when.isValid()) + when = QDateTime::fromString(text, Qt::ISODate); + return when; +} + +// Версии из launcher_profiles.json, которые есть в versions/, свежие сверху. +// Профили latest-release/latest-snapshot без lastVersionId пропускаются: без +// манифеста не узнать, во что они разворачивались. +QStringList profileVersions(const QString &gameDir, const QStringList &installed) +{ + const QJsonObject profiles = + readObject(gameDir + QStringLiteral("/launcher_profiles.json")) + .value(QStringLiteral("profiles")) + .toObject(); + + struct Used + { + QString id; + QDateTime when; + }; + QList used; + for (auto it = profiles.constBegin(); it != profiles.constEnd(); ++it) { + const QJsonObject profile = it.value().toObject(); + const QString id = profile.value(QStringLiteral("lastVersionId")).toString(); + if (id.isEmpty() || !installed.contains(id)) + continue; + used.append({id, parseLastUsed(profile.value(QStringLiteral("lastUsed")).toString())}); + } + // Профиль без даты считается самым старым. + std::stable_sort(used.begin(), used.end(), [](const Used &a, const Used &b) { + if (a.when.isValid() != b.when.isValid()) + return a.when.isValid(); + return a.when > b.when; + }); + + QStringList ids; + for (const Used &u : used) { + if (!ids.contains(u.id)) + ids << u.id; + } + return ids; +} + +// Установленные версии по времени правки описания, свежие сверху. +QStringList versionsByMtime(const QString &gameDir, QStringList installed) +{ + std::stable_sort(installed.begin(), installed.end(), [&](const QString &a, const QString &b) { + return QFileInfo(versionJsonPath(gameDir, a)).lastModified() + > QFileInfo(versionJsonPath(gameDir, b)).lastModified(); + }); + return installed; +} + +QList load(const QString &gameDir, const QStringList &ids) +{ + QList out; + for (const QString &id : ids) { + const QJsonObject json = readObject(versionJsonPath(gameDir, id)); + // Битое описание не запустится — такую версию не предлагаем. + if (json.isEmpty()) + continue; + out.append({id, json, !json.value(QStringLiteral("inheritsFrom")).toString().isEmpty()}); + } + return out; +} + +// "1.20.1-47.2.0" → "47.2.0". Хвост "-1.7.10" у старых Forge остаётся: в +// каталоге модлоадеров версии записаны так же. +QString stripGamePrefix(const QString &version, const QString &game) +{ + if (!game.isEmpty() && version.startsWith(game + u'-')) + return version.mid(game.size() + 1); + return version; +} + +} + +namespace GameDirImport { + +LoaderInfo loaderOf(const QJsonObject &versionJson) +{ + const QString game = versionJson.value(QStringLiteral("inheritsFrom")).toString(); + + for (const QJsonValue &value : versionJson.value(QStringLiteral("libraries")).toArray()) { + // group:artifact:version[:classifier] + const QStringList parts = value.toObject().value(QStringLiteral("name")).toString().split(u':'); + if (parts.size() < 3) + continue; + const QString coord = parts[0] + u':' + parts[1]; + const QString version = parts[2]; + if (coord == QLatin1String("net.fabricmc:fabric-loader")) + return {QStringLiteral("fabric"), version}; + if (coord == QLatin1String("org.quiltmc:quilt-loader")) + return {QStringLiteral("quilt"), version}; + if (coord == QLatin1String("net.neoforged:neoforge")) + return {QStringLiteral("neoforge"), version}; + // NeoForge для 1.20.1 публиковался под старым именем артефакта. + if (coord == QLatin1String("net.neoforged:forge")) + return {QStringLiteral("neoforge"), stripGamePrefix(version, game)}; + if (coord == QLatin1String("net.minecraftforge:forge")) + return {QStringLiteral("forge"), stripGamePrefix(version, game)}; + } + + // Библиотек модлоадера в описании может не быть (их вынесли в родителя или + // описание урезали руками) — тогда смотрим на имя, которое дают установщики. + const QString id = versionJson.value(QStringLiteral("id")).toString(); + for (const auto &[key, prefix] : {std::pair{QStringLiteral("fabric"), QStringLiteral("fabric-loader-")}, + std::pair{QStringLiteral("quilt"), QStringLiteral("quilt-loader-")}}) { + const QString suffix = u'-' + game; + if (!game.isEmpty() && id.startsWith(prefix) && id.endsWith(suffix)) + return {key, id.mid(prefix.size(), id.size() - prefix.size() - suffix.size())}; + } + if (id.startsWith(QLatin1String("neoforge-"))) + return {QStringLiteral("neoforge"), id.mid(9)}; + const QString forgeInfix = game + QStringLiteral("-forge-"); + if (!game.isEmpty() && id.startsWith(forgeInfix)) + return {QStringLiteral("forge"), id.mid(forgeInfix.size())}; + return {}; +} + +std::optional detect(const QString &gameDir) +{ + if (gameDir.isEmpty() || !QFileInfo(gameDir).isDir()) + return std::nullopt; + + const QStringList installed = VersionLoader::installedVersions(gameDir); + const bool content = hasContent(gameDir); + if (installed.isEmpty() && !content) + return std::nullopt; + + const QList fromProfiles = load(gameDir, profileVersions(gameDir, installed)); + const QList fromVersions = load(gameDir, versionsByMtime(gameDir, installed)); + + // С модами в папке ванильная версия почти наверняка не та, с которой играли: + // сначала ищем профиль модлоадера, и только потом берём что есть. + const Candidate *chosen = nullptr; + QString source; + auto pick = [&](const QList &list, const QString &from, bool needInherits) { + if (chosen) + return; + for (const Candidate &c : list) { + if (!needInherits || c.inherits) { + chosen = &c; + source = from; + return; + } + } + }; + if (hasMods(gameDir)) { + pick(fromProfiles, QStringLiteral("launcher_profiles"), true); + pick(fromVersions, QStringLiteral("versions"), true); + } + pick(fromProfiles, QStringLiteral("launcher_profiles"), false); + pick(fromVersions, QStringLiteral("versions"), false); + + Detected result; + if (!chosen) { + result.source = QStringLiteral("content"); + return result; + } + + result.source = source; + if (chosen->inherits) { + result.minecraftVersion = chosen->json.value(QStringLiteral("inheritsFrom")).toString(); + result.resolvedVersionId = chosen->id; + const LoaderInfo loader = loaderOf(chosen->json); + result.loader = loader.key; + result.loaderVersion = loader.version; + } else { + result.minecraftVersion = chosen->id; + } + return result; +} + +} diff --git a/gamedirimport.h b/gamedirimport.h new file mode 100644 index 0000000..46bd869 --- /dev/null +++ b/gamedirimport.h @@ -0,0 +1,38 @@ +#pragma once + +#include +#include + +#include + +// Что уже разложено в папке игры, когда лаунчер запускают впервые. Только +// читает файлы: .minecraft не меняется ни при каком исходе. +namespace GameDirImport { + +// Модлоадер, найденный в описании профиля versions//.json. +struct LoaderInfo +{ + QString key; // "" | forge | fabric | neoforge | quilt + QString version; // в том же виде, что и в каталоге ModLoaderVersionService +}; + +// Сборка, которую стоит завести под содержимое папки игры. +struct Detected +{ + QString minecraftVersion; // inheritsFrom профиля, а без него — сам id + QString resolvedVersionId; // id профиля, если он наследует другую версию + QString loader; + QString loaderVersion; + // Откуда взята версия: "launcher_profiles", "versions" или "content" — + // версии не нашлось, но моды или миры есть. Для тестов и отладки. + QString source; +}; + +// Модлоадер по библиотекам профиля, а если по ним не понять — по его id. +// Незнакомый профиль (OptiFine, LabyMod) даёт пустой key. +LoaderInfo loaderOf(const QJsonObject &versionJson); + +// nullopt — в папке нет ни установленных версий, ни модов, миров и настроек игры. +std::optional detect(const QString &gameDir); + +} diff --git a/i18n/translations.json b/i18n/translations.json index 5cf6e43..6302d83 100644 --- a/i18n/translations.json +++ b/i18n/translations.json @@ -31,6 +31,10 @@ } }, "strings": { + "app.label.version": { + "ru": "Версия: %1", + "en": "Version: %1" + }, "app.title": { "ru": "BiLauncher", "en": "BiLauncher" @@ -379,6 +383,10 @@ "ru": "Активна сборка «%1»", "en": "Build \"%1\" is active" }, + "build.status.imported": { + "ru": "В папке игры уже были файлы Minecraft, под них создана сборка «%1». Проверьте её версию и модлоадер в окне сборок.", + "en": "The game folder already had Minecraft files, so the build \"%1\" was created for them. Check its version and mod loader in the builds window." + }, "build.status.named": { "ru": "Сборка «%1»: %2", "en": "Build \"%1\": %2" @@ -387,6 +395,10 @@ "ru": "Готова к запуску", "en": "Ready to launch" }, + "build.title.fromGameDir": { + "ru": "Сборка из папки игры", + "en": "Build from the game folder" + }, "build.title.numbered": { "ru": "Сборка %1", "en": "Build %1" diff --git a/launcherbackend.cpp b/launcherbackend.cpp index 11956bb..229f073 100644 --- a/launcherbackend.cpp +++ b/launcherbackend.cpp @@ -15,6 +15,7 @@ #include "msaauthservice.h" #include "modloaderversionservice.h" #include "buildswitcher.h" +#include "gamedirimport.h" #include "seasonalbuildservice.h" #include "seasonalpackdownloader.h" @@ -97,11 +98,20 @@ LauncherBackend::LauncherBackend(QObject *parent) if (!LauncherPaths::ensureRootExists(&storageError)) m_storageIssues << Loc::text("storage.error.launcherDirFailed").arg(storageError); + // Первый запуск — ни настроек, ни списка сборок, ни старого versions.json. + // Смотреть надо до loadData(): битый файл она отводит в .bak, а + // ensureDataFiles() создаёт пустые. Пустой customBuilds.json — это уже + // решение пользователя «сборок нет», и сборку из папки игры мы не заводим. + const bool firstRun = !QFile::exists(LauncherPaths::settingsFile()) + && !QFile::exists(LauncherPaths::customBuildsFile()) + && !QFile::exists(LauncherPaths::legacyCustomBuildsFile()); + loadSettings(); // Порядок важен: миграция должна успеть до того, как ensureDataFiles() // создаст пустой customBuilds.json поверх старых данных. migrateLegacyBuildsFile(); loadData(); + const QString importedBuild = firstRun && m_builds.isEmpty() ? importGameDir() : QString(); ensureDataFiles(); // Прошлое переключение оборвалось на середине. Доигрываем его сами, а не @@ -370,6 +380,10 @@ LauncherBackend::LauncherBackend(QObject *parent) }); // Интерфейс подключается к сигналам уже после конструктора — сообщаем отложенно. + // Ошибки хранилища идут следом: плашка одна, и важнее показать их. + if (!importedBuild.isEmpty()) { + QTimer::singleShot(0, this, [this, importedBuild] { emit gameDirImported(importedBuild); }); + } if (!m_storageIssues.isEmpty()) { QTimer::singleShot(0, this, [this] { emit launchError(m_storageIssues.join(u'\n')); @@ -2483,6 +2497,36 @@ bool LauncherBackend::migrateLegacyBuildsFile() return true; } +// Сборка описывает то, что уже разложено в .minecraft, поэтому BuildSwitcher не +// нужен: активная сборка без архива — штатное состояние. Через addCustomBuild() +// не идём, чтобы при старте не началась установка. +QString LauncherBackend::importGameDir() +{ + const std::optional found = GameDirImport::detect(gameDir()); + if (!found) + return {}; + + CustomBuild build; + build.id = m_nextBuildId++; + build.minecraftVersion = found->minecraftVersion; + build.resolvedVersionId = found->resolvedVersionId; + build.loader = found->loader; + build.loaderVersion = found->loaderVersion; + if (build.minecraftVersion.isEmpty()) { + build.name = Loc::text("build.title.fromGameDir"); + } else if (const auto loader = loaderFromKey(build.loader)) { + build.name = loaderTitle(*loader) + u' ' + build.minecraftVersion; + } else { + build.name = QStringLiteral("Minecraft ") + build.minecraftVersion; + } + m_builds.append(build); + + m_settings.insert(QStringLiteral("activeBuildId"), build.id); + saveSettings(); + saveCustomBuilds(); + return build.name; +} + // Файлы могли не появиться при первом запуске, быть удалены или отложены в .bak — // создаём заново, чтобы папка лаунчера всегда была укомплектована. void LauncherBackend::ensureDataFiles() diff --git a/launcherbackend.h b/launcherbackend.h index 5c84a0b..c978251 100644 --- a/launcherbackend.h +++ b/launcherbackend.h @@ -232,6 +232,9 @@ signals: // Сезонная сборка установлена и активна — можно запускать игру. void seasonalInstallFinished(const QString &seasonalId, const QString &buildName); + // Первый запуск: по содержимому папки игры заведена активная сборка. + void gameDirImported(const QString &buildName); + // Сборка Java установлена: окно настроек обновляет подпись, не переоткрываясь. void javaRuntimeInstalled(const QString &runtimeId); @@ -313,6 +316,9 @@ private: // Перекладывает /versions.json в customBuilds.json при первом запуске // после переименования. true — миграция прошла, читать нужно новый файл. bool migrateLegacyBuildsFile(); + // Первый запуск: заводит активную сборку под то, что уже лежит в папке игры. + // Папку игры только читает. Возвращает имя сборки или пустую строку. + QString importGameDir(); void invalidateCatalog(); void rebuildCatalog() const; diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 0fc9249..31ceed5 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -60,7 +60,7 @@ qt_add_qml_module(bilauncher_testlib ) target_include_directories(bilauncher_testlib PUBLIC "${PROJECT_SOURCE_DIR}") target_compile_definitions(bilauncher_testlib PUBLIC - LAUNCHER_VERSION="${PROJECT_VERSION}" + LAUNCHER_VERSION="${LAUNCHER_VERSION}" ) target_link_libraries(bilauncher_testlib PUBLIC Qt6::Quick Qt6::QuickControls2 Qt6::Qml Qt6::Svg Qt6::Core Qt6::CorePrivate Qt6::Gui Qt6::Network diff --git a/tests/backend/tst_launcherbackend.cpp b/tests/backend/tst_launcherbackend.cpp index a149ddc..895715b 100644 --- a/tests/backend/tst_launcherbackend.cpp +++ b/tests/backend/tst_launcherbackend.cpp @@ -76,6 +76,24 @@ class TestLauncherBackend : public QObject return Fixtures::installFakeJava(m_game + "/../" + name, "17.0.8", config); } + // Папка игры, в которой до лаунчера играли с Fabric. Возвращает её состав. + static QStringList populateGameDir(const QString &dir) + { + QDir(dir).removeRecursively(); + const QString fabric = QStringLiteral("fabric-loader-0.15.0-1.20.1"); + Fixtures::write(dir + "/versions/1.20.1/1.20.1.json", R"({"id": "1.20.1", "type": "release"})"); + Fixtures::write(dir + "/versions/" + fabric + "/" + fabric + ".json", + Fixtures::read("versions/" + fabric + ".json")); + Fixtures::writeJson(dir + "/launcher_profiles.json", + {{"profiles", QJsonObject{{"fabric", QJsonObject{ + {"type", "custom"}, {"lastVersionId", fabric}, + {"lastUsed", "2026-03-01T10:00:00.000Z"}}}}}}); + Fixtures::write(dir + "/mods/sodium.jar", "jar"); + Fixtures::write(dir + "/saves/World/level.dat", "world"); + Fixtures::write(dir + "/options.txt", "fov:90"); + return Fixtures::listTree(dir); + } + private slots: void initTestCase() { @@ -116,6 +134,89 @@ private slots: Localization::instance().setLanguage(QStringLiteral("en")); } + void firstRunImportsGameDir() + { + // Настоящий первый запуск: ни одного файла лаунчера, папка игры по умолчанию. + QFile::remove(LauncherPaths::settingsFile()); + const QString game = LauncherPaths::defaultMinecraftDir(); + const QStringList before = populateGameDir(game); + const QByteArray profilesBefore = Fixtures::readFile(game + "/launcher_profiles.json"); + + { + auto backend = make(); + QSignalSpy imported(backend.get(), &LauncherBackend::gameDirImported); + QSignalSpy errors(backend.get(), &LauncherBackend::launchError); + QVERIFY(imported.wait(2000)); // сообщение откладывается до подключения интерфейса + QCOMPARE(imported.constFirst().constFirst().toString(), QStringLiteral("Fabric Loader 1.20.1")); + QCOMPARE(backend->customBuildNames(), QStringList{"Fabric Loader 1.20.1"}); + QCOMPARE(backend->activeBuildIndex(), 0); + const QVariantMap build = backend->customBuildAt(0); + QCOMPARE(build.value("minecraftVersion").toString(), QStringLiteral("1.20.1")); + QCOMPARE(build.value("loader").toString(), QStringLiteral("fabric")); + QCOMPARE(build.value("loaderVersion").toString(), QStringLiteral("0.15.0")); + QCOMPARE(build.value("launchVersionId").toString(), QStringLiteral("fabric-loader-0.15.0-1.20.1")); + const int id = QJsonDocument::fromJson(Fixtures::readFile(LauncherPaths::customBuildsFile())) + .array().at(0).toObject().value("id").toInt(); + QCOMPARE(id, 1); + QCOMPARE(backend->settings().value("activeBuildId").toInt(), id); + QCOMPARE(Fixtures::readJsonFile(LauncherPaths::settingsFile()).value("activeBuildId").toInt(), id); + // Ничего не качается, ничего не переключается, папка игры та же. + QVERIFY(!backend->downloading()); + QVERIFY(!backend->switching()); + QVERIFY2(errors.isEmpty(), qPrintable(strings(errors).join('\n'))); + } + QCOMPARE(Fixtures::listTree(game), before); + QCOMPARE(Fixtures::readFile(game + "/launcher_profiles.json"), profilesBefore); + QVERIFY(!QFile::exists(LauncherPaths::buildStorageDir() + "/index.json")); + + // Второй запуск — уже не первый: новой сборки нет. + { + auto backend = make(); + QSignalSpy imported(backend.get(), &LauncherBackend::gameDirImported); + QTest::qWait(50); + QVERIFY(imported.isEmpty()); + QCOMPARE(backend->customBuildNames(), QStringList{"Fabric Loader 1.20.1"}); + } + QDir(game).removeRecursively(); + } + + void importOnlyOnFirstRun() + { + // Настройки уже есть (init() их пишет), а списка сборок нет — это не первый запуск. + populateGameDir(m_game); + { + auto backend = make(); + QSignalSpy imported(backend.get(), &LauncherBackend::gameDirImported); + QTest::qWait(50); + QVERIFY(imported.isEmpty()); + QVERIFY(backend->customBuildNames().isEmpty()); + } + + // Пустой список сборок — пользователь сам всё удалил; воскрешать не надо. + TestEnv::resetLauncherRoot(); + QVERIFY(LauncherPaths::ensureRootExists()); + const QString game = LauncherPaths::defaultMinecraftDir(); + populateGameDir(game); + Fixtures::write(LauncherPaths::customBuildsFile(), "[]"); + { + auto backend = make(); + QTest::qWait(50); + QVERIFY(backend->customBuildNames().isEmpty()); + } + QDir(game).removeRecursively(); + } + + void firstRunWithEmptyGameDirCreatesNothing() + { + QFile::remove(LauncherPaths::settingsFile()); + QDir(LauncherPaths::defaultMinecraftDir()).removeRecursively(); + auto backend = make(); + QSignalSpy imported(backend.get(), &LauncherBackend::gameDirImported); + QTest::qWait(50); + QVERIFY(imported.isEmpty()); + QCOMPARE(backend->activeBuildIndex(), -1); + } + void profilesCrudAndPersistence() { { diff --git a/tests/unit/CMakeLists.txt b/tests/unit/CMakeLists.txt index 696250a..db7744d 100644 --- a/tests/unit/CMakeLists.txt +++ b/tests/unit/CMakeLists.txt @@ -11,6 +11,7 @@ foreach(test IN ITEMS tst_javalocator tst_zlibreference tst_buildarchiveworker - tst_buildswitcher) + tst_buildswitcher + tst_gamedirimport) bilauncher_add_test(${test} LABEL unit SOURCES ${test}.cpp) endforeach() diff --git a/tests/unit/tst_gamedirimport.cpp b/tests/unit/tst_gamedirimport.cpp new file mode 100644 index 0000000..d1d99e9 --- /dev/null +++ b/tests/unit/tst_gamedirimport.cpp @@ -0,0 +1,243 @@ +#include "testenv.h" +#include "fixtures.h" + +#include "gamedirimport.h" + +#include +#include +#include +#include + +class TestGameDirImport : public QObject +{ + Q_OBJECT + + QString m_game; + + void writeVersion(const QString &id, const QJsonObject &json) + { + QVERIFY(Fixtures::writeJson(m_game + "/versions/" + id + "/" + id + ".json", json)); + } + + void writeVanilla(const QString &id) { writeVersion(id, {{"id", id}, {"type", "release"}}); } + + void writeLoaderProfile(const QString &id, const QString &game, const QString &library) + { + QJsonArray libraries; + if (!library.isEmpty()) + libraries.append(QJsonObject{{"name", library}}); + writeVersion(id, {{"id", id}, {"inheritsFrom", game}, {"libraries", libraries}}); + } + + void writeProfiles(const QJsonObject &profiles) + { + QVERIFY(Fixtures::writeJson(m_game + "/launcher_profiles.json", {{"profiles", profiles}})); + } + + static QJsonObject profile(const QString &versionId, const QString &lastUsed) + { + return {{"type", "custom"}, {"lastVersionId", versionId}, {"lastUsed", lastUsed}}; + } + + // Мтайм ставим явно: файлы, записанные подряд, легко получают одну секунду. + void touch(const QString &id, int secondsAgo) + { + QFile file(m_game + "/versions/" + id + "/" + id + ".json"); + QVERIFY(file.open(QIODevice::ReadWrite)); + QVERIFY(file.setFileTime(QDateTime::currentDateTime().addSecs(-secondsAgo), + QFileDevice::FileModificationTime)); + } + +private slots: + void init() { m_game = TestEnv::freshDir(QStringLiteral("game")); } + + void missingOrEmptyFolderGivesNothing() + { + QVERIFY(!GameDirImport::detect(m_game + "/nope")); + QVERIFY(!GameDirImport::detect(m_game)); + QVERIFY(!GameDirImport::detect(QString())); + } + + void sharedFoldersAndLauncherJunkAreNotABuild() + { + Fixtures::write(m_game + "/libraries/x/y.jar", "lib"); + Fixtures::write(m_game + "/assets/indexes/5.json", "{}"); + Fixtures::write(m_game + "/launcher_log.txt", "log"); + Fixtures::write(m_game + "/webcache2/a", "a"); + QVERIFY(Fixtures::writeJson(m_game + "/launcher_profiles.json", {{"profiles", QJsonObject{}}})); + QVERIFY(QDir().mkpath(m_game + "/saves")); // пустая папка — не признак + QVERIFY(!GameDirImport::detect(m_game)); + } + + void contentWithoutVersionGivesEmptyBuild() + { + Fixtures::write(m_game + "/saves/World/level.dat", "w"); + const auto found = GameDirImport::detect(m_game); + QVERIFY(found); + QCOMPARE(found->source, QStringLiteral("content")); + QVERIFY(found->minecraftVersion.isEmpty()); + QVERIFY(found->resolvedVersionId.isEmpty()); + QVERIFY(found->loader.isEmpty()); + } + + void lastUsedProfileWins() + { + writeVanilla("1.20.1"); + writeVanilla("1.21.1"); + writeVanilla("1.19.4"); + touch("1.19.4", 0); // самая свежая по мтайму, но профили важнее + writeProfiles({{"a", profile("1.20.1", "2026-01-01T10:00:00.000Z")}, + {"b", profile("1.21.1", "2026-03-01T10:00:00.000Z")}, + // Версии нет на диске — пропускается, даже будучи последней. + {"c", profile("1.8.9", "2026-05-01T10:00:00.000Z")}, + // latest-release без lastVersionId не разрешить без манифеста. + {"d", QJsonObject{{"type", "latest-release"}, {"lastUsed", "2026-06-01T10:00:00Z"}}}}); + const auto found = GameDirImport::detect(m_game); + QVERIFY(found); + QCOMPARE(found->source, QStringLiteral("launcher_profiles")); + QCOMPARE(found->minecraftVersion, QStringLiteral("1.21.1")); + QVERIFY(found->resolvedVersionId.isEmpty()); + QVERIFY(found->loader.isEmpty()); + } + + void withoutProfilesNewestVersionWins() + { + writeVanilla("1.20.1"); + writeVanilla("1.21.1"); + touch("1.20.1", 0); + touch("1.21.1", 3600); + Fixtures::write(m_game + "/launcher_profiles.json", "{ broken"); + const auto found = GameDirImport::detect(m_game); + QVERIFY(found); + QCOMPARE(found->source, QStringLiteral("versions")); + QCOMPARE(found->minecraftVersion, QStringLiteral("1.20.1")); + } + + void brokenVersionJsonIsSkipped() + { + writeVanilla("1.20.1"); + Fixtures::write(m_game + "/versions/1.21.1/1.21.1.json", "not json"); + touch("1.20.1", 3600); + touch("1.21.1", 0); + const auto found = GameDirImport::detect(m_game); + QVERIFY(found); + QCOMPARE(found->minecraftVersion, QStringLiteral("1.20.1")); + } + + void modsPreferLoaderProfile() + { + writeVanilla("1.20.1"); + writeLoaderProfile("fabric-loader-0.15.0-1.20.1", "1.20.1", "net.fabricmc:fabric-loader:0.15.0"); + // Последней запускали ванилу, но моды лежат — значит, играли с Fabric. + writeProfiles({{"vanilla", profile("1.20.1", "2026-03-01T10:00:00.000Z")}}); + Fixtures::write(m_game + "/mods/sodium.jar", "jar"); + const auto found = GameDirImport::detect(m_game); + QVERIFY(found); + QCOMPARE(found->source, QStringLiteral("versions")); + QCOMPARE(found->minecraftVersion, QStringLiteral("1.20.1")); + QCOMPARE(found->resolvedVersionId, QStringLiteral("fabric-loader-0.15.0-1.20.1")); + QCOMPARE(found->loader, QStringLiteral("fabric")); + QCOMPARE(found->loaderVersion, QStringLiteral("0.15.0")); + + // Без модов тот же набор даёт ванилу из профилей. + QVERIFY(QFile::remove(m_game + "/mods/sodium.jar")); + QCOMPARE(GameDirImport::detect(m_game)->resolvedVersionId, QString()); + } + + void fabricFixtureIsRecognised() + { + const QJsonObject json = Fixtures::readJson("versions/fabric-loader-0.15.0-1.20.1.json"); + const auto loader = GameDirImport::loaderOf(json); + QCOMPARE(loader.key, QStringLiteral("fabric")); + QCOMPARE(loader.version, QStringLiteral("0.15.0")); + } + + void loaderFromLibraries_data() + { + QTest::addColumn("game"); + QTest::addColumn("library"); + QTest::addColumn("key"); + QTest::addColumn("version"); + + QTest::newRow("quilt") << "1.20.1" << "org.quiltmc:quilt-loader:0.26.0" << "quilt" << "0.26.0"; + QTest::newRow("forge-1.20.1") << "1.20.1" << "net.minecraftforge:forge:1.20.1-47.2.0:universal" + << "forge" << "47.2.0"; + QTest::newRow("forge-1.12.2") << "1.12.2" << "net.minecraftforge:forge:1.12.2-14.23.5.2860" + << "forge" << "14.23.5.2860"; + // Как в каталоге модлоадеров: хвост "-1.7.10" остаётся. + QTest::newRow("forge-1.7.10") << "1.7.10" << "net.minecraftforge:forge:1.7.10-10.13.4.1614-1.7.10" + << "forge" << "10.13.4.1614-1.7.10"; + QTest::newRow("neoforge") << "1.21.1" << "net.neoforged:neoforge:21.1.77:universal" + << "neoforge" << "21.1.77"; + QTest::newRow("neoforge-1.20.1") << "1.20.1" << "net.neoforged:forge:1.20.1-47.1.106:universal" + << "neoforge" << "47.1.106"; + QTest::newRow("optifine") << "1.20.1" << "optifine:OptiFine:1.20.1_HD_U_I6" << "" << ""; + } + + void loaderFromLibraries() + { + QFETCH(QString, game); + QFETCH(QString, library); + QFETCH(QString, key); + QFETCH(QString, version); + const QJsonObject json{{"id", "custom"}, {"inheritsFrom", game}, + {"libraries", QJsonArray{QJsonObject{{"name", "com.google:guava:31.0"}}, + QJsonObject{{"name", library}}}}}; + const auto loader = GameDirImport::loaderOf(json); + QCOMPARE(loader.key, key); + QCOMPARE(loader.version, version); + } + + void loaderFromIdWhenLibrariesSilent_data() + { + QTest::addColumn("id"); + QTest::addColumn("game"); + QTest::addColumn("key"); + QTest::addColumn("version"); + + QTest::newRow("fabric") << "fabric-loader-0.16.10-1.21.1" << "1.21.1" << "fabric" << "0.16.10"; + QTest::newRow("quilt") << "quilt-loader-0.26.0-1.20.1" << "1.20.1" << "quilt" << "0.26.0"; + QTest::newRow("forge") << "1.20.1-forge-47.2.0" << "1.20.1" << "forge" << "47.2.0"; + QTest::newRow("neoforge") << "neoforge-21.1.77" << "1.21.1" << "neoforge" << "21.1.77"; + QTest::newRow("unknown") << "1.20.1-OptiFine_HD_U_I6" << "1.20.1" << "" << ""; + } + + void loaderFromIdWhenLibrariesSilent() + { + QFETCH(QString, id); + QFETCH(QString, game); + QFETCH(QString, key); + QFETCH(QString, version); + const auto loader = GameDirImport::loaderOf({{"id", id}, {"inheritsFrom", game}}); + QCOMPARE(loader.key, key); + QCOMPARE(loader.version, version); + } + + void unknownInheritorKeepsResolvedId() + { + writeVanilla("1.20.1"); + writeLoaderProfile("1.20.1-OptiFine_HD_U_I6", "1.20.1", "optifine:OptiFine:1.20.1_HD_U_I6"); + writeProfiles({{"of", profile("1.20.1-OptiFine_HD_U_I6", "2026-03-01T10:00:00Z")}}); + const auto found = GameDirImport::detect(m_game); + QVERIFY(found); + QCOMPARE(found->minecraftVersion, QStringLiteral("1.20.1")); + QCOMPARE(found->resolvedVersionId, QStringLiteral("1.20.1-OptiFine_HD_U_I6")); + QVERIFY(found->loader.isEmpty()); + } + + void detectionDoesNotTouchGameDir() + { + writeVanilla("1.20.1"); + writeLoaderProfile("fabric-loader-0.15.0-1.20.1", "1.20.1", "net.fabricmc:fabric-loader:0.15.0"); + writeProfiles({{"f", profile("fabric-loader-0.15.0-1.20.1", "2026-03-01T10:00:00Z")}}); + Fixtures::write(m_game + "/mods/a.jar", "a"); + Fixtures::write(m_game + "/saves/W/level.dat", "w"); + const QStringList before = Fixtures::listTree(m_game); + QVERIFY(GameDirImport::detect(m_game)); + QCOMPARE(Fixtures::listTree(m_game), before); + } +}; + +BILAUNCHER_TEST_MAIN(TestGameDirImport) + +#include "tst_gamedirimport.moc"