Files
BiLauncher/doc/cpp/LauncherBackend.md
T
galeon 1d781b5324
tests / package-smoke-linux (push) Skipped
tests / package-smoke-windows (push) Skipped
tests / package-smoke-macos (push) Skipped
tests / macos (push) Successful in 1m20s
tests / linux (push) Successful in 2m14s
tests / windows (push) Failing after 23m21s
fix #23 by adding validating on first startup and close #30 by adding
version number

Signed-off-by: galeon <dertiellis@gmail.com>
2026-10-23 09:55:15 +03:00

30 KiB
Raw Permalink Blame History

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 — на сервис версий модлоадеров и на установщик версий. Владельцем этих объектов остаётся бэкенд.

Что происходит при старте

Конструктор делает следующее, порядок важен:

  1. LauncherPaths::ensureRootExists() создаёт папку лаунчера и проверяет, что в неё можно писать.
  2. loadSettings() заполняет настройки значениями по умолчанию и накладывает сверху settings.json.
  3. migrateLegacyBuildsFile() переносит старый versions.json в customBuilds.json. Это должно случиться до следующего шага, иначе поверх старых данных появится пустой файл.
  4. loadData() читает профили и сборки и сортирует их по id.
  5. Если это первый запуск, importGameDir() заводит сборку под то, что уже лежит в папке игры (см. ниже).
  6. ensureDataFiles() создаёт недостающие файлы.
  7. Если BuildSwitcher нашёл журнал оборванного переключения, бэкенд сразу продолжает его через resumeInterrupted(). Пользователю ничего не предлагается. Если начать переключение заново, оно сначала заархивирует полуразобранную .minecraft поверх целого архива прежней сборки.
  8. Подписка на сигналы сервисов.

Все проблемы, найденные по пути (битый 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 из окна профиля

  1. startMicrosoftLogin(profileIndex) испускает microsoftLoginUrlReady(url). -1 значит, что профиля ещё нет и его нужно создать по нику из аккаунта. Без WebEngine сразу приходит microsoftLoginFailed.
  2. MicrosoftLoginDialog открывает страницу и на каждую смену адреса вызывает 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, и этот поток скрыт внутри него.

Пример

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)
    }
}

При создании этого документа использовался ИИ.