Files
BiLauncher/doc/cpp/LauncherBackend.md
T
2026-10-11 22:32:13 +03:00

364 lines
29 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# LauncherBackend
`launcherbackend.h`, `launcherbackend.cpp`
Через этот класс QML работает со всем остальным кодом. Интерфейс читает его свойства, вызывает
методы и слушает сигналы, а к сервисам напрямую не обращается.
Сам бэкенд почти ничего не скачивает и не запускает. Он хранит профили, сборки и настройки,
создаёт двенадцать сервисов и следит, чтобы они не мешали друг другу: нельзя ставить Java во время
смены сборки, удалять версию, пока она качается, или запускать игру посреди установки модлоадера.
Ещё он сводит каталоги из сети с тем, что лежит на диске, чтобы окнам не пришлось этого делать.
Экземпляр один, его объявляет [Main.qml](../qml/Main.md). Во вложенные окна он передаётся через
`required property var backend`. Тип зарегистрирован через `QML_ELEMENT` в модуле `BiLauncher`;
синглтоном он не сделан.
## Сервисы
Все создаются в конструкторе с бэкендом в роли родителя.
| Поле | Класс | Зачем |
|------|-------|-------|
| `m_auth` | [AuthService](AuthService.md) | Ely.by, офлайн, загрузка authlib-injector |
| `m_msa` | [MsaAuthService](MsaAuthService.md) | вход через Microsoft |
| `m_launcher` | [GameLauncher](GameLauncher.md) | запуск JVM |
| `m_manifest` | [VersionManifestService](VersionManifestService.md) | каталог версий Mojang |
| `m_installer` | [VersionInstaller](VersionInstaller.md) | установка версии игры |
| `m_loaderMeta` | [ModLoaderVersionService](ModLoaderVersionService.md) | версии модлоадеров |
| `m_loaderInstaller` | [ModLoaderInstaller](ModLoaderInstaller.md) | установка модлоадера |
| `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) | загрузка архива сезонной сборки |
`VersionInstaller` получает указатель на сервис манифеста, `ModLoaderInstaller` — на сервис версий
модлоадеров и на установщик версий. Владельцем этих объектов остаётся бэкенд.
## Что происходит при старте
Конструктор делает следующее, порядок важен:
1. `LauncherPaths::ensureRootExists()` создаёт папку лаунчера и проверяет, что в неё можно писать.
2. `loadSettings()` заполняет настройки значениями по умолчанию и накладывает сверху
`settings.json`.
3. `migrateLegacyBuildsFile()` переносит старый `versions.json` в `customBuilds.json`. Это должно
случиться до следующего шага, иначе поверх старых данных появится пустой файл.
4. `loadData()` читает профили и сборки и сортирует их по `id`.
5. `ensureDataFiles()` создаёт недостающие файлы.
6. Если `BuildSwitcher` нашёл журнал оборванного переключения, бэкенд сразу продолжает его через
`resumeInterrupted()`. Пользователю ничего не предлагается. Если начать переключение заново,
оно сначала заархивирует полуразобранную `.minecraft` поверх целого архива прежней сборки.
7. Подписка на сигналы сервисов.
Все проблемы, найденные по пути (битый JSON, папка без права записи, пропущенные записи сборок),
копятся в `m_storageIssues` и уходят одним `launchError` через `QTimer::singleShot(0)`: к этому
моменту QML уже подписан на сигнал.
## Файлы на диске
Все пути берутся из [launcherpaths.h](launcherpaths.md).
- `settings.json` — объект с настройками;
- `profiles.json` — массив профилей;
- `customBuilds.json` — массив сборок.
Если файла нет, это первый запуск. Если он не читается как JSON или в нём не тот тип (объект вместо
массива и наоборот), файл переименовывается в `<имя>.bak`, а на его месте создаётся пустой.
Данные из `.bak` можно потом достать руками.
Перед каждой записью снова вызывается `ensureRootExists()`: папку могли удалить, пока лаунчер
работал, и без этой проверки сохранения пропадали бы без ошибки. Пишется всё через обычный `QFile`,
кроме `builds/<id>/season.json`, для которого используется `QSaveFile`.
Пароли Ely.by и токены лежат в `profiles.json` открытым текстом.
### Настройки
| Ключ | По умолчанию | Что это |
|------|--------------|---------|
| `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 BiLauncher
Window {
visible: true
LauncherBackend {
id: backend
onLaunchError: (message) => console.warn(message)
onTwoFactorRequired: (profileName) => twoFactorDialog.open()
}
Button {
text: backend.activeBuildName
enabled: !backend.busy && !backend.gameRunning
onClicked: backend.launchGame(profileBox.currentIndex, backend.activeBuildIndex)
}
}
```
---
При создании этого документа использовался ИИ.