version number Signed-off-by: galeon <dertiellis@gmail.com>
30 KiB
LauncherBackend
launcherbackend.h, launcherbackend.cpp
Через этот класс QML работает со всем остальным кодом. Интерфейс читает его свойства, вызывает методы и слушает сигналы, а к сервисам напрямую не обращается.
Сам бэкенд почти ничего не скачивает и не запускает. Он хранит профили, сборки и настройки, создаёт двенадцать сервисов и следит, чтобы они не мешали друг другу: нельзя ставить Java во время смены сборки, удалять версию, пока она качается, или запускать игру посреди установки модлоадера. Ещё он сводит каталоги из сети с тем, что лежит на диске, чтобы окнам не пришлось этого делать.
Экземпляр один, его объявляет Main.qml. Во вложенные окна он передаётся через
required property var backend. Тип зарегистрирован через QML_ELEMENT в модуле BiLauncher;
синглтоном он не сделан.
Сервисы
Все создаются в конструкторе с бэкендом в роли родителя.
| Поле | Класс | Зачем |
|---|---|---|
m_auth |
AuthService | Ely.by, офлайн, загрузка authlib-injector |
m_msa |
MsaAuthService | вход через Microsoft |
m_launcher |
GameLauncher | запуск JVM |
m_manifest |
VersionManifestService | каталог версий Mojang |
m_installer |
VersionInstaller | установка версии игры |
m_loaderMeta |
ModLoaderVersionService | версии модлоадеров |
m_loaderInstaller |
ModLoaderInstaller | установка модлоадера |
m_switcher |
BuildSwitcher | смена активной сборки, раскатка пака |
m_javaMeta |
JavaRuntimeService | каталог Java |
m_javaInstaller |
JavaInstaller | установка Java |
m_seasonalMeta |
SeasonalBuildService | каталог сезонных сборок |
m_packDownloader |
SeasonalPackDownloader | загрузка архива сезонной сборки |
VersionInstaller получает указатель на сервис манифеста, ModLoaderInstaller — на сервис версий
модлоадеров и на установщик версий. Владельцем этих объектов остаётся бэкенд.
Что происходит при старте
Конструктор делает следующее, порядок важен:
LauncherPaths::ensureRootExists()создаёт папку лаунчера и проверяет, что в неё можно писать.loadSettings()заполняет настройки значениями по умолчанию и накладывает сверхуsettings.json.migrateLegacyBuildsFile()переносит старыйversions.jsonвcustomBuilds.json. Это должно случиться до следующего шага, иначе поверх старых данных появится пустой файл.loadData()читает профили и сборки и сортирует их поid.- Если это первый запуск,
importGameDir()заводит сборку под то, что уже лежит в папке игры (см. ниже). ensureDataFiles()создаёт недостающие файлы.- Если
BuildSwitcherнашёл журнал оборванного переключения, бэкенд сразу продолжает его черезresumeInterrupted(). Пользователю ничего не предлагается. Если начать переключение заново, оно сначала заархивирует полуразобранную.minecraftповерх целого архива прежней сборки. - Подписка на сигналы сервисов.
Все проблемы, найденные по пути (битый JSON, папка без права записи, пропущенные записи сборок),
копятся в m_storageIssues и уходят одним launchError через QTimer::singleShot(0): к этому
моменту QML уже подписан на сигнал.
Сборка из папки игры
Первым запуском считается старт, когда нет ни settings.json, ни customBuilds.json, ни
старого versions.json. Проверка делается до loadData(): битый файл она отводит в .bak, а
ensureDataFiles() потом создаёт пустой. Существующий пустой customBuilds.json — это решение
пользователя «сборок нет», и импорта тогда нет.
Если GameDirImport::detect() что-то нашёл, запись сборки добавляется сразу,
минуя addCustomBuild() (тот начал бы установку), становится активной и сохраняется вместе с
activeBuildId. BuildSwitcher не вызывается: сборка описывает то, что уже разложено в папке
игры, а активная сборка без архива — штатное состояние. Сама папка игры только читается.
Имя: <loaderTitle> <версия> или Minecraft <версия>, а без версии — build.title.fromGameDir.
Через QTimer::singleShot(0) приходит gameDirImported(buildName), раньше сообщения об ошибках
хранилища, чтобы оно их не перекрыло. Main.qml показывает по нему зелёную плашку.
Файлы на диске
Все пути берутся из launcherpaths.h.
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 |
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.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 из окна профиля
startMicrosoftLogin(profileIndex)испускаетmicrosoftLoginUrlReady(url).-1значит, что профиля ещё нет и его нужно создать по нику из аккаунта. Без WebEngine сразу приходитmicrosoftLoginFailed.- MicrosoftLoginDialog открывает страницу и на каждую смену
адреса вызывает
inspectMicrosoftRedirect(url). Ответ:{matched, code, error}. Разбор сделан в C++, чтобы правила совпадали с тем, какMsaAuthServiceстроитredirect_uri. - Код уходит в
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).
- Запись. Если сборки с таким
seasonalIdнет, она создаётся. Если есть, в неё записываются новые версия, модлоадер и адрес сервера. Ревизию пока не трогаем: её запишем, только когда файлы лягут на диск, иначе оборванная установка выглядела бы законченной. - Switching. Сборка становится активной: пак раскатывается поверх текущей
.minecraft. - Java. Нужная версия берётся из
javaMajorкаталога или из таблицы требований. Если подходящая java уже есть (своя, системная или скачанная раньше), шаг пропускается. Иначе загружается каталог Java и ставится лучшая сборка для этой версии. Java ставится раньше модлоадера, потому что она нужна установщику Forge. - Loader. Для ванильной сборки ставится версия игры, для остальных — модлоадер. Если всё уже установлено и укомплектовано, шаг пропускается.
- Downloading. Архив качается в
builds/<id>/.pack-r<ревизия>.zip. Точка в начале имени нужна, чтобыBuildSwitcherне принял файл за архив сборки и не стёр его. - 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, и этот поток скрыт внутри него.
Пример
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)
}
}
При создании этого документа использовался ИИ.