fix #23 by adding validating on first startup and close #30 by adding
tests / package-smoke-linux (push) Skipped
tests / package-smoke-windows (push) Skipped
tests / package-smoke-macos (push) Skipped
tests / macos (push) Successful in 1m20s
tests / linux (push) Successful in 2m14s
tests / windows (push) Failing after 23m21s

version number

Signed-off-by: galeon <dertiellis@gmail.com>
This commit is contained in:
galeon committed 2026-10-23 09:55:15 +03:00
1 parent bc205fa2e7
commit 1d781b5324
19 files changed
+805 -10

No files matched your search

+1
View File
@@ -61,6 +61,7 @@ replay_pid*
build/
.qtcreator/
.DS_Store
/doc/adr/
CMakeLists.txt.user*
stage/
dist/
+6 -2
View File
@@ -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)
+12
View File
@@ -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 ───────────────────────────────────────────────────────
+9 -2
View File
@@ -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).
## Что собирается
+21 -3
View File
@@ -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` не вызывается: сборка описывает то, что уже разложено в папке
игры, а активная сборка без архива — штатное состояние. Сама папка игры только читается.
Имя: `<loaderTitle> <версия>` или `Minecraft <версия>`, а без версии — `build.title.fromGameDir`.
Через `QTimer::singleShot(0)` приходит `gameDirImported(buildName)`, раньше сообщения об ошибках
хранилища, чтобы оно их не перекрыло. Main.qml показывает по нему зелёную плашку.
## Файлы на диске
Все пути берутся из [launcherpaths.h](launcherpaths.md).
+57
View File
@@ -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<Detected>`: `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. установленные версии по убыванию времени правки `<id>/<id>.json`.
Версия с нечитаемым описанием пропускается. Если в `mods/` есть хотя бы один `.jar`, сначала
ищется профиль с `inheritsFrom` (сперва в первом списке, потом во втором), и только потом берётся
что есть. Не нашлось ничего — `source` равен `content`, поля версии пустые.
У профиля с `inheritsFrom` версия игры — это `inheritsFrom`, а `resolvedVersionId` — его `id`,
поэтому запустится ровно найденный профиль, даже если модлоадер не опознан (OptiFine, LabyMod).
У профиля без `inheritsFrom` версия — сам `id`.
## loaderOf(versionJson)
Модлоадер по `libraries[].name` описания профиля:
| Библиотека | `key` | `version` |
|------------|-------|-----------|
| `net.fabricmc:fabric-loader:<v>` | `fabric` | `<v>` |
| `org.quiltmc:quilt-loader:<v>` | `quilt` | `<v>` |
| `net.neoforged:neoforge:<v>` | `neoforge` | `<v>` |
| `net.neoforged:forge:<mc>-<v>` | `neoforge` | `<v>` (NeoForge для 1.20.1) |
| `net.minecraftforge:forge:<mc>-<v>` | `forge` | `<v>` |
Префикс `<mc>-` срезается, хвост `-1.7.10` у старых Forge остаётся: в каталоге
[ModLoaderVersionService](ModLoaderVersionService.md) версии записаны так же, и карточка сборки
найдёт выбранную строку.
Если таких библиотек нет, смотрит на `id`, который дают установщики: `fabric-loader-<v>-<mc>`,
`quilt-loader-<v>-<mc>`, `neoforge-<v>`, `<mc>-forge-<v>`. Не опознано — пустой `key`.
---
При создании этого документа использовался ИИ.
+2 -1
View File
@@ -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` внутри бандла.
+1
View File
@@ -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) | все пути к данным лаунчера |
+4
View File
@@ -25,6 +25,10 @@
сборки справа сверху, кнопки папки и настроек под списком профилей. Кнопка сезонных сборок стоит в
правом нижнем углу, потому что остальные углы заняты.
В левом верхнем углу фона стоит подпись `launcherVersion` — «Версия: 1.0.1-dev». Текст из
`app.label.version`, номер из `Qt.application.version`, то есть из `LAUNCHER_VERSION`
(см. [main.cpp](../cpp/main.md)).
Обе панели прогресса (загрузки и смены сборки) стоят в левом нижнем углу на одних и тех же якорях.
Одновременно они не появляются: пока лаунчер занят загрузкой, бэкенд не начнёт смену сборки, и
наоборот. Панель смены сборки отменить нельзя.
+1
View File
@@ -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` | каталог сезонных сборок: проверки полей, формат, кеш по адресу |
+244
View File
@@ -0,0 +1,244 @@
#include "gamedirimport.h"
#include "minecraftversion.h"
#include <QDateTime>
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QJsonArray>
#include <QJsonDocument>
#include <QList>
#include <algorithm>
#include <utility>
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> 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<Candidate> load(const QString &gameDir, const QStringList &ids)
{
QList<Candidate> 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<Detected> 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<Candidate> fromProfiles = load(gameDir, profileVersions(gameDir, installed));
const QList<Candidate> fromVersions = load(gameDir, versionsByMtime(gameDir, installed));
// С модами в папке ванильная версия почти наверняка не та, с которой играли:
// сначала ищем профиль модлоадера, и только потом берём что есть.
const Candidate *chosen = nullptr;
QString source;
auto pick = [&](const QList<Candidate> &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;
}
}
+38
View File
@@ -0,0 +1,38 @@
#pragma once
#include <QJsonObject>
#include <QString>
#include <optional>
// Что уже разложено в папке игры, когда лаунчер запускают впервые. Только
// читает файлы: .minecraft не меняется ни при каком исходе.
namespace GameDirImport {
// Модлоадер, найденный в описании профиля versions/<id>/<id>.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<Detected> detect(const QString &gameDir);
}
+12
View File
@@ -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"
+44
View File
@@ -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<GameDirImport::Detected> 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()
+6
View File
@@ -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:
// Перекладывает <root>/versions.json в customBuilds.json при первом запуске
// после переименования. true — миграция прошла, читать нужно новый файл.
bool migrateLegacyBuildsFile();
// Первый запуск: заводит активную сборку под то, что уже лежит в папке игры.
// Папку игры только читает. Возвращает имя сборки или пустую строку.
QString importGameDir();
void invalidateCatalog();
void rebuildCatalog() const;
+1 -1
View File
@@ -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
+101
View File
@@ -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()
{
{
+2 -1
View File
@@ -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()
+243
View File
@@ -0,0 +1,243 @@
#include "testenv.h"
#include "fixtures.h"
#include "gamedirimport.h"
#include <QDateTime>
#include <QDir>
#include <QFile>
#include <QJsonArray>
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<QString>("game");
QTest::addColumn<QString>("library");
QTest::addColumn<QString>("key");
QTest::addColumn<QString>("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<QString>("id");
QTest::addColumn<QString>("game");
QTest::addColumn<QString>("key");
QTest::addColumn<QString>("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"