documentation rewrite

Signed-off-by: galeon <dertiellis@gmail.com>
This commit is contained in:
galeon committed 2026-10-11 22:32:13 +03:00
1 parent 1943996608
commit f10b654953
34 files changed
+2099 -4576

No files matched your search

+58 -141
View File
@@ -1,170 +1,87 @@
# AuthService
## Обзор класса
`authservice.h`, `authservice.cpp`
`Minecraft_launcher` поддерживает три способа входа: офлайн-профиль без пароля, учётную запись
Ely.by и учётную запись Microsoft. `AuthService` закрывает первые два: это Yggdrasil-клиент Ely.by
плюс офлайн-режим. За третий отвечает [MsaAuthService](MsaAuthService.md).
Вход через Ely.by, офлайн-профиль и загрузка `authlib-injector`. Вход через Microsoft живёт
отдельно, в [MsaAuthService](MsaAuthService.md), но отдаёт результат в той же структуре
`AuthResult`. Поэтому запуск игры дальше идёт одинаково, как бы игрок ни вошёл.
Результат любого способа — структура `AuthResult`, объявленная в этом же заголовке. Она содержит
ровно то, что подставляется в аргументы запуска вида `${auth_*}`, поэтому дальше запуск игры идёт
по общему пути независимо от того, как пользователь вошёл.
Экземпляр создаёт и держит [LauncherBackend](LauncherBackend.md). Сервис ничего не пишет на диск,
кроме скачанного jar: токены между запусками сохраняет бэкенд в `profiles.json`.
Класс также умеет скачивать `authlib-injector` — библиотеку, которая перенаправляет обращения игры
к серверу авторизации на Ely.by.
## AuthResult
## Место в проекте и зависимости
Каждое поле соответствует подстановке в аргументах запуска.
Экземпляр создаётся и принадлежит [LauncherBackend](LauncherBackend.md); других владельцев нет.
Заголовок подключает [MsaAuthService](MsaAuthService.md) — ради общей структуры `AuthResult` — и
[GameLauncher](GameLauncher.md) через поля `LaunchOptions`, которые заполняются из результата
авторизации.
| Поле | Подстановка / смысл |
|------|---------------------|
| `ok` | вход удался |
| `twoFactorRequired` | Ely.by требует одноразовый код |
| `licenseMissing` | Microsoft: вход прошёл, но игры на аккаунте нет. Отдельный флаг, потому что повторный вход тут не поможет |
| `error` | текст ошибки, если `ok == false` |
| `playerName` | `${auth_player_name}` |
| `uuid` | `${auth_uuid}`, hex без дефисов |
| `accessToken` | `${auth_access_token}` |
| `clientToken` | `${clientid}` |
| `userType` | `${user_type}`: `legacy` (офлайн), `ELYBY`, `msa` |
| `refreshToken` | только Microsoft: продление сессии без пароля |
| `xuid` | только Microsoft: `${auth_xuid}` |
| `expiresAt` | только Microsoft: когда истекает `accessToken`, UTC |
Требования сборки: `Qt6::Core` (`QDateTime`, `QJsonObject`, `QString`) и `Qt6::Network`
(`QNetworkAccessManager`).
## Офлайн
## Иерархия и роль
`static AuthResult offline(nickname)` работает без сети. UUID считается так же, как в самом
Minecraft: MD5 от `OfflinePlayer:<ник>`, после чего выставляются биты версии 3 и варианта RFC 4122.
Значит, у одного ника всегда один UUID, и на офлайн-сервере игрок не теряет инвентарь. В
`accessToken` и `clientToken` записывается `"0"`.
Наследует `QObject`: даёт мета-объектную систему, сигнал `progress` и владение по родителю.
Виртуальных методов базового класса не переопределяет.
Пустой ник — ошибка.
## Публичные структуры
## Ely.by
### AuthResult
Сервер — `https://authserver.ely.by`, протокол Yggdrasil, JSON по HTTPS.
Результат авторизации — то, что подставляется в `${auth_*}` аргументы запуска.
`loginElyBy(login, password, clientToken, accessToken, callback)` старается не спрашивать пароль:
| Поле | Тип | Описание |
|------|-----|----------|
| `ok` | `bool` | Авторизация удалась |
| `twoFactorRequired` | `bool` | Ely.by отклонил пароль с пометкой two factor — нужен одноразовый код |
| `licenseMissing` | `bool` | Вход в Microsoft прошёл, но копии игры на аккаунте нет. Обрабатывается отдельно от прочих ошибок, потому что чинится только покупкой |
| `error` | `QString` | Текст ошибки, когда `ok` равен `false` |
| `playerName` | `QString` | Подставляется в `${auth_player_name}` |
| `uuid` | `QString` | Подставляется в `${auth_uuid}`; hex без дефисов |
| `accessToken` | `QString` | Подставляется в `${auth_access_token}` |
| `clientToken` | `QString` | Подставляется в `${clientid}` |
| `userType` | `QString` | Подставляется в `${user_type}`; принимает значения `legacy` (офлайн), `msa` (Microsoft) и `ELYBY` |
| `refreshToken` | `QString` | Только для аккаунтов Microsoft: продлевает сессию без ввода пароля |
| `xuid` | `QString` | Только для Microsoft; подставляется в `${auth_xuid}` |
| `expiresAt` | `QDateTime` | Только для Microsoft: UTC-время, когда протухает `accessToken` |
1. Если сохранённого `accessToken` нет, сразу `/auth/authenticate` с паролем.
2. Если есть, `/auth/validate`. Ответ 200 — токен рабочий, вход закончен.
3. Иначе `/auth/refresh`. Если продлить удалось, приходят новый токен, ник и UUID.
4. Если и это не вышло, `/auth/authenticate` с паролем.
## Псевдонимы типов
Если сервер недоступен (статус 0 и сетевая ошибка), цепочка обрывается с ошибкой, без попытки войти
по паролю.
`AuthService::Callback` — `std::function<void(const AuthResult &)>`. Все сетевые методы
асинхронные: колбэк вызывается ровно один раз и всегда в потоке GUI.
Второй фактор Ely.by не сообщает отдельным кодом. Сервис ищет в тексте ошибки подстроку
`two factor` и выставляет `twoFactorRequired`. Тогда бэкенд спрашивает код у игрока и вызывает
`loginElyByWithTotp(login, password, totp, clientToken, callback)`, который отправляет пароль в виде
`пароль:код`, как требует Ely.by.
## Публичные методы
`clientToken` генерируется один раз на профиль (`generateClientToken()`, случайный UUID) и хранится
вместе с ним: Yggdrasil привязывает выданный `accessToken` к этому значению.
#### explicit AuthService(QObject \*parent = nullptr)
По ходу работы испускается `progress(message)`. Бэкенд пересылает его в `launchProgress`.
Создаёт сервис и его `QNetworkAccessManager`. Конструктор помечен `explicit`.
## authlib-injector
#### static AuthResult offline(const QString &nickname)
Без него игра пойдёт проверять сессию на серверы Mojang, и токен Ely.by окажется недействительным.
Jar подключается к JVM через `-javaagent` (см. [GameLauncher](GameLauncher.md)).
Готовит результат для офлайн-профиля без единого сетевого запроса. UUID выводится из ника так же,
как это делает сам Minecraft в офлайне: `UUID.nameUUIDFromBytes(("OfflinePlayer:" + name)
.getBytes(UTF_8))`. Благодаря этому один и тот же ник всегда даёт один и тот же UUID, и прогресс
на сервере не теряется.
`ensureAuthlibInjector(targetDir, callback)`:
#### static QString generateClientToken()
- если `<targetDir>/authlib-injector.jar` уже есть, сразу отдаёт путь. Версия не проверяется, и
обновить файл можно, только удалив его;
- иначе читает `https://authlib-injector.yushi.moe/artifact/latest.json`, скачивает jar по
`download_url` и, если в манифесте есть `checksums.sha256`, сверяет хеш.
Случайный `clientToken` лаунчера. Генерируется один раз на профиль и хранится вместе с ним:
Yggdrasil связывает выданный `accessToken` именно с этим значением.
Колбэк получает либо путь, либо текст ошибки. `targetDir` — это `<root>/runtime`.
#### void loginElyBy(const QString &login, const QString &password, const QString &clientToken, const QString &accessToken, Callback callback)
## Как вызываются колбэки
Полный цикл входа в Ely.by: сначала проверка имеющегося токена, затем его продление, и только при
неудаче — авторизация по паролю. Пароль можно оставить пустым, если уже есть рабочий
`accessToken`, — тогда пользователю не придётся вводить его заново.
Все сетевые методы асинхронные, колбэк вызывается один раз в GUI-потоке. Если сервис удалить во
время запроса, менеджер сети уйдёт вместе с ним, и колбэк не вызовется вообще. Бэкенд к тому же
проверяет в каждом колбэке, не отменили ли запуск, пока шёл запрос.
По ходу работы испускает `progress` с описанием текущего шага. Результат приходит в `callback`
один раз; при ответе с пометкой двухфакторной аутентификации в нём выставлен
`twoFactorRequired`, и вызывающий код должен спросить у пользователя код и продолжить через
`loginElyByWithTotp()`.
#### void loginElyByWithTotp(const QString &login, const QString &password, const QString &totp, const QString &clientToken, Callback callback)
Повтор авторизации с одноразовым кодом двухфакторной аутентификации. Пароль и код объединяются в
одно поле в формате «пароль:код», как того требует Ely.by.
#### void ensureAuthlibInjector(const QString &targetDir, std::function&lt;void(const QString &path, const QString &error)&gt; callback)
Скачивает `authlib-injector` в `targetDir`, если его там ещё нет. Колбэк получает либо путь к
готовому jar, либо текст ошибки — заполнено всегда ровно одно из двух.
Библиотека нужна только для профилей Ely.by: она подключается к JVM аргументом `-javaagent` и
перенаправляет обращения игры к серверу авторизации.
## Сигналы
#### progress(const QString &message)
Описание текущего шага авторизации. Испускается по ходу всех сетевых операций.
Обработчик показывает сообщение пользователю: в главном окне лаунчера оно попадает в плашку
статуса и держится до следующего сообщения, потому что шаг может занять заметное время.
## Владение и время жизни
Класс наследует `QObject` и принимает `parent` в конструкторе — родитель его и удалит.
`QNetworkAccessManager` создаётся в конструкторе с сервисом в роли родителя и уничтожается вместе
с ним.
Колбэки захватываются по значению и живут до своего единственного вызова. Уничтожение сервиса во
время незавершённого запроса отменяет запрос вместе с менеджером сети — колбэк в этом случае не
вызывается, поэтому захватывать в него сырые указатели на объекты с меньшим временем жизни, чем у
сервиса, нельзя.
## Потокобезопасность
Только поток GUI. Все сетевые методы асинхронные, и их колбэки вызываются в том же потоке, в
котором создан сервис. Собственной синхронизации в классе нет.
## Взаимодействие с другими классами
`LauncherBackend` вызывает методы входа при запуске игры и переправляет сигнал `progress` в
интерфейс. Полученный `AuthResult` он раскладывает по полям `LaunchOptions`, которые уходят в
[GameLauncher](GameLauncher.md). Путь, возвращённый `ensureAuthlibInjector()`, попадает в поле
`authlibInjectorPath` тех же параметров запуска.
Сохранением токенов между запусками занимается `LauncherBackend`: сам сервис ничего не пишет на
диск, кроме скачанного jar.
## Внешнее взаимодействие
**Сеть, исходящие запросы.** Класс общается с сервером авторизации Ely.by через
`QNetworkAccessManager`. Формат — JSON поверх HTTPS, запросы инициирует всегда лаунчер. Внутренний
помощник `postJson()` разделяет три исхода: успешный ответ, ответ с кодом ошибки и транспортную
ошибку — последняя отдаётся отдельным параметром, чтобы отличить недоступную сеть от отказа
сервера.
Отдельным каналом идёт загрузка `authlib-injector` — обычная HTTPS-загрузка файла в
`targetDir`. Повторных попыток при неудаче класс не делает: решение о повторе принимает вызывающий
код.
Все сигналы и колбэки приходят в поток GUI.
## Пример использования
```cpp
auto *auth = new AuthService(this);
connect(auth, &AuthService::progress, this, &Backend::showStatus);
auth->loginElyBy(profile.login, profile.password,
profile.clientToken, profile.accessToken,
[this](const AuthResult &result) {
if (result.twoFactorRequired) {
emit twoFactorRequired(m_pendingProfileName);
return;
}
if (!result.ok) {
emit launchError(result.error);
return;
}
continueLaunch(result);
});
```
Повторных попыток при сетевых ошибках нет.
---
+33 -144
View File
@@ -1,165 +1,54 @@
# BuildArchiveWorker
## Обзор класса
`buildarchiveworker.h`, `buildarchiveworker.cpp`
Смена сборки в лаунчере — это перекладывание содержимого `.minecraft`: текущее упаковывается в
архив, папка вычищается, на её место распаковывается архив другой сборки. Речь о гигабайтах модов,
конфигов и миров, и гонять их в потоке GUI нельзя — окно замерзало бы на всё время смены.
Файловые операции над `.minecraft`: упаковать, очистить, распаковать, раскатать пак. В
`.minecraft` могут лежать гигабайты модов и миров, поэтому это единственная часть проекта,
которая работает не в GUI-потоке.
`BuildArchiveWorker` — исполнитель этих операций в отдельном потоке. Он умеет четыре вещи:
упаковать, вычистить, распаковать и докатить пак сезонной сборки поверх уже разложенного
содержимого.
Объект создаёт [BuildSwitcher](BuildSwitcher.md) без родителя, переносит в свой `QThread` и вызывает
слоты через `QMetaObject::invokeMethod(..., Qt::QueuedConnection)`. Работник не знает ни про
сборки, ни про журнал. Он получает пути и отвечает сигналами.
Класс намеренно ничего не знает ни о сборках, ни о путях лаунчера: он принимает готовые пути и
списки. Порядок шагов и восстановление после сбоя — дело [BuildSwitcher](BuildSwitcher.md).
ZIP читается и пишется через `QZipReader` и `QZipWriter` из `Qt6::CorePrivate`.
## Место в проекте и зависимости
## Слоты
Создаётся и целиком управляется [BuildSwitcher](BuildSwitcher.md), который же и переносит его в
собственный `QThread`. Больше к классу никто не обращается.
Каждый слот заканчивается одним `finished(ok, error)`. По ходу работы испускается
`progress(done, total, currentPath)` на каждый файл.
Требования сборки: `Qt6::Core` и `Qt6::CorePrivate` — последний нужен ради `QZipReader` и
`QZipWriter`, которыми читаются и пишутся архивы сборок.
### archive(gameDir, tempZipPath, excludeTop)
## Иерархия и роль
Упаковывает всё содержимое `gameDir`, кроме папок и файлов верхнего уровня из `excludeTop`.
Символьные ссылки пропускаются. Сжатие `AutoCompress`: Qt сам не сжимает то, что уже сжато.
При ошибке или отмене недописанный архив удаляется.
Наследует `QObject`: мета-объектная система, слоты, сигналы и возможность жить в отдельном потоке
через `moveToThread()`. Виртуальных методов базового класса не переопределяет.
### clear(gameDir, keepTop)
Объект живёт в своём `QThread` и вызывается только через очередь сигналов — прямых вызовов его
слотов из потока GUI быть не должно.
Удаляет всё на верхнем уровне `gameDir`, кроме `keepTop`. Символьная ссылка удаляется как файл,
то, на что она указывает, не трогается. Если `gameDir` нет, это успех. Отменить нельзя.
## Публичные методы
### restore(zipPath, gameDir)
#### explicit BuildArchiveWorker(QObject \*parent = nullptr)
Распаковывает архив в `gameDir`. Пути с `..`, абсолютные, с `\` или `:` останавливают распаковку.
Создаёт исполнителя. Конструктор помечен `explicit`.
### applyPack(zipPath, gameDir, removeRelative, forbiddenTop)
#### void requestCancel()
1. Сначала проверяет весь архив. Небезопасный путь или файл внутри общих папок (`forbiddenTop`:
`versions`, `libraries` и т. д.) — отказ до любых изменений. Пак не может подменить jar игры
или библиотеки.
2. Удаляет файлы из `removeRelative`. Опасные пути и пути в общих папках молча пропускаются. После
этого удаляются папки, которые стали пустыми.
3. Распаковывает пак, перезаписывая существующие файлы.
4. Испускает `packEntries(список файлов пака)`, потом `finished(true)`.
Просит прервать текущую операцию. Вызывается из потока GUI прямо во время работы — это
единственный метод класса, предназначенный для вызова снаружи рабочего потока.
## Отмена
Отмена не прерывает операцию мгновенно: признак проверяется между файлами. Хранится он в
`QAtomicInt`, поэтому запись из одного потока и чтение из другого безопасны без блокировок.
`requestCancel()` и `clearCancel()` выставляют атомарный флаг, его можно трогать из любого потока.
Флаг проверяется перед каждым файлом в `archive`, `restore` и `applyPack`.
#### void clearCancel()
Сбрасывает признак отмены перед началом новой операции.
## Публичные слоты
Все четыре слота вызываются через очередь сигналов и выполняются в рабочем потоке. Каждый
завершается сигналом `finished`.
#### void archive(const QString &gameDir, const QString &tempZipPath, const QStringList &excludeTop)
Упаковывает всё содержимое `gameDir` во временный файл `tempZipPath`. Элементы верхнего уровня,
перечисленные в `excludeTop`, в архив не попадают — так из архива сборки исключаются общие
каталоги лаунчера, которые не принадлежат ни одной сборке.
Запись идёт во временный файл, чтобы прерванная упаковка не оставила повреждённый архив на месте
настоящего.
#### void clear(const QString &gameDir, const QStringList &keepTop)
Удаляет из `gameDir` всё, кроме элементов верхнего уровня, перечисленных в `keepTop`. Выполняется
после упаковки, перед распаковкой другой сборки.
#### void restore(const QString &zipPath, const QString &gameDir)
Распаковывает архив сборки в `gameDir`.
#### void applyPack(const QString &zipPath, const QString &gameDir, const QStringList &removeRelative, const QStringList &forbiddenTop)
Докатывает пак сезонной сборки поверх уже разложенного содержимого. Сначала удаляет файлы из
`removeRelative` — те, что ушли из сборки в новой ревизии, — затем распаковывает архив с
перезаписью.
Того, чего нет ни в списке, ни в архиве, операция не касается: миры и скриншоты игрока остаются на
месте. Именно это отличает обновление сезонной сборки от её переустановки.
Параметр `forbiddenTop` — элементы верхнего уровня, которые паку трогать нельзя: общие каталоги
лаунчера. Проверяется здесь, а не только у издателя пака, потому что архив приезжает из сети.
## Сигналы
#### progress(int done, int total, const QString &currentPath)
Ход операции: сколько файлов обработано из скольких и какой обрабатывается сейчас. Испускается по
ходу всех четырёх операций.
Обработчик — `BuildSwitcher` — пересчитывает долю выполнения и обновляет панель прогресса. Сигнал
приходит в поток GUI через очередь, поэтому прямого доступа к состоянию рабочего потока у
обработчика нет.
#### finished(bool ok, const QString &error)
Операция завершена. При `ok == false` во втором параметре — текст ошибки.
Обработчик решает, что делать дальше: перейти к следующему шагу смены сборки или откатить уже
сделанное.
#### packEntries(const QStringList &entries)
Что именно принёс пак — относительными путями. Испускается только из `applyPack()`.
Список сохраняется в описании сезонной сборки: следующему обновлению он нужен, чтобы вычислить,
какие файлы из сборки ушли, и передать их в `removeRelative`.
## Владение и время жизни
Класс наследует `QObject` и принимает `parent`, но на практике родителя не имеет: объект,
перенесённый в другой поток через `moveToThread()`, не может иметь родителя в потоке GUI.
Ответственность за его удаление лежит на `BuildSwitcher`, который создаёт и поток, и исполнителя.
Удалять объект следует безопасным для потоков способом — не напрямую из потока GUI во время
работы. Уничтожение потока раньше исполнителя приведёт к обрыву незавершённой операции.
## Потокобезопасность
Класс рассчитан на жизнь в отдельном потоке. Все четыре слота выполняются в рабочем потоке;
вызывать их напрямую нельзя — только через очередь сигналов.
Единственная точка межпотокового взаимодействия — признак отмены в `QAtomicInt`:
`requestCancel()` и `clearCancel()` пишут его из потока GUI, а рабочий поток читает между файлами.
Другого разделяемого состояния у класса нет, поэтому иных блокировок не требуется.
## Взаимодействие с другими классами
Единственный собеседник — [BuildSwitcher](BuildSwitcher.md). Он создаёт поток и исполнителя,
переносит второй в первый, вызывает слоты через очередь сигналов и принимает `progress`,
`finished` и `packEntries`.
Пути и списки исключений `BuildSwitcher` берёт из [launcherpaths.h](launcherpaths.md) и из
описаний сборок; сам исполнитель к ним не обращается.
## Внешнее взаимодействие
**Файловая система.** Класс читает, пишет и удаляет файлы в папке игры и в каталоге архивов
сборок. Сетевых обращений и дочерних процессов у него нет: архив сезонной сборки к моменту вызова
`applyPack()` уже скачан [SeasonalPackDownloader](SeasonalPackDownloader.md).
Все операции выполняются в рабочем потоке, все сигналы приходят в поток GUI через очередь.
## Пример использования
```cpp
auto *thread = new QThread(this);
auto *worker = new BuildArchiveWorker; // без родителя: уедет в другой поток
worker->moveToThread(thread);
connect(thread, &QThread::finished, worker, &QObject::deleteLater);
thread->start();
connect(worker, &BuildArchiveWorker::progress, this, &Switcher::onProgress);
connect(worker, &BuildArchiveWorker::finished, this, &Switcher::onStepFinished);
worker->clearCancel();
QMetaObject::invokeMethod(worker, "archive", Qt::QueuedConnection,
Q_ARG(QString, gameDir),
Q_ARG(QString, tempZipPath),
Q_ARG(QStringList, excludeTop));
```
Файлы распаковываются через `QFile`, а не `QSaveFile`. Если операцию прервать, последний файл
может остаться недописанным. Поэтому `BuildSwitcher` и не даёт отменить шаги после архивации.
---
+72 -177
View File
@@ -1,210 +1,105 @@
# BuildSwitcher
## Обзор класса
`buildswitcher.h`, `buildswitcher.cpp`
`BuildSwitcher` меняет активную сборку: содержимое `.minecraft` уезжает в архив своей сборки,
папка чистится, на её место разворачивается архив выбранной. Он же докатывает пак сезонной
сборки поверх уже разложенного содержимого.
Смена активной сборки и раскатка пака сезонной сборки. Файлы двигает
[BuildArchiveWorker](BuildArchiveWorker.md) в отдельном потоке, а этот класс решает, в каком порядке
это делать, и ведёт журнал, по которому можно продолжить после падения.
Порядок шагов подчинён одному правилу: пока новый архив не записан целиком и не переименован на
место, из `.minecraft` не удаляется ничего. Отметка о начатом переключении пишется в
`builds/index.json` до первого разрушающего действия, поэтому обрыв питания или принудительное
завершение процесса всегда обнаружим на следующем запуске.
Экземпляр держит [LauncherBackend](LauncherBackend.md).
Сами файловые операции выполняет [BuildArchiveWorker](BuildArchiveWorker.md) в отдельном потоке;
`BuildSwitcher` — это порядок шагов, учёт состояния и восстановление после сбоя.
## Как устроены сборки на диске
## Место в проекте и зависимости
В `.minecraft` в каждый момент разложена одна сборка, активная. Содержимое остальных лежит в
архивах `<root>/builds/<id>/<дата>_<имя>.zip`, по одному на сборку.
Экземпляр создаётся и принадлежит [LauncherBackend](LauncherBackend.md). Класс создаёт и целиком
владеет своим `QThread` и объектом [BuildArchiveWorker](BuildArchiveWorker.md).
Общие для всех сборок папки и файлы никогда не архивируются и не удаляются (`sharedTopLevel()`):
Пути к папкам сборок и файлу состояния даёт [launcherpaths.h](launcherpaths.md)
(`buildStorageDir()`, `buildDir()`).
- `assets`
- `libraries`
- `versions`
- `launcher_profiles.json`
- `runtime`
Требования сборки: `Qt6::Core` (`QThread`, `QJsonObject`) и `Qt6::CorePrivate` — опосредованно,
через исполнителя.
Всё остальное (моды, конфиги, миры, скриншоты, `options.txt`) принадлежит сборке.
## Иерархия и роль
В папке сборки могут лежать и другие zip, но файлы с точкой в начале имени не считаются архивом
сборки. Это временные архивы (`.tmp-*.zip`) и скачанные паки сезонных сборок (`.pack-r*.zip`).
Наследует `QObject`: мета-объектная система, три сигнала и владение по родителю. Объявлен
виртуальный деструктор — класс обязан корректно остановить свой поток.
## switchTo(fromId, fromName, toId, toName, gameDir)
## Публичные методы
1. **Архивация.** Если прежней сборки нет (`fromId < 0`) или папки игры нет, шаг пропускается.
Иначе сначала проверяется свободное место: размер всего, что уйдёт в архив, плюс 256 МБ. Потом
архив пишется во временный `builds/<from>/.tmp-<uuid>.zip`. Если на этом шаге что-то пошло не
так, `.minecraft` не тронута.
2. **Фиксация архива.** Временный файл переименовывается в `<гггг-мм-дд>_<имя>.zip`, остальные
архивы этой сборки удаляются. В `index.json` записывается `state: "switching"` с `from` и `to`.
3. **Очистка.** Из `.minecraft` удаляется всё, кроме общего.
4. **Распаковка.** Если у новой сборки есть архив, он распаковывается. Если нет (новая сборка),
`.minecraft` остаётся пустой, только с общими папками.
5. В `index.json` записываются `state: "idle"` и `active`, испускается `finished(toId)`.
#### explicit BuildSwitcher(QObject \*parent = nullptr)
При ошибке приходит `failed(toId, message, gameDirIntact)`. `gameDirIntact` равен `true`, если
ошибка случилась до очистки. Бэкенд по нему выбирает текст: «ничего не изменилось» или «папка
игры разобрана».
Создаёт переключатель, рабочий поток и исполнителя, переносит второй в первый и запускает поток.
Конструктор помечен `explicit`.
`cancel()` работает только на шаге архивации. После него отступать уже некуда.
#### bool isRunning() const
## applyPack(buildId, buildName, packZip, removeRelative, note, gameDir)
Идёт ли сейчас переключение или раскатка пака.
Раскатка пака поверх активной сборки. Сначала в `index.json` пишется `state: "applying"` со всем,
что нужно для повтора: имя файла пака, имя сборки, список на удаление и `note`. Потом работник
удаляет файлы прошлой ревизии и распаковывает пак.
#### QString stage() const
После успеха пак удаляется, `index.json` возвращается в `idle`, а `lastPackEntries()` и
`lastPackNote()` содержат список разложенных файлов и `note`. Бэкенд забирает их в обработчике
`finished` и пишет `season.json`.
Текущий этап словами — заголовок панели прогресса.
`gameDirIntact` при ошибке раскатки равен `true`, только если работник не успел обработать ни одного
файла.
#### QString status() const
## После падения
Строка состояния: что обрабатывается сейчас.
При старте бэкенд вызывает `interruptedSwitchWarning()`. Если в `index.json` осталось
`switching` или `applying`, метод возвращает текст для пользователя, а `resumeInterrupted(gameDir)`
продолжает операцию:
#### double fraction() const
- `switching` — архив прежней сборки уже записан целиком, поэтому начинаем с очистки и
распаковываем сборку `to`. Начинать с архивации нельзя: заархивировалась бы полуразобранная
`.minecraft`;
- `applying` — раскатка повторяется с тем же паком и тем же списком на удаление. Если файла пака
уже нет, журнал просто сбрасывается в `idle`.
Доля выполнения от `0` до `1` либо `-1`, пока итог неизвестен.
Если программа упала во время самой архивации, в журнале ещё ничего нет, и продолжать нечего:
`.minecraft` цела, а временный архив удалится при следующем старте (`sweepTempArchives()` в
конструкторе).
#### static QStringList sharedTopLevel()
## Прочее
Общие для всех сборок каталоги верхнего уровня. Они не архивируются и не чистятся: принадлежат
лаунчеру, а не какой-либо сборке. Список передаётся исполнителю как `excludeTop` при упаковке и
как `keepTop` при очистке.
- `forgetBuild(id)` — удаляет запись о сборке из `index.json` и папку `builds/<id>` целиком.
- `hasArchive(id)` — есть ли у сборки архив.
- `isRunning()`, `stage()`, `status()` (текущий файл), `fraction()` — для панели прогресса.
Сигнал `progressChanged()` не ограничен по частоте и приходит на каждый обработанный файл.
#### void switchTo(int fromBuildId, const QString &fromName, int toBuildId, const QString &toName, const QString &gameDir)
### index.json
Переключает активную сборку. Проходит четыре этапа: упаковка текущего содержимого во временный
файл, фиксация архива переименованием на место, очистка папки игры и распаковка архива целевой
сборки.
`<root>/builds/index.json`:
Имена сборок нужны для текста в панели прогресса, идентификаторы — для путей к архивам.
#### void applyPack(int buildId, const QString &buildName, const QString &packZipPath, const QStringList &removeRelative, const QJsonObject &note, const QString &gameDir)
Докатывает пак сезонной сборки поверх содержимого `.minecraft`. Вызывается только для уже активной
сборки: пак ложится на то, что сейчас разложено, а не внутрь чужого архива.
Параметр `removeRelative` — файлы, ушедшие из сборки в новой ревизии; они удаляются перед
распаковкой. Всего остального операция не касается, поэтому миры и скриншоты игрока переживают
обновление.
Параметр `note` — непрозрачные данные вызывающей стороны. Они переживают перезапуск вместе с
отметкой о незавершённой операции и возвращаются через `lastPackNote()`, когда раскатка доиграна.
Сам переключатель в них не заглядывает.
#### QStringList lastPackEntries() const
Что принёс последний успешно раскатанный пак — относительными путями. Список нужен следующему
обновлению, чтобы вычислить, какие файлы из сборки ушли.
#### QJsonObject lastPackNote() const
Данные, с которыми пришёл последний успешно раскатанный пак, — те самые, что передавались в
`applyPack()`.
#### void cancel()
Отменяет текущую операцию. Отмена доходит до исполнителя через признак, проверяемый между
файлами.
Отменять имеет смысл только на этапе упаковки: после очистки `.minecraft` отступать некуда, и
операцию нужно довести до конца. Именно поэтому панель прогресса смены сборки объявлена
неотменяемой.
#### bool forgetBuild(int buildId)
Сборку удалили — убирает её архив и запись о нём. Возвращает `false`, если папку архива не удалось
удалить целиком.
#### bool hasArchive(int buildId) const
Есть ли у сборки сохранённый архив. Используется в тексте предупреждения об удалении: вместе со
сборкой пропадут её моды, конфиги и миры.
#### QString interruptedSwitchWarning() const
Незавершённое переключение с прошлого запуска. Возвращает пустую строку, если всё в порядке, иначе
— готовый текст для пользователя.
Проверяется при старте лаунчера: отметка в `builds/index.json` пишется до первого разрушающего
действия, поэтому прерванная операция обнаруживается всегда.
#### void resumeInterrupted(const QString &gameDir)
Доигрывает прерванное переключение: очистку и распаковку целевой сборки. Архив исходной сборки к
этому моменту уже записан — правило порядка шагов это гарантирует.
## Сигналы
#### progressChanged()
Изменились числа прогресса. Обработчик перечитывает `stage()`, `status()` и `fraction()`.
#### finished(int toBuildId)
Переключение или раскатка завершены успешно; в параметре — идентификатор сборки, ставшей активной.
Обработчик снимает признак переключения, обновляет активную сборку и, после раскатки пака,
забирает `lastPackEntries()` и `lastPackNote()`, чтобы записать их в описание сезонной сборки.
#### failed(int toBuildId, const QString &message, bool gameDirIntact)
Операция не удалась. Третий параметр — ключевой: он говорит, цела ли папка игры. Неудача на этапе
упаковки оставляет `.minecraft` нетронутой, неудача после очистки — нет, и сообщение пользователю
должно различать эти случаи.
## Владение и время жизни
Класс наследует `QObject` и принимает `parent` — родитель его и удалит.
Рабочий поток и исполнитель создаются в конструкторе и принадлежат переключателю. Исполнитель
родителя не имеет — объект, живущий в другом потоке, не может принадлежать объекту из потока GUI;
его удаление привязано к завершению потока. Деструктор обязан остановить поток и дождаться его
завершения, иначе рабочая операция переживёт своего владельца.
Временный архив на диске переживает аварийное завершение процесса; накопившиеся временные файлы
подчищаются при следующем запуске.
## Потокобезопасность
Сам переключатель живёт в потоке GUI: все его публичные методы вызываются оттуда. Тяжёлая работа
вынесена в отдельный поток к [BuildArchiveWorker](BuildArchiveWorker.md), общение с которым идёт
исключительно через очередь сигналов — прямых вызовов слотов исполнителя нет.
Единственный межпотоковый примитив — атомарный признак отмены внутри исполнителя.
## Взаимодействие с другими классами
`LauncherBackend` вызывает `switchTo()` при смене активной сборки, `applyPack()` — при установке
или обновлении сезонной сборки, `forgetBuild()` — при удалении сборки. Сигналы переключателя он
переправляет в свойства, которые читает QML: панель прогресса главного окна показывает `stage()`,
`status()` и `fraction()`, а признак переключения блокирует список сборок и кнопки в
[BuildsDialog](../qml/BuildsDialog.md).
При старте бэкенд спрашивает `interruptedSwitchWarning()` и, если операция была прервана,
предлагает доиграть её через `resumeInterrupted()`.
Архив сезонной сборки к моменту вызова `applyPack()` уже скачан
[SeasonalPackDownloader](SeasonalPackDownloader.md).
## Внешнее взаимодействие
**Файловая система.** Класс читает и пишет `builds/index.json` — отметку о состоянии переключения,
— управляет папками архивов сборок и подчищает временные файлы. Все операции с содержимым самой
папки игры делегированы исполнителю в рабочем потоке.
Сети и дочерних процессов у класса нет.
## Пример использования
```cpp
auto *switcher = new BuildSwitcher(this);
connect(switcher, &BuildSwitcher::progressChanged, this, &Backend::switchChanged);
connect(switcher, &BuildSwitcher::finished, this, &Backend::onSwitchFinished);
connect(switcher, &BuildSwitcher::failed, this,
[this](int, const QString &message, bool gameDirIntact) {
emit launchError(gameDirIntact
? message
: tr("%1. Папка игры осталась незавершённой.").arg(message));
});
// при старте лаунчера
const QString warning = switcher->interruptedSwitchWarning();
if (!warning.isEmpty()) {
showStatus(warning);
switcher->resumeInterrupted(gameDir);
```json
{
"state": "idle",
"active": 3,
"builds": {
"2": { "archive": "2026-10-09_Vanilla.zip", "name": "Vanilla", "savedAt": "..." }
}
}
```
Во время работы к нему добавляются `from`, `to`, а для раскатки ещё `pack`, `packName`,
`packRemove`, `packNote`. Пишется через `QSaveFile`.
Деструктор просит работника остановиться и ждёт поток до 5 секунд.
---
При создании этого документа использовался ИИ.
+77 -170
View File
@@ -1,199 +1,106 @@
# GameLauncher
## Обзор класса
`gamelauncher.h`, `gamelauncher.cpp`
`GameLauncher` — то, ради чего существует весь остальной лаунчер: он готовит и запускает JVM с
Minecraft. К моменту его вызова уже известно всё — версия разобрана, файлы скачаны, пользователь
авторизован, — и класс превращает это в командную строку и дочерний процесс.
Собирает командную строку, распаковывает нативные библиотеки и запускает JVM с игрой. К этому
моменту версия уже разобрана ([minecraftversion.h](minecraftversion.md)), файлы скачаны, а игрок
вошёл в аккаунт. Всё остальное приходит в структуре `LaunchOptions`.
Кроме самого запуска класс умеет проверять комплектность `.minecraft` и распаковывать нативные
библиотеки, без которых игра не стартует.
Экземпляр один, его держит [LauncherBackend](LauncherBackend.md). Одновременно может работать
только одна игра.
Один экземпляр — одна игра одновременно.
## LaunchOptions
## Место в проекте и зависимости
| Поле | По умолчанию | Что это |
|------|--------------|---------|
| `gameDir` | | папка игры, она же рабочий каталог процесса |
| `versionId` | | папка в `versions/` |
| `playerName`, `uuid`, `accessToken`, `userType`, `clientToken`, `xuid` | | из [AuthResult](AuthService.md) |
| `javaPath` | пусто | пусто — ищет [JavaLocator](javalocator.md) |
| `minMemoryMb`, `maxMemoryMb` | 512, 4096 | `-Xms`, `-Xmx`; 0 — не передавать |
| `extraJvmArgs` | | аргументы JVM из настроек |
| `windowWidth`, `windowHeight` | 0, 0 | 0 — не передавать размер |
| `fullscreen` | `false` | |
| `serverAddress` | пусто | `host[:port]`, сервер для автоподключения |
| `authlibInjectorPath` | пусто | путь к `authlib-injector.jar`; пусто — без него |
| `authlibInjectorApi` | `ely.by` | сервер авторизации для инжектора |
| `launcherName`, `launcherVersion` | `BiLauncher`, `LAUNCHER_VERSION` | бэкенд передаёт имя и версию из `QCoreApplication` |
Подключает [minecraftversion.h](minecraftversion.md): разобранная версия — половина входных данных
запуска, вторая половина приходит структурой `LaunchOptions` из этого же заголовка.
## launch(options, version, &error)
Экземпляр создаётся и принадлежит [LauncherBackend](LauncherBackend.md), который заполняет
`LaunchOptions` из настроек, выбранной сборки и результата авторизации.
1. Если игра уже идёт, отказ.
2. `missingFiles()`: если чего-то не хватает, отказ со списком.
3. Распаковка нативных библиотек в `<gameDir>/versions/<id>/natives`.
4. `JavaLocator::select()` с `version.javaMajor` и `options.javaPath`. На Windows вместо
`java.exe` запускается `javaw.exe`, чтобы не было консольного окна.
5. `QProcess` с рабочим каталогом `gameDir` и объединёнными stdout и stderr.
6. Ожидание старта до 15 секунд. Если процесс не стартовал, отказ.
Требования сборки: `Qt6::Core` (`QProcess`, `QStringList`) и `Qt6::CorePrivate` — последний нужен
ради `QZipReader`, которым распаковываются нативные библиотеки LWJGL. Зависимость от приватного
модуля привязывает проект к конкретной версии Qt; в `CMakeLists.txt` это осознанный выбор, и
предупреждение о нём отключено.
Если всё прошло, метод возвращает `true` и испускает `gameStarted(commandLine)`. В этой строке
значение после `--accessToken` заменено на `<скрыт>`, чтобы токен не попал в журнал. Других
мест, где мог бы оказаться токен (например, в `${auth_session}`), замена не касается.
## Иерархия и роль
Перед шагами 2–4 испускается `progress(message)`.
Наследует `QObject`: мета-объектная система, четыре сигнала и владение по родителю. Виртуальных
методов базового класса не переопределяет.
Вывод игры читается кусками и испускается построчно в `output(line)`. Когда процесс завершится,
придёт `gameFinished(exitCode, crashed)`, где `crashed` значит `QProcess::CrashExit`.
## Публичные структуры
`terminate()` вызывает `kill()`, а не `terminate()`. Игра не успеет ничего сохранить, это то же
самое, что снять процесс в диспетчере задач.
### LaunchOptions
## missingFiles(options, version, limit = 12)
Всё, что лаунчер знает к моменту нажатия кнопки запуска.
Проверяет, что на месте папка игры, клиентский jar, все библиотеки, индекс ресурсов и папка
`assets/objects`. Возвращает до `limit` путей. Если папки игры нет, в списке будет только она.
| Поле | Тип | По умолчанию | Описание |
|------|-----|--------------|----------|
| `gameDir` | `QString` | — | Папка `.minecraft` |
| `versionId` | `QString` | — | Папка в `versions`, которую запускаем |
| `playerName` | `QString` | — | Ник игрока из результата авторизации |
| `uuid` | `QString` | — | UUID игрока |
| `accessToken` | `QString` | — | Токен доступа |
| `userType` | `QString` | — | Тип учётной записи: `legacy`, `msa` или `ELYBY` |
| `clientToken` | `QString` | — | Токен клиента |
| `xuid` | `QString` | — | Идентификатор Xbox; пустое значение заменяется на `0`, как в офлайне |
| `javaPath` | `QString` | — | Путь к java; пустое значение означает «искать самим» |
| `minMemoryMb` | `int` | `512` | Значение `-Xms` |
| `maxMemoryMb` | `int` | `4096` | Значение `-Xmx` |
| `extraJvmArgs` | `QStringList` | — | Дополнительные аргументы JVM из настроек |
| `windowWidth` | `int` | `0` | Ширина окна игры; `0` — не передавать `--width` и `--height` |
| `windowHeight` | `int` | `0` | Высота окна игры |
| `fullscreen` | `bool` | `false` | Запускать в полноэкранном режиме |
| `serverAddress` | `QString` | — | `host[:port]` для автоматического захода на сервер |
| `authlibInjectorPath` | `QString` | — | Путь к `authlib-injector`; пустое значение — не подключать |
| `authlibInjectorApi` | `QString` | `ely.by` | Сервер авторизации, на который перенаправляется игра |
| `launcherName` | `QString` | `BiLauncher` | Имя лаунчера, которое видит игра |
| `launcherVersion` | `QString` | `1.0` | Версия лаунчера |
Размеры и хеши не проверяются, только наличие. Функция статическая, бэкенд вызывает её и для
подсказки в интерфейсе, и перед запуском.
## Публичные методы
## extractNatives(options, version, nativesDir, &error)
#### explicit GameLauncher(QObject \*parent = nullptr)
Для каждой библиотеки с `native == true` открывает jar через `QZipReader` (отсюда зависимость от
`Qt6::CorePrivate`) и вынимает файлы `.dll`, `.so`, `.so.N`, `.dylib`, `.jnilib`. Пропускаются
`META-INF/` и пути из `extract.exclude` описания версии. Все файлы кладутся в одну плоскую папку.
Если файл такого же размера уже лежит, его не переписывают.
Создаёт объект. Процесс игры при этом не запускается. Конструктор помечен `explicit`.
## buildArguments(options, version, nativesDir)
#### bool isRunning() const
Порядок аргументов:
Идёт ли сейчас игра. От этого зависит доступность кнопки запуска в интерфейсе.
1. `-javaagent:<authlib-injector>=ely.by` и `-Dauthlibinjector.side=client`, если инжектор задан;
2. `-Xms`, `-Xmx`, потом `extraJvmArgs`;
3. JVM-аргументы из описания версии;
4. аргумент логирования, если файл конфигурации логов скачан;
5. главный класс;
6. аргументы игры из описания версии;
7. `--width`/`--height`, если размер задан, а описание версии само его не передаёт;
8. `--fullscreen`;
9. сервер: `--quickPlayMultiplayer host:port` для версий, которые это понимают, иначе
`--server host --port port` (порт по умолчанию 25565).
#### static QStringList missingFiles(const LaunchOptions &options, const MinecraftVersion &version, int limit = 12)
Подстановки: `auth_player_name`, `auth_uuid`, `auth_access_token`, `auth_session`
(`token:<токен>:<uuid>`), `auth_xuid` (пусто → `0`), `clientid` (пусто → `0`), `user_type`,
`user_properties` (`{}`), `version_name`, `version_type`, `game_directory`, `assets_root`,
`assets_index_name`, `game_assets`, `natives_directory`, `library_directory`, `classpath`,
`classpath_separator`, `primary_jar`, `launcher_name`, `launcher_version`, `resolution_width`,
`resolution_height`.
Проверяет `.minecraft` на комплектность и возвращает описания недостающих файлов. Пустой список
означает, что всё на месте и сборку можно запускать.
Аргумент, в котором после подстановки остался `${`, выбрасывается целиком. Так отсекаются
переменные, о которых лаунчер не знает (например, связанные с демо-режимом).
Параметр `limit` ограничивает длину списка: перечислять все отсутствующие файлы у неустановленной
версии бессмысленно, важен сам факт и пара примеров.
У версий со старыми ресурсами (`legacy`, `pre-1.6`) `game_assets` указывает на
`assets/virtual/legacy`.
Метод статический, ничего не меняет и вызывается интерфейсом для строки состояния сборки.
Classpath — это все не-нативные библиотеки и в конце клиентский jar.
#### static QStringList buildArguments(const LaunchOptions &options, const MinecraftVersion &version, const QString &nativesDir)
### Forge 1.17+ и `-DignoreList`
Собирает аргументы ровно в том порядке, в котором их ждёт JVM: сначала аргументы JVM, затем главный
класс, затем аргументы игры. Подстановки вида `${...}` из версии заменяются значениями из
параметров запуска.
Метод статический и не имеет побочных эффектов, поэтому годится и для показа собранной командной
строки без запуска.
#### static bool extractNatives(const LaunchOptions &options, const MinecraftVersion &version, const QString &nativesDir, QString \*error)
Распаковывает файлы `.dll`, `.so` и `.dylib` из нативных библиотек в `<версия>/natives`. Учитывает
поле `extractExclude` каждой библиотеки — перечисленные там префиксы не распаковываются.
Возвращает `false` и заполняет `error` при неудаче. Без этого шага игра не стартует: LWJGL ищет
нативные библиотеки именно в этой папке.
#### bool launch(const LaunchOptions &options, const MinecraftVersion &version, QString \*error)
Полный цикл запуска: проверка комплектности, распаковка нативных библиотек, поиск java, старт
процесса.
Возвращает `false` и заполняет `error`, если что-то из перечисленного не удалось; `true` означает,
что процесс запущен — дальнейшая судьба игры приходит сигналами.
Путь к java берётся из `options.javaPath`, а при пустом значении ищется через
[JavaLocator](javalocator.md) с учётом требования версии.
#### void terminate()
Завершает процесс игры. Если игра не запущена, ничего не делает.
## Сигналы
#### progress(const QString &message)
Описание текущего шага подготовки: проверка файлов, распаковка нативных библиотек, поиск java.
Обработчик показывает сообщение пользователю — подготовка занимает заметное время.
#### output(const QString &line)
Одна строка вывода процесса игры. Обработчик пишет её в журнал; в лаунчере это вывод в консоль.
#### gameStarted(const QString &commandLine)
Процесс запущен; в параметре — собранная командная строка целиком. Удобно для диагностики: по ней
видно, с какими аргументами и какой java стартовала игра.
#### gameFinished(int exitCode, bool crashed)
Игра завершилась. `exitCode` — код выхода процесса, `crashed` отличает аварийное завершение от
обычного.
Обработчик снимает признак «игра идёт», возвращает доступность кнопки запуска и сообщает
пользователю итог: ненулевой код или выставленный `crashed` показываются как ошибка.
## Владение и время жизни
Класс наследует `QObject` и принимает `parent` — родитель его и удалит.
Процесс игры хранится в поле `m_process` и создаётся при запуске. Один экземпляр рассчитан ровно
на одну игру одновременно: повторный вызов `launch()` при работающем процессе не предусмотрен, и
вызывающий код обязан проверять `isRunning()`.
Дочерний процесс переживает уничтожение объекта не сам по себе — завершать игру перед выходом
должен вызывающий код через `terminate()`.
## Потокобезопасность
Только поток GUI. `QProcess` привязан к потоку, в котором создан, и все сигналы приходят туда же.
Статические методы (`missingFiles()`, `buildArguments()`, `extractNatives()`) состояния не имеют,
но выполняют файловый ввод-вывод и на большой версии могут заметно задержать вызывающий поток.
## Взаимодействие с другими классами
`LauncherBackend` собирает `LaunchOptions` из трёх источников: настроек лаунчера, описания
выбранной сборки и `AuthResult` от [AuthService](AuthService.md) или
[MsaAuthService](MsaAuthService.md). Разобранную версию он получает через
`VersionLoader::load()` из [minecraftversion.h](minecraftversion.md).
Все четыре сигнала бэкенд переправляет в QML: `progress` и `gameFinished` попадают в плашку
сообщений главного окна, `output` — в консоль, а `gameStarted` меняет признак `gameRunning`.
Статический `missingFiles()` вызывается отдельно от запуска — из метода проверки комплектности
сборки, результат которого показывает [BuildsDialog](../qml/BuildsDialog.md).
## Внешнее взаимодействие
**Дочерний процесс.** Класс запускает java через `QProcess`. Аргументы собираются
`buildArguments()`; для профилей Ely.by в них добавляется `-javaagent` с путём к
`authlib-injector`. Стандартный вывод процесса читается построчно и отдаётся сигналом `output`,
завершение — сигналом `gameFinished`. Направление обмена одностороннее: лаунчер запускает процесс
и читает его вывод, ничего не передавая обратно после старта.
Все сигналы процесса приходят в поток GUI.
## Пример использования
```cpp
auto *launcher = new GameLauncher(this);
connect(launcher, &GameLauncher::progress, this, &Backend::showStatus);
connect(launcher, &GameLauncher::gameFinished, this, &Backend::onGameFinished);
LaunchOptions options;
options.gameDir = settings.resolvedGameDir;
options.versionId = build.resolvedVersionId;
options.playerName = auth.playerName;
options.uuid = auth.uuid;
options.accessToken = auth.accessToken;
options.userType = auth.userType;
options.maxMemoryMb = settings.maxMemoryMb;
QString error;
if (!launcher->launch(options, version, &error))
emit launchError(error);
```
Forge собирает слой модулей из classpath и пропускает файлы, имя которых начинается с записи в
`-DignoreList`. В профиле Forge там записан `${version_name}.jar`. Официальный лаунчер кладёт в
classpath jar самого профиля, а у нас своего jar у профиля нет, и в classpath попадает
родительский, например `1.20.1.jar`. Если его имени нет в списке, он становится вторым модулем с
классами Minecraft, и игра падает с ошибкой вида `Modules minecraft and _1._20._1 export package
...`. Поэтому к `-DignoreList` дописывается имя клиентского jar, если его там ещё нет.
---
+40 -180
View File
@@ -1,200 +1,60 @@
# JavaInstaller
## Обзор класса
`javainstaller.h`, `javainstaller.cpp`
`JavaInstaller` ставит сборку Java в `<root>/java/<id>`. Как и
[ModLoaderInstaller](ModLoaderInstaller.md), он прячет за одним фасадом два разных пути.
Ставит одну сборку Java из каталога [JavaRuntimeService](JavaRuntimeService.md) в
`<root>/java/<id>/`. Сборки Temurin приходят архивом, сборки Mojang — деревом отдельных файлов,
поэтому путей установки два.
**Temurin** отдаёт один архив: лаунчер качает его, сверяет sha256 и распаковывает. **Mojang**
отдаёт манифест с деревом файлов: лаунчер качает файлы по отдельности, как это делает официальный
лаунчер.
Экземпляр держит [LauncherBackend](LauncherBackend.md). Геттеры прогресса такие же, как у других
установщиков: `label`, `stage`, `currentFile`, `bytesDone`, `bytesTotal`, `fraction`, плюс
`runtimeId`. Сигналы: `started(label)`, `progressChanged()` (не чаще раза в 100 мс),
`finished(runtimeId, javaPath)`, `failed(label, message)`, `canceled(label)`.
Набор геттеров прогресса повторяет [VersionInstaller](VersionInstaller.md): панель загрузки в
интерфейсе читает их одинаково, независимо от того, кто сейчас работает.
## install(entry)
## Место в проекте и зависимости
Если установка уже идёт, вызов молча игнорируется. Иначе:
Подключает [javaruntime.h](javaruntime.md): на вход установщик принимает запись каталога
`JavaRuntimeEntry`, а результат записывает через `JavaRuntimeStore`.
1. проверяется папка лаунчера;
2. **папка `<root>/java/<id>` удаляется целиком**, если она была, и создаётся заново. Повторная
установка — это всегда установка с нуля;
3. дальше одна из двух веток.
Экземпляр создаётся и принадлежит [LauncherBackend](LauncherBackend.md); записи каталога приходят
от [JavaRuntimeService](JavaRuntimeService.md).
### Temurin (`zip`, `tar.gz`)
Требования сборки: `Qt6::Core` (`QCryptographicHash`, `QSaveFile`, `QProcess`, `QTimer`),
`Qt6::CorePrivate` (`QZipReader` для распаковки zip) и `Qt6::Network`.
1. Архив качается в `<root>/cache/java/<имя файла>` через `QSaveFile`, sha256 считается на ходу и
сверяется с каталогом.
2. `zip` распаковывается через `QZipReader` по 24 записи за такт таймера, чтобы окно не
подвисало. Права из архива переносятся. Записи, которые ведут за пределы папки сборки,
останавливают установку.
3. `tar.gz` распаковывается системным `tar -xzf`. Он есть на Linux, macOS и Windows 10 и новее.
`QZipReader` tar не умеет, а держать ради этого ещё одну библиотеку незачем.
4. Архив удаляется.
## Иерархия и роль
Прогресс при загрузке считается в байтах, при распаковке zip — в записях. Для tar прогресса нет.
Наследует `QObject`: мета-объектная система, пять сигналов и владение по родителю. Объявлен
виртуальный деструктор — класс владеет незавершёнными загрузками, открытым архивом и процессом
распаковки.
### Mojang
## Публичные структуры
1. Качается `manifest.json` компонента. Его sha1 из каталога сейчас **не проверяется**.
2. Из `files` строится дерево: папки создаются сразу, ссылки откладываются, файлы ставятся в
очередь.
3. До 4 файлов качаются параллельно, sha1 каждого сверяется. При временной ошибке (таймаут,
5xx и т. п.) и при несовпадении хеша файл уходит в конец очереди без паузы. После третьей
неудачи установка падает. Файлам с `executable: true` ставится бит исполнения.
4. Ссылки создаются через `QFile::link`. Если не вышло (на Windows без прав разработчика), файл
копируется.
### JavaFileTask
### Конец
Один файл рантайма Mojang.
`JavaRuntimeStore::locateBinary()` находит `bin/java`. На Unix ему ставится бит исполнения.
`runtime.json` записывается последним: пока его нет, `JavaRuntimeStore` сборку не видит. Поэтому
недокачанная сборка в списке не появится, даже если лаунчер упал на середине.
| Поле | Тип | По умолчанию | Описание |
|------|-----|--------------|----------|
| `url` | `QUrl` | — | Откуда качать |
| `path` | `QString` | — | Абсолютный путь назначения |
| `sha1` | `QString` | — | Контрольная сумма файла |
| `size` | `qint64` | `0` | Размер в байтах |
| `executable` | `bool` | `false` | Файлу нужно выставить право на исполнение — иначе `bin/java` не запустится |
| `attempts` | `int` | `0` | Сколько попыток уже сделано |
При ошибке и при отмене папка сборки и скачанный архив удаляются.
### JavaActiveDownload
## cancel()
Файл в процессе скачивания: задача, сетевой ответ, открытый `QSaveFile`, накапливаемая
контрольная сумма и число принятых байт. Файлы пишутся потоком — рантайм весит около двухсот
мегабайт.
Структура `ZipExtraction`, хранящая состояние распаковки, объявлена вперёд и спрятана в
`.cpp`: так приватный заголовок `QZipReader` не расходится по проекту вместе с этим заголовком.
## Публичные методы
#### explicit JavaInstaller(QObject \*parent = nullptr)
Создаёт установщик, его `QNetworkAccessManager` и два таймера. Конструктор помечен `explicit`.
#### bool isRunning() const
Идёт ли установка прямо сейчас.
#### QString runtimeId() const
Идентификатор устанавливаемой сборки.
#### QString label() const
Подпись установки для интерфейса.
#### QString stage() const
Текущий этап словами: загрузка, проверка, распаковка.
#### QString currentFile() const
Файл, который обрабатывается сейчас.
#### qint64 bytesDone() const
Сколько байт уже получено. Байты считаются только на загрузке: на распаковке считать нечего, и
панель по нулевому итогу сама прячет мегабайты.
#### qint64 bytesTotal() const
Ожидаемый общий объём загрузки.
#### double fraction() const
Доля выполнения от `0` до `1` либо `-1`, пока итог неизвестен. На этапе распаковки доля считается
по числу обработанных записей архива или файлов дерева, а не по байтам.
#### void install(const JavaRuntimeEntry &entry)
Ставит сборку по записи каталога. Путь установки выбирается по полю `archive` записи: значения
`zip` и `tar.gz` ведут по пути Temurin, значение `mojang` — по пути манифеста.
По завершении установщик находит исполняемый файл java в распакованном дереве через
`JavaRuntimeStore::locateBinary()` и записывает описание сборки рядом с ней.
Одновременно ставится одна сборка; очереди у этого установщика нет.
#### void cancel()
Отменяет установку. Отмена проверяется между файлами и между кусками распаковки, поэтому
срабатывает не мгновенно, но без замораживания интерфейса.
## Сигналы
#### started(const QString &label)
Установка началась. Обработчик показывает панель прогресса.
#### progressChanged()
Изменились числа прогресса; испускается не чаще, чем позволяет внутренний таймер. Обработчик
перечитывает геттеры.
#### finished(const QString &runtimeId, const QString &javaPath)
Сборка установлена; во втором параметре — абсолютный путь к исполняемому файлу java.
Обработчик обновляет каталог: сборка становится помеченной как скачанная, а диалог настроек
перечитывает её описание.
#### failed(const QString &label, const QString &message)
Установка не удалась; в параметре — текст ошибки для пользователя.
#### canceled(const QString &label)
Установка отменена пользователем.
## Владение и время жизни
Класс наследует `QObject` и принимает `parent` — родитель его и удалит.
`QNetworkAccessManager` и оба таймера создаются в конструкторе с установщиком в роли родителя.
Владение внутренними ресурсами построено на RAII: скачиваемый архив хранится как
`std::unique_ptr<QSaveFile>`, состояние распаковки — как `std::unique_ptr<ZipExtraction>`,
активные загрузки — как `std::shared_ptr<JavaActiveDownload>` с собственным `QSaveFile` внутри.
Незавершённая запись отменяется вместе с уничтожением объекта, и испорченный файл не попадает на
место назначения.
Процесс `tar`, используемый для распаковки архивов `tar.gz`, создаётся по ходу работы и
завершается в деструкторе.
## Потокобезопасность
Только поток GUI. Отдельного потока у класса нет намеренно: распаковка zip идёт по кускам по
таймеру — держать поток GUI занятым на всю сотню мегабайт нельзя, а заводить поток ради одной
операции незачем. Распаковка `tar.gz` отдана внешнему процессу, который работает параллельно сам.
## Взаимодействие с другими классами
`LauncherBackend` вызывает `install()` с записью, полученной от
[JavaRuntimeService](JavaRuntimeService.md), и переправляет сигналы прогресса в те же свойства,
что и остальные установщики. Сигнал `finished` бэкенд переправляет в QML под собственным именем —
на него подписан диалог настроек, чтобы обновить строку выбранной сборки, когда та докачается.
Раскладку папки `<root>/java`, поиск исполняемого файла и запись описания обеспечивает
`JavaRuntimeStore` из [javaruntime.h](javaruntime.md).
## Внешнее взаимодействие
**Сеть, исходящие запросы.** Загрузка по HTTPS с серверов Adoptium или Mojang. Архив Temurin
скачивается одним запросом с проверкой sha256; дерево Mojang — множеством параллельных запросов,
каждый с проверкой sha1. Неудачная задача повторяется, счётчик попыток хранится в самой задаче.
**Дочерний процесс.** Архивы `tar.gz` распаковываются системным `tar` через `QProcess` —
собственного распаковщика для этого формата в Qt нет. Обмен односторонний: лаунчер запускает
процесс и ждёт его завершения.
**Файловая система.** После распаковки дерева Mojang применяются символические ссылки из
манифеста, а файлам с признаком `executable` выставляется право на исполнение.
Все сигналы приходят в поток GUI.
## Пример использования
```cpp
auto *javaInstaller = new JavaInstaller(this);
connect(javaInstaller, &JavaInstaller::finished, this,
[this](const QString &runtimeId, const QString &javaPath) {
m_settings.javaRuntime = runtimeId;
m_resolvedJavaPath = javaPath;
emit javaRuntimeInstalled(runtimeId);
});
const auto entry = javaCatalog->find(runtimeId);
if (entry)
javaInstaller->install(*entry);
```
Обрывает загрузки и убивает `tar`. `canceled` приходит, когда вернутся все активные запросы.
---
+45 -133
View File
@@ -1,155 +1,67 @@
# JavaRuntimeService
## Обзор класса
`javaruntimeservice.h`, `javaruntimeservice.cpp`
`JavaRuntimeService` — каталог сборок Java, которые лаунчер умеет скачать: качает, кэширует в
папке лаунчера и отдаёт из кэша, пока тот не устарел. Устроен так же, как
[VersionManifestService](VersionManifestService.md).
Каталог сборок Java, которые лаунчер может скачать. Устроен как
[VersionManifestService](VersionManifestService.md), но кэш хранится сутки и лежит в
`<root>/cache/java_runtimes.json`.
Источников два, и они дополняют друг друга. **Mojang** (категория «Java») — ровно тот рантайм,
которым запускает игру официальный лаунчер: версий немного, зато они заведомо совместимы.
**Eclipse Temurin** (категории JDK и JRE) — свежие сборки всех мажорных версий, включая те, до
которых Mojang ещё не дошёл.
Источников два:
Отдаются только сборки под текущие операционную систему и архитектуру: выбрать заведомо
неработающую нечем. Исключение — macOS на Apple Silicon, где под Java 8 и 16 сборок `aarch64` нет
вовсе и приходится брать `x64`, который работает через Rosetta.
- **Mojang** (в интерфейсе категория «Java») — те же рантаймы, с которыми игру запускает
официальный лаунчер. Их немного, зато с игрой они точно совместимы.
- **Eclipse Temurin** (JDK и JRE) — свежие сборки всех мажорных версий, в том числе тех, которых у
Mojang ещё нет.
## Место в проекте и зависимости
В каталог попадают только сборки под текущую ОС и архитектуру.
Подключает [javaruntime.h](javaruntime.md): перечисление `JavaRuntimeKind` и структура
`JavaRuntimeEntry` приходят оттуда.
Экземпляр держит [LauncherBackend](LauncherBackend.md), ставит сборки
[JavaInstaller](JavaInstaller.md).
Экземпляр создаётся и принадлежит [LauncherBackend](LauncherBackend.md). Записями каталога
пользуется [JavaInstaller](JavaInstaller.md).
## Как обновляется каталог
Путь к файлу кэша даёт `LauncherPaths::javaCatalogFile()` из [launcherpaths.h](launcherpaths.md).
Запросы идут параллельно. Каталог заменяется целиком, когда ответят все.
Требования сборки: `Qt6::Core` (`QDateTime`, `QHash`, `QSet`) и `Qt6::Network`
(`QNetworkAccessManager`).
1. `api.adoptium.net/v3/info/available_releases` — какие мажорные версии есть и какие из них LTS.
2. Для каждой мажорной версии `assets/feature_releases/<major>/ga` с фильтрами: ОС, архитектура,
HotSpot, обычная куча, vendor `eclipse`, три последних релиза. Из каждого берутся бинарники с
`image_type` = `jdk` или `jre` в виде `.zip` или `.tar.gz` по `https`.
3. `piston-meta.mojang.com/.../java-runtime/.../all.json` — раздел под текущую платформу
(`windows-x64`, `mac-os-arm64`, `linux` и т. п.), по одному варианту на компонент. Компонент
`minecraft-java-exe` пропускается: это не Java.
## Иерархия и роль
**Нет сборки под aarch64.** У Temurin под ARM нет Java 8 и 16. Если для aarch64 ничего не нашлось,
запрос повторяется для `x64`. На Mac с Apple Silicon такая сборка работает через Rosetta. Сейчас
повтор делается на любой ОС с aarch64, хотя комментарий в заголовке говорит только про macOS.
Наследует `QObject`: мета-объектная система, два сигнала и владение по родителю. Виртуальных
методов базового класса не переопределяет.
Id строк: `temurin-<jdk|jre>-<версия>-<арх>` и `mojang-<компонент>`, пропущенные через
`JavaRuntimeStore::sanitizeId()`.
## Псевдонимы типов
Версия Temurin 8 показывается как `8u<security>`, остальные — как `openjdk_version` без `-LTS`.
Мажорная версия у Mojang берётся из имени (`8u…` → 8, иначе первое число).
`JavaRuntimeService::Callback` — `std::function<void(bool ok, const QString &warning)>`. Как и в
остальных каталогах лаунчера, `ok == true` с непустым `warning` означает данные из устаревшего
кэша.
У сборок Mojang `size` равен 0: в каталоге указан только размер манифеста, а не рантайма.
## Публичные методы
Если какие-то запросы упали, а другие ответили, каталог всё равно обновляется, а предупреждения
склеиваются через `; `. Если не пришло ничего, но старые данные есть, колбэк получает `ok = true` и
предупреждение.
#### explicit JavaRuntimeService(QObject \*parent = nullptr)
Кэш помечается платформой (`mojangPlatform()`). Кэш с другой платформы не читается. Это возможно,
например, если папку лаунчера перенесли с другой машины.
Создаёт сервис и его `QNetworkAccessManager`. Кэш читается лениво. Конструктор помечен `explicit`.
## Методы
#### void ensureLoaded(Callback callback, bool forceRefresh = false)
- `ensureLoaded(callback, forceRefresh)` — как у манифеста версий.
- `entries(kind)` — строки одного вида, отсортированы по мажорной версии, потом по дате выхода,
новые сверху.
- `find(id)`.
- `bestFor(major)` — что скачать, если игре нужна Java `major`, а подходящей нет. Выбирается
наименьшая мажорная версия не ниже `major`. При равенстве порядок такой: Temurin JDK, потом
Mojang, потом Temurin JRE. JDK идёт первым, потому что установщикам Forge и NeoForge иногда нужны
инструменты из полного комплекта. Если и здесь равенство, берётся более свежая.
- `isRefreshing()`, `hasData()`.
Единственная точка входа. Свежий кэш означает отложенный вызов колбэка без сети; иначе запускается
обновление. Параметр `forceRefresh` обходит проверку свежести — так работает кнопка обновления
каталога.
Одно обновление складывается из нескольких запросов сразу к обоим источникам. Ответы приходят
вразнобой, поэтому они накапливаются, и каталог подменяется целиком только когда пришли все.
#### QList&lt;JavaRuntimeEntry&gt; entries(JavaRuntimeKind kind) const
Сборки одной категории: `Mojang`, `Jdk` или `Jre`. Новые версии идут первыми.
#### std::optional&lt;JavaRuntimeEntry&gt; find(const QString &id) const
Запись по идентификатору сборки; `std::nullopt`, если такой нет. Поиск идёт по внутреннему
указателю.
#### std::optional&lt;JavaRuntimeEntry&gt; bestFor(int major) const
Что скачать, если для версии игры нужна Java указанной мажорной версии, а подходящей в системе
нет.
Правила выбора: точное совпадение мажорной версии предпочтительнее более новой, а JDK
предпочтительнее JRE — установщики Forge и NeoForge иногда требуют инструментов из полного
комплекта. `std::nullopt` означает, что предложить нечего.
#### bool isRefreshing() const
Идут ли сейчас запросы. Признак остаётся истинным, пока не завершится последний из них.
#### bool hasData() const
Есть ли в каталоге хоть что-то — из сети или из кэша.
## Сигналы
#### catalogChanged()
Каталог заменён новыми данными. Испускается один раз за обновление, когда пришли ответы от всех
источников, а не по каждому из них.
Обработчик перечитывает `entries()` для нужных категорий и обновляет интерфейс.
#### refreshingChanged()
Изменился признак обновления. Обработчик показывает или убирает индикатор загрузки.
## Владение и время жизни
Класс наследует `QObject` и принимает `parent` — родитель его и удалит.
`QNetworkAccessManager` создаётся в конструкторе с сервисом в роли родителя.
Отложенные колбэки хранятся до завершения текущего обновления. Уничтожение сервиса с
незавершёнными запросами обрывает их вместе с менеджером сети, и колбэки не вызываются.
## Потокобезопасность
Только поток GUI. Чтение и запись кэша выполняются синхронно в вызывающем потоке.
## Взаимодействие с другими классами
`LauncherBackend` вызывает `ensureLoaded()` при открытии окна выбора Java и по кнопке обновления,
а сигналы переправляет в свойства, которые читает QML. Перед отдачей в интерфейс он сводит записи
каталога с уже установленными сборками из `JavaRuntimeStore` — окно выбора показывает статус, не
считая ничего само.
[JavaInstaller](JavaInstaller.md) получает запись каталога и по ней скачивает и распаковывает
сборку. `bestFor()` используется, когда для запуска не хватает Java и лаунчер должен сам
предложить, что поставить.
Требования версий игры к Java берутся из пространства имён `JavaRequirement` в
[javaruntime.h](javaruntime.md).
## Внешнее взаимодействие
**Сеть, исходящие запросы.** Класс обращается к двум внешним каталогам: API Adoptium (сборки
Temurin) и списку рантаймов Mojang. Формат обоих — JSON поверх HTTPS, разбор разделён на две
функции.
Запросов на одно обновление несколько: список сборок Temurin запрашивается по мажорным версиям,
и на macOS с Apple Silicon неудачный запрос сборки `aarch64` может быть переспрошен для `x64` —
это и есть тот самый случай Java 8 и 16.
Общий счётчик незавершённых запросов сводит их воедино: пока он не обнулился, каталог не
подменяется, а предупреждения от отдельных источников накапливаются.
Все сигналы и колбэки приходят в поток GUI.
## Пример использования
```cpp
auto *javaCatalog = new JavaRuntimeService(this);
connect(javaCatalog, &JavaRuntimeService::catalogChanged, this, &Backend::rebuildJavaCatalog);
javaCatalog->ensureLoaded([this, javaCatalog](bool ok, const QString &warning) {
if (!ok) {
emit javaCatalogError(warning);
return;
}
const auto best = javaCatalog->bestFor(version.javaMajor);
if (best)
emit suggestJavaInstall(best->id, best->version);
});
```
Сигналы: `catalogChanged()`, `refreshingChanged()`.
---
+310 -488
View File
@@ -1,531 +1,353 @@
# LauncherBackend
## Обзор класса
`launcherbackend.h`, `launcherbackend.cpp`
`LauncherBackend` — единственный класс проекта, видимый из QML, и центр всего приложения. Интерфейс
лаунчера ничего не знает ни о сети, ни о файлах, ни о процессах: он читает свойства этого класса,
вызывает его методы и слушает его сигналы.
Через этот класс QML работает со всем остальным кодом. Интерфейс читает его свойства, вызывает
методы и слушает сигналы, а к сервисам напрямую не обращается.
Сам бэкенд почти ничего не делает руками. Он владеет двенадцатью сервисами — авторизация, запуск
игры, каталоги версий, модлоадеров, Java и сезонных сборок, три установщика, загрузчик паков и
переключатель сборок — и отвечает за то, чтобы они работали в правильном порядке. Кроме того, он
хранит состояние лаунчера: профили игрока, пользовательские сборки и настройки запуска, которые
читает и пишет в файлы папки лаунчера.
Сам бэкенд почти ничего не скачивает и не запускает. Он хранит профили, сборки и настройки,
создаёт двенадцать сервисов и следит, чтобы они не мешали друг другу: нельзя ставить Java во время
смены сборки, удалять версию, пока она качается, или запускать игру посреди установки модлоадера.
Ещё он сводит каталоги из сети с тем, что лежит на диске, чтобы окнам не пришлось этого делать.
Ещё одна его задача — приводить данные к виду, удобному QML. Каталоги отдаются в интерфейс уже
сведёнными с локальным состоянием: строка версии знает, скачана ли она, строка сезонной сборки —
установлена ли и не устарела ли. Окно показывает статус, не считая ничего само.
Экземпляр один, его объявляет [Main.qml](../qml/Main.md). Во вложенные окна он передаётся через
`required property var backend`. Тип зарегистрирован через `QML_ELEMENT` в модуле `BiLauncher`;
синглтоном он не сделан.
## Место в проекте и зависимости
## Сервисы
Единственный экземпляр создаётся декларативно в [Main.qml](../qml/Main.md); в `main.cpp` он не
упоминается.
Все создаются в конструкторе с бэкендом в роли родителя.
Владеет двенадцатью сервисами, каждому из которых посвящена своя страница:
| Поле | Класс | Роль |
|------|-------|------|
| `m_auth` | [AuthService](AuthService.md) | вход через Ely.by и офлайн |
| Поле | Класс | Зачем |
|------|-------|-------|
| `m_auth` | [AuthService](AuthService.md) | Ely.by, офлайн, загрузка authlib-injector |
| `m_msa` | [MsaAuthService](MsaAuthService.md) | вход через Microsoft |
| `m_launcher` | [GameLauncher](GameLauncher.md) | запуск JVM с игрой |
| `m_launcher` | [GameLauncher](GameLauncher.md) | запуск JVM |
| `m_manifest` | [VersionManifestService](VersionManifestService.md) | каталог версий Mojang |
| `m_installer` | [VersionInstaller](VersionInstaller.md) | установка версии игры |
| `m_loaderMeta` | [ModLoaderVersionService](ModLoaderVersionService.md) | списки версий модлоадеров |
| `m_loaderMeta` | [ModLoaderVersionService](ModLoaderVersionService.md) | версии модлоадеров |
| `m_loaderInstaller` | [ModLoaderInstaller](ModLoaderInstaller.md) | установка модлоадера |
| `m_switcher` | [BuildSwitcher](BuildSwitcher.md) | смена активной сборки |
| `m_javaMeta` | [JavaRuntimeService](JavaRuntimeService.md) | каталог сборок Java |
| `m_switcher` | [BuildSwitcher](BuildSwitcher.md) | смена активной сборки, раскатка пака |
| `m_javaMeta` | [JavaRuntimeService](JavaRuntimeService.md) | каталог Java |
| `m_javaInstaller` | [JavaInstaller](JavaInstaller.md) | установка Java |
| `m_seasonalMeta` | [SeasonalBuildService](SeasonalBuildService.md) | каталог сезонных сборок |
| `m_packDownloader` | [SeasonalPackDownloader](SeasonalPackDownloader.md) | загрузка архива сезонной сборки |
Пути ко всем файлам состояния берутся из [launcherpaths.h](launcherpaths.md), описания версий — из
[minecraftversion.h](minecraftversion.md), словарь модлоадеров — из [modloader.h](modloader.md),
поиск системной Java — из [javalocator.md](javalocator.md).
`VersionInstaller` получает указатель на сервис манифеста, `ModLoaderInstaller` — на сервис версий
модлоадеров и на установщик версий. Владельцем этих объектов остаётся бэкенд.
Требования сборки: `Qt6::Core`, `Qt6::Gui`, `Qt6::Network`, `Qt6::CorePrivate` (опосредованно) и
`Qt6::Quick` — класс зарегистрирован в QML-модуле `Minecraft_launcher`, объявленном в
`CMakeLists.txt`.
## Что происходит при старте
## Иерархия и роль
Конструктор делает следующее, порядок важен:
Наследует `QObject`: мета-объектная система, свойства, сигналы и владение по родителю. Объявлен
виртуальный деструктор. Виртуальных методов базового класса не переопределяет.
1. `LauncherPaths::ensureRootExists()` создаёт папку лаунчера и проверяет, что в неё можно писать.
2. `loadSettings()` заполняет настройки значениями по умолчанию и накладывает сверху
`settings.json`.
3. `migrateLegacyBuildsFile()` переносит старый `versions.json` в `customBuilds.json`. Это должно
случиться до следующего шага, иначе поверх старых данных появится пустой файл.
4. `loadData()` читает профили и сборки и сортирует их по `id`.
5. `ensureDataFiles()` создаёт недостающие файлы.
6. Если `BuildSwitcher` нашёл журнал оборванного переключения, бэкенд сразу продолжает его через
`resumeInterrupted()`. Пользователю ничего не предлагается. Если начать переключение заново,
оно сначала заархивирует полуразобранную `.minecraft` поверх целого архива прежней сборки.
7. Подписка на сигналы сервисов.
## Свойства Q_PROPERTY
Все проблемы, найденные по пути (битый JSON, папка без права записи, пропущенные записи сборок),
копятся в `m_storageIssues` и уходят одним `launchError` через `QTimer::singleShot(0)`: к этому
моменту QML уже подписан на сигнал.
### Профили и сборки
## Файлы на диске
| Свойство | Тип | READ | WRITE | NOTIFY | Описание |
|----------|-----|------|-------|--------|----------|
| `profileNames` | `QStringList` | `profileNames` | — | `profilesChanged` | Имена профилей игрока в порядке добавления. Только для чтения; модель выпадающего списка профилей |
| `customBuildNames` | `QStringList` | `customBuildNames` | — | `customBuildsChanged` | Имена пользовательских сборок. Только для чтения; модель списка в окне сборок |
| `activeBuildIndex` | `int` | `activeBuildIndex` | `setActiveBuildIndex` | `activeBuildChanged` | Сборка, которую запускает кнопка игры. Хранится по идентификатору сборки, а не по индексу: удаление соседней записи не должно переназначать активную. Запись в свойство запускает смену сборки |
| `activeBuildName` | `QString` | `activeBuildName` | — | `activeBuildChanged` | Имя активной сборки для подписи на кнопке. Только для чтения |
| `installedVersions` | `QStringList` | `installedVersions` | — | `installedVersionsChanged` | Версии, реально установленные в папке игры. Только для чтения |
Все пути берутся из [launcherpaths.h](launcherpaths.md).
### Занятость и смена сборки
- `settings.json` — объект с настройками;
- `profiles.json` — массив профилей;
- `customBuilds.json` — массив сборок.
| Свойство | Тип | READ | WRITE | NOTIFY | Описание |
|----------|-----|------|-------|--------|----------|
| `gameRunning` | `bool` | `gameRunning` | — | `gameRunningChanged` | Игра запущена. Только для чтения; выключает кнопку запуска |
| `switching` | `bool` | `switching` | — | `switchChanged` | Идёт архивация или распаковка `.minecraft` при смене сборки. Отдельно от `busy`, потому что на это время блокируется ещё и список сборок. Только для чтения |
| `switchProgress` | `double` | `switchProgress` | — | `switchChanged` | Доля выполнения смены сборки от `0` до `1`; `-1` — итог неизвестен. Только для чтения |
| `switchStage` | `QString` | `switchStage` | — | `switchChanged` | Этап смены сборки словами. Только для чтения |
| `switchStatus` | `QString` | `switchStatus` | — | `switchChanged` | Строка состояния смены сборки. Только для чтения |
| `busy` | `bool` | `busy` | — | `busyChanged` | Лаунчер занят: пока идёт загрузка версии, кнопка запуска гаснет. Только для чтения |
| `microsoftAvailable` | `bool` | `microsoftAvailable` | — | — | Собран ли лаунчер с Qt WebEngine. Константное свойство: без WebEngine окно входа Microsoft показать нечем, и интерфейс не должен предлагать этот путь |
Если файла нет, это первый запуск. Если он не читается как JSON или в нём не тот тип (объект вместо
массива и наоборот), файл переименовывается в `<имя>.bak`, а на его месте создаётся пустой.
Данные из `.bak` можно потом достать руками.
### Каталог версий
Перед каждой записью снова вызывается `ensureRootExists()`: папку могли удалить, пока лаунчер
работал, и без этой проверки сохранения пропадали бы без ошибки. Пишется всё через обычный `QFile`,
кроме `builds/<id>/season.json`, для которого используется `QSaveFile`.
| Свойство | Тип | READ | WRITE | NOTIFY | Описание |
|----------|-----|------|-------|--------|----------|
| `versionCatalog` | `QVariantList` | `versionCatalog` | — | `versionCatalogChanged` | Объединённый список для выбора версии: установленные, разделитель, затем весь каталог Mojang. Строка содержит поля `id`, `label`, `category`, `installed` и `search`. Только для чтения |
| `catalogLoading` | `bool` | `catalogLoading` | — | `catalogLoadingChanged` | Идёт загрузка манифеста версий. Только для чтения |
### Каталог Java
| Свойство | Тип | READ | WRITE | NOTIFY | Описание |
|----------|-----|------|-------|--------|----------|
| `javaCatalog` | `QVariantList` | `javaCatalog` | — | `javaCatalogChanged` | Сборки Java для окна выбора: скачиваемые из сети плюс те, что уже лежат в папке лаунчера. Строка содержит поля `id`, `label`, `kind`, `major`, `installed`, `downloadable`, `lts`, `sizeMb`, `detail`, `coverage` и `search`. Только для чтения |
| `javaCatalogLoading` | `bool` | `javaCatalogLoading` | — | `javaCatalogLoadingChanged` | Идёт загрузка каталога Java. Только для чтения |
### Сезонные сборки
| Свойство | Тип | READ | WRITE | NOTIFY | Описание |
|----------|-----|------|-------|--------|----------|
| `seasonalCatalog` | `QVariantList` | `seasonalCatalog` | — | `seasonalCatalogChanged` | Готовые сборки с сервера. Строки уже сведены с локальными записями: окно показывает статус, не считая ничего само. Только для чтения |
| `seasonalCatalogLoading` | `bool` | `seasonalCatalogLoading` | — | `seasonalCatalogLoadingChanged` | Идёт загрузка каталога сезонных сборок. Только для чтения |
| `seasonalCatalogError` | `QString` | `seasonalCatalogError` | — | `seasonalCatalogChanged` | Текст ошибки обращения к серверу сборок; пусто — всё в порядке. Только для чтения |
| `seasonalInstalling` | `bool` | `seasonalInstalling` | — | `seasonalInstallingChanged` | Идёт установка сезонной сборки: окно не даёт начать вторую. Только для чтения |
### Загрузка
Одного сигнала на все свойства загрузки достаточно: установщик уже ограничивает частоту, а QML всё
равно перечитывает их разом.
| Свойство | Тип | READ | WRITE | NOTIFY | Описание |
|----------|-----|------|-------|--------|----------|
| `downloading` | `bool` | `downloading` | — | `downloadChanged` | Идёт какая-либо загрузка. Только для чтения |
| `downloadProgress` | `double` | `downloadProgress` | — | `downloadChanged` | Доля выполнения от `0` до `1`; `-1` — итог неизвестен. Только для чтения |
| `downloadVersion` | `QString` | `downloadVersion` | — | `downloadChanged` | Что именно качается — версия, модлоадер, сборка Java или пак. Только для чтения |
| `downloadStatus` | `QString` | `downloadStatus` | — | `downloadChanged` | Строка состояния загрузки. Только для чтения |
| `downloadBytesDone` | `qint64` | `downloadBytesDone` | — | `downloadChanged` | Принято байт. Только для чтения |
| `downloadBytesTotal` | `qint64` | `downloadBytesTotal` | — | `downloadChanged` | Ожидаемый объём в байтах; `0` — неизвестен. Только для чтения |
Панель загрузки одна на все четыре источника: свойства отдают числа того установщика или
загрузчика, который работает сейчас.
## Методы Q_INVOKABLE
Все перечисленные ниже методы вызываются из QML.
### Профили
#### void addProfile(const QString &name, const QString &login, const QString &password, const QString &authType = "offline")
Добавляет профиль игрока. Параметр `authType` принимает значения `offline`, `elyby` и `microsoft`.
Для офлайн-профиля пароль не нужен, для профиля Microsoft не нужны ни логин, ни пароль. Испускает
`profilesChanged`.
#### void updateProfile(int index, const QString &name, const QString &login, const QString &password, const QString &authType = "offline")
Перезаписывает профиль по индексу теми же полями. Испускает `profilesChanged`.
#### QVariantMap profileAt(int index) const
Данные профиля для диалога редактирования: имя, логин, пароль, тип, а также признак наличия
действующей сессии Microsoft и ник, полученный при официальной авторизации.
#### void removeProfile(int index)
Удаляет профиль. Испускает `profilesChanged`.
### Вход через Microsoft
#### void startMicrosoftLogin(int profileIndex)
Начинает вход в аккаунт Microsoft: испускает `microsoftLoginUrlReady` с адресом страницы входа.
Значение `-1` в `profileIndex` означает, что вход ещё не привязан к профилю: профиль создастся по
нику, который вернут Minecraft Services.
#### void finishMicrosoftLogin(const QString &code)
Завершает вход по коду авторизации, перехваченному окном браузера. Итог приходит сигналом
`microsoftLoginSucceeded` или `microsoftLoginFailed`.
#### void cancelMicrosoftLogin()
Сбрасывает начатую сессию входа. Вызывается, когда пользователь закрыл окно или адрес возврата
пришёл без кода.
#### QVariantMap inspectMicrosoftRedirect(const QString &url) const
Разбирает адрес, на который встроенное окно возвращается после входа. Возвращает карту с полями
`matched` (является ли адрес адресом возврата), `code` и `error`.
Разбор живёт в C++, чтобы правила совпадения не разъезжались с теми, по которым сервис сам строит
`redirect_uri`.
### Сборки
#### void addCustomBuild(const QString &name, const QString &serverUrl, const QString &minecraftVersion = QString())
Создаёт пользовательскую сборку. Испускает `customBuildsChanged`.
#### void updateCustomBuild(int index, const QVariantMap &fields)
Мержит в сборку только присланные ключи: `name`, `serverUrl`, `minecraftVersion`, `loader`,
`loaderVersion`, `resolvedVersionId`. Остальные поля остаются как были — это и позволяет карточке
сборки сохранять правки по одному полю за раз.
#### QVariantMap customBuildAt(int index) const
Данные сборки для карточки редактирования.
#### QVariantMap customBuildRemovalInfo(int index) const
Что именно потеряется при удалении сборки — для текста предупреждения. Возвращает имя сборки и три
признака: есть ли у неё архив, активна ли она сейчас и последняя ли она.
#### void removeCustomBuild(int index)
Удаляет сборку вместе с её архивом. Испускает `customBuildsChanged`.
#### void installCustomBuild(int index)
Докачивает то, чего не хватает выбранной сборке: версию игры и, если он выбран, модлоадер.
Вынесено отдельной кнопкой, потому что карточка сборки сохраняет правки по ходу редактирования и
установка не должна начинаться сама при каждой правке.
#### QStringList checkInstallation(int buildIndex) const
Проверка комплектности без запуска — для подсказки в интерфейсе. Пустой список означает, что
сборку можно запускать; иначе возвращаются описания недостающих файлов.
### Запуск игры
#### void launchGame(int profileIndex, int buildIndex)
Главная кнопка. Проверяет выбор профиля и версии, комплектность `.minecraft`, при необходимости
авторизуется и стартует игру.
Авторизация асинхронна, поэтому метод возвращается сразу; дальнейший ход виден по сигналам
`launchProgress`, `launched`, `launchError` и `twoFactorRequired`.
#### void submitTwoFactorCode(const QString &code)
Продолжает прерванный запуск, отдавая одноразовый код двухфакторной аутентификации. Вызывается
после сигнала `twoFactorRequired`.
#### void cancelPendingLaunch()
Отменяет запуск, остановленный на ожидании кода двухфакторной аутентификации.
#### void stopGame()
Завершает процесс игры.
### Каталог версий
#### void refreshVersionCatalog(bool force = false)
Обновляет каталог версий. Вызывается при открытии окна выбора версии: свежий кэш отвечает без
сети. Параметр `force` обходит проверку свежести.
#### bool isVersionInstalled(const QString &versionId) const
Установлена ли версия в папке игры.
#### void installVersion(const QString &versionId)
Ставит версию игры в фоне. Ход виден по свойствам загрузки.
#### QVariantMap versionRemovalInfo(const QString &versionId) const
Что потеряется при удалении версии — для текста предупреждения. Возвращает признак установки,
занимаемый объём в мегабайтах, список зависящих профилей модлоадеров и список сборок, которые эту
версию используют.
#### void removeVersion(const QString &versionId)
Удаляет файлы версии из `versions/`. Библиотеки и ресурсы остаются: они общие для всех версий.
Испускает `installedVersionsChanged`.
#### void cancelDownload()
Отменяет текущую загрузку.
### Модлоадеры
#### QVariantList loaderVersions(const QString &loaderKey, const QString &gameVersion) const
Версии модлоадера для выбранной версии игры. Возвращает список карт с полями `version`, `label`,
`recommended` и `stable`.
Несовместимых строк в списке нет — отбор заложен в сам источник данных, поэтому проверять
совместимость вызывающему коду не нужно. Пустой список означает, что лоадер эту версию игры не
поддерживает.
Параметр `loaderKey` принимает значения `forge`, `fabric`, `neoforge` и `quilt`.
#### void refreshLoaderVersions(const QString &loaderKey, const QString &gameVersion, bool force = false)
Запрашивает обновление списка версий лоадера. Результат приходит сигналом `loaderVersionsChanged`.
#### bool loaderVersionsLoading(const QString &loaderKey, const QString &gameVersion) const
Идёт ли сейчас запрос по этой паре. Позволяет интерфейсу отличить «ещё грузим» от «не
поддерживается».
#### void installLoaderForBuild(int index)
Ставит модлоадер, выбранный в сборке, и записывает получившийся профиль в `resolvedVersionId`.
Пароли Ely.by и токены лежат в `profiles.json` открытым текстом.
### Настройки
#### QVariantMap settings() const
Настройки запуска одной картой: `gameDir`, `javaPath`, `javaRuntime`, `minMemoryMb`,
`maxMemoryMb`, `jvmArgs`, `windowWidth`, `windowHeight`, `fullscreen`, `language` и вычисленный
`resolvedGameDir`.
#### void updateSettings(const QVariantMap &values)
Записывает настройки и сохраняет их на диск. Испускает `settingsChanged`.
Смену `language` после записи пробрасывает в [Localization](Localization.md) — порядок
«сохранили → переключили» гарантирует, что выбранный язык переживёт падение сразу после
переключения. Сам `Localization` в `settings.json` не пишет: файл ведёт только бэкенд.
#### QStringList detectedJava() const
Пути ко всем java, найденным в системе. Показывается справочной строкой в диалоге настроек.
### Сборки Java
#### void refreshJavaCatalog(bool force = false)
Обновляет каталог сборок Java. Вызывается при открытии окна выбора: свежий кэш отвечает без сети.
#### void installJavaRuntime(const QString &runtimeId)
Скачивает и распаковывает сборку Java. По завершении испускается `javaRuntimeInstalled`.
#### void removeJavaRuntime(const QString &runtimeId)
Удаляет скачанную сборку Java из папки лаунчера.
#### QVariantMap javaRuntimeInfo(const QString &runtimeId) const
Описание установленной сборки: подпись, версия, путь к java и признак установки. Пустая карта
означает, что сборки с таким идентификатором в папке лаунчера нет.
Метод не является привязкой и сам не пересчитывается, когда сборка докачается, — диалог настроек
обновляет его по сигналу `javaRuntimeInstalled`.
#### int requiredJavaMajor(int buildIndex) const
Минимальная мажорная версия Java для версии игры выбранной сборки; `0` — версия не выбрана.
Передаётся в окно выбора Java, чтобы пометить слишком старые сборки.
### Сезонные сборки
#### void refreshSeasonalCatalog(bool force = false)
Обновляет каталог сезонных сборок.
#### void installSeasonalBuild(const QString &seasonalId)
Ставит или обновляет сборку целиком одной цепочкой: запись сборки, версия игры, модлоадер, Java и
файлы.
Порядок шагов жёсткий: сначала сборка делается активной, затем ставится Java — она нужна
установщику Forge, — затем модлоадер, затем качается пак и только в конце его файлы раскатываются
поверх `.minecraft`. Раскатывать файлы имеет смысл только когда всё остальное на месте.
Строка каталога копируется на момент старта: обновление списка посреди установки не должно менять
то, что ставится. По завершении испускается `seasonalInstallFinished`.
#### void cancelSeasonalInstall()
Отменяет установку сезонной сборки.
### Папки
#### void openMinecraftFolder()
Открывает папку модов Minecraft в файловом менеджере системы.
#### void openGameFolder()
Открывает корневую папку игры в файловом менеджере системы.
## Сигналы
### Сигналы уведомления свойств
Эти сигналы объявлены как `NOTIFY` соответствующих свойств; обработчик перечитывает свойство.
`profilesChanged`, `customBuildsChanged`, `activeBuildChanged`, `switchChanged`,
`installedVersionsChanged`, `settingsChanged`, `gameRunningChanged`, `busyChanged`,
`versionCatalogChanged`, `catalogLoadingChanged`, `javaCatalogChanged`,
`javaCatalogLoadingChanged`, `seasonalCatalogChanged`, `seasonalCatalogLoadingChanged`,
`seasonalInstallingChanged` и `downloadChanged`.
#### loaderVersionsChanged(const QString &loaderKey, const QString &gameVersion)
Список версий модлоадера изменился. Параметры сужают событие до конкретной пары, поэтому
обработчик обязан сверить их со своим текущим состоянием: обновление может относиться к другой
строке лоадера или к прошлой версии игры. Именно так поступает [LoaderRow](../qml/LoaderRow.md).
### События установки
#### seasonalInstallFinished(const QString &seasonalId, const QString &buildName)
Сезонная сборка установлена и активна — можно запускать игру. Обработчик показывает сообщение
пользователю.
#### javaRuntimeInstalled(const QString &runtimeId)
Сборка Java установлена. Диалог настроек по этому сигналу обновляет подпись выбранной сборки, не
переоткрываясь.
### Запуск игры
#### launchProgress(const QString &message)
Описание текущего шага запуска. Обработчик показывает сообщение без таймаута: шаг может занять
заметное время, и сообщение должно держаться до следующего.
#### launched(const QString &profileName, const QString &buildName, const QString &serverUrl)
Игра запущена. Обработчик сообщает пользователю, какой профиль и какая сборка стартовали.
#### launchError(const QString &message)
Запуск не удался либо произошла ошибка, о которой нужно сказать пользователю. Через этот же сигнал
сообщается о проблемах записи файлов лаунчера.
#### twoFactorRequired(const QString &profileName)
Ely.by отклонил пароль с пометкой two factor. Обработчик открывает диалог ввода кода и передаёт
введённое значение в `submitTwoFactorCode()`; отказ должен вызвать `cancelPendingLaunch()`, иначе
запуск останется висеть в ожидании.
#### gameOutput(const QString &line)
Строка вывода процесса игры. Обработчик пишет её в журнал.
#### gameFinished(int exitCode, bool crashed)
Игра завершилась. Обработчик сообщает итог: ненулевой код или выставленный `crashed` показываются
как ошибка.
### Вход через Microsoft
#### microsoftLoginUrlReady(const QString &url)
Окну входа Microsoft: открыться на этом адресе. Обработчик создаёт окно (в сборке с Qt WebEngine)
и открывает его.
#### microsoftLoginSucceeded(const QString &playerName)
Вход выполнен. Обработчик сообщает об этом пользователю, но не трогает выбор в списке профилей:
новый профиль уже выбран тем, кто его создал, а повторный вход мог быть и не в последний профиль.
#### microsoftLoginFailed(const QString &message)
Вход не удался.
#### microsoftReloginRequired(int profileIndex)
Сессия профиля протухла настолько, что нужен повторный вход руками. Обработчик обычно сразу
вызывает `startMicrosoftLogin()` для этого профиля.
## Владение и время жизни
Класс наследует `QObject` и принимает `parent`. Экземпляр создаётся декларативно в QML, поэтому
временем его жизни управляет движок QML: объект живёт столько же, сколько главное окно.
Все двенадцать сервисов создаются в конструкторе с бэкендом в роли родителя и уничтожаются вместе
с ним. Порядок создания важен для двоих: [VersionInstaller](VersionInstaller.md) принимает в
конструктор сервис манифеста, а [ModLoaderInstaller](ModLoaderInstaller.md) — сервис версий
лоадеров и установщик версий; эти указатели не переходят во владение принимающей стороны.
Кэши каталогов помечены `mutable` и пересобираются лениво из константных геттеров: QML читает
свойства помногу раз за кадр, пока открыт список, и пересборка по каждому чтению обошлась бы
дорого.
## Потокобезопасность
Только поток GUI. Единственная работа в другом потоке — файловые операции над содержимым
`.minecraft`, и она полностью инкапсулирована в [BuildSwitcher](BuildSwitcher.md): сам бэкенд
общается с ним обычными сигналами и слотами.
## Доступ из QML
Класс зарегистрирован макросом `QML_ELEMENT` в модуле `Minecraft_launcher`, объявленном через
`qt_add_qml_module` в `CMakeLists.txt`. Имя типа в QML совпадает с именем класса —
`LauncherBackend`.
Из QML доступны все 26 свойств, все 42 метода `Q_INVOKABLE` и все сигналы, перечисленные выше.
Синглтоном тип не объявлен: экземпляр создаётся декларативно в [Main.qml](../qml/Main.md) и
передаётся во вложенные диалоги через их свойство `backend`. Все диалоги проекта объявляют его как
`required property var backend`.
Объект, созданный из QML, принадлежит движку QML — удалять его из C++ нельзя.
## Взаимодействие с другими классами
**Вниз, к сервисам.** Бэкенд подписан на сигналы всех двенадцати сервисов и сводит их к своим
свойствам. Четыре разных источника загрузки — установщик версий, установщик модлоадеров,
установщик Java и загрузчик паков — отображаются в одну группу свойств `download*`, поэтому панель
в интерфейсе не различает, кто работает; какой из источников показывать, бэкенд решает сам.
**Вверх, к QML.** Интерфейс не обращается ни к одному сервису напрямую. Каталоги отдаются уже
сведёнными с локальным состоянием: строка версии знает про `installed`, строка сезонной сборки —
про установленную ревизию и доступное обновление.
**Состояние на диске.** Профили, сборки и настройки читаются при создании и пишутся при каждом
изменении. Отсутствие файла — норма (первый запуск), а повреждённое содержимое отводится в файл с
расширением `.bak`, чтобы рабочий файл создался заново. Проблемы хранилища, замеченные на старте,
накапливаются и показываются одним сообщением, когда интерфейс уже подключился к сигналам.
Отдельно предусмотрена миграция: файл `versions.json` от прежней схемы именования переносится в
`customBuilds.json` при первом запуске после переименования.
**Восстановление после сбоя.** При старте бэкенд спрашивает у переключателя сборок, не было ли
прервано переключение, и предлагает доиграть его.
## Внешнее взаимодействие
Собственных сетевых обращений и дочерних процессов у класса нет: всё внешнее взаимодействие
делегировано сервисам — сеть у каталогов, установщиков и служб авторизации, процессы у
[GameLauncher](GameLauncher.md), [ModLoaderInstaller](ModLoaderInstaller.md) и
[JavaInstaller](JavaInstaller.md), файловые операции над `.minecraft` у
[BuildSwitcher](BuildSwitcher.md).
Единственное прямое обращение к системе — открытие папки игры в файловом менеджере методами
`openMinecraftFolder()` и `openGameFolder()`.
## Пример использования
Класс предназначен для создания из QML, а не из C++:
| Ключ | По умолчанию | Что это |
|------|--------------|---------|
| `gameDir` | пусто | папка игры; пусто — `LauncherPaths::defaultMinecraftDir()` |
| `javaPath` | пусто | путь к java, заданный руками |
| `javaRuntime` | пусто | id сборки Java из `<root>/java`; важнее `javaPath` |
| `minMemoryMb`, `maxMemoryMb` | 512, 4096 | `-Xms`, `-Xmx` |
| `jvmArgs` | пусто | дополнительные аргументы JVM одной строкой, делятся по пробелам |
| `windowWidth`, `windowHeight` | 0, 0 | размер окна игры; 0 — не передавать |
| `fullscreen` | `false` | |
| `language` | `system` | язык интерфейса, см. [Localization](Localization.md) |
| `activeBuildId` | 0 | `id` активной сборки |
| `seasonalBaseUrl` | `https://launcher-builds.galeonworx.com` | сервер сезонных сборок |
`seasonalBaseUrl` в интерфейсе нет. Он нужен, чтобы проверять установку против локального
HTTP-сервера. Хвостовые слэши отрезаются.
Имя и версия лаунчера для `${launcher_name}` и `${launcher_version}` в настройках не хранятся: они
берутся из `QCoreApplication`, то есть из `project(... VERSION)` в CMake. Ключи `launcherName` и
`launcherVersion`, оставшиеся в старых `settings.json`, при загрузке выбрасываются и пропадают из
файла при следующем сохранении.
`settings()` возвращает всю карту и добавляет к ней вычисленный `resolvedGameDir`.
`updateSettings()` вливает присланные ключи, сохраняет файл и испускает `settingsChanged`. Если
сменился язык, после записи вызывается `Localization::setLanguage()`: так выбор сохранится, даже если
лаунчер упадёт сразу после переключения. Если сменилась папка игры, перестраивается каталог версий.
## Профили
Профиль хранит имя, логин, пароль, тип входа (`offline`, `elyby`, `microsoft`; всё прочее читается
как `offline`) и полученные при входе данные: токены, UUID, ник с сервера, `xuid`, срок жизни токена.
- `addProfile(name, login, password, authType)` — добавить.
- `updateProfile(index, ...)` — перезаписать. Если у профиля Ely.by поменялись логин или пароль,
сохранённый токен сбрасывается. Если поменялся тип входа, сбрасывается всё, что было получено
при входе. Переименование профиля Microsoft сессию не трогает.
- `profileAt(index)` — `name`, `login`, `password`, `authType`, `resolvedName` и
`hasMicrosoftSession` (есть ли refresh-токен).
- `removeProfile(index)` — удалить.
Все испускают `profilesChanged`. Свойство `profileNames` отдаёт имена в порядке `id`.
## Сборки
Сборка — это имя, адрес сервера для автоподключения, версия Minecraft и, если выбран, модлоадер с
версией. После установки модлоадера в `resolvedVersionId` записывается id профиля в `versions/`,
который на самом деле запускается. У сезонных сборок есть ещё `seasonalId` и `seasonalRevision`.
Какую папку из `versions/` запускать, решает `launchVersionIdOf()`: сначала `resolvedVersionId`,
потом `minecraftVersion`, а если и её нет, то имя сборки. Последний вариант остался от старых
записей, у которых версия была записана в имени.
- `addCustomBuild(name, serverUrl, minecraftVersion)` — создать и сразу начать ставить версию, если
она указана и её нет на диске. Если версия пустая (так создаёт сборку кнопка «+ Новая сборка»),
ничего не ставится.
- `updateCustomBuild(index, fields)` — изменить только переданные ключи. Карточка сборки сохраняет
правки по одному полю, поэтому установка здесь не запускается. Если сменилась версия игры, а
`loaderVersion` в том же вызове не прислали, `loaderVersion` и `resolvedVersionId` сбрасываются;
сам `loader` остаётся.
- `customBuildAt(index)` — все поля плюс `launchVersionId`.
- `customBuildRemovalInfo(index)` — `name`, `hasArchive`, `active`, `lastOne` для текста
предупреждения.
- `removeCustomBuild(index)` — удалить запись и архив. Пока лаунчер занят или игра запущена,
отказывает. Если удалили активную, активной становится первая из оставшихся, и запускается
переключение.
- `installCustomBuild(index)` — поставить, чего не хватает. Для ванильной сборки это версия игры,
для сборки с модлоадером — модлоадер вместе с версией. Вызывает `installLoaderForBuild()`.
- `installLoaderForBuild(index)` — то же самое; по завершении записывает `resolvedVersionId`.
- `checkInstallation(index)` — список того, чего не хватает для запуска; пустой список значит, что
всё на месте.
### Активная сборка
`activeBuildIndex` хранится в настройках как `activeBuildId`, а не как индекс. Если этот `id`
пропал, активной считается первая сборка.
Запись в свойство запускает `BuildSwitcher::switchTo()`: содержимое `.minecraft` уезжает в архив
прежней сборки, на его место распаковывается архив новой. Смена отклоняется, если уже идёт другая
смена, запущена игра или работает любой установщик. `activeBuildId` меняется только в обработчике
`BuildSwitcher::finished`, поэтому после неудачи активной остаётся прежняя сборка.
Пока идёт смена, выставлено `switching`, а ход виден в `switchProgress` (от 0 до 1, или −1, пока
объём неизвестен), `switchStage` и `switchStatus`.
## Версии игры
- `versionCatalog` — плоский список строк `{id, type, category, label, installed, search}` в порядке
манифеста, новые сверху. `category` — `release`, `snapshot`, `old_beta`, `old_alpha` или `other`.
В конец добавлены установленные версии, которых нет в манифесте (профили модлоадеров), по
алфавиту, с категорией `other`. Разделы и поиск делает
[VersionPickerDialog](../qml/VersionPickerDialog.md).
- `catalogLoading` — идёт запрос манифеста.
- `refreshVersionCatalog(force)` — сбросить кэш строк и попросить манифест. Свежий кэш манифеста
отвечает без сети.
- `installedVersions`, `isVersionInstalled(id)` — что лежит в `versions/`.
- `installVersion(id)` — поставить; если эта версия уже ставится или стоит в очереди, ничего не
делает.
- `versionRemovalInfo(id)` — `id`, `installed`, `sizeMb`, `dependents` (профили модлоадеров поверх
этой версии), `builds` (имена сборок, которые на неё ссылаются).
- `removeVersion(id)` — удалить папку версии. Библиотеки и ресурсы общие, их не трогает. Пока
лаунчер занят или запущена игра, отказывает. Сборки, ссылающиеся на версию, не меняются:
ссылка на неустановленную версию — нормальное состояние.
Список строк каталога строится лениво и кэшируется: QML читает свойство много раз за кадр, пока
открыт список. То же сделано для каталогов Java и сезонных сборок.
## Модлоадеры
- `loaderVersions(loaderKey, gameVersion)` — список `{version, label, recommended, stable}` для этой
версии игры, новые сверху. Несовместимых строк в нём не бывает. Пустой список означает, что
модлоадер эту версию не поддерживает.
- `refreshLoaderVersions(loaderKey, gameVersion, force)` — обновить список. По окончании всегда
приходит `loaderVersionsChanged(loaderKey, gameVersion)`, даже если ответил кэш. Обработчик должен
сверить пару со своей: сигнал может относиться к другому модлоадеру или к прошлой версии игры.
- `loaderVersionsLoading(loaderKey, gameVersion)` — помогает отличить «ещё грузится» от «не
поддерживается».
Ключи: `forge`, `fabric`, `neoforge`, `quilt`.
Установщику модлоадера передаётся та же Java, что пойдёт в игру: сначала скачанная лаунчером
сборка, потом `javaPath`.
## Java
- `javaCatalog` — строки `{id, kind, major, version, label, detail, coverage, sizeMb, installed, lts,
downloadable, search}`. Сначала то, что уже скачано в `<root>/java`, потом каталог Mojang, Temurin
JDK и Temurin JRE.
- `javaCatalogLoading`, `refreshJavaCatalog(force)` — как у версий.
- `installJavaRuntime(id)` — скачать. Пока работает другой установщик или идёт смена сборки,
отказывает. По завершении приходит `javaRuntimeInstalled(id)`.
- `removeJavaRuntime(id)` — удалить из папки лаунчера. Если это была выбранная в настройках сборка,
`javaRuntime` очищается.
- `javaRuntimeInfo(id)` — `id`, `kind`, `major`, `version`, `label`, `javaPath`, `installed`.
Если сборка выбрана, но не скачана, `installed` будет `false` и `javaPath` пустым. Для пустого
`id` возвращается пустая карта. Метод не пересчитывается сам, поэтому окно настроек перечитывает
его по `javaRuntimeInstalled`.
- `requiredJavaMajor(buildIndex)` — минимальная Java для версии игры сборки, 0 если версия не
выбрана.
- `detectedJava()` — найденные в системе java строками вида `Java 17 — /путь`, для справки в
настройках.
## Запуск игры
`launchGame(profileIndex, buildIndex)` сначала проверяет всё, что можно проверить синхронно:
- не работает ни один установщик, не идёт другой запуск и игра не запущена;
- профиль и сборка выбраны, папка игры существует;
- версия сборки есть в `versions/`;
- описание версии читается (`VersionLoader::load`), и все нужные файлы на месте
(`GameLauncher::missingFiles`).
После этого выставляется `busy`, а сборка запоминается по `id`, а не по индексу: пока идёт вход,
её могут удалить в другом окне.
Дальше зависит от типа профиля:
- **офлайн** — ник из логина или имени профиля, UUID считается из ника;
- **Ely.by** — сначала докачивается `authlib-injector` в `<root>/runtime`, потом вход. Если Ely.by
просит второй фактор, `busy` снимается и приходит `twoFactorRequired(profileName)`. Окно должно
вызвать `submitTwoFactorCode(code)` или `cancelPendingLaunch()`, иначе запуск так и останется
в ожидании;
- **Microsoft** — если refresh-токена нет, запуск прерывается с `microsoftReloginRequired(index)`.
Если сохранённый токен Minecraft живёт ещё дольше двух минут, сеть не трогается. Иначе сессия
продлевается по refresh-токену. Если продлить не вышло, тоже `microsoftReloginRequired`. Если у
аккаунта нет лицензии, просто ошибка.
Полученные при входе ник, UUID и токены сохраняются в профиль. Java выбирается так: скачанная
лаунчером сборка из `javaRuntime`, иначе `javaPath`, иначе её ищет `GameLauncher`. Если
`javaRuntime` задан, но такой сборки на диске нет, об этом будет сообщение в `launchProgress`.
Ход виден по сигналам `launchProgress`, `launched(profileName, buildName, serverUrl)` и
`launchError`. `stopGame()` завершает процесс. Пока игра работает, `gameRunning` равно `true`;
вывод процесса приходит построчно в `gameOutput`, конец — в `gameFinished(exitCode, crashed)`.
## Вход через Microsoft из окна профиля
1. `startMicrosoftLogin(profileIndex)` испускает `microsoftLoginUrlReady(url)`. `-1` значит, что
профиля ещё нет и его нужно создать по нику из аккаунта. Без WebEngine сразу приходит
`microsoftLoginFailed`.
2. [MicrosoftLoginDialog](../qml/MicrosoftLoginDialog.md) открывает страницу и на каждую смену
адреса вызывает `inspectMicrosoftRedirect(url)`. Ответ: `{matched, code, error}`. Разбор сделан
в C++, чтобы правила совпадали с тем, как `MsaAuthService` строит `redirect_uri`.
3. Код уходит в `finishMicrosoftLogin(code)`. Итог приходит в `microsoftLoginSucceeded(playerName)`
или `microsoftLoginFailed(message)`.
`cancelMicrosoftLogin()` забывает начатый вход. Если окно закрыли, пока шёл обмен кода, результат
будет выброшен.
`microsoftAvailable` — константа: собран ли лаунчер с Qt WebEngine.
## Сезонные сборки
- `seasonalCatalog` — строки каталога с полями сервера (`id`, `name`, `minecraftVersion`, `loader`,
`loaderTitle`, `loaderVersion`, `modCount`, `seasonStart`, `seasonEnd` в виде `дд.мм.гггг`,
`sizeBytes`, `revision`, `description`, `serverUrl`) и локальным состоянием (`installed`,
`installedRevision`, `updateAvailable`, `status`). Сборка считается установленной, если есть
запись с таким `seasonalId`. Обновление доступно, если её ревизия меньше серверной.
- `seasonalCatalogLoading`, `seasonalCatalogError`.
- `refreshSeasonalCatalog(force)` — перед запросом заново читает `seasonalBaseUrl`.
- `installSeasonalBuild(id)` — поставить или обновить.
- `cancelSeasonalInstall()` — отменить.
- `seasonalInstalling` — идёт установка.
Установка — цепочка шагов. Каждый шаг либо запускает асинхронную работу и ждёт сигнала, либо
решает, что делать нечего, и сразу передаёт ход следующему (`seasonAdvance()` со сквозными `case`).
1. **Запись.** Если сборки с таким `seasonalId` нет, она создаётся. Если есть, в неё записываются
новые версия, модлоадер и адрес сервера. Ревизию пока не трогаем: её запишем, только когда файлы
лягут на диск, иначе оборванная установка выглядела бы законченной.
2. **Switching.** Сборка становится активной: пак раскатывается поверх текущей `.minecraft`.
3. **Java.** Нужная версия берётся из `javaMajor` каталога или из таблицы требований. Если
подходящая java уже есть (своя, системная или скачанная раньше), шаг пропускается. Иначе
загружается каталог Java и ставится лучшая сборка для этой версии. Java ставится раньше
модлоадера, потому что она нужна установщику Forge.
4. **Loader.** Для ванильной сборки ставится версия игры, для остальных — модлоадер. Если всё уже
установлено и укомплектовано, шаг пропускается.
5. **Downloading.** Архив качается в `builds/<id>/.pack-r<ревизия>.zip`. Точка в начале имени нужна,
чтобы `BuildSwitcher` не принял файл за архив сборки и не стёр его.
6. **Applying.** `BuildSwitcher::applyPack()` удаляет всё, что принесла прошлая ревизия (список из
`season.json`), и распаковывает новую. Миры и скриншоты в этот список не попадают, потому что
пак их не приносил.
После раскатки `recordSeasonalPack()` пишет `builds/<id>/season.json` (`seasonalId`, `revision`,
`sha256`, `installedAt`, `entries`) и проставляет ревизию в запись сборки. Это делается и тогда,
когда раскатка была доиграна после перезапуска лаунчера. Конец цепочки — `seasonalInstallFinished
(seasonalId, buildName)`.
При ошибке на любом шаге скачанный архив удаляется, и приходит `launchError`. На шагах Switching и
Applying отменить нельзя: `.minecraft` уже разобрана, операцию нужно довести до конца.
Строка каталога копируется в `m_season.entry` при старте. Если каталог обновится во время установки,
ставиться будет всё равно то, что выбрали.
## Панель загрузки
Источников загрузки четыре: установщик версий, установщик модлоадера, установщик Java и загрузчик
пака. Наружу они видны через одну группу свойств: `downloading`, `downloadProgress`,
`downloadVersion` (что качается), `downloadStatus` (`этап — файл`), `downloadBytesDone`,
`downloadBytesTotal`. Все обновляются одним сигналом `downloadChanged`.
Чьи числа показывать, решается так. Пак качается, только когда остальные уже закончили. Java не
качается одновременно с версией или модлоадером. Во время установки модлоадера доля берётся у
`VersionInstaller`, если он сейчас качает файлы версии, потому что его оценка точнее.
`cancelDownload()` останавливает первое, что работает, в порядке: модлоадер (он сам остановит
вложенную установку версии), версия, Java, пак.
`busy` равно `true`, пока идёт вход или запуск, работает любой установщик, идёт смена сборки или
качается пак. На это время в интерфейсе гаснет кнопка запуска.
## Папки
- `openMinecraftFolder()` открывает `<gameDir>/mods` (создаёт, если её нет).
- `openGameFolder()` открывает саму папку игры.
## Потоки
Весь класс работает в GUI-потоке. В отдельном потоке выполняются только файловые операции
`BuildSwitcher`, и этот поток скрыт внутри него.
## Пример
```qml
import QtQuick
import Minecraft_launcher
import BiLauncher
Window {
id: window
visible: true
LauncherBackend {
id: backend
onLaunchError: (message) => console.warn(message)
onTwoFactorRequired: (profileName) => twoFactorDialog.open()
onMicrosoftLoginUrlReady: (url) => window.openMicrosoftLogin(url)
}
Button {
+103 -94
View File
@@ -1,17 +1,15 @@
# Localization
`localization.h` / `localization.cpp`
`localization.h`, `localization.cpp`
Единственный источник всех текстов интерфейса. Каталог — [`i18n/translations.json`](#каталог),
он лежит в ресурсах и правится руками. Штатных `.ts`/`.qm` в проекте нет намеренно: `lupdate`,
`lrelease` и Linguist не нужны, а все переводы видны в одном файле.
Все тексты интерфейса на всех языках лежат в одном файле, `i18n/translations.json`, и читаются
через этот класс. Стандартные `.ts`/`.qm`, `lupdate`, `lrelease` и Linguist в проекте не
используются. Язык переключается без перезапуска.
Язык переключается на лету: при смене все привязки QML перевычисляются, перезапуск не требуется.
## Из QML
## Доступ из QML
Синглтон зарегистрирован декларативно (`QML_NAMED_ELEMENT(Loc)` + `QML_SINGLETON`) и доступен
в любом файле модуля без импорта.
Синглтон `Loc` (`QML_NAMED_ELEMENT(Loc)` + `QML_SINGLETON`) доступен в любом файле модуля без
импорта.
```qml
Text { text: Loc.t.settings.title }
@@ -19,31 +17,22 @@ Text { text: Loc.t.java.progress.downloading.arg(label) }
DarkCombo { model: Loc.t.profile.authTypes }
```
Точечный путь `Loc.t.a.b.c` — это обращение к вложенным объектам дерева, которое строится из
плоских ключей каталога разбиением по точке.
`Loc.t` — дерево JS-объектов, собранное из плоских ключей каталога разбиением по точке.
**Почему свойство, а не метод.** Вызов `Q_INVOKABLE` не регистрирует зависимость привязки, и
`text: Loc.t("ключ")` никогда бы не обновился при смене языка. Чтение `Q_PROPERTY` с сигналом
`NOTIFY` зависимость регистрирует: `languageChanged` перевычисляет все привязки, которые читали
`Loc.t`. Сегменты после `t` — обычные обращения к членам JS-объекта, отслеживать их не нужно,
потому что при смене языка дерево заменяется целиком.
Почему `t` сделано свойством, а не методом `Loc.t("ключ")`. Вызов `Q_INVOKABLE` не регистрирует
зависимость привязки, и текст не обновился бы при смене языка. Чтение `Q_PROPERTY` с `NOTIFY`
регистрирует, поэтому `languageChanged` перевычисляет все привязки, где читали `Loc.t`. Дальнейшие
`.settings.title` — обычные обращения к полям JS-объекта. Следить за ними не нужно, потому что при
смене языка дерево заменяется целиком.
Тип свойства — `QJSValue`, а не `QVariantMap`: `QVariantMap` пересобирался бы в новый JS-объект
при каждом чтении, а привязок в проекте полторы сотни. `QJSValue` строится один раз на смену
языка.
Тип свойства — `QJSValue`. С `QVariantMap` движок собирал бы новый JS-объект при каждом чтении, а
привязок в проекте около полутора сотен. `QJSValue` строится один раз на каждую смену языка.
Из тела JS-функции `Loc.t` читается так же — это просто чтение свойства:
Кроме `t` есть `language` (что выбрано: `system`, `ru`, `en`), `resolvedLanguage` (во что это
превратилось, всегда `ru` или `en`) и `availableLanguages()`. Последний сейчас не используется:
список языков в настройках [Main.qml](../qml/Main.md) задан руками (`stLanguage`).
```qml
onLaunched: (profileName, buildName) =>
window.showToast(Loc.t.launch.status.started.arg(profileName).arg(buildName), "#4b7a1f")
```
## Доступ из C++
Свободные функции, а не методы: их вызывают и из namespace-обёрток
([LauncherPaths](launcherpaths.md), [JavaLocator](javalocator.md),
[ZlibReference](zlibreference.md)), где никакого `QObject` нет.
## Из C++
```cpp
#include "localization.h"
@@ -53,29 +42,34 @@ emit launchProgress(Loc::text("java.progress.downloading").arg(label));
const QStringList kinds = Loc::list("profile.authTypes");
```
Ключа нет — возвращается сам ключ, а в отладочной сборке ещё и `qWarning`: строка вида
`launch.error.noProfile` в интерфейсе сразу бросается в глаза.
Это свободные функции, потому что их вызывают и из пространств имён без `QObject`
([launcherpaths.h](launcherpaths.md), [javalocator.h](javalocator.md),
[zlibreference.h](zlibreference.md)).
Если ключа нет, возвращается сам ключ, а в отладочной сборке ещё пишется `qWarning`. В интерфейсе
такую строку легко заметить.
### Потоки
`Loc::text()` и `Loc::list()` можно звать из любого потока. Каталог заполняется ровно один раз
в `load()`, который отрабатывает в `main()` до того, как [BuildSwitcher](BuildSwitcher.md)
создаст свой поток; дальше он только читается, а копирование `QString` из хэша безопасно само по
себе. Единственное, что меняется на ходу, — индекс текущего языка, и он `QAtomicInt`. В худшем
случае сообщение, которое собиралось в момент переключения, уедет на прежнем языке.
`Loc::text()` и `Loc::list()` можно вызывать из любого потока. Каталог заполняется один раз в
`load()`, до того как [BuildSwitcher](BuildSwitcher.md) запустит свой поток, и дальше только
читается. На ходу меняется лишь индекс текущего языка, а он `QAtomicInt`. В худшем случае строка,
собранная в момент переключения, окажется на прежнем языке.
`setLanguage()` и рассылка `languageChanged` — только поток GUI; это проверяется `Q_ASSERT`.
`setLanguage()` вызывается только из GUI-потока, это проверяет `Q_ASSERT`.
## Каталог
`i18n/translations.json` попадает в ресурсы через список `RESOURCES` в `qt_add_qml_module`,
поэтому читается по пути `:/qt/qml/Minecraft_launcher/i18n/translations.json`.
Файл попадает в ресурсы через `RESOURCES` в `qt_add_qml_module` и читается из
`:/qt/qml/BiLauncher/i18n/translations.json`.
```json
{
"_meta": {
"languages": ["ru", "en"],
"displayNames": { "ru": "Русский", "en": "English" }
"displayNames": { "ru": "Русский", "en": "English" },
"_readme": ["..."],
"_glossary": { "сборка": "build", "...": "..." }
},
"strings": {
"settings.title": { "ru": "Настройки запуска", "en": "Launch settings" },
@@ -88,77 +82,92 @@ const QStringList kinds = Loc::list("profile.authTypes");
}
```
Ключи плоские, языки рядом: забытый перевод виден на соседней строке, правка пары — один участок
файла, а третий язык добавляется колонкой без правок загрузчика.
Ключи плоские, языки записаны рядом. Так сразу видно, если перевод забыли, а новый язык
добавляется ещё одним полем без правок загрузчика. `_readme` и `_glossary` — подсказки для тех, кто
правит файл. Загрузчик их не читает.
**Имя ключа — `<домен>.<вид>.<имя>`**, сегменты в lowerCamelCase.
Значение — либо строка, либо список строк (для моделей выпадающих списков). Тип определяется по
первому языку.
- **Домен** — область, а не имя файла: `app`, `common`, `settings`, `profile`, `build`,
`seasonal`, `version`, `java`, `loader`, `auth` (с `auth.ely.*`, `auth.msa.*`), `launch`,
`game`, `switch`, `storage`, `zlib`.
- **Вид** — `title`, `label`, `button`, `placeholder`, `hint`, `header`; для сообщений `error`,
`progress`, `status`, `warning`.
- **Имя** описывает условие, а не формулировку, чтобы перевод не переименовывал ключ:
`gameRunning`, `downloadFailed`, `checksumMismatch`.
### Имена ключей
Литерал, который нужен в двух и более файлах, живёт в `common.*`.
`<домен>.<вид>.<имя>`, сегменты в lowerCamelCase.
Подстановки `%1`/`%2` сохраняются дословно: их одинаково понимают `QString::arg()` и
QML-овский `String.arg()`. **Набор `%N` в `ru` и `en` обязан совпадать** — порядок слов может
отличаться, состав нет. Множественного числа формат не поддерживает; в коде сейчас нет ни одной
строки, которой оно требуется.
- **Домен** — область интерфейса, а не файл: `app`, `common`, `settings`, `profile`, `build`,
`seasonal`, `version`, `java`, `loader`, `auth` (`auth.ely.*`, `auth.msa.*`), `launch`, `game`,
`switch`, `storage`, `zlib`.
- **Вид**: `title`, `label`, `button`, `placeholder`, `hint`, `header`, `tooltip`; для сообщений
`error`, `progress`, `status`, `warning`.
- **Имя** описывает ситуацию, а не формулировку: `gameRunning`, `downloadFailed`,
`checksumMismatch`. Тогда при правке перевода ключ не придётся переименовывать.
## Как добавить строку
Строка, которая нужна в двух и более местах, кладётся в `common.*`.
1. Добавить запись в `strings` файла `i18n/translations.json` — сразу с `ru` и `en`.
2. Сослаться на неё: `Loc.t.домен.вид.имя` в QML или `Loc::text("домен.вид.имя")` в C++.
3. Прогнать `python3 tools/check_translations.py`.
`%1`, `%2` пишутся как есть: их одинаково понимают `QString::arg()` и `String.arg()` в QML. Набор
`%N` в `ru` и `en` должен совпадать, порядок может быть разным. Множественного числа формат не
поддерживает, и пока ни одна строка его не требует.
## Как добавить язык
## Добавить строку
1. Дописать код в `_meta.languages` и название в `_meta.displayNames`.
2. Добавить колонку с этим кодом в каждую запись `strings`.
3. Добавить код в `stLanguage.codes` и пункт в модель комбобокса в [Main.qml](../qml/Main.md),
а также ключ `settings.language.<код>` с эндонимом (название языка не переводится — оно
одинаково во всех колонках).
4. При необходимости поправить `systemLanguage()` в `localization.cpp`: сейчас он выбирает
русский для русской системной локали и английский во всех остальных случаях.
1. Запись в `strings` сразу с `ru` и `en`.
2. `Loc.t.домен.вид.имя` в QML или `Loc::text("домен.вид.имя")` в C++.
3. `python3 tools/check_translations.py`.
## Выбор языка и его хранение
## Добавить язык
Ключ настройки — `language`, значения `"system"`, `"ru"`, `"en"`, по умолчанию `"system"`.
Хранится в `settings.json` рядом с остальными настройками; в интерфейсе — первым пунктом
диалога «Настройки запуска», применяется по кнопке «Сохранить».
1. Код в `_meta.languages`, название в `_meta.displayNames`.
2. Значение для этого кода в каждой записи `strings`.
3. Ключ `settings.language.<код>` с названием языка на нём самом, одинаковым во всех колонках.
4. Пункт в модели `stLanguage` и код в `stLanguage.codes` в [Main.qml](../qml/Main.md).
5. Если нужно, поправить `systemLanguage()`: сейчас при русской системной локали выбирается
русский, при любой другой — английский.
Круг замкнут в одну сторону, циклической зависимости нет:
## Где хранится выбранный язык
Ключ `language` в `settings.json`, по умолчанию `system`. Меняется в окне настроек и применяется
по кнопке «Сохранить».
```
main.cpp ──► Localization::load() ──► LauncherPaths::settingsFile() (только чтение, один раз)
LauncherBackend::updateSettings() ──► Localization::setLanguage() (в одну сторону)
main.cpp ──► Localization::load() ──► читает settings.json (один раз)
LauncherBackend::updateSettings() ──► Localization::setLanguage()
```
`Localization` ничего не знает про [LauncherBackend](LauncherBackend.md) — писать `settings.json`
по-прежнему может только он. Читать настройки самому приходится потому, что язык нужен раньше,
чем QML вычислит первую привязку, а бэкенд появляется только вместе с движком.
`settings.json` записывает только [LauncherBackend](LauncherBackend.md). Localization его только
читает, и только при старте: язык нужен раньше первой привязки QML, а бэкенд создаётся вместе с
движком. Обратной зависимости от бэкенда у Localization нет.
Неизвестное значение (файл правили руками) откатывается на `"system"` с предупреждением.
Отсутствующий или испорченный каталог — ошибка на старте: `main()` пишет причину и возвращает
`-1`. Файл вкомпилирован в бинарник, так что это может быть только ошибка сборки, а лаунчер,
у которого все подписи выглядят как точечные ключи, хуже, чем лаунчер, который сказал, почему
не запустился.
Неизвестный язык в файле (если его правили руками) заменяется на `system` с предупреждением.
**Известное ограничение.** Уже сложенные в поля C++ строки не перепереводятся:
Если каталог не найден или не разбирается, это ошибка сборки, потому что файл вкомпилирован в
бинарник. `main()` пишет причину в `qCritical` и возвращает `-1`.
### Что не переводится при смене языка
Строки, которые уже собраны и лежат в полях C++, остаются на старом языке:
`LauncherBackend::m_storageIssues` собирается при старте, а `stage()`/`status()` у
[BuildSwitcher](BuildSwitcher.md) заменяются на следующем тике прогресса. Все они
диагностические и короткоживущие.
[BuildSwitcher](BuildSwitcher.md) обновятся только на следующем шаге. Всё это короткоживущие
диагностические сообщения.
## Проверка
Отдельно: `GameLauncher` прячет токен в строке запуска за `<скрыт>`, и эта строка не берётся из
каталога.
`tools/check_translations.py` — только чтение, ненулевой код возврата при любой ошибке:
## Проверка каталога
- каждый `Loc::text("…")` и `Loc::list("…")` из C++ есть в каталоге и совпадает по типу значения;
- каждый путь `Loc.t.a.b.c` из QML разворачивается в существующий ключ;
- наборы ключей у всех языков совпадают, пустых значений нет, типы одинаковы;
- наборы `%N` совпадают по языкам, длины списков равны;
- не осталось ни одного `tr(`, `qsTr(` или `QCoreApplication::translate`;
- ключи, на которые никто не ссылается, — предупреждением.
`tools/check_translations.py` ничего не меняет и возвращает ненулевой код при любой ошибке.
Проверяет:
- у всех языков одинаковый набор ключей и типы, пустых значений нет, длины списков равны;
- набор `%N` совпадает по языкам;
- каждый `Loc::text("…")` и `Loc::list("…")` в C++ есть в каталоге с нужным типом;
- каждый `Loc.t.a.b.c` в QML ведёт к существующему ключу (последний сегмент может быть методом
JS, например `.arg`);
- нигде не осталось `tr(`, `qsTr(`, `QCoreApplication::translate`.
Ключи, на которые никто не ссылается, выводятся предупреждением.
Скрипт смотрит только файлы в корне репозитория. Если исходники разложат по подпапкам, его нужно
будет поправить.
---
При создании этого документа использовался ИИ.
+54 -167
View File
@@ -1,190 +1,77 @@
# ModLoaderInstaller
## Обзор класса
`modloaderinstaller.h`, `modloaderinstaller.cpp`
`ModLoaderInstaller` ставит модлоадер в `.minecraft`. Под одним фасадом он прячет два совершенно
разных пути установки.
Ставит модлоадер в `.minecraft`. Fabric и Quilt ставятся иначе, чем Forge и NeoForge, но снаружи у
них одинаковый интерфейс.
**Fabric и Quilt** отдают готовое описание версии: лаунчер кладёт его в
`versions/<id>/<id>.json` и передаёт дальше [VersionInstaller](VersionInstaller.md), который по
полю `inheritsFrom` сам поставит ванильную версию и библиотеки лоадера.
- **Fabric и Quilt** отдают готовое описание версии. Его достаточно положить в
`versions/<id>/<id>.json`, а остальное докачает [VersionInstaller](VersionInstaller.md).
- **Forge и NeoForge** при установке патчат клиентский jar, поэтому запускается их официальный
`installer.jar` без окна. После него проверяется, какой профиль появился в `versions/`.
**Forge и NeoForge** так не умеют: их установка — это патч клиентского jar. Поэтому лаунчер
запускает официальный `installer.jar` найденной Java в headless-режиме и смотрит, какой профиль
появился в `versions`. Процессу установщика при этом подсовывается эталонный zlib — см.
[ZlibReference](zlibreference.md).
Экземпляр держит [LauncherBackend](LauncherBackend.md). Геттеры прогресса устроены так же, как у
`VersionInstaller` (`label`, `stage`, `currentFile`, `bytesDone`, `bytesTotal`, `fraction`), и
панель загрузки читает их одинаково.
Оба пути начинаются одинаково: пока ванильная версия не скачана целиком, ставить лоадер некуда.
## install(gameDir, loader, gameVersion, loaderVersion, javaPreference)
Набор геттеров прогресса намеренно повторяет `VersionInstaller`: панель загрузки в интерфейсе
читает их одинаково, независимо от того, кто сейчас работает.
Если установка уже идёт или не указана версия, сразу приходит `failed`. Иначе:
## Место в проекте и зависимости
1. Список версий модлоадера через [ModLoaderVersionService](ModLoaderVersionService.md).
2. **Базовая версия игры.** Если её нет или не хватает файлов, она ставится через
`VersionInstaller`, и ход идёт дальше только после её `finished`. Без целой ванильной версии
модлоадер ставить некуда.
3. Дальше один из двух путей.
Подключает [modloader.h](modloader.md). В конструктор принимает
[ModLoaderVersionService](ModLoaderVersionService.md) — за адресом установщика — и
[VersionInstaller](VersionInstaller.md) — за базовой версией и за докачиванием того, что
`installer.jar` не положил.
### Fabric и Quilt
Экземпляр создаётся и принадлежит [LauncherBackend](LauncherBackend.md).
Описание скачивается из `meta.fabricmc.net/v2/versions/loader/<игра>/<лоадер>/profile/json`
(у Quilt `meta.quiltmc.org/v3/...`). Проверяется, что в нём есть безопасный `id` и что
`inheritsFrom` совпадает с выбранной версией игры. Файл сохраняется, и `VersionInstaller` получает
этот `id`.
Требования сборки: `Qt6::Core` (`QProcess`, `QFile`) и `Qt6::Network` (`QNetworkAccessManager`,
`QNetworkReply`).
### Forge и NeoForge
## Иерархия и роль
1. Ссылка на установщик берётся из записи `ModLoaderVersionService::find()`. Если записи нет,
значит, эта пара версий несовместима. Установщик качается в
`<root>/cache/loaders/<ключ>-<версия>-installer.jar`.
2. Если в `.minecraft` нет `launcher_profiles.json`, создаётся заглушка. Без неё установщик
отказывается работать: он считает, что официальный лаунчер не установлен.
3. Java берётся через `JavaLocator::select()` по `javaMajor` базовой версии. `javaPreference` — это
та же Java, что выбрана для игры.
4. Запоминается список профилей в `versions/`.
5. Запуск: `java -jar installer.jar --installClient <gameDir>`. Рабочий каталог — папка загрузок
модлоадеров. На Linux в окружение процесса подставляется эталонный zlib (см.
[zlibreference.h](zlibreference.md)).
6. Вывод установщика построчно уходит в `log(line)`, бэкенд показывает его в журнале. Полностью он
пишется в `<root>/cache/loaders/<ключ>-<версия>-install.log`, а последние 12 строк хранятся в
памяти для текста ошибки.
7. Через 15 минут процесс убивается.
Наследует `QObject`: мета-объектная система, шесть сигналов и владение по родителю. Объявлен
виртуальный деструктор — класс владеет процессом установщика и открытым файлом журнала.
После успешного завершения новый профиль ищется как разница списков до и после. Угадывать имя по
строке нельзя: у Forge в разные годы оно составлялось по-разному. Если новых папок нет (установщик
переписал существующую), ищется профиль, который наследует базовую версию и содержит в имени версию
модлоадера. Если новых папок несколько, берётся та, где есть версия модлоадера.
## Публичные методы
Найденный профиль передаётся `VersionInstaller`, который докачивает библиотеки, не положенные
установщиком. Когда он отчитается, приходит `finished(loaderKey, gameVersion, loaderVersion,
producedVersionId)`. Бэкенд записывает `producedVersionId` в `resolvedVersionId` сборки.
#### ModLoaderInstaller(ModLoaderVersionService \*meta, VersionInstaller \*versionInstaller, QObject \*parent = nullptr)
### Если установщик упал
Создаёт установщик поверх двух сервисов. Ни один из них не переходит во владение установщика —
оба обязаны пережить его.
Все папки, которые он успел создать в `versions/`, удаляются. В текст ошибки идут код выхода,
хвост журнала и путь к полному журналу.
#### bool isRunning() const
Если подменить zlib не удалось, а в журнале есть `Processor failed` или `invalid outputs`, к
сообщению добавляется прямая подсказка про zlib-ng. Почти наверняка дело в нём.
Идёт ли установка прямо сейчас.
## cancel()
#### QString label() const
Обрывает загрузку, убивает процесс установщика и удаляет то, что он создал. Если ход уже передан
`VersionInstaller`, отменяется и он, а `canceled` придёт после его отчёта.
Подпись текущей установки для интерфейса — название лоадера с версиями.
#### QString stage() const
Текущий этап словами.
#### QString currentFile() const
Файл, который обрабатывается сейчас.
#### qint64 bytesDone() const
Сколько байт уже получено.
#### qint64 bytesTotal() const
Ожидаемый общий объём; `0` — неизвестен.
#### double fraction() const
Доля выполнения от `0` до `1` либо `-1`, пока итог неизвестен. У пути с `installer.jar` доля
почти всё время равна `-1`: сколько работы осталось внутри чужого процесса, лаунчер не знает.
#### void install(const QString &gameDir, ModLoader loader, const QString &gameVersion, const QString &loaderVersion, const QString &javaPreference)
Ставит модлоадер. Параметр `javaPreference` — путь к java, указанный пользователем в настройках;
он проверяется первым, а при пустом или неподходящем значении java ищется сама. Java нужна даже на
пути Fabric и Quilt, потому что перед установкой лоадера скачивается базовая версия игры.
Перед запуском `installer.jar` установщик записывает заглушку `launcher_profiles.json`: без этого
файла официальные установщики Forge и NeoForge отказываются работать.
#### void cancel()
Отменяет установку. Если `installer.jar` уже успел отработать, за появившиеся в `versions` папки
отвечает лаунчер, и при отмене они убираются.
## Сигналы
#### started(const QString &label)
Установка началась. Обработчик показывает панель прогресса.
#### progressChanged()
Изменились числа прогресса. Обработчик перечитывает геттеры — тот же набор, что у
[VersionInstaller](VersionInstaller.md).
#### finished(const QString &loaderKey, const QString &gameVersion, const QString &loaderVersion, const QString &producedVersionId)
Модлоадер установлен. Последний параметр — идентификатор появившегося профиля `versions/<id>`.
Он важен именно для Forge: заранее этот идентификатор неизвестен, поэтому новый профиль ищется
разницей между списком версий до и после запуска установщика, а не угадыванием строки —
идентификаторы Forge отличаются по эпохам.
Обработчик записывает `producedVersionId` в сборку: именно этот профиль будет запускаться.
#### failed(const QString &label, const QString &message)
Установка не удалась. Текст ошибки по возможности объясняет причину: отдельно распознаётся случай,
когда в системе стоит zlib-ng, а подменить его было нечем — это почти наверняка причина
расхождения sha1 у Forge, и о ней стоит сказать прямым текстом.
#### canceled(const QString &label)
Установка отменена пользователем.
#### log(const QString &line)
Строка вывода процесса `installer.jar`. Обработчик пишет её в журнал; вывод также сохраняется в
файл, чтобы разбираться с неудачной установкой после закрытия лаунчера.
## Владение и время жизни
Класс наследует `QObject` и принимает `parent` — родитель его и удалит.
Указатели на `ModLoaderVersionService` и `VersionInstaller`, переданные в конструктор, **не
принадлежат** установщику: оба сервиса создаются раньше и живут дольше.
`QNetworkAccessManager` создаётся в конструкторе с установщиком в роли родителя; сетевой ответ,
процесс установщика и файл журнала создаются по ходу работы и закрываются в деструкторе.
Установка асинхронна и состоит из вложенных продолжений: `ensureBaseVersion()` принимает функцию,
которая будет вызвана после появления базовой версии. Уничтожение установщика посреди этой цепочки
обрывает её.
## Потокобезопасность
Только поток GUI. Дочерний процесс `installer.jar` работает параллельно, но общение с ним идёт
через сигналы `QProcess`, которые приходят в поток GUI.
## Взаимодействие с другими классами
`LauncherBackend` вызывает `install()` при установке сборки с модлоадером и переправляет сигналы
прогресса в те же свойства, что и у `VersionInstaller`, — панель загрузки не различает, кто
работает. По сигналу `finished` бэкенд записывает `producedVersionId` в поле сборки
`resolvedVersionId`, которое до установки пусто и означает запуск на чистой ванили.
[ModLoaderVersionService](ModLoaderVersionService.md) даёт адрес `installer.jar`,
[VersionInstaller](VersionInstaller.md) ставит базовую версию до начала работы и докачивает
недостающие библиотеки после неё, [ZlibReference](zlibreference.md) готовит окружение процесса.
## Внешнее взаимодействие
**Сеть, исходящие запросы.** Загрузка `installer.jar` по HTTPS для Forge и NeoForge. Для Fabric и
Quilt сеть используется только через сервис версий и `VersionInstaller`.
**Дочерний процесс.** `installer.jar` запускается java в headless-режиме через `QProcess`.
Окружение процесса готовится `ZlibReference::applyTo()`: на Linux с zlib-ng туда дописывается
`LD_PRELOAD` с эталонной библиотекой, иначе установщик Forge падает с сообщением «Processor
failed, invalid outputs» — он сверяет sha1 собранных им же jar-файлов с эталоном, посчитанным на
обычном zlib.
Вывод процесса читается построчно, отдаётся сигналом `log` и параллельно пишется в файл. Результат
установки определяется не кодом выхода, а появлением новой папки в `versions`.
Все сигналы приходят в поток GUI.
## Пример использования
```cpp
auto *loaderInstaller = new ModLoaderInstaller(loaderVersions, versionInstaller, this);
connect(loaderInstaller, &ModLoaderInstaller::finished, this,
[this](const QString &key, const QString &game,
const QString &loaderVersion, const QString &producedVersionId) {
m_build.resolvedVersionId = producedVersionId;
saveBuilds();
});
connect(loaderInstaller, &ModLoaderInstaller::failed, this,
[this](const QString &, const QString &message) { emit launchError(message); });
loaderInstaller->install(gameDir, ModLoader::Forge,
QStringLiteral("1.20.1"), QStringLiteral("47.4.0"),
settings.javaPath);
```
Деструктор убивает процесс установщика, если тот ещё работает.
---
+49 -130
View File
@@ -1,152 +1,71 @@
# ModLoaderVersionService
## Обзор класса
`modloaderversionservice.h`, `modloaderversionservice.cpp`
Каждый модлоадер публикует свой список версий, и у каждого он устроен по-своему. `ModLoaderVersionService`
приводит все четыре к одному виду: качает списки, кэширует в папке лаунчера и отдаёт из кэша, пока
тот не устарел. Устроен так же, как [VersionManifestService](VersionManifestService.md).
Списки версий Forge, Fabric, NeoForge и Quilt. Устроен так же, как
[VersionManifestService](VersionManifestService.md): качает, хранит копию в
`<root>/cache/loader_<ключ>.json` и отвечает из неё, пока она моложе 6 часов.
Главная особенность класса — в том, чего в нём нет: отдельной проверки совместимости с версией
игры. Совместимость заложена в структуру данных. Fabric и Quilt отдают список сразу под нужную
версию игры, а `maven-metadata` Forge и NeoForge раскладывается по версиям игры при разборе.
Версии игры, под которую сборок нет, соответствует пустой список — выбрать несовместимый лоадер
физически нечем.
Совместимость с версией игры отдельно не проверяется: данные с самого начала разложены по версиям
игры. Под версию, для которой сборок нет, лежит пустой список, и выбрать несовместимый модлоадер
нечем.
## Место в проекте и зависимости
Экземпляр держит [LauncherBackend](LauncherBackend.md). [ModLoaderInstaller](ModLoaderInstaller.md)
берёт отсюда ссылку на `installer.jar` через `find()`.
Подключает [modloader.h](modloader.md): перечисление `ModLoader` и структура `LoaderVersionEntry`
приходят оттуда.
## Откуда берутся данные
Экземпляр создаётся и принадлежит [LauncherBackend](LauncherBackend.md). Ссылку на него получает
[ModLoaderInstaller](ModLoaderInstaller.md) — из записи списка он берёт адрес `installer.jar`.
**Fabric и Quilt** спрашиваются отдельно под каждую версию игры:
`meta.fabricmc.net/v2/versions/loader/<игра>` и `meta.quiltmc.org/v3/versions/loader/<игра>`.
Признак стабильности берётся из ответа, а если его нет, угадывается по имени.
Путь к файлу кэша даёт `LauncherPaths::loaderCacheFile()` из [launcherpaths.h](launcherpaths.md).
**Forge** — один `maven-metadata.xml` на все версии игры. Строка `1.20.1-47.4.0` делится по первому
дефису. Параллельно запрашивается `promotions_slim.json`, оттуда берутся отметки recommended. Если
он не ответил, список всё равно сохраняется, только без отметок.
Требования сборки: `Qt6::Core` (`QDateTime`, `QHash`, `QSet`) и `Qt6::Network`
(`QNetworkAccessManager`).
**NeoForge** тоже из `maven-metadata.xml`, но версия игры в номере не записана. Её приходится
выводить: `21.1.248` → `1.21.1`, `20.4.80` → `1.20.4`, `21.0.x` → `1.21`. Для четырёхчастных
номеров вроде `25.1.0.1` правило другое: `25.1.0.1` → `25.1`. Вторым запросом читается старый
репозиторий `net/neoforged/forge` с первыми сборками NeoForge под 1.20.1, которые выходили под
именем артефакта `forge` и номерами в стиле Forge.
## Иерархия и роль
Ссылка на установщик собирается по схеме maven:
`<репозиторий>/<версия>/<артефакт>-<версия>-installer.jar`.
Наследует `QObject`: мета-объектная система, два сигнала и владение по родителю. Виртуальных
методов базового класса не переопределяет.
Версии внутри каждой версии игры отсортированы от новых к старым. Числа сравниваются как числа,
поэтому `47.10` новее `47.9`.
## Псевдонимы типов
## ensureLoaded(loader, gameVersion, callback, forceRefresh)
`ModLoaderVersionService::Callback` — `std::function<void(bool ok, const QString &warning)>`.
Как и у сервиса манифеста, `ok == true` с непустым `warning` означает, что данные отдали из
устаревшего кэша.
Логика та же, что у манифеста версий. Отличие в том, что свежесть у Fabric и Quilt считается для
каждой версии игры отдельно, а у Forge и NeoForge для модлоадера целиком. По этому же ключу
объединяются одновременные запросы.
## Публичные методы
- Пустая `gameVersion` — сразу `ok` и пустой список.
- При первом обращении к модлоадеру читается кэш, и если он есть, испускается
`versionsChanged(key, gameVersion)`.
- Если сеть не ответила, но какие-то данные по модлоадеру есть, колбэк получит `ok = true` и
`warning`.
- Если ответ не разобрался, хороший кэш не затирается.
#### explicit ModLoaderVersionService(QObject \*parent = nullptr)
`versionsChanged(loaderKey, gameVersion)` испускается после каждого успешного обновления.
`refreshingChanged()` — в начале и в конце запроса. `isRefreshing(loader, gameVersion)` отвечает,
идёт ли запрос по этому ключу.
Создаёт сервис и его `QNetworkAccessManager`. Кэши читаются лениво, при первом обращении к
конкретному лоадеру. Конструктор помечен `explicit`.
## Формат кэша
#### void ensureLoaded(ModLoader loader, const QString &gameVersion, Callback callback, bool forceRefresh = false)
Единственная точка входа. Свежий кэш означает отложенный вызов колбэка без сети; иначе запускается
один сетевой запрос на всех, кто успел попросить.
Свежесть считается по-разному в зависимости от лоадера: Fabric и Quilt спрашиваются по каждой
версии игры отдельно, поэтому отметка времени у них своя на каждую версию; Forge и NeoForge
приходят одним `maven-metadata` на все версии сразу, и отметка у них одна на весь лоадер.
Параметр `forceRefresh` обходит проверку свежести.
#### QList&lt;LoaderVersionEntry&gt; versions(ModLoader loader, const QString &gameVersion) const
Список сборок лоадера под конкретную версию игры. Новые сборки идут первыми, поэтому первая строка
— самая свежая; именно её интерфейс подставляет по умолчанию.
Пустой список означает, что лоадер эту версию игры не поддерживает.
#### bool isRefreshing(ModLoader loader, const QString &gameVersion) const
Идёт ли сейчас запрос по этой паре. Интерфейс по этому признаку отличает «ещё грузим» от «не
поддерживается» — оба случая выглядят пустым списком.
#### std::optional&lt;LoaderVersionEntry&gt; find(ModLoader loader, const QString &gameVersion, const QString &loaderVersion) const
Запись по версии лоадера; `std::nullopt`, если такой нет. Из неё установщик берёт ссылку на
`installer.jar`.
## Сигналы
#### versionsChanged(const QString &loaderKey, const QString &gameVersion)
Список версий изменился. Параметры сужают событие до конкретной пары: `loaderKey` принимает
значения `forge`, `fabric`, `neoforge`, `quilt`.
Обработчик должен сверить оба параметра со своим текущим состоянием и перечитать `versions()`,
только если они совпадают, — иначе обновление относится к другой строке лоадера. Именно так
поступает [LoaderRow](../qml/LoaderRow.md).
#### refreshingChanged()
Изменился признак сетевого обновления у какой-либо пары. Обработчик перечитывает
`isRefreshing()` для интересующей его пары.
## Владение и время жизни
Класс наследует `QObject` и принимает `parent` — родитель его и удалит.
`QNetworkAccessManager` создаётся в конструкторе с сервисом в роли родителя.
Отложенные колбэки хранятся по ключу запроса до завершения соответствующего обращения к сети.
Уничтожение сервиса с незавершённым запросом обрывает его, и накопленные колбэки не вызываются.
Ссылку на сервис держит установщик модлоадеров; уничтожать сервис раньше установщика нельзя.
## Потокобезопасность
Только поток GUI. Чтение и запись кэша выполняются синхронно в вызывающем потоке.
## Взаимодействие с другими классами
`LauncherBackend` оборачивает сервис тремя методами, доступными из QML: получить список, запросить
обновление и узнать, идёт ли загрузка. Сигнал `versionsChanged` он переправляет в QML под тем же
именем, поэтому строка лоадера в карточке сборки подписывается прямо на него.
[ModLoaderInstaller](ModLoaderInstaller.md) обращается к `find()` за адресом установщика перед
началом установки.
## Внешнее взаимодействие
**Сеть, исходящие запросы.** Класс обращается к четырём разным источникам метаданных, и форматы
ответов различаются: у Fabric и Quilt это JSON, у Forge и NeoForge — XML `maven-metadata`. Разбор
разделён на две функции соответственно.
Испорченный ответ разбирается в пустой результат, и хороший кэш им не затирается — это сознательное
решение: лучше показать вчерашний список, чем стереть его из-за сбоя на сервере.
При недоступной сети данные отдаются из устаревшего кэша с `ok == true` и заполненным `warning`.
Все сигналы и колбэки приходят в поток GUI.
## Пример использования
```cpp
auto *loaders = new ModLoaderVersionService(this);
connect(loaders, &ModLoaderVersionService::versionsChanged,
this, [this](const QString &key, const QString &game) {
if (key == loaderKey(ModLoader::Fabric) && game == m_gameVersion)
emit fabricVersionsChanged();
});
loaders->ensureLoaded(ModLoader::Fabric, QStringLiteral("1.21.1"),
[this, loaders](bool ok, const QString &warning) {
if (!ok) {
showStatus(warning);
return;
}
const auto list = loaders->versions(ModLoader::Fabric,
QStringLiteral("1.21.1"));
if (!list.isEmpty())
selectVersion(list.first().loaderVersion);
});
```json
{
"fetchedAt": "...",
"games": {
"1.20.1": { "fetchedAt": "...", "entries": [ { "loaderVersion": "47.4.0", ... } ] }
}
}
```
Верхний `fetchedAt` используется для Forge и NeoForge, `fetchedAt` внутри версии игры — для Fabric и
Quilt.
---
При создании этого документа использовался ИИ.
+57 -137
View File
@@ -1,160 +1,80 @@
# MsaAuthService
## Обзор класса
`msaauthservice.h`, `msaauthservice.cpp`
`MsaAuthService` — авторизация через учётную запись Microsoft, то есть вход с лицензионной копией
игры. Это та же цепочка, что и в официальном лаунчере: OAuth2 → Xbox Live → XSTS → Minecraft
Services → проверка лицензии.
Вход через аккаунт Microsoft, то есть с лицензионной копией игры. Цепочка такая же, как у
официального лаунчера: OAuth2 → Xbox Live → XSTS → Minecraft Services → проверка лицензии →
профиль. Результат приходит в `AuthResult` из [AuthService](AuthService.md).
Результат отдаётся тем же `AuthResult`, что и [AuthService](AuthService.md) для Ely.by и офлайна,
поэтому запуск игры дальше идёт по общему пути и ничего не знает о способе входа.
Окно входа сервис не показывает. Страницу Microsoft открывает
[MicrosoftLoginDialog](../qml/MicrosoftLoginDialog.md), а сервис даёт адрес этой страницы и умеет
узнать адрес возврата. Класс компилируется всегда, но без Qt WebEngine показать страницу нечем, и
[LauncherBackend](LauncherBackend.md) сразу отвечает ошибкой.
Класс не показывает окно входа сам: страницу Microsoft открывает
[MicrosoftLoginDialog](../qml/MicrosoftLoginDialog.md) на стороне QML, а сервис даёт ему адрес
страницы и разбирает адрес возврата.
## Чьё это приложение
## Место в проекте и зависимости
Сейчас используется `client_id` официального лаунчера Mojang (`00000000402b5328`). Это старое
приложение Live Connect, поэтому и адреса от `login.live.com`:
Подключает `authservice.h` — ради общей структуры `AuthResult`. Экземпляр создаётся и принадлежит
[LauncherBackend](LauncherBackend.md).
| Константа | Значение |
|-----------|----------|
| `kAuthorizeUrl` | `https://login.live.com/oauth20_authorize.srf` |
| `kTokenUrl` | `https://login.live.com/oauth20_token.srf` |
| `kRedirectUri` | `https://login.live.com/oauth20_desktop.srf` |
| `kScope` | `service::user.auth.xboxlive.com::MBI_SSL` |
| `kRpsTicketPrefix` | пусто |
Требования сборки: `Qt6::Core` (`QJsonObject`, `QString`, `QUrl`) и `Qt6::Network`
(`QNetworkAccessManager`).
Это временное решение. Чтобы перейти на своё приложение в Azure (с включённым «Allow public client
flows»), меняются эти шесть констант. Что именно подставить, написано в комментарии в начале
`msaauthservice.cpp`. Главное, что легко пропустить: у нового приложения в `RpsTicket` нужна
приставка `d=`.
Сам класс собирается всегда и не зависит от Qt WebEngine — от наличия WebEngine зависит только
окно, в котором показывается страница входа. Поэтому в сборке без WebEngine сервис существует, но
воспользоваться им нельзя: показать страницу нечем.
`clientId()` отдаёт этот id наружу. Бэкенд подставляет его в `${clientid}`, потому что официальный
лаунчер передаёт туда id приложения, а не случайный токен.
## Иерархия и роль
## Окно входа
Наследует `QObject`: мета-объектная система, сигнал `progress`, владение по родителю. Виртуальных
методов базового класса не переопределяет.
`authorizationUrl()` — адрес страницы входа с `client_id`, `response_type=code`, `redirect_uri`,
`scope` и `prompt=select_account`. Последний параметр нужен, чтобы при повторном входе можно было
выбрать другой аккаунт.
## Псевдонимы типов
`matchRedirect(url, &code, &error)` возвращает `true`, только если хост и путь совпадают с
`kRedirectUri`. Код или ошибка ищутся сначала в query, потом во фрагменте. В `error` попадает
`error_description`, а если его нет, то `error`. Эта проверка сделана в C++, а не в QML, чтобы она
всегда совпадала с тем адресом, который сервис сам передаёт в `redirect_uri`.
`MsaAuthService::Callback` — `std::function<void(const AuthResult &)>`. Колбэк вызывается ровно
один раз и в потоке GUI.
## Цепочка
## Публичные методы
`loginWithCode(code, callback)` меняет код на токен (`grant_type=authorization_code`).
`loginWithRefreshToken(refreshToken, callback)` делает то же по refresh-токену. Дальше путь общий:
#### explicit MsaAuthService(QObject \*parent = nullptr)
1. **Токен Microsoft.** Форма на `kTokenUrl`. Из ответа берутся `access_token` и `refresh_token`.
2. **Xbox Live.** `user.auth.xboxlive.com/user/authenticate`, `RpsTicket` = приставка + токен
Microsoft.
3. **XSTS.** `xsts.auth.xboxlive.com/xsts/authorize` для `rp://api.minecraftservices.com/`. При 401
код `XErr` переводится в понятный текст: нет профиля Xbox, страна не поддерживается, нужна
проверка возраста, детский аккаунт без разрешения взрослого. Отсюда же берутся `uhs` и `xid`
(он станет `xuid`).
4. **Minecraft Services.** `login_with_xbox` с `XBL3.0 x=<uhs>;<xsts>`. В ответе токен Minecraft и
`expires_in`, обычно сутки; от него считается `expiresAt`.
5. **Лицензия.** `entitlements/mcstore`. Результат только запоминается и сам вход не останавливает.
6. **Профиль.** `minecraft/profile`. Ответы 404 и 401 означают, что профиля нет: выставляется
`licenseMissing`. Текст ошибки зависит от шага 5. Если игра куплена, значит, игрок ещё не выбрал
ник. Если не куплена, значит, лицензии нет.
Создаёт сервис и его `QNetworkAccessManager`. Конструктор помечен `explicit`.
`userType` = `msa`, `clientToken` = id приложения.
#### static QString clientId()
Каждый шаг испускает `progress(message)` до отправки запроса. Таймаут на каждый запрос 20 секунд,
повторных попыток нет. Сетевая ошибка и отказ сервера дают разные тексты.
Идентификатор приложения лаунчера в Microsoft. Игра ждёт его в `${clientid}`: официальный лаунчер
подставляет туда именно идентификатор приложения, а не случайный токен сессии.
Refresh-токен Microsoft живёт намного дольше суточного токена Minecraft, поэтому при обычном
запуске окно входа не нужно: бэкенд вызывает `loginWithRefreshToken()`. Если Microsoft не вернул
новый refresh-токен, бэкенд оставляет старый.
#### static QUrl authorizationUrl()
## Колбэки
Адрес страницы входа для встроенного окна браузера. В адресе уже собраны идентификатор клиента,
`redirect_uri` и параметр выбора аккаунта — вызывающему коду достаточно открыть эту ссылку.
#### static bool matchRedirect(const QUrl &url, QString \*code, QString \*error)
Отличает адрес, на который Microsoft возвращает управление после входа, от остальной навигации
внутри окна. Возвращает `true` только для адреса возврата.
При совпадении заполняется ровно одно из двух: `code` — код авторизации при успешном входе, либо
`error` — текст отказа. Разбор адреса живёт здесь, а не в QML, потому что правила совпадения
обязаны совпадать с теми, по которым сервис сам строит `redirect_uri`.
#### void loginWithCode(const QString &code, Callback callback)
Полный вход по коду, полученному из окна браузера. Проходит всю цепочку: обмен кода на токен
Microsoft, аутентификация в Xbox Live, авторизация XSTS, вход в Minecraft Services, проверка
лицензии и получение профиля.
По ходу испускает `progress` с описанием текущего шага — цепочка длинная, и без обратной связи
вход выглядел бы зависанием. Результат приходит в `callback` один раз.
Если вход прошёл, но копии игры на аккаунте нет, в результате выставлен `licenseMissing`: этот
случай чинится только покупкой, поэтому обрабатывается отдельно от прочих ошибок.
#### void loginWithRefreshToken(const QString &refreshToken, Callback callback)
Продление сессии без участия пользователя. Refresh-токен Microsoft живёт куда дольше суточного
токена Minecraft, так что при повторном запуске лаунчера обычно хватает его, и окно входа
показывать не приходится.
Проходит ту же цепочку, начиная с обмена refresh-токена. Неудача означает, что токен окончательно
протух и нужен полноценный вход через окно.
## Сигналы
#### progress(const QString &message)
Описание текущего шага цепочки авторизации.
Обработчик показывает сообщение пользователю. Сигнал особенно важен для этого класса: шагов пять,
каждый — отдельный сетевой запрос, и между ними проходит заметное время.
## Владение и время жизни
Класс наследует `QObject` и принимает `parent` — родитель его и удалит. `QNetworkAccessManager`
создаётся в конструкторе с сервисом в роли родителя.
Каждый шаг цепочки вызывается из колбэка предыдущего, поэтому незавершённый вход держит цепочку
захваченных колбэков до своего конца. Уничтожение сервиса посреди цепочки обрывает её вместе с
менеджером сети, и колбэк не вызывается.
## Потокобезопасность
Только поток GUI. Все методы асинхронные, колбэки и сигналы приходят в поток, где создан сервис.
## Взаимодействие с другими классами
`LauncherBackend` создаёт сервис, отдаёт в QML адрес страницы входа, принимает от окна код
авторизации и вызывает `loginWithCode()`. Полученные `refreshToken` и `expiresAt` он сохраняет в
профиле, чтобы при следующем запуске обойтись `loginWithRefreshToken()`.
Заполненный `AuthResult` дальше раскладывается по полям `LaunchOptions` для
[GameLauncher](GameLauncher.md) — ровно так же, как результат от [AuthService](AuthService.md).
Со стороны QML вход выглядит так: `LauncherBackend` испускает сигнал с адресом страницы, главное
окно открывает [MicrosoftLoginDialog](../qml/MicrosoftLoginDialog.md), тот следит за навигацией и
возвращает код обратно в бэкенд.
## Внешнее взаимодействие
**Сеть, исходящие запросы.** Класс последовательно обращается к пяти внешним службам: конечной
точке OAuth2 Microsoft, Xbox Live, XSTS, Minecraft Services и профильной конечной точке Minecraft.
Формат — JSON поверх HTTPS, кроме первого шага, где тело запроса отправляется как форма
(`postForm()`); дальше используются `postJson()` и `getJson()` с токеном в заголовке
авторизации.
Все запросы инициирует лаунчер. Ответ каждого шага разбирается тремя исходами: успех, ошибка с
кодом состояния и транспортная ошибка — последняя приходит отдельным параметром, чтобы отличить
недоступную сеть от отказа службы.
Повторных попыток класс не делает. Все сигналы и колбэки приходят в поток GUI.
## Пример использования
```cpp
auto *msa = new MsaAuthService(this);
connect(msa, &MsaAuthService::progress, this, &Backend::showStatus);
// 1. открыть окно браузера на этом адресе
emit microsoftLoginUrlReady(MsaAuthService::authorizationUrl());
// 2. когда окно поймало адрес возврата
QString code, error;
if (MsaAuthService::matchRedirect(url, &code, &error) && !code.isEmpty()) {
msa->loginWithCode(code, [this](const AuthResult &result) {
if (result.licenseMissing) {
emit loginFailed(tr("На аккаунте нет копии Minecraft"));
return;
}
if (!result.ok) {
emit loginFailed(result.error);
return;
}
storeSession(result);
});
}
```
Колбэк вызывается один раз в GUI-потоке. Шаги вызываются из колбэков предыдущих. Если удалить
сервис посреди цепочки, она оборвётся вместе с менеджером сети, и колбэк не вызовется.
---
+78 -159
View File
@@ -1,175 +1,94 @@
# SeasonalBuildService
## Обзор класса
`seasonalbuildservice.h`, `seasonalbuildservice.cpp`
Кроме сборок, которые пользователь собирает сам, лаунчер умеет ставить готовые сезонные сборки с
собственного файлового сервера: набор модов под конкретную версию игры и модлоадер, подготовленный
заранее и выдаваемый целиком.
Каталог сезонных сборок, то есть готовых паков, которые команда публикует на своём файловом
сервере. Сервис читает `<baseUrl>/index.json`, хранит копию в
`<root>/cache/seasonal_builds.json` и отвечает из неё 15 минут.
`SeasonalBuildService` — каталог этих сборок: скачивает `index.json` с файлового сервера, кэширует
в папке лаунчера и отдаёт из кэша, пока тот не устарел. Устройство повторяет
[VersionManifestService](VersionManifestService.md) — включая то, что пустой разбор считается
испорченным ответом и хороший кэш им не затирается.
Экземпляр держит [LauncherBackend](LauncherBackend.md). Он же сводит строки каталога с
установленными сборками и ставит выбранную.
## Место в проекте и зависимости
## Адрес сервера
Экземпляр создаётся и принадлежит [LauncherBackend](LauncherBackend.md). Записи каталога
используются [SeasonalPackDownloader](SeasonalPackDownloader.md) — оттуда берётся адрес архива и
его контрольная сумма.
По умолчанию `https://launcher-builds.galeonworx.com`. Переопределяется ключом `seasonalBaseUrl` в
`settings.json`, например чтобы проверить установку на локальном HTTP-сервере. Бэкенд передаёт
адрес через `setBaseUrl()` при старте и перед каждым `refreshSeasonalCatalog()`.
Путь к файлу кэша даёт `LauncherPaths::seasonalCatalogFile()` из
[launcherpaths.h](launcherpaths.md).
Если адрес сменился, накопленные данные сбрасываются, а кэш с другим `baseUrl` не читается: ссылки
в нём ведут на старый сервер. Пустой адрес или адрес без хоста — сразу ошибка.
Требования сборки: `Qt6::Core` (`QDate`, `QDateTime`, `QHash`, `QUrl`) и `Qt6::Network`
(`QNetworkAccessManager`).
## Формат index.json
## Иерархия и роль
Это контракт с теми, кто публикует сборки.
Наследует `QObject`: мета-объектная система, два сигнала и владение по родителю. Виртуальных
методов базового класса не переопределяет.
## Публичные структуры
### SeasonalBuildEntry
Одна готовая сборка с сервера сезонных сборок.
| Поле | Тип | По умолчанию | Описание |
|------|-----|--------------|----------|
| `id` | `QString` | — | Идентификатор вида `season-5`; он же ключ, по которому сборка узнаётся среди локальных записей |
| `name` | `QString` | — | Название для интерфейса, например «Сезон 5: Пустоши» |
| `revision` | `int` | `0` | Растёт при каждой публикации; сравнение с установленной ревизией даёт признак доступного обновления |
| `minecraftVersion` | `QString` | — | Версия игры, например `1.20.1` |
| `loader` | `QString` | — | Ключ модлоадера: пустая строка (чистая ваниль), `forge`, `fabric`, `neoforge` или `quilt` |
| `loaderVersion` | `QString` | — | Версия модлоадера |
| `modCount` | `int` | `0` | Число модов в сборке; показывается колонкой в таблице |
| `seasonStart` | `QDate` | — | Начало сезона |
| `seasonEnd` | `QDate` | — | Конец сезона; невалидная дата означает, что сезон ещё не закончен |
| `serverUrl` | `QString` | — | Адрес игрового сервера — не файлового, с которого качается сборка |
| `javaMajor` | `int` | `0` | Требуемая версия Java; `0` означает «определять по версии игры» |
| `description` | `QString` | — | Описание сборки; показывается в подвале окна каталога |
| `archiveUrl` | `QUrl` | — | Адрес архива сборки |
| `archiveSize` | `qint64` | `0` | Размер архива в байтах |
| `archiveSha256` | `QString` | — | Контрольная сумма архива |
Метод `isValid()` возвращает `true`, когда заполнен `id`, `revision` больше нуля и `archiveUrl`
корректен.
## Псевдонимы типов
`SeasonalBuildService::Callback` — `std::function<void(bool ok, const QString &warning)>`.
Сочетание `ok == true` с непустым `warning` означает данные из устаревшего кэша.
## Публичные методы
#### explicit SeasonalBuildService(QObject \*parent = nullptr)
Создаёт сервис и его `QNetworkAccessManager`. Конструктор помечен `explicit`.
#### QList&lt;SeasonalBuildEntry&gt; builds() const
Текущий каталог сборок. Возвращает копию.
#### bool hasData() const
Есть ли в каталоге хоть что-то — из сети или из кэша.
#### bool isRefreshing() const
Идёт ли сейчас сетевое обновление.
#### QString lastError() const
Последняя ошибка обращения к серверу; пустая строка означает, что всё в порядке.
В отличие от остальных каталогов лаунчера, ошибка здесь хранится отдельным полем: пустой список и
ошибка выглядят одинаково пустыми, и окно каталога показывает причину прямо на месте строк.
#### std::optional&lt;SeasonalBuildEntry&gt; find(const QString &id) const
Запись по идентификатору сборки; `std::nullopt`, если такой нет.
#### void setBaseUrl(const QUrl &baseUrl)
Задаёт адрес сервера сборок. Адрес меняется из настроек, поэтому при смене хоста накопленные
данные и кэш сбрасываются: ссылки в них указывают на старый сервер и после смены недействительны.
#### QUrl baseUrl() const
Текущий адрес сервера сборок.
#### void ensureLoaded(Callback callback, bool forceRefresh = false)
Единственная точка входа. Свежий кэш означает отложенный вызов колбэка без сети; иначе запускается
один сетевой запрос на всех, кто успел попросить. Параметр `forceRefresh` обходит проверку
свежести — так работает кнопка обновления списка.
Окно каталога вызывает этот метод при каждом открытии без принудительного обновления: свежий кэш
отвечает без сети, поэтому вызов ничего не стоит.
## Сигналы
#### buildsChanged()
Каталог изменился — пришли новые данные или прочитан кэш. Обработчик перечитывает `builds()` и
обновляет таблицу.
#### refreshingChanged()
Изменился признак обновления. Обработчик показывает или убирает индикатор загрузки; в окне
каталога по нему же выключается кнопка обновления списка.
## Владение и время жизни
Класс наследует `QObject` и принимает `parent` — родитель его и удалит.
`QNetworkAccessManager` создаётся в конструкторе с сервисом в роли родителя.
Отложенные колбэки хранятся до завершения текущего запроса; уничтожение сервиса с незавершённым
запросом обрывает его, и колбэки не вызываются.
## Потокобезопасность
Только поток GUI. Чтение и запись кэша выполняются синхронно в вызывающем потоке.
## Взаимодействие с другими классами
`LauncherBackend` вызывает `ensureLoaded()` при открытии окна каталога и по кнопке обновления, а
сигналы переправляет в свойства для QML. Перед отдачей в интерфейс он сводит записи каталога с
локальными: строка таблицы уже содержит готовый статус и признак доступного обновления, поэтому
[SeasonalBuildsDialog](../qml/SeasonalBuildsDialog.md) ничего не считает сам.
Установка сезонной сборки начинается с `find()`: по записи бэкенд получает адрес архива и передаёт
его [SeasonalPackDownloader](SeasonalPackDownloader.md), а скачанный пак раскатывает
[BuildSwitcher](BuildSwitcher.md).
## Внешнее взаимодействие
**Сеть, исходящие запросы.** Класс скачивает `index.json` с файлового сервера сборок, адрес
которого задаётся через `setBaseUrl()`. Формат — JSON поверх HTTPS, запрос инициирует лаунчер.
Испорченный или пустой ответ не затирает хороший кэш. При недоступной сети данные отдаются из
устаревшего кэша с `ok == true` и заполненным `warning`; текст ошибки при этом попадает и в
`lastError()`.
Все сигналы и колбэки приходят в поток GUI.
## Пример использования
```cpp
auto *seasonal = new SeasonalBuildService(this);
seasonal->setBaseUrl(QUrl(settings.seasonalServer));
connect(seasonal, &SeasonalBuildService::buildsChanged, this, &Backend::rebuildSeasonalCatalog);
seasonal->ensureLoaded([this, seasonal](bool ok, const QString &warning) {
if (!ok) {
emit seasonalCatalogError(seasonal->lastError());
return;
```json
{
"formatVersion": 1,
"builds": [
{
"id": "season-5",
"name": "Сезон 5: Пустоши",
"revision": 3,
"minecraftVersion": "1.20.1",
"loader": "forge",
"loaderVersion": "47.4.0",
"modCount": 142,
"seasonStart": "2026-09-01",
"seasonEnd": "",
"serverUrl": "play.example.com:25565",
"javaMajor": 17,
"description": "…",
"archive": {
"url": "https://launcher-builds.galeonworx.com/season-5/r3.zip",
"size": 734003200,
"sha256": "…64 hex…"
}
}
if (!warning.isEmpty())
showStatus(warning);
});
]
}
```
| Поле | Обязательно | Правила |
|------|-------------|---------|
| `formatVersion` | да | только `1`. Другое значение — весь каталог отклоняется с понятным текстом (если версия больше, пользователю предложат обновить лаунчер) |
| `id` | да | `[a-z0-9][a-z0-9._-]{0,63}`. По нему лаунчер узнаёт уже установленную сборку, менять его нельзя |
| `name` | да | |
| `revision` | да | > 0. Увеличивается при каждой публикации. Если у игрока ревизия меньше, ему покажут «доступно обновление» |
| `minecraftVersion` | да | без `/`, `\`, `:`, не длиннее 128 символов |
| `loader` | нет | пусто (ваниль) или `forge`, `fabric`, `neoforge`, `quilt` |
| `loaderVersion` | если есть `loader` | |
| `modCount` | нет | для таблицы |
| `seasonStart`, `seasonEnd` | нет | ISO-даты. Пустой `seasonEnd` значит, что сезон идёт |
| `serverUrl` | нет | адрес игрового сервера для автоподключения |
| `javaMajor` | нет | 0 или нет поля — определить по версии игры |
| `description` | нет | показывается под таблицей |
| `archive.url` | да | `http` или `https`, **та же схема, хост и порт, что у `baseUrl`** |
| `archive.size` | да | ≥ 0 |
| `archive.sha256` | да | 64 hex-символа |
Записи, которые не проходят проверку, молча пропускаются. Если не осталось ни одной, это считается
испорченным ответом, и хороший кэш не затирается.
Требование «тот же хост» не даёт подложить в каталог ссылку на чужой сервер. Пак распаковывается
прямо в `.minecraft`, поэтому качать его откуда попало нельзя.
Пак — это zip, содержимое которого раскладывается в корень `.minecraft` (`mods/`, `config/` и т. п.).
Файлы в `versions/`, `libraries/`, `assets/`, `runtime/` и `launcher_profiles.json` запрещены, пак
с ними не установится. Подробнее — [BuildArchiveWorker](BuildArchiveWorker.md).
Порядок в каталоге: сначала идущие сезоны, потом по дате начала (новые сверху), потом по имени.
## Интерфейс
- `ensureLoaded(callback, forceRefresh)` — как у
[VersionManifestService](VersionManifestService.md), только кэш живёт 15 минут.
- `builds()`, `find(id)`, `hasData()`, `isRefreshing()`.
- `lastError()` — текст последней ошибки сервера или пустая строка. Окно сезонных сборок
показывает его на месте таблицы.
- Сигналы `buildsChanged()` (в том числе после неудачного запроса, чтобы обновился `lastError`) и
`refreshingChanged()`.
---
При создании этого документа использовался ИИ.
+19 -149
View File
@@ -1,161 +1,31 @@
# SeasonalPackDownloader
## Обзор класса
`seasonalpackdownloader.h`, `seasonalpackdownloader.cpp`
`SeasonalPackDownloader` скачивает один архив сезонной сборки в файл. Задача узкая и отдельная по
двум причинам: пак — это сотни мегабайт, поэтому он пишется потоком, а не держится в памяти; и его
sha256 обязательно сверяется, потому что распаковывать битую загрузку поверх рабочей `.minecraft`
нельзя.
Качает один архив сезонной сборки в файл и проверяет sha256. Пак весит сотни мегабайт, поэтому
пишется потоком через `QSaveFile`, а не собирается в памяти. Битый архив распаковывать поверх
рабочей `.minecraft` нельзя, поэтому файл появляется на диске только после совпадения хеша.
Распаковкой класс не занимается — это делает [BuildSwitcher](BuildSwitcher.md) в отдельном потоке
вместе с остальными операциями над содержимым папки игры.
Распаковывает пак [BuildSwitcher](BuildSwitcher.md). Экземпляр держит
[LauncherBackend](LauncherBackend.md), который и вызывает загрузку на шаге Downloading установки
сезонной сборки.
Набор геттеров прогресса повторяет [VersionInstaller](VersionInstaller.md) и
[JavaInstaller](JavaInstaller.md): панель загрузки в интерфейсе читает их одинаково, независимо от
того, кто сейчас работает.
## download(url, targetPath, sha256, expectedSize, label)
## Место в проекте и зависимости
- `targetPath` — `<root>/builds/<id>/.pack-r<ревизия>.zip`. Если файл уже есть, он будет
перезаписан: недокачанный пак с прошлой попытки не должен дожить до новой.
- `expectedSize` из каталога используется для прогресса, пока сервер не сообщил настоящий размер.
- Таймаут — 60 секунд без данных. Повторных попыток нет: если загрузка оборвалась, установку
нужно начать заново.
Экземпляр создаётся и принадлежит [LauncherBackend](LauncherBackend.md). Адрес архива, его размер
и контрольную сумму даёт запись каталога от [SeasonalBuildService](SeasonalBuildService.md).
Если загрузка уже идёт, сразу приходит `failed`.
Требования сборки: `Qt6::Core` (`QCryptographicHash`, `QSaveFile`, `QTimer`) и `Qt6::Network`
(`QNetworkAccessManager`, `QNetworkReply`).
Сигналы: `started(label)`, `progressChanged()` (не чаще раза в 100 мс), `finished(path)`,
`failed(label, message)`, `canceled(label)`. Геттеры прогресса такие же, как у других
установщиков.
## Иерархия и роль
Наследует `QObject`: мета-объектная система, пять сигналов и владение по родителю. Объявлен
виртуальный деструктор — класс владеет незавершённой загрузкой и открытым файлом.
## Публичные методы
#### explicit SeasonalPackDownloader(QObject \*parent = nullptr)
Создаёт загрузчик, его `QNetworkAccessManager` и таймер сглаживания прогресса. Конструктор помечен
`explicit`.
#### bool isRunning() const
Идёт ли загрузка прямо сейчас.
#### QString label() const
Подпись загрузки для интерфейса — как правило, название сезонной сборки.
#### QString stage() const
Текущий этап словами.
#### QString currentFile() const
Файл, который качается сейчас.
#### qint64 bytesDone() const
Сколько байт уже получено.
#### qint64 bytesTotal() const
Ожидаемый размер архива.
#### double fraction() const
Доля выполнения от `0` до `1` либо `-1`, пока итог неизвестен — например, когда сервер не сообщил
размер, а в записи каталога он не был указан.
#### void download(const QUrl &url, const QString &targetPath, const QString &sha256, qint64 expectedSize, const QString &label)
Скачивает архив по адресу `url` в `targetPath`, сверяя sha256 с переданным значением.
Файл `targetPath` перезаписывается: недокачанный пак с прошлой попытки не должен пережить новую.
Запись идёт через `QSaveFile`, поэтому на месте назначения файл появляется только целиком и только
после успешной проверки контрольной суммы.
Параметр `expectedSize` берётся из записи каталога и используется для расчёта доли выполнения,
пока сервер не сообщил размер сам.
#### void cancel()
Отменяет загрузку. Недокачанный файл на месте назначения не остаётся.
## Сигналы
#### started(const QString &label)
Загрузка началась. Обработчик показывает панель прогресса.
#### progressChanged()
Изменились числа прогресса; испускается не чаще, чем позволяет внутренний таймер, — иначе сигнал
на каждый принятый блок обошёлся бы дороже самой загрузки. Обработчик перечитывает геттеры.
#### finished(const QString &path)
Архив скачан и проверен; в параметре — путь к готовому файлу.
Обработчик передаёт этот путь [BuildSwitcher](BuildSwitcher.md) для раскатки поверх содержимого
`.minecraft`.
#### failed(const QString &label, const QString &message)
Загрузка не удалась: сеть недоступна, сервер ответил ошибкой или не сошлась контрольная сумма.
Последний случай особенно важен — он означает, что архив повреждён и распаковывать его нельзя.
#### canceled(const QString &label)
Загрузка отменена пользователем.
## Владение и время жизни
Класс наследует `QObject` и принимает `parent` — родитель его и удалит.
`QNetworkAccessManager` и таймер создаются в конструкторе с загрузчиком в роли родителя.
Скачиваемый файл хранится как `std::unique_ptr<QSaveFile>`: незавершённая запись отменяется вместе
с уничтожением объекта, и повреждённый архив не попадает на место назначения. Сетевой ответ
создаётся по ходу работы и закрывается в деструкторе.
## Потокобезопасность
Только поток GUI. Данные пишутся на диск блоками по мере поступления, поэтому длительных
синхронных операций в потоке нет.
## Взаимодействие с другими классами
`LauncherBackend` вызывает `download()` при установке или обновлении сезонной сборки, передавая
адрес и контрольную сумму из записи [SeasonalBuildService](SeasonalBuildService.md). Сигналы
прогресса он переправляет в те же свойства, что и остальные загрузчики, поэтому панель в главном
окне не различает, кто работает.
По сигналу `finished` бэкенд передаёт путь к архиву в `BuildSwitcher::applyPack()` вместе со
списком уходящих файлов из описания предыдущей ревизии.
## Внешнее взаимодействие
**Сеть, исходящие запросы.** Одна загрузка по HTTPS с файлового сервера сборок. Направление
одностороннее, тело ответа — двоичный архив.
Данные пишутся потоком через `QSaveFile` с одновременным подсчётом sha256; несовпадение суммы
приводит к сигналу `failed`, и файл на месте назначения не появляется. Повторных попыток класс не
делает: решение о повторе принимает пользователь.
Все сигналы приходят в поток GUI.
## Пример использования
```cpp
auto *packLoader = new SeasonalPackDownloader(this);
connect(packLoader, &SeasonalPackDownloader::progressChanged, this, &Backend::downloadChanged);
connect(packLoader, &SeasonalPackDownloader::finished, this, [this](const QString &path) {
m_switcher->applyPack(m_buildId, m_buildName, path,
m_previousEntries, m_note, m_gameDir);
});
connect(packLoader, &SeasonalPackDownloader::failed, this,
[this](const QString &, const QString &message) { emit launchError(message); });
packLoader->download(entry.archiveUrl, targetPath,
entry.archiveSha256, entry.archiveSize, entry.name);
```
При ошибке и несовпадении хеша файл удаляется. `cancel()` обрывает запрос, и `canceled` приходит
после его завершения.
---
+70 -205
View File
@@ -1,210 +1,75 @@
# VersionInstaller
## Обзор класса
Установить версию Minecraft — значит положить в `.minecraft` пять групп файлов: описание версии,
клиентский jar, библиотеки, индекс ресурсов и сами ресурсы. Последних — десятки тысяч мелких
файлов.
`VersionInstaller` делает это фоном, не блокируя интерфейс: складывает всё нужное в очередь
загрузок, качает несколько файлов параллельно, пишет их потоком на диск и по ходу сообщает
прогресс. Одна версия ставится за раз, остальные ждут в очереди.
Класс также разворачивает цепочку наследования: если у версии есть `inheritsFrom`, родительская
версия ставится перед ней.
## Место в проекте и зависимости
Подключает [minecraftversion.h](minecraftversion.md) — по разобранной версии он и понимает, что
качать. В конструктор принимает [VersionManifestService](VersionManifestService.md): оттуда
берётся адрес описания версии.
Экземпляр создаётся и принадлежит [LauncherBackend](LauncherBackend.md). На него же опирается
[ModLoaderInstaller](ModLoaderInstaller.md) — профиль модлоадера ставится поверх установленной
версии игры.
Требования сборки: `Qt6::Core` (`QCryptographicHash`, `QSaveFile`, `QQueue`, `QTimer`) и
`Qt6::Network` (`QNetworkAccessManager`, `QNetworkReply`).
## Иерархия и роль
Наследует `QObject`: мета-объектная система, пять сигналов и владение по родителю. Объявлен
виртуальный деструктор — класс владеет незавершёнными загрузками и обязан их закрыть.
## Публичные структуры
### DownloadTask
Один файл, который нужно положить в `.minecraft`.
| Поле | Тип | По умолчанию | Описание |
|------|-----|--------------|----------|
| `kind` | `DownloadTask::Kind` | `ClientJar` | Что это за файл |
| `url` | `QUrl` | — | Откуда качать |
| `path` | `QString` | — | Абсолютный путь назначения |
| `sha1` | `QString` | — | Контрольная сумма; пустая означает «не проверять» |
| `size` | `qint64` | `0` | Ожидаемый размер; `0` — неизвестен |
| `label` | `QString` | — | Что показать в панели прогресса |
| `attempts` | `int` | `0` | Сколько попыток уже сделано |
#### Перечисление DownloadTask::Kind
| Значение | Описание |
|----------|----------|
| `ClientJar` | Клиентский jar версии |
| `Library` | Библиотека из `libraries/` |
| `AssetIndex` | Индекс ресурсов |
| `AssetObject` | Один файл ресурсов |
| `LoggingConfig` | Конфигурация журналирования log4j |
### ActiveDownload
Файл в процессе скачивания: задача, сетевой ответ, открытый `QSaveFile`, накапливаемая
контрольная сумма и число принятых байт. Файлы пишутся потоком — держать десятки мегабайт в
памяти незачем, а нескольких параллельных загрузок хватило бы на сотни.
## Публичные методы
#### explicit VersionInstaller(VersionManifestService \*manifest, QObject \*parent = nullptr)
Создаёт установщик поверх сервиса манифеста. Сервис не переходит во владение установщика и обязан
пережить его. Конструктор помечен `explicit`.
#### bool isRunning() const
Идёт ли установка прямо сейчас.
#### QString versionId() const
Идентификатор версии, которая ставится в данный момент.
#### QString stage() const
Текущий этап установки словами — это же значение показывается в заголовке панели прогресса.
#### QString currentFile() const
Подпись файла, который качается сейчас.
#### qint64 bytesDone() const
Сколько байт уже получено, с учётом идущих загрузок.
#### qint64 bytesTotal() const
Ожидаемый общий объём. Растёт по ходу установки: полный размер ресурсов становится известен
только после разбора их индекса.
#### double fraction() const
Доля выполнения от `0` до `1` либо `-1`, пока итоговый объём неизвестен. Значение `-1` панель
прогресса показывает многоточием вместо процентов.
#### void install(const QString &gameDir, const QString &versionId)
Ставит версию в указанную папку игры. Если установка уже идёт, версия становится в очередь.
Порядок работы: разрешение записи манифеста, загрузка описания версии, подготовка списка задач,
скачивание, разворачивание индекса ресурсов и — для версий до 1.6 — раскладка ресурсов в плоскую
папку, которую те версии умеют читать.
#### bool isQueued(const QString &versionId) const
Стоит ли версия в очереди на установку. Позволяет не ставить одну и ту же версию дважды.
#### void cancel()
Отменяет текущую установку и очищает очередь. Незавершённые файлы не остаются на диске: они
пишутся через `QSaveFile` и фиксируются только целиком.
## Сигналы
#### started(const QString &versionId)
Установка версии началась. Обработчик показывает панель прогресса и выставляет признак занятости.
#### progressChanged()
Изменились числа прогресса. Испускается не чаще десяти раз в секунду: при тысячах мелких файлов
сигнал на каждый принятый блок обошёлся бы дороже самой загрузки.
Обработчик перечитывает `stage()`, `currentFile()`, `bytesDone()`, `bytesTotal()` и `fraction()`.
#### finished(const QString &versionId)
Версия установлена успешно. Обработчик убирает панель прогресса, обновляет список установленных
версий и пересчитывает комплектность сборок.
#### failed(const QString &versionId, const QString &message)
Установка не удалась; в параметре — текст ошибки для пользователя. Часть файлов при этом может
остаться на диске: повторная установка докачает недостающее.
#### canceled(const QString &versionId)
Установка отменена пользователем. В отличие от `failed`, ошибку показывать не нужно.
## Владение и время жизни
Класс наследует `QObject` и принимает `parent` — родитель его и удалит.
Указатель на `VersionManifestService`, переданный в конструктор, **не принадлежит** установщику:
сервис создаётся раньше и живёт дольше. `QNetworkAccessManager` и оба таймера создаются в
конструкторе с установщиком в роли родителя.
Активные загрузки хранятся как `std::shared_ptr<ActiveDownload>`, а файл внутри каждой — как
`std::unique_ptr<QSaveFile>`: незавершённая запись отменяется вместе с уничтожением объекта, и
испорченный файл не попадает на место назначения.
## Потокобезопасность
Только поток GUI, как и остальной сетевой код лаунчера. Параллелизм здесь — не потоки, а
несколько одновременных сетевых запросов в одном цикле событий. Раскладка ресурсов для старых
версий выполняется порциями по таймеру, чтобы не занимать поток надолго.
## Взаимодействие с другими классами
`LauncherBackend` вызывает `install()` при установке сборки и переправляет все пять сигналов в
свойства, которые читает QML: панель прогресса главного окна показывает `stage()`, `fraction()` и
байты, а `finished` обновляет список установленных версий.
[ModLoaderInstaller](ModLoaderInstaller.md) держит ссылку на установщик: профиль модлоадера
требует, чтобы базовая версия игры была уже на месте.
Разбор описания версии идёт через `VersionLoader::load()` из
[minecraftversion.h](minecraftversion.md).
## Внешнее взаимодействие
**Сеть, исходящие запросы.** Класс качает файлы с серверов Mojang через `QNetworkAccessManager`.
Направление одностороннее, протокол — HTTPS; описание версии и индекс ресурсов приходят как JSON,
остальное — двоичными файлами.
Каждый файл пишется потоком через `QSaveFile` с одновременным подсчётом sha1; несовпадение
контрольной суммы считается неудачей загрузки. Неудачная задача повторяется — счётчик попыток
хранится в самой задаче, — и только исчерпав попытки, приводит к сигналу `failed`.
Существующие файлы сверяются только по размеру: перехеширование сотен мегабайт при каждом
добавлении версии дороже, чем риск битого файла.
Все сигналы приходят в поток GUI.
## Пример использования
```cpp
auto *installer = new VersionInstaller(manifestService, this);
connect(installer, &VersionInstaller::progressChanged, this, [this, installer] {
emit downloadProgress(installer->fraction(), installer->currentFile());
});
connect(installer, &VersionInstaller::finished, this, &Backend::onVersionInstalled);
connect(installer, &VersionInstaller::failed, this, [this](const QString &id, const QString &message) {
emit launchError(tr("Не удалось установить %1: %2").arg(id, message));
});
if (!installer->isQueued(versionId))
installer->install(gameDir, versionId);
```
`versioninstaller.h`, `versioninstaller.cpp`
Ставит версию игры в `.minecraft`: описание версии, клиентский jar, библиотеки, индекс ресурсов,
сами ресурсы и конфиг логирования. Одновременно ставится одна версия, остальные ждут в очереди.
Всё работает в GUI-потоке на асинхронной сети.
Экземпляр держит [LauncherBackend](LauncherBackend.md). Ещё им пользуется
[ModLoaderInstaller](ModLoaderInstaller.md): перед установкой модлоадера нужно поставить базовую
версию, а после установки докачать библиотеки нового профиля.
## Интерфейс
- `install(gameDir, versionId)` — поставить в очередь. Если эта версия уже ставится или ждёт,
ничего не происходит. Если id содержит слэши, `.` или `..`, сразу приходит `failed`.
- `cancel()` — отменить текущую установку и очистить очередь.
- `isRunning()`, `isQueued(id)`, `versionId()`.
- Для панели прогресса: `stage()`, `currentFile()`, `bytesDone()`, `bytesTotal()`, `fraction()`
(от 0 до 1; −1, если общий объём неизвестен).
Сигналы: `started(id)`, `progressChanged()` (не чаще раза в 100 мс), `finished(id)`,
`failed(id, message)`, `canceled(id)`.
`started` испускается один раз на всю серию установок из очереди. `finished` испускается для
каждой версии.
## Как идёт установка
1. **Описание версии.** У [VersionManifestService](VersionManifestService.md) берётся ссылка на
`<id>.json`, файл скачивается, sha1 сверяется с манифестом, и файл сохраняется в
`versions/<id>/`. Если версии в манифесте нет (профиль модлоадера) или сеть недоступна, берётся
файл, который уже лежит на диске. Если нет и его, установка падает.
2. **Родитель.** Если в описании есть `inheritsFrom`, а родителя на диске нет, текущая версия
уступает место: в начало очереди встают сначала родитель, потом она сама. Глубина цепочки — до
10 звеньев.
3. **Список файлов.** `VersionLoader::load()` сводит цепочку, и из результата составляется список:
клиентский jar, все библиотеки, индекс ресурсов и конфиг логов. Ссылки не по `https` и
библиотеки без адреса пропускаются с предупреждением.
4. **Загрузка.** До 8 файлов одновременно. Каждый пишется потоком в `QSaveFile`, sha1 считается на
ходу. При временной ошибке (таймаут, обрыв, HTTP 429 или 5xx) и при несовпадении хеша файл
ставится обратно в начало очереди с паузой 400 мс, потом 800 мс. После третьей неудачи
установка падает. Другие ошибки сразу её прерывают. Таймаут на файл 30 секунд.
5. **Ресурсы.** Как только индекс ресурсов на диске, он разворачивается в задачи на загрузку из
`resources.download.minecraft.net/<xx>/<hash>`. До этого момента в общем объёме учитывается
`totalSize` из описания версии, а потом он заменяется точной суммой.
6. **Старые ресурсы.** У версий до 1.6 (`virtual`, `map_to_resources`, `assets` = `legacy` или
`pre-1.6`) игра ищет ресурсы не в `assets/objects`, а в обычной папке с именами файлов. Они
копируются в `assets/virtual/legacy/` или в `resources/` по 64 файла за такт таймера, чтобы
окно не подвисало.
7. **Проверка.** `GameLauncher::missingFiles()`. Если чего-то нет, это ошибка.
## Уже скачанные файлы
Существующий файл считается целым, если его размер совпадает с ожидаемым (или размер неизвестен).
Хеш не пересчитывается: перехеширование сотен мегабайт ресурсов при каждой установке обходится
дороже, чем редкий битый файл. Для будущей кнопки «Проверить файлы» есть флаг `m_verifyExisting`,
сейчас он всегда `false`.
Пропуск файлов идёт пачками по 256 за такт. Иначе установка версии, у которой уже есть все
ресурсы, повесила бы окно на перебор десятков тысяч записей.
## Ошибки и отмена
При ошибке все активные загрузки обрываются, недописанные файлы откатываются (`QSaveFile`), а
**очередь очищается**: остальные версии из неё тоже не поставятся. К тексту ошибки добавляются до
трёх накопленных предупреждений.
Отмена обрывает активные запросы и ждёт, пока все они вернутся. Только после этого приходит
`canceled`. Пока отмена не закончилась, `install()` отвечает отказом.
Деструктор блокирует сигналы и отменяет установку.
---
+45 -145
View File
@@ -1,167 +1,67 @@
# VersionManifestService
## Обзор класса
`versionmanifestservice.h`, `versionmanifestservice.cpp`
Каталог версий Minecraft — это манифест Mojang: около тысячи записей от альф 2010 года до
свежайших снапшотов. `VersionManifestService` отвечает за него целиком: скачивает манифест,
кэширует в папке лаунчера и отдаёт из кэша, пока тот не устарел.
Каталог версий Mojang. Скачивает `version_manifest_v2.json`, хранит его копию в папке лаунчера и
отвечает из неё, пока копия свежая.
Класс нужен двум потребителям: окну выбора версии, которому нужен весь список, и установщику,
которому по идентификатору версии нужна ссылка на её описание.
Экземпляр держит [LauncherBackend](LauncherBackend.md): из каталога он строит список для окна
выбора версии. [VersionInstaller](VersionInstaller.md) берёт из каталога ссылку на описание версии
через `find()`.
## Место в проекте и зависимости
## RemoteVersionEntry
Экземпляр создаётся и принадлежит [LauncherBackend](LauncherBackend.md). Ссылку на него получает
[VersionInstaller](VersionInstaller.md) — установщик берёт из манифеста адрес описания версии.
| Поле | Что это |
|------|---------|
| `id` | `1.21.8`, `25w33a`, `b1.7.3` |
| `type` | `release`, `snapshot`, `old_beta`, `old_alpha` |
| `url` | адрес `<id>.json` |
| `sha1` | хеш этого json |
| `releaseTime` | |
Путь к файлу кэша даёт `LauncherPaths::versionManifestFile()` из
[launcherpaths.h](launcherpaths.md).
Записи без `id` или с адресом не по `https` отбрасываются. Список отсортирован по `releaseTime`,
новые сверху. При одинаковом времени сортировка идёт по `id`.
Требования сборки: `Qt6::Core` (`QDateTime`, `QHash`, `QList`, `QUrl`) и `Qt6::Network`
(`QNetworkAccessManager`).
## ensureLoaded(callback, forceRefresh = false)
## Иерархия и роль
Через этот метод сервис получает данные; других способов нет.
Наследует `QObject`: мета-объектная система, два сигнала и владение по родителю. Виртуальных
методов базового класса не переопределяет.
1. При первом вызове читается кэш `<root>/cache/version_manifest.json`. Если он разобрался,
сразу испускается `versionsChanged`, и окно показывает список ещё до ответа сети.
2. Если данные моложе 6 часов и `forceRefresh` не задан, колбэк вызывается через
`QTimer::singleShot(0)` с `ok = true`. Сеть не трогается.
3. Иначе колбэк встаёт в очередь. Если запрос уже идёт, второй не отправляется.
## Публичные структуры
Ответ сети разбирается, кэш перезаписывается через `QSaveFile`, испускается `versionsChanged`, и
все колбэки из очереди получают результат.
### RemoteVersionEntry
Если сеть не ответила, но старые данные есть, колбэк получит `ok = true` и непустой `warning`:
работать можно, просто список, возможно, неполный. Если данных нет совсем, придёт `ok = false`.
Бэкенд показывает `warning` пользователю только во втором случае.
Одна строка манифеста Mojang.
Таймаут запроса 20 секунд.
| Поле | Тип | Описание |
|------|-----|----------|
| `id` | `QString` | Идентификатор версии: `1.21.8`, `25w33a`, `b1.7.3` |
| `type` | `QString` | Категория: `release`, `snapshot`, `old_beta` или `old_alpha` |
| `url` | `QUrl` | Адрес `<id>.json` с описанием версии |
| `sha1` | `QString` | Контрольная сумма самого описания |
| `releaseTime` | `QDateTime` | Дата выпуска; по ней список сортируется новыми вперёд |
## Остальное
Категории `type` — те же ключи, по которым окно выбора версии делит каталог на вкладки; версии, не
попавшие ни в одну из четырёх, интерфейс относит к категории «прочие».
- `versions()` — весь список.
- `find(id)` — запись по id (через хеш-таблицу, а не перебором).
- `hasData()`, `isRefreshing()`, `fetchedAt()`.
- `refreshingChanged` испускается в начале и в конце запроса. Бэкенд превращает его в
`catalogLoading`.
## Псевдонимы типов
## Формат кэша
`VersionManifestService::Callback` — `std::function<void(bool ok, const QString &warning)>`.
Сочетание `ok == true` с непустым `warning` означает особый случай: данные отдали, но из
устаревшего кэша — сеть недоступна, а показать что-то нужно.
## Публичные методы
#### explicit VersionManifestService(QObject \*parent = nullptr)
Создаёт сервис и его `QNetworkAccessManager`. Манифест при этом не читается — чтение кэша
откладывается до первого обращения. Конструктор помечен `explicit`.
#### QList&lt;RemoteVersionEntry&gt; versions() const
Текущий список версий. Возвращает копию; пустой список означает, что данных ещё нет.
#### bool hasData() const
Есть ли хоть какие-то данные — из сети или из кэша.
#### bool isRefreshing() const
Идёт ли сейчас сетевое обновление. Интерфейс показывает по этому признаку строку загрузки вместо
пустого списка.
#### QDateTime fetchedAt() const
Когда данные были получены. По этой отметке решается, устарел ли кэш.
#### std::optional&lt;RemoteVersionEntry&gt; find(const QString &id) const
Запись по идентификатору версии; `std::nullopt`, если такой версии в манифесте нет. Из неё
установщик берёт ссылку на описание версии. Поиск идёт по внутреннему указателю, а не перебором.
#### void ensureLoaded(Callback callback, bool forceRefresh = false)
Единственная точка входа. Свежий кэш означает отложенный вызов колбэка без обращения к сети; иначе
запускается один сетевой запрос на всех, кто успел попросить, — колбэки накапливаются и вызываются
все разом по его завершении.
Параметр `forceRefresh` обходит проверку свежести кэша: так работает кнопка принудительного
обновления.
Колбэк вызывается ровно один раз и всегда в потоке GUI, в том числе когда данные уже есть.
## Сигналы
#### versionsChanged()
Список версий изменился — пришли новые данные из сети или прочитан кэш.
Обработчик перечитывает `versions()` и обновляет интерфейс. В лаунчере на этот сигнал завязано
свойство каталога версий, которое читает окно выбора.
#### refreshingChanged()
Изменился признак сетевого обновления. Обработчик показывает или убирает индикатор загрузки.
## Владение и время жизни
Класс наследует `QObject` и принимает `parent` — родитель его и удалит.
`QNetworkAccessManager` создаётся в конструкторе с сервисом в роли родителя.
Отложенные колбэки хранятся в списке до завершения текущего запроса. Уничтожение сервиса с
незавершённым запросом обрывает его вместе с менеджером сети, и накопленные колбэки не
вызываются.
Ссылку на сервис держит установщик версий; уничтожать сервис раньше установщика нельзя.
## Потокобезопасность
Только поток GUI — так же, как [AuthService](AuthService.md). Чтение и запись кэша выполняются в
вызывающем потоке, поэтому первое обращение к манифесту делает короткую файловую операцию
синхронно.
## Взаимодействие с другими классами
`LauncherBackend` вызывает `ensureLoaded()` при открытии окна выбора версии и по кнопке
обновления, а сигналы `versionsChanged` и `refreshingChanged` переправляет в свойства, которые
читает QML. Список из `versions()` он сводит с установленными версиями и отдаёт в интерфейс уже
готовыми строками.
[VersionInstaller](VersionInstaller.md) обращается к `find()`, чтобы получить адрес описания
версии перед началом загрузки.
## Внешнее взаимодействие
**Сеть, исходящие запросы.** Класс скачивает манифест версий Mojang через
`QNetworkAccessManager`. Формат — JSON поверх HTTPS, запрос инициирует лаунчер.
Стратегия при недоступной сети встроена в контракт колбэка: если есть устаревший кэш, он
отдаётся с `ok == true` и заполненным `warning`, и интерфейс показывает список вместо ошибки.
Полное отсутствие данных даёт `ok == false`.
**Файловый кэш.** Манифест сохраняется в файл, путь к которому даёт
`LauncherPaths::versionManifestFile()`, вместе с отметкой времени получения.
Сигналы и колбэки приходят в поток GUI.
## Пример использования
```cpp
auto *manifest = new VersionManifestService(this);
connect(manifest, &VersionManifestService::versionsChanged, this, &Backend::rebuildCatalog);
manifest->ensureLoaded([this, manifest](bool ok, const QString &warning) {
if (!ok) {
emit catalogError(warning);
return;
}
if (!warning.isEmpty())
showStatus(warning); // список из устаревшего кэша
const auto entry = manifest->find(QStringLiteral("1.21.8"));
if (entry)
startDownload(entry->url);
});
```json
{
"fetchedAt": "2026-10-11T08:00:00Z",
"versions": [
{ "id": "1.21.8", "type": "release", "url": "https://...", "sha1": "...", "releaseTime": "..." }
]
}
```
Хранится только то, что нужно лаунчеру, а не весь манифест Mojang.
---
При создании этого документа использовался ИИ.
+40 -57
View File
@@ -1,76 +1,59 @@
# javalocator.h — JavaLocator
# JavaLocator
## Обзор
`javalocator.h`, `javalocator.cpp`
Для запуска Minecraft нужна Java подходящей версии: старым версиям игры — восьмая, новым —
семнадцатая или двадцать первая. Лаунчер умеет скачивать сборки Java сам (см.
[JavaInstaller](JavaInstaller.md)), но сначала стоит посмотреть, что уже есть на машине.
Поиск java, уже установленной на машине, и выбор подходящей под версию игры. Скачиванием
занимается [JavaInstaller](JavaInstaller.md), а здесь только смотрим, что есть.
`JavaLocator` отвечает за поиск: собирает все доступные java, определяет их версии и выбирает
подходящую под требование версии игры. Пространство имён используется при подготовке запуска и при
показе списка найденных Java в настройках.
Это пространство имён со свободными функциями. Его используют [GameLauncher](GameLauncher.md) при
запуске, [LauncherBackend](LauncherBackend.md) для списка в настройках и для шага Java в установке
сезонной сборки.
## Пространства имён
## findAll(gameDir)
`JavaLocator` группирует четыре функции поиска и выбора. Состояния нет, но результат определения
версии кэшируется внутри реализации.
Список путей к исполняемому `java` (`java.exe` на Windows) без повторов. Порядок такой:
## Функции
1. сборки, скачанные лаунчером в `<root>/java` (через `JavaRuntimeStore::installed()`);
2. всё, что найдётся рекурсивно в `<gameDir>/runtime`: туда кладёт Java официальный лаунчер;
3. `$JAVA_HOME/bin`;
4. `java` из `PATH`;
5. стандартные каталоги установки. На Windows это `Program Files`, `Program Files (x86)` и
`ProgramW6432` с подпапками `Java`, `Eclipse Adoptium`, `Eclipse Foundation`, `AdoptOpenJDK`,
`Amazon Corretto`, `Zulu`, `BellSoft`, `Microsoft`, `Semeru` и JBR из Android Studio. На macOS
это `/Library/Java/JavaVirtualMachines` и такая же папка в домашнем каталоге. На Linux —
`/usr/lib/jvm`, `/usr/java`, `/opt/java`, `/opt/jdk`.
#### QStringList findAll(const QString &gameDir)
В каждом каталоге установки проверяются `bin`, `Contents/Home/bin` (так устроены JDK на macOS) и
`jre/bin`.
Все java, которые удалось найти, без дублей и в порядке приоритета. Просматриваются четыре
источника: рантайм самого Minecraft внутри папки игры, переменная `JAVA_HOME`, переменная `PATH` и
стандартные каталоги установки JDK и JRE.
## majorVersion(javaPath)
Параметр `gameDir` — папка игры; из неё берётся первый источник.
Запускает `java -version` и достаёт мажорную версию из строки `version "..."`. Старая схема
`1.8.0_x` даёт 8. Если процесс не ответил за 5 секунд или вывод не разобрался, возвращается 0.
Результат показывается в диалоге настроек списком «что нашлось в системе».
Результат кэшируется в статической таблице на всё время работы лаунчера. Если java по тому же пути
обновить, лаунчер узнает об этом только после перезапуска. Кэш не защищён от гонок, поэтому
функцию можно вызывать только из одного потока, и в проекте это GUI-поток.
#### int majorVersion(const QString &javaPath)
## select(gameDir, requiredMajor, preferred, &error)
Мажорная версия java по её пути: 8, 17, 21 и так далее. Возвращает `0`, если запустить
исполняемый файл не удалось — путь неверен, файл не исполняемый или это не java.
Выбирает java для запуска.
Результат кэшируется: определение версии требует запуска процесса, а один и тот же путь
проверяется многократно.
- Если `preferred` задан, он идёт первым кандидатом. Это может быть путь к файлу или к домашней
папке JDK (тогда берётся `bin/java`). Если по этому пути ничего нет, функция сразу возвращает
ошибку и других кандидатов не смотрит: пользователь явно указал путь, и молча подменять его
было бы неправильно.
- Дальше добавляется всё из `findAll()`.
- Возвращается первый кандидат с версией не ниже `requiredMajor`. Если таких нет, в `error`
пишется, какая самая новая java нашлась и где.
#### QString windowlessVariant(const QString &javaPath)
Функция возвращает первую подходящую, а не самую новую. Поэтому скачанная лаунчером Java 17
выиграет у системной Java 21, если игре нужна 17.
Заменяет `java.exe` на `javaw.exe`, чтобы игра не открывала окно консоли. На Unix возвращает вход
без изменений — там разницы нет.
## windowlessVariant(javaPath)
#### QString select(const QString &gameDir, int requiredMajor, const QString &preferred, QString \*error)
Выбирает java не ниже `requiredMajor`. Если задан `preferred` — путь, указанный пользователем в
настройках, — он проверяется первым и, если подходит, побеждает. Иначе перебираются найденные
`findAll()` варианты.
При неудаче возвращает пустую строку и заполняет `error` — текст объясняет, что именно не нашлось:
подходящей версии нет вовсе или указанный пользователем путь не подошёл.
Функция запускает процессы для определения версий, поэтому может занять заметное время; вызывать
её в обработчике нажатия не стоит.
## Зависимости
Подключает `QString` и `QStringList`. От классов проекта не зависит.
## Пример использования
```cpp
QString error;
const QString java = JavaLocator::select(gameDir,
version.javaMajor,
settings.javaPath,
&error);
if (java.isEmpty()) {
emit launchError(error);
return;
}
process.start(JavaLocator::windowlessVariant(java), arguments);
```
На Windows заменяет `java.exe` на `javaw.exe` из той же папки, если он там есть. Тогда при запуске
игры не появляется консольное окно. На других системах возвращает путь без изменений.
---
+76 -135
View File
@@ -1,153 +1,94 @@
# javaruntime.h — JavaRuntime, JavaRuntimeStore и JavaRequirement
# javaruntime.h
## Обзор
`javaruntime.h`, `javaruntime.cpp`
`Minecraft_launcher` умеет скачивать Java сам: официальные сборки Mojang — те же, которыми игру
запускает официальный лаунчер, — и сборки Eclipse Temurin в вариантах JDK и JRE. Скачанные сборки
живут в папке лаунчера, по одной подпапке на сборку.
Общие типы для Java: виды сборок, строка каталога, запись об установленной сборке, работа с папкой
`<root>/java` и таблица «какая Java нужна какой версии игры».
Заголовок `javaruntime.h` — словарь этой части проекта. Он даёт перечисление видов сборок, две
структуры (строка каталога и уже установленная сборка), пространство имён для работы с папкой
`<root>/java` и таблицу требований версий игры к Java.
## Виды сборок
Заголовок подключают [JavaRuntimeService](JavaRuntimeService.md) (каталог),
[JavaInstaller](JavaInstaller.md) (скачивание и распаковка) и
[LauncherBackend](LauncherBackend.md) (отдача каталога в QML).
| `JavaRuntimeKind` | Ключ | Что это |
|-------------------|------|---------|
| `Mojang` | `java` | сборки, которые Mojang раздаёт для своего лаунчера |
| `Jdk` | `jdk` | Eclipse Temurin JDK |
| `Jre` | `jre` | Eclipse Temurin JRE |
## Типы
`javaKindKey()`, `javaKindFromKey()`, `javaKindTitle()` переводят между перечислением, ключом и
подписью для интерфейса. Ключ хранится в `runtime.json` установленной сборки и используется в QML.
| Имя | Вид | Описание |
|-----|-----|----------|
| `JavaRuntimeKind` | `enum class` | Откуда взялась сборка и что именно в ней лежит |
| `JavaRuntimeEntry` | `struct` | Строка каталога — то, что можно скачать |
| `InstalledJavaRuntime` | `struct` | Сборка, уже распакованная в папке лаунчера |
## JavaRuntimeEntry
### JavaRuntimeKind
Строка каталога [JavaRuntimeService](JavaRuntimeService.md), то есть то, что можно скачать.
| Значение | Ключ | Описание |
|----------|------|----------|
| `Mojang` | `java` | Тот же рантайм, которым игру запускает официальный лаунчер |
| `Jdk` | `jdk` | Eclipse Temurin JDK: компилятор и инструменты в комплекте |
| `Jre` | `jre` | Eclipse Temurin JRE: только то, что нужно для запуска |
| Поле | Что это |
|------|---------|
| `id` | `temurin-jdk-21.0.12.1_1`, `mojang-java-runtime-delta` |
| `kind` | |
| `major` | 8, 17, 21… |
| `version` | `21.0.12.1+1`, `21.0.7` |
| `component` | только у Mojang: `java-runtime-delta` и т. п. |
| `released` | |
| `url` | архив Temurin или `manifest.json` компонента Mojang |
| `checksum` | sha256 архива Temurin или sha1 манифеста Mojang |
| `size` | размер загрузки, 0 если неизвестен |
| `archive` | `zip`, `tar.gz` или `mojang` |
| `architecture` | `x64`, `aarch64`; на macOS может быть не родной (см. JavaRuntimeService) |
| `lts` | |
Эти же ключи служат именами категорий в окне выбора Java.
## InstalledJavaRuntime
### JavaRuntimeEntry
Скачанная сборка: `id`, `kind` (ключ), `major`, `version`, `javaPath` (абсолютный путь к
`java`/`java.exe`), `size` (сколько заняла на диске).
| Поле | Тип | Описание |
|------|-----|----------|
| `id` | `QString` | Идентификатор сборки, например `temurin-jdk-21.0.12.1_1` или `mojang-java-runtime-delta` |
| `kind` | `JavaRuntimeKind` | Вид сборки; по умолчанию `Jdk` |
| `major` | `int` | Мажорная версия: 8, 17, 21… |
| `version` | `QString` | Полная версия, например `21.0.12.1+1` или `21.0.7` |
| `component` | `QString` | Имя компонента вида `java-runtime-delta`; только у сборок Mojang |
| `released` | `QDateTime` | Дата выпуска; по ней каталог сортируется новыми вперёд |
| `url` | `QUrl` | Архив Temurin либо `manifest.json` компонента Mojang |
| `checksum` | `QString` | sha256 архива Temurin или sha1 манифеста Mojang |
| `size` | `qint64` | Размер загрузки в байтах; `0` — неизвестен |
| `archive` | `QString` | Формат: `zip`, `tar.gz` или `mojang` — последний означает не архив, а манифест с пофайловой загрузкой |
| `architecture` | `QString` | `x64` или `aarch64`; на macOS бывает и не родная архитектура |
| `lts` | `bool` | Версия с длительной поддержкой; помечается в списке |
## JavaRuntimeStore
Метод `isValid()` возвращает `true`, когда заполнен `id`, `major` больше нуля и `url` корректен.
Каждая сборка лежит в `<root>/java/<id>/`, а рядом с ней `runtime.json`:
### InstalledJavaRuntime
| Поле | Тип | Описание |
|------|-----|----------|
| `id` | `QString` | Идентификатор сборки, он же имя подпапки |
| `kind` | `QString` | Вид сборки строкой: `java`, `jdk` или `jre` |
| `major` | `int` | Мажорная версия |
| `version` | `QString` | Полная версия |
| `javaPath` | `QString` | Абсолютный путь к исполняемому файлу `java` или `java.exe` |
| `size` | `qint64` | Сколько сборка заняла на диске по итогам установки |
## Функции
### Перевод видов сборок
#### QString javaKindKey(JavaRuntimeKind kind)
Строковый ключ вида сборки: `java`, `jdk` или `jre`.
#### std::optional&lt;JavaRuntimeKind&gt; javaKindFromKey(const QString &key)
Обратный перевод; `std::nullopt` для неизвестного ключа.
#### QString javaKindTitle(JavaRuntimeKind kind)
Человекочитаемое название вида сборки для интерфейса.
### JavaRuntimeStore — папка `<root>/java`
Устройство папки: одна подпапка на сборку плюс её описание внутри. Отдельного индекса нет
намеренно — удалённую вручную папку не пришлось бы вычищать ещё и из общего файла.
#### QString dirFor(const QString &id)
Папка конкретной сборки внутри `<root>/java`.
#### QList&lt;InstalledJavaRuntime&gt; installed()
Всё, что лежит в `<root>/java` и на что нашлась java. Новые версии идут первыми.
#### std::optional&lt;InstalledJavaRuntime&gt; find(const QString &id)
Описание одной установленной сборки; `std::nullopt`, если такой нет.
#### QString locateBinary(const QString &rootDir)
Ищет `bin/java` в распакованном дереве. Раскладка отличается между поставщиками: у Temurin на macOS
это `Contents/Home/bin`, у Mojang — `jre.bundle/Contents/Home/bin`.
#### bool writeMeta(const InstalledJavaRuntime &runtime, QString \*error)
Записывает описание сборки в её папку. Вызывается по завершении установки. При неудаче возвращает
`false` и заполняет `error`.
#### bool remove(const QString &id, QString \*error)
Удаляет папку сборки целиком. При неудаче возвращает `false` и заполняет `error`.
#### QString sanitizeId(const QString &id)
Превращает идентификатор каталога в безопасное имя папки: всё, кроме букв, цифр, точки, дефиса и
подчёркивания, заменяется. Идентификатор приходит из сети, поэтому подставлять его в путь как есть
нельзя.
### JavaRequirement — какая Java нужна какой версии игры
Точный ответ лежит в `client.json` версии (поле `javaVersion.majorVersion`) и берётся оттуда при
запуске. Эта таблица нужна раньше — когда версия ещё не скачана, а подсказку в каталоге показать
надо.
#### int minimumFor(const QString &minecraftVersionId)
Минимальная мажорная версия Java для версии игры; `0`, если разобрать идентификатор версии не
удалось.
#### QString coverage(int javaMajor)
Подпись к строке каталога вида «Minecraft 1.20.5 и новее» — какие версии игры покрывает эта версия
Java.
## Зависимости
Подключает `QDateTime`, `QList`, `QString`, `QUrl` и `<optional>` — только Qt Core. От классов
проекта не зависит.
## Пример использования
```cpp
const auto runtime = JavaRuntimeStore::find(settings.javaRuntime);
if (runtime && QFileInfo::exists(runtime->javaPath)) {
javaPath = runtime->javaPath;
} else {
// сборка удалена вручную — возвращаемся к поиску в системе
javaPath = JavaLocator::select(gameDir, version.javaMajor, settings.javaPath, &error);
}
```json
{ "id": "...", "kind": "jdk", "major": 21, "version": "21.0.7", "javaPath": "bin/java", "size": 197132288 }
```
`javaPath` хранится относительно папки сборки, так что папку лаунчера можно переносить.
- `sanitizeId(id)` — всё, кроме букв, цифр, `.`, `-`, `_`, заменяется на `_`. Ведущие точки
убираются.
- `dirFor(id)` — `<root>/java/<sanitizeId(id)>`.
- `find(id)` — читает `runtime.json`. Возвращает пусто, если файла нет, `id` в нём другой или
`java` по указанному пути не существует. Поэтому недокачанная сборка без `runtime.json` в
списке не появится.
- `installed()` — всё, что нашлось, от новых мажорных версий к старым.
- `locateBinary(rootDir)` — ищет `bin/java` внутри распакованного дерева и берёт самый короткий
путь. Нужно потому, что у архивов разная вложенность: `jdk-21/bin`, `jdk-21/Contents/Home/bin`
и т. п.
- `writeMeta(runtime, &error)` — пишет `runtime.json` через `QSaveFile`.
- `remove(id, &error)` — удаляет папку сборки. Перед этим проверяет, что путь лежит внутри
`<root>/java/`.
## JavaRequirement
`minimumFor(minecraftVersionId)` — минимальная Java для версии игры:
| Версия игры | Java |
|-------------|------|
| до 1.16 включительно, беты, альфы, всё нераспознанное | 8 |
| 1.17 | 16 |
| 1.18 — 1.20.4 | 17 |
| 1.20.5 и новее | 21 |
| версии с новой нумерацией (первое число не 1, например `26.1`) | 21 |
| снапшоты `21wNNx` | 16 |
| снапшоты `22w`, `23w` | 17 |
| снапшоты `24w` и новее | 21 |
| снапшоты до `21w` | 8 |
Пустая строка даёт 0. Таблица нужна там, где описания версии ещё нет на диске: в окне выбора Java
и при установке сезонной сборки. При запуске игры используется `javaVersion.majorVersion` из
описания версии.
Профили модлоадеров (`fabric-loader-...`) по этой таблице дают 8, поэтому бэкенд передаёт сюда
`minecraftVersion` сборки, а не `resolvedVersionId`.
`coverage(javaMajor)` — подпись для окна выбора: какие версии игры эта Java потянет.
---
При создании этого документа использовался ИИ.
+51 -131
View File
@@ -1,144 +1,64 @@
# launcherpaths.h — LauncherPaths
# launcherpaths.h
## Обзор
`launcherpaths.h`, `launcherpaths.cpp`
`Minecraft_launcher` хранит собственные данные отдельно от папки игры: настройки, профили, описания
сборок, архивы содержимого `.minecraft`, скачанные сборки Java и кэши каталогов. Пространство имён
`LauncherPaths` — единственное место, где эти пути вычисляются.
Все пути к данным лаунчера. Пространство имён `LauncherPaths` из свободных функций, состояния нет:
каждая функция просто собирает строку.
Собственная папка `BiLauncher` лежит рядом со стандартной `.minecraft`, а не в системном
каталоге данных приложения: так все файлы лаунчера остаются там же, где сама игра, и переносятся
вместе с ней.
Данные лаунчера лежат в папке `BiLauncher` рядом со стандартной папкой игры, а не в системном
каталоге данных приложения. `QStandardPaths` не используется, поэтому имя приложения и организации
из `main.cpp` на эти пути не влияют.
К этому заголовку обращается почти каждый сервис проекта — везде, где нужно прочитать или записать
файл в папке лаунчера.
## Где что лежит
## Пространства имён
`containerDir()` — папка, в которой живут и игра, и лаунчер:
`LauncherPaths` группирует функции, возвращающие абсолютные пути, и одну функцию создания корневой
папки. Состояния у пространства имён нет: все функции вычисляют путь заново при каждом вызове.
| ОС | `containerDir()` | папка игры по умолчанию | `rootDir()` |
|----|------------------|-------------------------|-------------|
| Windows | `%USERPROFILE%/AppData/Roaming` | `.minecraft` | `BiLauncher` |
| macOS | `~/Library/Application Support` | `minecraft` (без точки, как у официального лаунчера) | `BiLauncher` |
| Linux | `~` | `.minecraft` | `BiLauncher` |
## Функции
На Linux папка лаунчера получается `~/BiLauncher`, без точки, то есть видимая.
### Корневые каталоги
Внутри `rootDir()`:
#### QString containerDir()
Родительская папка, в которой лежит `.minecraft`, а рядом с ней — `BiLauncher`. От неё
отсчитываются и папка игры по умолчанию, и корень данных лаунчера.
#### QString rootDir()
Корень данных лаунчера — `<containerDir>/BiLauncher`.
#### QString defaultMinecraftDir()
Стандартная папка игры. Используется, когда пользователь не задал свою в настройках.
#### bool ensureRootExists(QString *error = nullptr)
Создаёт папку лаунчера, если её ещё нет. Вызывается при каждом запуске и перед каждой записью.
Возвращает `false` и заполняет `error`, если папку не удалось создать или в неё не пишется. Все
остальные функции пространства имён только считают строки и в этом смысле не могут завершиться
неудачей — проверять доступность каталога нужно этой функцией.
### Файлы состояния
#### QString settingsFile()
Файл настроек запуска: папка игры, путь к Java, память, аргументы JVM, размер окна.
#### QString profilesFile()
Файл профилей игрока.
#### QString customBuildsFile()
`<root>/customBuilds.json` — пользовательские сборки.
#### QString legacyCustomBuildsFile()
`<root>/versions.json` — как сборки назывались до переименования. Читается один раз при миграции и
больше ни для чего не нужен.
### Сборки и их архивы
#### QString buildStorageDir()
`<root>/builds` — архивы содержимого `.minecraft`, по одному на сборку.
#### QString buildDir(int buildId)
`<root>/builds/<id>` — папка одной сборки: её архив и, у сезонных, скачанный пак с описанием
установленной ревизии.
#### QString seasonalStateFile(int buildId)
`<root>/builds/<id>/season.json` — какая ревизия сезонной сборки установлена и какие файлы она
принесла. Список файлов нужен, чтобы при обновлении убрать те, что из сборки ушли.
### Java
#### QString javaDir()
`<root>/java` — сборки Java, скачанные лаунчером. Каждая в своей подпапке, имя подпапки —
идентификатор сборки из каталога.
#### QString javaCatalogFile()
Слепок каталога доступных сборок Java с отметкой времени.
#### QString javaDownloadDir()
Каталог, куда качаются архивы Temurin до распаковки.
### Кэши и загрузки
#### QString cacheDir()
`<root>/cache` — данные, которые можно удалить без потерь.
#### QString versionManifestFile()
Слепок манифеста версий Mojang с отметкой времени.
#### QString seasonalCatalogFile()
Слепок каталога сезонных сборок с отметкой времени.
#### QString loaderCacheFile(const QString &loaderKey)
Слепок списка версий одного модлоадера. Параметр `loaderKey` принимает значения `forge`, `fabric`,
`neoforge` и `quilt` — те же ключи, что возвращает `loaderKey()` из [modloader.h](modloader.md).
#### QString loaderDownloadDir()
Каталог, куда качаются `installer.jar` модлоадеров.
#### QString runtimeDir()
Каталог, куда качается `authlib-injector` — библиотека, подменяющая сервер авторизации при входе
через Ely.by.
## Зависимости
Единственный подключаемый заголовок — `QString`. Пространство имён не зависит ни от одного класса
проекта, поэтому его можно подключать откуда угодно без риска циклических зависимостей.
## Пример использования
```cpp
QString error;
if (!LauncherPaths::ensureRootExists(&error)) {
qWarning() << "папка лаунчера недоступна:" << error;
return;
}
QFile file(LauncherPaths::customBuildsFile());
if (file.open(QIODevice::WriteOnly))
file.write(document.toJson());
```
BiLauncher/
├── settings.json settingsFile()
├── profiles.json profilesFile()
├── customBuilds.json customBuildsFile()
├── versions.json legacyCustomBuildsFile() — старое имя, читается только при миграции
├── builds/ buildStorageDir()
│ ├── index.json журнал BuildSwitcher
│ └── <id>/ buildDir(id): архив сборки, скачанный пак
│ └── season.json seasonalStateFile(id)
├── java/ javaDir(): скачанные сборки Java, каждая в <id>/
├── runtime/ runtimeDir(): authlib-injector.jar
└── cache/ cacheDir(): всё, что можно удалить без потерь
├── version_manifest.json versionManifestFile()
├── java_runtimes.json javaCatalogFile()
├── seasonal_builds.json seasonalCatalogFile()
├── loader_<ключ>.json loaderCacheFile(key)
├── loaders/ loaderDownloadDir(): installer.jar и журналы установщиков
└── java/ javaDownloadDir(): архивы Temurin до распаковки
```
Папка игры задаётся в настройках (`gameDir`). `defaultMinecraftDir()` используется, только если
там пусто.
## ensureRootExists(&error)
Создаёт `rootDir`, `runtime`, `cache`, `cache/loaders`, `builds`, `java` и `cache/java`, а затем
проверяет, что каждая из них действительно папка и в неё можно писать. `mkpath` возвращает `true`
и для существующей папки без прав на запись, и тогда, когда на месте папки лежит файл. Без этой
проверки сохранения потом молча не работали бы.
Вызывается при старте бэкенда, перед каждой записью JSON-файлов бэкенда, перед записью кэшей
каталогов и перед установками. Остальные функции только собирают строки и ошибиться не могут.
`launcherpaths.cpp` подключает `localization.h` ради текста ошибки, заголовок зависит только от
`QString`.
---
+28 -89
View File
@@ -1,96 +1,35 @@
# main.cpp — точка входа
# main.cpp
## Обзор
Точка входа. Объектов предметной области здесь нет: `LauncherBackend` создаёт сам
[Main.qml](../qml/Main.md), а `main()` только готовит приложение и загружает QML.
`Minecraft_launcher` — десктопный лаунчер Minecraft на Qt 6 Quick. Интерфейс написан на QML,
вся работа — авторизация, скачивание версий, установка модлоадеров и Java, запуск игры — лежит в
C++-классе [LauncherBackend](LauncherBackend.md) и его сервисах.
## По порядку
`main.cpp` — стартовая последовательность приложения: здесь при необходимости инициализируется
Qt WebEngine, создаётся объект приложения, читается каталог переводов, выбирается стиль
Qt Quick Controls, загружается QML-модуль и запускается цикл событий. Файл намеренно короткий:
ни одного объекта предметной области он не создаёт — всё, что нужно, QML заводит сам.
1. **WebEngine.** Если лаунчер собран с Qt WebEngine (`LAUNCHER_HAS_WEBENGINE`),
`QtWebEngineQuick::initialize()` вызывается до создания `QGuiApplication`. После создания
приложения общий контекст OpenGL уже не выставить.
2. **`QGuiApplication`**, а не `QApplication`: виджетов в проекте нет.
3. **Имя и версия.** `applicationName` = `BiLauncher`, `applicationVersion` = `LAUNCHER_VERSION`
(берётся из `project(... VERSION)` в CMake), организация `GaleonDev`, домен `galeonworx.com`. На
пути к данным лаунчера это не влияет, см. [launcherpaths.h](launcherpaths.md).
4. **Иконка окна.** На Windows и Linux в `QIcon` складываются все PNG из ресурса `:/icons/`
(16–512 и `@2x`). На macOS этот шаг пропущен: там иконку берут из `icon.icns` внутри бандла.
5. **`setDesktopFileName("bilauncher")`.** По этому имени окружения рабочего стола в Linux
связывают окно с `bilauncher.desktop` и показывают правильную иконку в панели.
6. **Тексты.** `Localization::instance().load()` читает `i18n/translations.json` и язык из
`settings.json`. Это нужно сделать до создания движка: язык должен быть известен до первой
привязки. Если каталог не загрузился, `main()` возвращает `-1` (см.
[Localization](Localization.md)).
7. **Стиль `Basic`.** Окна оформлены вручную, а нативный стиль Windows игнорирует свои
`contentItem` и `background` и засыпает журнал предупреждениями.
8. **Движок.** `QQmlApplicationEngine`. Если корневой объект не создался, приложение
завершается с `-1`. Соединение отложенное (`Qt::QueuedConnection`), чтобы выход произошёл уже в
цикле событий, а не посреди загрузки.
9. `engine.loadFromModule("BiLauncher", "Main")` — загрузка по URI модуля и имени типа.
10. `app.exec()`.
## Настройка приложения Qt
Создаётся `QGuiApplication` — не `QApplication`: интерфейс целиком на Qt Quick, виджеты не
используются, и модуль Qt Widgets в проект не подключён.
До создания приложения, при сборке с Qt WebEngine, вызывается `QtWebEngineQuick::initialize()`.
Порядок здесь принципиален: инициализация выставляет общий контекст OpenGL, а после создания
объекта приложения это уже не действует. Вызов обёрнут в условную компиляцию по макросу
`LAUNCHER_HAS_WEBENGINE`.
## Каталог переводов
Сразу после создания приложения вызывается `Localization::instance().load()` — он читает
`i18n/translations.json` из ресурсов и определяет язык интерфейса. Порядок важен: язык нужно
знать до того, как QML вычислит первую привязку, а [LauncherBackend](LauncherBackend.md),
который ведёт `settings.json`, появляется только вместе с движком — поэтому ключ `language`
[Localization](Localization.md) читает из файла сам.
Неудача — не повод продолжать: каталог вкомпилирован в бинарник, значит его отсутствие или
поломка означают ошибку сборки. `main()` пишет причину и возвращает `-1`.
## Стиль Qt Quick Controls
Стиль Qt Quick Controls принудительно выставляется в `Basic`
вызовом `QQuickStyle::setStyle()`. Причина в оформлении: всё окно лаунчера стилизовано вручную, а
нативный стиль Windows игнорирует пользовательские `contentItem` и `background` и сыплет
предупреждениями.
## Обработка командной строки
Аргументы командной строки не разбираются: `argc` и `argv` передаются в конструктор
`QGuiApplication` и дальше не используются. Ни `QCommandLineParser`, ни собственного разбора в
файле нет.
## Создание объектов верхнего уровня
В `main()` создаётся ровно два объекта.
| Объект | Тип | Роль |
|--------|-----|------|
| `app` | `QGuiApplication` | объект приложения и цикл событий |
| `engine` | `QQmlApplicationEngine` | загружает и исполняет QML-модуль лаунчера |
Экземпляр `LauncherBackend` здесь не создаётся: тип зарегистрирован через `QML_ELEMENT`, и главное
окно объявляет его само декларативно. Поэтому в `main.cpp` нет ни одного `#include` классов
предметной области.
## Связывание и подключения
Единственное подключение — обработка неудачи создания корневого объекта: сигнал
`QQmlApplicationEngine::objectCreationFailed` замыкается на лямбду, которая завершает приложение
с кодом `-1`. Соединение создаётся с типом `Qt::QueuedConnection` и с объектом `app` в роли
контекста, чтобы выход из приложения происходил уже внутри цикла событий, а не в разгар загрузки
QML.
Контекстные свойства не задаются, начальные свойства корневому объекту не передаются: связь между
QML и C++ идёт исключительно через зарегистрированный тип.
## Цикл событий
QML загружается вызовом `engine.loadFromModule("Minecraft_launcher", "Main")` — по URI модуля и
имени типа, а не по пути к файлу. Модуль объявлен в `CMakeLists.txt` через `qt_add_qml_module`, а
`Main` — это [Main.qml](../qml/Main.md), корневой элемент которого `Window` с `visible: true`,
поэтому окно показывается само.
Цикл событий запускается `app.exec()`, его результат возвращается из `main()` как код завершения
процесса.
## Зависимости
| Заголовок | Что даёт |
|-----------|----------|
| `QGuiApplication` | объект приложения и цикл событий для приложения без виджетов |
| `QQmlApplicationEngine` | загрузка QML-модуля и создание корневого объекта |
| `QQuickStyle` | выбор стиля Qt Quick Controls до загрузки QML |
| `QtWebEngineQuick` | инициализация WebEngine; подключается только при сборке с Qt WebEngine |
Модули сборки: `Qt6::Quick`, `Qt6::QuickControls2`, `Qt6::Core`, `Qt6::CorePrivate`, `Qt6::Gui`,
`Qt6::Network` и опционально `Qt6::WebEngineQuick`. Макрос `LAUNCHER_HAS_WEBENGINE` определяется в
`CMakeLists.txt` только тогда, когда `find_package` нашёл `Qt6WebEngineQuick`.
Аргументы командной строки не разбираются. Контекстных свойств нет: QML общается с C++ только
через зарегистрированные типы `LauncherBackend` и `Loc`.
---
+74 -107
View File
@@ -1,135 +1,102 @@
# minecraftversion.h — MinecraftVersion и VersionLoader
# minecraftversion.h
## Обзор
`minecraftversion.h`, `minecraftversion.cpp`
Каждая версия Minecraft описывается файлом `versions/<id>/<id>.json` — в нём главный класс,
аргументы запуска, список библиотек, индекс ресурсов и требуемая версия Java. Файлы образуют
цепочку: профиль модлоадера наследуется от версии игры через поле `inheritsFrom`, а та, в свою
очередь, может наследоваться дальше.
Чтение описаний версий из `<gameDir>/versions/<id>/<id>.json` и всё, что с ними связано: правила
Mojang, нативные библиотеки под текущую платформу, список установленного, удаление.
Заголовок `minecraftversion.h` даёт две вещи: структуры, описывающие версию в разобранном виде, и
пространство имён `VersionLoader` — чтение, разворачивание цепочки наследования и работа с
установленными версиями на диске.
Профиль модлоадера обычно содержит только отличия от базовой версии и ссылается на неё через
`inheritsFrom`. `VersionLoader::load()` проходит эту цепочку и сводит её в одну структуру
`MinecraftVersion`, с которой уже работают [GameLauncher](GameLauncher.md) и
[VersionInstaller](VersionInstaller.md).
Заголовок подключают [GameLauncher](GameLauncher.md) (собирает из версии командную строку),
[VersionInstaller](VersionInstaller.md) (по списку библиотек понимает, что качать) и
[LauncherBackend](LauncherBackend.md) (проверяет комплектность и удаляет версии).
## MinecraftLibrary
## Типы
Одна библиотека, к которой уже применены правила.
| Имя | Вид | Описание |
|-----|-----|----------|
| `MinecraftLibrary` | `struct` | Одна библиотека из `client.json` с уже разрешёнными правилами |
| `MinecraftVersion` | `struct` | `client.json`, «схлопнутый» по всей цепочке `inheritsFrom` |
| Поле | Что это |
|------|---------|
| `name` | Maven-координаты, например `org.lwjgl:lwjgl:3.3.1:natives-windows` |
| `path` | путь относительно `libraries/` |
| `url` | откуда качать; может быть пустым |
| `sha1`, `size` | для проверки; могут отсутствовать |
| `native` | распаковывается в `natives`, в classpath не идёт |
| `extractExclude` | префиксы путей внутри jar, которые не распаковываются |
### MinecraftLibrary
## MinecraftVersion
| Поле | Тип | Описание |
|------|-----|----------|
| `name` | `QString` | Maven-координаты, например `org.lwjgl:lwjgl:3.3.1:natives-windows` |
| `path` | `QString` | Путь относительно `.minecraft/libraries` |
| `url` | `QString` | Откуда качать, если файла нет; может быть пустым — тогда файл должен уже лежать на месте |
| `sha1` | `QString` | Контрольная сумма для проверки скачанного |
| `size` | `qint64` | Размер в байтах; `0` — неизвестен |
| `native` | `bool` | Библиотека распаковывается в `natives`, а не кладётся в classpath |
| `extractExclude` | `QStringList` | Префиксы путей внутри архива, которые не распаковываются |
| Поле | Что это |
|------|---------|
| `id` | имя папки в `versions/` |
| `mainClass` | |
| `type` | `release`, `snapshot` и т. д.; по умолчанию `release` |
| `assetIndexId` | id индекса ресурсов |
| `assetsKind` | поле `assets`: `legacy`, `pre-1.6` или тот же id |
| `clientJarPath` | абсолютный путь к клиентскому jar; часто это jar родителя |
| `javaMajor` | из `javaVersion.majorVersion`; по умолчанию 8 |
| `jvmArgs`, `gameArgs` | с ещё не подставленными `${...}` |
| `libraries` | |
| `loggingArgument`, `loggingConfigPath` | аргумент log4j и путь к его xml в `assets/log_configs` |
| `supportsQuickPlay` | среди аргументов игры есть `quickPlayMultiplayer` (1.20+) |
| `hasCustomResolutionArgs` | описание само передаёт `resolution_width` |
### MinecraftVersion
`isValid()` возвращает `true`, если заданы `id` и `mainClass`.
| Поле | Тип | Описание |
|------|-----|----------|
| `id` | `QString` | Имя папки в `versions`, оно же значение аргумента `--version` |
| `mainClass` | `QString` | Главный класс, который запускает java |
| `type` | `QString` | `release`, `snapshot` или `modified`; идёт в `--versionType` |
| `assetIndexId` | `QString` | Идентификатор индекса ресурсов для `--assetIndex` |
| `assetsKind` | `QString` | Поле `assets` версии: `legacy`, `pre-1.6` либо тот же идентификатор |
| `clientJarPath` | `QString` | Абсолютный путь к `<id>.jar`; может лежать у родительской версии |
| `javaMajor` | `int` | Требуемая мажорная версия Java; по умолчанию `8` |
| `jvmArgs` | `QStringList` | Аргументы JVM ещё с неподставленными подстановками вида `${...}` |
| `gameArgs` | `QStringList` | Аргументы игры, тоже с неподставленными подстановками |
| `libraries` | `QList<MinecraftLibrary>` | Библиотеки версии с уже применёнными правилами |
| `loggingArgument` | `QString` | Аргумент вида `-Dlog4j.configurationFile=${path}` |
| `loggingConfigPath` | `QString` | Абсолютный путь к xml-конфигурации журнала; пуст, если конфигурации нет |
| `supportsQuickPlay` | `bool` | Версия 1.20 и новее понимает `--quickPlayMultiplayer` — так лаунчер подключается к серверу сразу при запуске |
| `hasCustomResolutionArgs` | `bool` | Версия принимает аргументы размера окна |
## Как сводится цепочка
Метод `isValid()` возвращает `true`, когда заполнены и `id`, и `mainClass`: именно этим проверяется
успешность чтения версии.
`load(gameDir, versionId, features, &error)` идёт от запрошенной версии к родителям по
`inheritsFrom`. Ограничения: не больше 10 звеньев, без циклов. Если файл отсутствует или не
разбирается, это ошибка, и возвращается невалидный объект.
## Функции
Дальше поля применяются от корня к потомку:
Пространство имён `VersionLoader`.
- `mainClass`, `type`, `assets`, `assetIndex`, `javaVersion`, `logging` — потомок перекрывает
родителя;
- `minecraftArguments` (старый формат, одна строка) **заменяет** `gameArgs` целиком;
- `arguments.jvm` и `arguments.game` (новый формат) **дописываются** к тому, что дал родитель.
#### QStringList installedVersions(const QString &gameDir)
Библиотеки собираются в обратном порядке, от потомка к корню. Повторы убираются по ключу
`группа:артефакт[:классификатор]`, без версии. Поэтому если Forge приносит свою версию библиотеки,
она вытесняет ванильную.
Версии, реально установленные в `<gameDir>/versions`: есть и папка, и файл `<id>.json`. Одной
только папки недостаточно — она остаётся после неудачной установки.
Если среди JVM-аргументов нет `-cp`, значит это старая версия без блока `arguments.jvm`. Тогда
добавляются аргументы, которые официальный лаунчер подставляет сам: `-Djava.library.path`, бренд и
версия лаунчера, `-cp ${classpath}`.
#### QStringList dependentsOf(const QString &gameDir, const QString &versionId)
Клиентский jar берётся первый найденный по цепочке, от потомка к корню. Если его нет нигде,
указывается путь у корня. `GameLauncher::missingFiles()` потом скажет, что его не хватает.
Установленные профили, у которых `inheritsFrom` равен `versionId`. Без базовой версии они не
запустятся, поэтому список показывается пользователю перед удалением версии.
`features` нужен для правил вида `"features": {"has_custom_resolution": true}`. Бэкенд передаёт
`has_custom_resolution`, если в настройках задан размер окна.
#### qint64 installedSize(const QString &gameDir, const QString &versionId)
## Правила и натив
Размер `<gameDir>/versions/<id>` в байтах; `0`, если папки нет. Используется в предупреждении об
удалении — версия весит десятки мегабайт, и стоит показать, сколько освободится.
`rulesAllow(rules, features)` реализует алгоритм Mojang: правила проверяются по порядку,
действует последнее совпавшее; пустой список означает «разрешено». Правило совпадает, если подошли
`features`, `os.name`, `os.arch` и регулярное выражение `os.version` (сверяется с
`QSysInfo::productVersion()`).
#### bool remove(const QString &gameDir, const QString &versionId, QString \*error)
Нативные библиотеки встречаются в двух форматах:
Сносит `<gameDir>/versions/<id>`. Отсутствие папки считается успехом. При неудаче возвращает
`false` и заполняет `error`.
- старый: блок `natives` с классификатором под ОС, `${arch}` заменяется на `32` или `64`;
- новый (LWJGL 3.3+): отдельные библиотеки с классификатором `natives-<ос>[-<арх>]`.
Библиотеки и ресурсы в `libraries/` и `assets/` не трогаются: они общие для всех версий.
`pickNativesForCurrentArch()` оставляет для каждого артефакта один классификатор. Лучше всего
точный (`nativeClassifier()`, например `natives-macos-arm64`), потом общий для ОС
(`natives-macos`), потом какой попадётся.
#### MinecraftVersion load(const QString &gameDir, const QString &versionId, const QSet&lt;QString&gt; &features, QString \*error)
`osName()` возвращает `windows`, `osx` или `linux`. `osArch()` возвращает архитектуру из
`QSysInfo`, причём `i386` и `i686` сводятся к `x86`.
Читает версию и разворачивает всю цепочку `inheritsFrom` в один объект. Параметр `features` —
набор включённых возможностей, влияющих на применение правил (например, запрошен ли пользовательский
размер окна).
## Установленные версии
При ошибке возвращает объект, у которого `isValid()` даёт `false`, и заполняет `error`.
#### bool rulesAllow(const QJsonArray &rules, const QSet&lt;QString&gt; &features = {})
Стандартный алгоритм Mojang для блоков `rules`: правила применяются по порядку, побеждает последнее
совпавшее. Используется и для библиотек, и для аргументов запуска.
#### QString nativeClassifier()
Классификатор нативных библиотек для текущей машины: `natives-windows`, `natives-macos-arm64`,
`natives-linux` и подобные. По нему из списка библиотек отбираются те, что нужно распаковать.
#### QString osName()
Имя операционной системы в терминах Mojang — оно подставляется в правила и аргументы.
#### QString osArch()
Архитектура в терминах Mojang.
## Зависимости
Подключает `QJsonArray`, `QList`, `QSet`, `QString` и `QStringList` — только Qt Core. От классов
проекта не зависит.
## Пример использования
```cpp
QString error;
const MinecraftVersion version = VersionLoader::load(gameDir, versionId, features, &error);
if (!version.isValid()) {
emit launchError(error);
return;
}
QStringList classpath;
for (const MinecraftLibrary &library : version.libraries) {
if (!library.native)
classpath << gameDir + "/libraries/" + library.path;
}
classpath << version.clientJarPath;
```
- `installedVersions(gameDir)` — подпапки `versions/`, в которых есть `<id>.json`, по алфавиту.
- `dependentsOf(gameDir, id)` — установленные профили с `inheritsFrom == id`. Без базовой версии они
не запустятся, поэтому их перечисляют в предупреждении об удалении.
- `installedSize(gameDir, id)` — размер папки в байтах.
- `remove(gameDir, id, &error)` — удаляет `versions/<id>`. Сначала проверяет, что id не пустой,
не начинается с точки и не содержит слэшей, а итоговый путь лежит внутри `versions/`. Если папки
нет, это не ошибка.
---
+29 -72
View File
@@ -1,86 +1,43 @@
# modloader.h — ModLoader
# modloader.h
## Обзор
Перечисление модлоадеров, запись одной версии модлоадера и перевод между перечислением и
строковым ключом. Только заголовок, `.cpp` нет.
`Minecraft_launcher` умеет ставить четыре модлоадера: Minecraft Forge, Fabric Loader, NeoForge и
Quilt Loader. Заголовок `modloader.h` — общий словарь для всей этой части проекта: перечисление
самих лоадеров, описание одной их сборки и функции перевода между перечислением и строковым
ключом.
## ModLoader
Лоадеры взаимоисключающи: игра запускается ровно с одним профилем в `<gameDir>/versions`, поэтому в
сборке лаунчера хранится один ключ лоадера, а не набор.
`Forge`, `Fabric`, `NeoForge`, `Quilt`.
Заголовок подключают [ModLoaderVersionService](ModLoaderVersionService.md),
[ModLoaderInstaller](ModLoaderInstaller.md) и [LauncherBackend](LauncherBackend.md).
Модлоадеры взаимоисключающие: игра запускается с одним профилем из `versions/`, поэтому у сборки
может быть не больше одного модлоадера. [LoaderRow](../qml/LoaderRow.md) снимает отметку с
остальных, когда пользователь отмечает один.
## Типы
## Ключи и названия
| Имя | Вид | Описание |
|-----|-----|----------|
| `ModLoader` | `enum class` | Модлоадеры, которые лаунчер умеет ставить |
| `LoaderVersionEntry` | `struct` | Одна сборка модлоадера под конкретную версию игры |
| Значение | `loaderKey()` | `loaderTitle()` |
|----------|---------------|-----------------|
| `Forge` | `forge` | Minecraft Forge |
| `Fabric` | `fabric` | Fabric Loader |
| `NeoForge` | `neoforge` | NeoForge |
| `Quilt` | `quilt` | Quilt Loader |
### ModLoader
Ключ используется в QML, в имени файла кэша (`cache/loader_<ключ>.json`) и в поле `loader` записи
сборки в `customBuilds.json`. Если его поменять, старые сборки и кэши перестанут читаться.
| Значение | Ключ | Название | Описание |
|----------|------|----------|----------|
| `Forge` | `forge` | Minecraft Forge | Старейший загрузчик; ставится собственным установщиком, который собирает часть файлов на месте |
| `Fabric` | `fabric` | Fabric Loader | Лёгкий загрузчик; профиль версии формируется из метаданных без запуска установщика |
| `NeoForge` | `neoforge` | NeoForge | Ответвление Forge; ставится так же собственным установщиком |
| `Quilt` | `quilt` | Quilt Loader | Ответвление Fabric; ставится так же, как Fabric |
`loaderFromKey(key)` возвращает `std::nullopt` для любой другой строки, в том числе пустой. В
бэкенде это значит «ванильная сборка».
Перечисление объявлено как `enum class`, поэтому неявного приведения к целому нет.
Названия не переводятся: это имена собственные.
### LoaderVersionEntry
## LoaderVersionEntry
| Поле | Тип | Описание |
|------|-----|----------|
| `loaderVersion` | `QString` | Версия самого лоадера — например `47.4.0`, `0.19.3` или `21.1.248` |
| `gameVersion` | `QString` | Версия Minecraft, под которую эта сборка — например `1.20.1` |
| `versionId` | `QString` | Предсказанный идентификатор профиля `versions/<id>`. У Forge пуст: он выясняется только после работы установщика |
| `installerUrl` | `QUrl` | Адрес установщика; заполнен только у Forge и NeoForge |
| `recommended` | `bool` | Сборка помечена авторами как рекомендуемая; по умолчанию `false` |
| `stable` | `bool` | Сборка стабильна, а не тестовая; по умолчанию `true` |
## Функции
#### QString loaderKey(ModLoader loader)
Строковый ключ лоадера: `forge`, `fabric`, `neoforge` или `quilt`. Один и тот же ключ используется
в трёх местах — в интерфейсе, в имени файла кэша на диске и в поле `loader` файла
`customBuilds.json`, — поэтому менять его нельзя без миграции сохранённых сборок.
Объявлена `inline` в заголовке.
#### QString loaderTitle(ModLoader loader)
Человекочитаемое название лоадера для интерфейса: «Minecraft Forge», «Fabric Loader», «NeoForge»,
«Quilt Loader». Объявлена `inline`.
#### std::optional&lt;ModLoader&gt; loaderFromKey(const QString &key)
Обратный перевод: ключ в перечисление. Возвращает `std::nullopt` для неизвестного ключа, в том
числе для пустой строки — а пустая строка в сборке означает чистую ваниль без лоадера. Объявлена
`inline`.
## Зависимости
Подключает `QString`, `QUrl` и `<optional>`. От классов проекта не зависит и сам подключается
всюду, где речь идёт о модлоадерах.
## Пример использования
```cpp
const auto loader = loaderFromKey(build.loader);
if (!loader) {
// сборка без модлоадера — запускаем чистую ваниль
return;
}
qInfo() << "ставим" << loaderTitle(*loader)
<< "версии" << entry.loaderVersion
<< "под Minecraft" << entry.gameVersion;
```
| Поле | Что это |
|------|---------|
| `loaderVersion` | `47.4.0`, `0.19.3`, `21.1.248` |
| `gameVersion` | `1.20.1` |
| `versionId` | ожидаемое имя профиля в `versions/`. У Fabric и Quilt оно известно заранее (`fabric-loader-<версия>-<игра>`), у Forge и NeoForge пусто: имя становится известно только после работы установщика |
| `installerUrl` | ссылка на `installer.jar`, только у Forge и NeoForge |
| `recommended` | у Forge помечено как recommended в `promotions_slim.json` |
| `stable` | не альфа, не бета, не rc/pre |
---
+40 -53
View File
@@ -1,75 +1,62 @@
# zlibreference.h — ZlibReference
# zlibreference.h
## Обзор
`zlibreference.h`, `zlibreference.cpp`
`Minecraft_launcher` умеет ставить модлоадеры Forge и NeoForge, а их установщики — это Java-программы,
которые собирают часть jar-файлов прямо на машине пользователя и сверяют sha1 каждого собранного
файла с эталоном из `install_profile.json`.
Обход одной конкретной проблемы на Linux: Forge и NeoForge не ставятся на системах, где вместо
zlib стоит zlib-ng.
Эталон посчитан на обычном zlib. Дистрибутивы вроде CachyOS и Fedora подставляют вместо него
zlib-ng: сжатие корректное, но побайтово другое — поэтому установка падает на любой версии игры
сообщением «Processor failed, invalid outputs». Сменить Java не выйдет: сборки OpenJDK под Linux
берут `libz.so.1` из системы.
## В чём проблема
`ZlibReference` — обход этой проблемы. Рядом с лаунчером лежит собранный обычный zlib, и процессу
установщика он подсовывается через `LD_PRELOAD`. Системный zlib-ng при этом остаётся на месте:
подмена живёт ровно один процесс.
Установщики Forge и NeoForge собирают часть jar прямо на машине пользователя и сверяют sha1
каждого собранного файла с эталоном из `install_profile.json`. Эталон посчитан на обычном zlib.
CachyOS, Fedora и некоторые другие дистрибутивы ставят вместо него zlib-ng в режиме совместимости.
Он сжимает корректно, но результат побайтово другой. Поэтому установка падает на любой версии игры
с сообщением `Processor failed, invalid outputs`.
Пространство имён используется установщиком модлоадеров при подготовке окружения дочернего
процесса Java.
Другая Java не помогает: сборки OpenJDK под Linux берут `libz.so.1` из системы.
## Пространства имён
## Что сделано
`ZlibReference` группирует три функции: проверку системной библиотеки, поиск собранного эталона и
подготовку окружения процесса. Состояния нет.
В репозитории лежат исходники zlib 1.3.1 (каталог `zlib/`). На Linux CMake собирает из них цель
`launcher_zlib_reference` (`libz.so.1`). В дереве сборки она лежит в `<папка бинарника>/zlib/`, при
установке — в `<prefix>/lib/BiLauncher/`. Сам лаунчер с ней не линкуется. Библиотека
подкладывается через `LD_PRELOAD` только процессу установщика, системный zlib-ng не трогается.
Исходники лежат в репозитории, а не скачиваются при сборке, по двум причинам: сборка не зависит от
сети, и версия зафиксирована. От версии зависит побайтовый результат сжатия.
## Функции
#### bool systemIsZlibNg()
На Windows и macOS все три функции ничего не делают: `systemIsZlibNg()` возвращает `false`,
`bundledPath()` пустую строку, `applyTo()` — `true`.
Отвечает на вопрос, окажется ли `libz.so.1`, который достанется процессу java, библиотекой zlib-ng.
От ответа зависит, нужна ли подмена вообще: на системе с обычным zlib она бессмысленна.
### systemIsZlibNg()
#### QString bundledPath()
Открывает `libz.so.1` через `dlopen`, то есть ту же библиотеку, которую загрузчик выдаст процессу
java, и смотрит, есть ли `zlib-ng` в строке `zlibVersion()`. Результат вычисляется один раз за
запуск.
Путь к собранному рядом эталонному `libz.so.1` или пустая строка, если его нет. Библиотека
собирается целью `launcher_zlib_reference` в `CMakeLists.txt` из исходников каталога `zlib/`,
которые лежат в репозитории, чтобы сборка не зависела от сети, а версия была зафиксирована — от
неё зависит побайтовый результат сжатия. Собирается только на Linux: на Windows и macOS проблемы
подмены системного zlib нет.
### bundledPath()
#### bool applyTo(QProcessEnvironment &env, QString *note = nullptr)
Ищет эталонную библиотеку по порядку:
Дописывает `LD_PRELOAD` в переданное окружение, если подмена нужна. Изменяет `env` на месте.
1. `<applicationDirPath>/zlib/libz.so.1` — запуск из дерева сборки;
2. `<applicationDirPath>/../lib/BiLauncher/libz.so.1` — установленный пакет, tar.gz и AppImage;
3. `LAUNCHER_ZLIB_INSTALL_DIR/libz.so.1` — путь, заданный при конфигурации CMake.
Возвращает `true`, когда окружение готово, — в том числе в случае, когда подменять нечего:
система с обычным zlib или платформа, где вопрос не стоит. `false` означает, что подмена нужна, но
эталонной библиотеки нет на месте.
Второй вариант рассчитан на `CMAKE_INSTALL_LIBDIR=lib`. Поэтому CI на Ubuntu передаёт его явно,
иначе библиотека легла бы в `lib/x86_64-linux-gnu`.
Необязательный параметр `note` заполняется строкой, пригодной и для журнала, и для текста ошибки:
она объясняет, была ли подмена применена и почему.
### applyTo(env, &note)
## Зависимости
- zlib-ng в системе нет — подменять нечего, возвращает `true`, `note` пуст.
- zlib-ng есть, эталон найден — дописывает его путь в начало `LD_PRELOAD` (чужое содержимое
переменной сохраняется), возвращает `true`, в `note` пишет, что подставлено.
- zlib-ng есть, эталона нет — возвращает `false`, в `note` пишет предупреждение.
Подключает `QString`; `QProcessEnvironment` объявлен вперёд и используется только по ссылке.
Реализация опирается на макрос `LAUNCHER_ZLIB_INSTALL_DIR`, который `CMakeLists.txt` определяет на
Linux — это путь установки библиотеки в системе, помимо каталога рядом с исполняемым файлом.
## Пример использования
```cpp
QProcessEnvironment env = QProcessEnvironment::systemEnvironment();
QString note;
if (!ZlibReference::applyTo(env, &note)) {
emit failed(note);
return;
}
QProcess installer;
installer.setProcessEnvironment(env);
installer.start(javaPath, arguments);
```
[ModLoaderInstaller](ModLoaderInstaller.md) пишет `note` в журнал установщика. Если `applyTo()`
вернула `false`, а установщик упал с ошибкой контрольных сумм, к ошибке добавляется прямая
подсказка про zlib-ng.
---