diff --git a/doc/build-and-release.md b/doc/build-and-release.md new file mode 100644 index 0000000..2b2762d --- /dev/null +++ b/doc/build-and-release.md @@ -0,0 +1,171 @@ +# Сборка, упаковка и релизы + +## Что нужно + +- CMake 3.20+, компилятор C++17 и C (C нужен для zlib на Linux). +- Qt 6.8 или новее (`qt_standard_project_setup(REQUIRES 6.8)`). CI собирает на Qt 6.11.1. +- Обязательные модули: `Quick`, `QuickControls2`, `Qml`, `Svg`, `Core`, `CorePrivate`, `Gui`, + `Network`. +- Необязательный: `WebEngineQuick`, для входа через Microsoft. + +`CorePrivate` нужен ради `QZipReader` и `QZipWriter`: ими распаковываются нативные библиотеки +LWJGL, архивы Temurin и архивы сборок. Приватные модули Qt не гарантируют совместимость между +версиями, поэтому проект привязан к конкретной версии Qt. Предупреждение об этом отключено +(`QT_NO_PRIVATE_MODULE_WARNING`). + +### Qt WebEngine + +Модуль ставится в MaintenanceTool отдельной галочкой и тянет за собой Qt WebChannel и Qt +Positioning. Чтобы он работал, нужны все три. + +Если сделать WebEngine обязательным, у любого, кто его не поставил, проект вообще не соберётся, +хотя офлайн-вход и Ely.by без него работают. Поэтому: + +- модуль найден — определяется `LAUNCHER_HAS_WEBENGINE`, `MicrosoftLoginDialog.qml` попадает в + модуль, `backend.microsoftAvailable` равно `true`; +- не найден — CMake выдаёт предупреждение со списком компонентов, которые надо доставить, а + лаунчер собирается без входа через Microsoft. + +Релизные сборки в CI всегда с WebEngine: скрипты подготовки раннеров проверяют, что он есть. + +## Локальная сборка + +```bash +cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release -DCMAKE_PREFIX_PATH=$HOME/Qt/6.11.1/macos +``` + +```bash +cmake --build build +``` + +Из Qt Creator проект открывается как обычный CMake-проект. + +Версия приложения задаётся в одном месте, в `project(BiLauncher VERSION x.y.z)`. Оттуда она идёт в +`LAUNCHER_VERSION`, в ресурс версии `.exe`, в `Info.plist` и в имена файлов релиза. + +## Что собирается + +**`BiLauncher`** — исполняемый файл и QML-модуль с URI `BiLauncher`. В модуль входят все `.cpp`, +`.qml`, картинки из `images/`, `i18n/translations.json`, а также `CHANGELOG.md`, `LICENSE` и +`README.md`. Последние три сейчас никто из кода не читает, они просто лежат в ресурсах. + +QML-модуль собирается в `/qml/BiLauncher` (`OUTPUT_DIRECTORY`). Если оставить по +умолчанию, на Linux папка `BiLauncher` совпадёт по имени с исполняемым файлом (у него нет +расширения), и линковка упадёт с «Is a directory». + +**`launcher_zlib_reference`** — только на Linux. Это обычный zlib 1.3.1 из каталога `zlib/`, +выходной файл `libz.so.1` в `/zlib/`. С ним лаунчер не линкуется. Библиотека нужна только +установщикам Forge и NeoForge на системах с zlib-ng, см. [zlibreference.h](cpp/zlibreference.md). +Исходники zlib — чужой код из upstream, документацией он не покрыт. + +## Иконки + +Все размеры собираются из одного файла `icons/master/icon_master.png` (1024×1024, рисунок до краёв +холста) скриптом `icons/generate_icons.sh`. Для него нужны ImageMagick и `iconutil`, то есть +запускается он на macOS. Правки вносятся в мастер, после чего скрипт перезапускается. Отдельные +размеры руками не правятся. + +Системы по-разному добавляют поля вокруг иконки, поэтому один и тот же рисунок кладётся +по-разному: + +- **macOS** сама поля не добавляет. По сетке Apple рисунок занимает 824 из 1024 точек, углы + скруглены радиусом 185.4. Результат — `icon.icns`, кладётся в `Contents/Resources` бандла. +- **Windows** ждёт картинку на весь холст. `icon.ico` содержит кадры 16–256, включая 20, 40 и 96 + для масштабов 125/150/200%. Без явного списка ImageMagick положил бы в `.ico` только 1024×1024, + и система размывала бы иконку при уменьшении. Вкомпилирована через `icon.rc.in`, где заодно + ресурс версии. +- **Linux**: PNG 16–512 ставятся в тему `hicolor` под именем `bilauncher.png`, плюс + `bilauncher.desktop`. Окружения рабочего стола ищут иконку по имени из `Icon=`, а не по имени + файла. + +Иконка окна задаётся в `main.cpp` из ресурса `:/icons/` (на всех системах, кроме macOS). + +## Установка (`cmake --install`) + +- исполняемый файл в `bin/` (на macOS — `BiLauncher.app`); +- `qt_generate_deploy_qml_app_script` раскладывает рядом Qt, плагины и QML-модули + (windeployqt/macdeployqt под капотом; на macOS с `-hardened-runtime`); +- `LICENSE`, `CHANGELOG.md`, `packaging/THIRD_PARTY_LICENSES.txt` в `share/doc/BiLauncher/` (кроме + macOS); +- на Linux ещё `libz.so.1` в `lib/BiLauncher/`, иконки и `.desktop`. + +На Linux ставьте с `-DCMAKE_INSTALL_LIBDIR=lib`. Лаунчер ищет эталонный zlib в +`/../lib/BiLauncher`, а на Ubuntu по умолчанию `LIBDIR` был бы `lib/x86_64-linux-gnu`. + +## Релизы + +Релизы собирает Gitea Actions, `.gitea/workflows/release.yml`. + +### Как выпустить версию + +1. Поднять версию в `project(BiLauncher VERSION ...)` в `CMakeLists.txt`. +2. Добавить в `CHANGELOG.md` раздел `## [x.y.z] — дата`. Его текст станет описанием релиза. +3. Закоммитить и запушить тег `vx.y.z`. Для предрелиза — `vx.y.z-rc1` (всё после дефиса делает + релиз pre-release). + +Если тег не совпадает с версией в `CMakeLists.txt` (без учёта суффикса), прогон сразу падает. + +### Что происходит + +1. **create-release** (Linux) — сверяет тег с версией, вырезает раздел из `CHANGELOG.md` и + создаёт **черновик** релиза. +2. Параллельно три сборки, каждая прикладывает свои файлы к черновику: + - **Linux**: `BiLauncher--x86_64.AppImage` и `BiLauncher--linux-x86_64.tar.gz`. Сборка + идёт с `-j 8`: на ВМ 6 ГБ памяти, и без ограничения компиляторы съедают её всю. + - **Windows**: `BiLauncher--win64-setup.exe` (Inno Setup) и + `BiLauncher--win64-portable.zip`. В portable нет `vc_redist`, среда выполнения MSVC должна + уже стоять в системе. Сборка идёт в `C:\r\...`, потому что дерево WebEngine легко выходит за + `MAX_PATH`. Zip делается системным `tar.exe` по полному пути: GNU tar из Git, который стоит в + `PATH` раньше, zip не умеет и принимает `C:` за имя удалённого хоста. + - **macOS**: `BiLauncher--macOS-universal.dmg` (arm64 + x86_64). Подпись ad-hoc: без неё + приложение вообще не запустится на Apple Silicon. Developer ID нет, поэтому Gatekeeper при + первом запуске всё равно спросит подтверждение. +3. **publish** собирает `SHA256SUMS.txt` из хешей всех трёх сборок, прикладывает его и снимает с + релиза флаг черновика. + +Если хоть одна сборка упала, релиз остаётся черновиком и пользователи его не видят. + +Ручной запуск (`workflow_dispatch`) делает те же сборки, но релиз не создаёт: файлы остаются +артефактами прогона. + +Секрет `RELEASE_TOKEN` — персональный токен Gitea с правом `repository: write`. + +### Установщик Windows + +`packaging/windows/BiLauncher.iss` (Inno Setup 6). Ставит в `Program Files\BiLauncher`, разрешает +выбрать установку только для текущего пользователя, создаёт ярлык в меню «Пуск» и, по желанию, на +рабочем столе, тихо ставит `vc_redist`, если он есть в дереве. Языки установщика — русский и +английский. + +`AppId` менять нельзя: по нему новая версия находит и обновляет старую установку. + +## Раннеры + +Раннеры работают в режиме host, то есть прямо на машине, без Docker. Метки: `linux`, `windows`, +`macos`. + +Пути к Qt раннер передаёт сборке через переменные окружения в своём `config.yaml`: + +- `QT_ROOT` — kit Qt (например, `~/Qt/6.11.1/gcc_64`); +- `QT_TOOLS` — папка `Tools` из Qt Online Installer, оттуда CMake и Ninja; +- `ISCC_DIR` — только Windows, папка Inno Setup. + +Qt с WebEngine, CMake и Ninja ставятся заранее через Qt Online Installer. Скрипты подготовки +докачивают остальное и регистрируют раннер. Повторный запуск безопасен. + +**Linux** (Ubuntu 22.04), `ci/setup-runner-linux.sh [токен]`. Запускать от пользователя, которому +принадлежит Qt, нужен sudo без пароля. Ставит компилятор, библиотеки для сборки и деплоя (xcb, +OpenGL, NSS и т. д.), `libfuse2` для appimagetool, Node.js (нужен `actions/checkout`), appimagetool, +gitea-runner и systemd-сервис `gitea-runner`. + +**Windows**, `ci/setup-runner-windows.ps1 [-Token ...]` из PowerShell от администратора. Ставит +VS 2022 Build Tools (это 20–40 минут), Git, Node.js, Inno Setup, включает длинные пути в системе и +в git, регистрирует gitea-runner. Раннер запускается задачей планировщика при входе пользователя, +поэтому пароль учётной записи не нужен, но без входа в систему раннер не поднимется. Чтобы он +стартовал при загрузке машины, задачу нужно пересоздать с `/sc onstart /rp *`. + +**macOS** — скрипта подготовки нет, раннер настроен вручную. + +--- + +При создании этого документа использовался ИИ. diff --git a/doc/cpp/AuthService.md b/doc/cpp/AuthService.md index 8038788..b09aed9 100644 --- a/doc/cpp/AuthService.md +++ b/doc/cpp/AuthService.md @@ -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`. Все сетевые методы -асинхронные: колбэк вызывается ровно один раз и всегда в потоке 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() +- если `/authlib-injector.jar` уже есть, сразу отдаёт путь. Версия не проверяется, и + обновить файл можно, только удалив его; +- иначе читает `https://authlib-injector.yushi.moe/artifact/latest.json`, скачивает jar по + `download_url` и, если в манифесте есть `checksums.sha256`, сверяет хеш. -Случайный `clientToken` лаунчера. Генерируется один раз на профиль и хранится вместе с ним: -Yggdrasil связывает выданный `accessToken` именно с этим значением. +Колбэк получает либо путь, либо текст ошибки. `targetDir` — это `/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<void(const QString &path, const QString &error)> 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); - }); -``` +Повторных попыток при сетевых ошибках нет. --- diff --git a/doc/cpp/BuildArchiveWorker.md b/doc/cpp/BuildArchiveWorker.md index e22eeb7..63d693d 100644 --- a/doc/cpp/BuildArchiveWorker.md +++ b/doc/cpp/BuildArchiveWorker.md @@ -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 ¤tPath) - -Ход операции: сколько файлов обработано из скольких и какой обрабатывается сейчас. Испускается по -ходу всех четырёх операций. - -Обработчик — `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` и не даёт отменить шаги после архивации. --- diff --git a/doc/cpp/BuildSwitcher.md b/doc/cpp/BuildSwitcher.md index ba79429..cfdbe05 100644 --- a/doc/cpp/BuildSwitcher.md +++ b/doc/cpp/BuildSwitcher.md @@ -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` в каждый момент разложена одна сборка, активная. Содержимое остальных лежит в +архивах `/builds//<дата>_<имя>.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//.tmp-.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/` целиком. +- `hasArchive(id)` — есть ли у сборки архив. +- `isRunning()`, `stage()`, `status()` (текущий файл), `fraction()` — для панели прогресса. + Сигнал `progressChanged()` не ограничен по частоте и приходит на каждый обработанный файл. -#### void switchTo(int fromBuildId, const QString &fromName, int toBuildId, const QString &toName, const QString &gameDir) +### index.json -Переключает активную сборку. Проходит четыре этапа: упаковка текущего содержимого во временный -файл, фиксация архива переименованием на место, очистка папки игры и распаковка архива целевой -сборки. +`/builds/index.json`: -Имена сборок нужны для текста в панели прогресса, идентификаторы — для путей к архивам. - -#### void applyPack(int buildId, const QString &buildName, const QString &packZipPath, const QStringList &removeRelative, const QJsonObject ¬e, 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 секунд. + --- При создании этого документа использовался ИИ. diff --git a/doc/cpp/GameLauncher.md b/doc/cpp/GameLauncher.md index 63eb9e9..d850ac6 100644 --- a/doc/cpp/GameLauncher.md +++ b/doc/cpp/GameLauncher.md @@ -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. Распаковка нативных библиотек в `/versions//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:=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:<токен>:`), `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, если его там ещё нет. --- diff --git a/doc/cpp/JavaInstaller.md b/doc/cpp/JavaInstaller.md index dbb798e..069752e 100644 --- a/doc/cpp/JavaInstaller.md +++ b/doc/cpp/JavaInstaller.md @@ -1,200 +1,60 @@ # JavaInstaller -## Обзор класса +`javainstaller.h`, `javainstaller.cpp` -`JavaInstaller` ставит сборку Java в `/java/`. Как и -[ModLoaderInstaller](ModLoaderInstaller.md), он прячет за одним фасадом два разных пути. +Ставит одну сборку Java из каталога [JavaRuntimeService](JavaRuntimeService.md) в +`/java//`. Сборки 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. **папка `/java/` удаляется целиком**, если она была, и создаётся заново. Повторная + установка — это всегда установка с нуля; +3. дальше одна из двух веток. -Экземпляр создаётся и принадлежит [LauncherBackend](LauncherBackend.md); записи каталога приходят -от [JavaRuntimeService](JavaRuntimeService.md). +### Temurin (`zip`, `tar.gz`) -Требования сборки: `Qt6::Core` (`QCryptographicHash`, `QSaveFile`, `QProcess`, `QTimer`), -`Qt6::CorePrivate` (`QZipReader` для распаковки zip) и `Qt6::Network`. +1. Архив качается в `/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`, состояние распаковки — как `std::unique_ptr`, -активные загрузки — как `std::shared_ptr` с собственным `QSaveFile` внутри. -Незавершённая запись отменяется вместе с уничтожением объекта, и испорченный файл не попадает на -место назначения. - -Процесс `tar`, используемый для распаковки архивов `tar.gz`, создаётся по ходу работы и -завершается в деструкторе. - -## Потокобезопасность - -Только поток GUI. Отдельного потока у класса нет намеренно: распаковка zip идёт по кускам по -таймеру — держать поток GUI занятым на всю сотню мегабайт нельзя, а заводить поток ради одной -операции незачем. Распаковка `tar.gz` отдана внешнему процессу, который работает параллельно сам. - -## Взаимодействие с другими классами - -`LauncherBackend` вызывает `install()` с записью, полученной от -[JavaRuntimeService](JavaRuntimeService.md), и переправляет сигналы прогресса в те же свойства, -что и остальные установщики. Сигнал `finished` бэкенд переправляет в QML под собственным именем — -на него подписан диалог настроек, чтобы обновить строку выбранной сборки, когда та докачается. - -Раскладку папки `/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` приходит, когда вернутся все активные запросы. --- diff --git a/doc/cpp/JavaRuntimeService.md b/doc/cpp/JavaRuntimeService.md index 2aea5c5..4448624 100644 --- a/doc/cpp/JavaRuntimeService.md +++ b/doc/cpp/JavaRuntimeService.md @@ -1,155 +1,67 @@ # JavaRuntimeService -## Обзор класса +`javaruntimeservice.h`, `javaruntimeservice.cpp` -`JavaRuntimeService` — каталог сборок Java, которые лаунчер умеет скачать: качает, кэширует в -папке лаунчера и отдаёт из кэша, пока тот не устарел. Устроен так же, как -[VersionManifestService](VersionManifestService.md). +Каталог сборок Java, которые лаунчер может скачать. Устроен как +[VersionManifestService](VersionManifestService.md), но кэш хранится сутки и лежит в +`/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//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--<версия>-<арх>` и `mojang-<компонент>`, пропущенные через +`JavaRuntimeStore::sanitizeId()`. -## Псевдонимы типов +Версия Temurin 8 показывается как `8u`, остальные — как `openjdk_version` без `-LTS`. +Мажорная версия у Mojang берётся из имени (`8u…` → 8, иначе первое число). -`JavaRuntimeService::Callback` — `std::function`. Как и в -остальных каталогах лаунчера, `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<JavaRuntimeEntry> entries(JavaRuntimeKind kind) const - -Сборки одной категории: `Mojang`, `Jdk` или `Jre`. Новые версии идут первыми. - -#### std::optional<JavaRuntimeEntry> find(const QString &id) const - -Запись по идентификатору сборки; `std::nullopt`, если такой нет. Поиск идёт по внутреннему -указателю. - -#### std::optional<JavaRuntimeEntry> 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()`. --- diff --git a/doc/cpp/LauncherBackend.md b/doc/cpp/LauncherBackend.md index c89d33d..1d9a019 100644 --- a/doc/cpp/LauncherBackend.md +++ b/doc/cpp/LauncherBackend.md @@ -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//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 из `/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}`. Сначала то, что уже скачано в `/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` в `/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//.pack-r<ревизия>.zip`. Точка в начале имени нужна, + чтобы `BuildSwitcher` не принял файл за архив сборки и не стёр его. +6. **Applying.** `BuildSwitcher::applyPack()` удаляет всё, что принесла прошлая ревизия (список из + `season.json`), и распаковывает новую. Миры и скриншоты в этот список не попадают, потому что + пак их не приносил. + +После раскатки `recordSeasonalPack()` пишет `builds//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()` открывает `/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 { diff --git a/doc/cpp/Localization.md b/doc/cpp/Localization.md index 38dab30..ba47086 100644 --- a/doc/cpp/Localization.md +++ b/doc/cpp/Localization.md @@ -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`. + +Ключи, на которые никто не ссылается, выводятся предупреждением. + +Скрипт смотрит только файлы в корне репозитория. Если исходники разложат по подпапкам, его нужно +будет поправить. + +--- + +При создании этого документа использовался ИИ. diff --git a/doc/cpp/ModLoaderInstaller.md b/doc/cpp/ModLoaderInstaller.md index 10e8df5..208a0f4 100644 --- a/doc/cpp/ModLoaderInstaller.md +++ b/doc/cpp/ModLoaderInstaller.md @@ -1,190 +1,77 @@ # ModLoaderInstaller -## Обзор класса +`modloaderinstaller.h`, `modloaderinstaller.cpp` -`ModLoaderInstaller` ставит модлоадер в `.minecraft`. Под одним фасадом он прячет два совершенно -разных пути установки. +Ставит модлоадер в `.minecraft`. Fabric и Quilt ставятся иначе, чем Forge и NeoForge, но снаружи у +них одинаковый интерфейс. -**Fabric и Quilt** отдают готовое описание версии: лаунчер кладёт его в -`versions//.json` и передаёт дальше [VersionInstaller](VersionInstaller.md), который по -полю `inheritsFrom` сам поставит ванильную версию и библиотеки лоадера. +- **Fabric и Quilt** отдают готовое описание версии. Его достаточно положить в + `versions//.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()`. Если записи нет, + значит, эта пара версий несовместима. Установщик качается в + `/cache/loaders/<ключ>-<версия>-installer.jar`. +2. Если в `.minecraft` нет `launcher_profiles.json`, создаётся заглушка. Без неё установщик + отказывается работать: он считает, что официальный лаунчер не установлен. +3. Java берётся через `JavaLocator::select()` по `javaMajor` базовой версии. `javaPreference` — это + та же Java, что выбрана для игры. +4. Запоминается список профилей в `versions/`. +5. Запуск: `java -jar installer.jar --installClient `. Рабочий каталог — папка загрузок + модлоадеров. На Linux в окружение процесса подставляется эталонный zlib (см. + [zlibreference.h](zlibreference.md)). +6. Вывод установщика построчно уходит в `log(line)`, бэкенд показывает его в журнале. Полностью он + пишется в `/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/`. -Он важен именно для 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); -``` +Деструктор убивает процесс установщика, если тот ещё работает. --- diff --git a/doc/cpp/ModLoaderVersionService.md b/doc/cpp/ModLoaderVersionService.md index b142ee5..c0dad18 100644 --- a/doc/cpp/ModLoaderVersionService.md +++ b/doc/cpp/ModLoaderVersionService.md @@ -1,152 +1,71 @@ # ModLoaderVersionService -## Обзор класса +`modloaderversionservice.h`, `modloaderversionservice.cpp` -Каждый модлоадер публикует свой список версий, и у каждого он устроен по-своему. `ModLoaderVersionService` -приводит все четыре к одному виду: качает списки, кэширует в папке лаунчера и отдаёт из кэша, пока -тот не устарел. Устроен так же, как [VersionManifestService](VersionManifestService.md). +Списки версий Forge, Fabric, NeoForge и Quilt. Устроен так же, как +[VersionManifestService](VersionManifestService.md): качает, хранит копию в +`/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`. -Как и у сервиса манифеста, `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<LoaderVersionEntry> versions(ModLoader loader, const QString &gameVersion) const - -Список сборок лоадера под конкретную версию игры. Новые сборки идут первыми, поэтому первая строка -— самая свежая; именно её интерфейс подставляет по умолчанию. - -Пустой список означает, что лоадер эту версию игры не поддерживает. - -#### bool isRefreshing(ModLoader loader, const QString &gameVersion) const - -Идёт ли сейчас запрос по этой паре. Интерфейс по этому признаку отличает «ещё грузим» от «не -поддерживается» — оба случая выглядят пустым списком. - -#### std::optional<LoaderVersionEntry> 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. + --- При создании этого документа использовался ИИ. diff --git a/doc/cpp/MsaAuthService.md b/doc/cpp/MsaAuthService.md index 6d2ef10..4137f58 100644 --- a/doc/cpp/MsaAuthService.md +++ b/doc/cpp/MsaAuthService.md @@ -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`. Колбэк вызывается ровно -один раз и в потоке 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=;`. В ответе токен 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-потоке. Шаги вызываются из колбэков предыдущих. Если удалить +сервис посреди цепочки, она оборвётся вместе с менеджером сети, и колбэк не вызовется. --- diff --git a/doc/cpp/SeasonalBuildService.md b/doc/cpp/SeasonalBuildService.md index 914fe9e..8f236df 100644 --- a/doc/cpp/SeasonalBuildService.md +++ b/doc/cpp/SeasonalBuildService.md @@ -1,175 +1,94 @@ # SeasonalBuildService -## Обзор класса +`seasonalbuildservice.h`, `seasonalbuildservice.cpp` -Кроме сборок, которые пользователь собирает сам, лаунчер умеет ставить готовые сезонные сборки с -собственного файлового сервера: набор модов под конкретную версию игры и модлоадер, подготовленный -заранее и выдаваемый целиком. +Каталог сезонных сборок, то есть готовых паков, которые команда публикует на своём файловом +сервере. Сервис читает `/index.json`, хранит копию в +`/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`. -Сочетание `ok == true` с непустым `warning` означает данные из устаревшего кэша. - -## Публичные методы - -#### explicit SeasonalBuildService(QObject \*parent = nullptr) - -Создаёт сервис и его `QNetworkAccessManager`. Конструктор помечен `explicit`. - -#### QList<SeasonalBuildEntry> builds() const - -Текущий каталог сборок. Возвращает копию. - -#### bool hasData() const - -Есть ли в каталоге хоть что-то — из сети или из кэша. - -#### bool isRefreshing() const - -Идёт ли сейчас сетевое обновление. - -#### QString lastError() const - -Последняя ошибка обращения к серверу; пустая строка означает, что всё в порядке. - -В отличие от остальных каталогов лаунчера, ошибка здесь хранится отдельным полем: пустой список и -ошибка выглядят одинаково пустыми, и окно каталога показывает причину прямо на месте строк. - -#### std::optional<SeasonalBuildEntry> 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()`. + --- При создании этого документа использовался ИИ. diff --git a/doc/cpp/SeasonalPackDownloader.md b/doc/cpp/SeasonalPackDownloader.md index 56a0651..8685704 100644 --- a/doc/cpp/SeasonalPackDownloader.md +++ b/doc/cpp/SeasonalPackDownloader.md @@ -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` — `/builds//.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`: незавершённая запись отменяется вместе -с уничтожением объекта, и повреждённый архив не попадает на место назначения. Сетевой ответ -создаётся по ходу работы и закрывается в деструкторе. - -## Потокобезопасность - -Только поток 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` приходит +после его завершения. --- diff --git a/doc/cpp/VersionInstaller.md b/doc/cpp/VersionInstaller.md index 540e9f0..dd842f5 100644 --- a/doc/cpp/VersionInstaller.md +++ b/doc/cpp/VersionInstaller.md @@ -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`, а файл внутри каждой — как -`std::unique_ptr`: незавершённая запись отменяется вместе с уничтожением объекта, и -испорченный файл не попадает на место назначения. - -## Потокобезопасность - -Только поток 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) берётся ссылка на + `.json`, файл скачивается, sha1 сверяется с манифестом, и файл сохраняется в + `versions//`. Если версии в манифесте нет (профиль модлоадера) или сеть недоступна, берётся + файл, который уже лежит на диске. Если нет и его, установка падает. +2. **Родитель.** Если в описании есть `inheritsFrom`, а родителя на диске нет, текущая версия + уступает место: в начало очереди встают сначала родитель, потом она сама. Глубина цепочки — до + 10 звеньев. +3. **Список файлов.** `VersionLoader::load()` сводит цепочку, и из результата составляется список: + клиентский jar, все библиотеки, индекс ресурсов и конфиг логов. Ссылки не по `https` и + библиотеки без адреса пропускаются с предупреждением. +4. **Загрузка.** До 8 файлов одновременно. Каждый пишется потоком в `QSaveFile`, sha1 считается на + ходу. При временной ошибке (таймаут, обрыв, HTTP 429 или 5xx) и при несовпадении хеша файл + ставится обратно в начало очереди с паузой 400 мс, потом 800 мс. После третьей неудачи + установка падает. Другие ошибки сразу её прерывают. Таймаут на файл 30 секунд. +5. **Ресурсы.** Как только индекс ресурсов на диске, он разворачивается в задачи на загрузку из + `resources.download.minecraft.net//`. До этого момента в общем объёме учитывается + `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()` отвечает отказом. + +Деструктор блокирует сигналы и отменяет установку. --- diff --git a/doc/cpp/VersionManifestService.md b/doc/cpp/VersionManifestService.md index 51639c6..3343f16 100644 --- a/doc/cpp/VersionManifestService.md +++ b/doc/cpp/VersionManifestService.md @@ -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` | адрес `.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. При первом вызове читается кэш `/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` | Адрес `.json` с описанием версии | -| `sha1` | `QString` | Контрольная сумма самого описания | -| `releaseTime` | `QDateTime` | Дата выпуска; по ней список сортируется новыми вперёд | +## Остальное -Категории `type` — те же ключи, по которым окно выбора версии делит каталог на вкладки; версии, не -попавшие ни в одну из четырёх, интерфейс относит к категории «прочие». +- `versions()` — весь список. +- `find(id)` — запись по id (через хеш-таблицу, а не перебором). +- `hasData()`, `isRefreshing()`, `fetchedAt()`. +- `refreshingChanged` испускается в начале и в конце запроса. Бэкенд превращает его в + `catalogLoading`. -## Псевдонимы типов +## Формат кэша -`VersionManifestService::Callback` — `std::function`. -Сочетание `ok == true` с непустым `warning` означает особый случай: данные отдали, но из -устаревшего кэша — сеть недоступна, а показать что-то нужно. - -## Публичные методы - -#### explicit VersionManifestService(QObject \*parent = nullptr) - -Создаёт сервис и его `QNetworkAccessManager`. Манифест при этом не читается — чтение кэша -откладывается до первого обращения. Конструктор помечен `explicit`. - -#### QList<RemoteVersionEntry> versions() const - -Текущий список версий. Возвращает копию; пустой список означает, что данных ещё нет. - -#### bool hasData() const - -Есть ли хоть какие-то данные — из сети или из кэша. - -#### bool isRefreshing() const - -Идёт ли сейчас сетевое обновление. Интерфейс показывает по этому признаку строку загрузки вместо -пустого списка. - -#### QDateTime fetchedAt() const - -Когда данные были получены. По этой отметке решается, устарел ли кэш. - -#### std::optional<RemoteVersionEntry> 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. + --- При создании этого документа использовался ИИ. diff --git a/doc/cpp/javalocator.md b/doc/cpp/javalocator.md index 96e97ff..36cd5ec 100644 --- a/doc/cpp/javalocator.md +++ b/doc/cpp/javalocator.md @@ -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. сборки, скачанные лаунчером в `/java` (через `JavaRuntimeStore::installed()`); +2. всё, что найдётся рекурсивно в `/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` из той же папки, если он там есть. Тогда при запуске +игры не появляется консольное окно. На других системах возвращает путь без изменений. --- diff --git a/doc/cpp/javaruntime.md b/doc/cpp/javaruntime.md index dd79306..972bb9e 100644 --- a/doc/cpp/javaruntime.md +++ b/doc/cpp/javaruntime.md @@ -1,153 +1,94 @@ -# javaruntime.h — JavaRuntime, JavaRuntimeStore и JavaRequirement +# javaruntime.h -## Обзор +`javaruntime.h`, `javaruntime.cpp` -`Minecraft_launcher` умеет скачивать Java сам: официальные сборки Mojang — те же, которыми игру -запускает официальный лаунчер, — и сборки Eclipse Temurin в вариантах JDK и JRE. Скачанные сборки -живут в папке лаунчера, по одной подпапке на сборку. +Общие типы для Java: виды сборок, строка каталога, запись об установленной сборке, работа с папкой +`/java` и таблица «какая Java нужна какой версии игры». -Заголовок `javaruntime.h` — словарь этой части проекта. Он даёт перечисление видов сборок, две -структуры (строка каталога и уже установленная сборка), пространство имён для работы с папкой -`/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` корректен. +Каждая сборка лежит в `/java//`, а рядом с ней `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<JavaRuntimeKind> javaKindFromKey(const QString &key) - -Обратный перевод; `std::nullopt` для неизвестного ключа. - -#### QString javaKindTitle(JavaRuntimeKind kind) - -Человекочитаемое название вида сборки для интерфейса. - -### JavaRuntimeStore — папка `/java` - -Устройство папки: одна подпапка на сборку плюс её описание внутри. Отдельного индекса нет -намеренно — удалённую вручную папку не пришлось бы вычищать ещё и из общего файла. - -#### QString dirFor(const QString &id) - -Папка конкретной сборки внутри `/java`. - -#### QList<InstalledJavaRuntime> installed() - -Всё, что лежит в `/java` и на что нашлась java. Новые версии идут первыми. - -#### std::optional<InstalledJavaRuntime> 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` и `` — только 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)` — `/java/`. +- `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)` — удаляет папку сборки. Перед этим проверяет, что путь лежит внутри + `/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 потянет. + --- При создании этого документа использовался ИИ. diff --git a/doc/cpp/launcherpaths.md b/doc/cpp/launcherpaths.md index 0cf752d..3a976c7 100644 --- a/doc/cpp/launcherpaths.md +++ b/doc/cpp/launcherpaths.md @@ -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() - -Корень данных лаунчера — `/BiLauncher`. - -#### QString defaultMinecraftDir() - -Стандартная папка игры. Используется, когда пользователь не задал свою в настройках. - -#### bool ensureRootExists(QString *error = nullptr) - -Создаёт папку лаунчера, если её ещё нет. Вызывается при каждом запуске и перед каждой записью. - -Возвращает `false` и заполняет `error`, если папку не удалось создать или в неё не пишется. Все -остальные функции пространства имён только считают строки и в этом смысле не могут завершиться -неудачей — проверять доступность каталога нужно этой функцией. - -### Файлы состояния - -#### QString settingsFile() - -Файл настроек запуска: папка игры, путь к Java, память, аргументы JVM, размер окна. - -#### QString profilesFile() - -Файл профилей игрока. - -#### QString customBuildsFile() - -`/customBuilds.json` — пользовательские сборки. - -#### QString legacyCustomBuildsFile() - -`/versions.json` — как сборки назывались до переименования. Читается один раз при миграции и -больше ни для чего не нужен. - -### Сборки и их архивы - -#### QString buildStorageDir() - -`/builds` — архивы содержимого `.minecraft`, по одному на сборку. - -#### QString buildDir(int buildId) - -`/builds/` — папка одной сборки: её архив и, у сезонных, скачанный пак с описанием -установленной ревизии. - -#### QString seasonalStateFile(int buildId) - -`/builds//season.json` — какая ревизия сезонной сборки установлена и какие файлы она -принесла. Список файлов нужен, чтобы при обновлении убрать те, что из сборки ушли. - -### Java - -#### QString javaDir() - -`/java` — сборки Java, скачанные лаунчером. Каждая в своей подпапке, имя подпапки — -идентификатор сборки из каталога. - -#### QString javaCatalogFile() - -Слепок каталога доступных сборок Java с отметкой времени. - -#### QString javaDownloadDir() - -Каталог, куда качаются архивы Temurin до распаковки. - -### Кэши и загрузки - -#### QString cacheDir() - -`/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 +│ └── / buildDir(id): архив сборки, скачанный пак +│ └── season.json seasonalStateFile(id) +├── java/ javaDir(): скачанные сборки Java, каждая в / +├── 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`. --- diff --git a/doc/cpp/main.md b/doc/cpp/main.md index 7b522cf..2daccd5 100644 --- a/doc/cpp/main.md +++ b/doc/cpp/main.md @@ -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`. --- diff --git a/doc/cpp/minecraftversion.md b/doc/cpp/minecraftversion.md index f45290e..bddaff5 100644 --- a/doc/cpp/minecraftversion.md +++ b/doc/cpp/minecraftversion.md @@ -1,135 +1,102 @@ -# minecraftversion.h — MinecraftVersion и VersionLoader +# minecraftversion.h -## Обзор +`minecraftversion.h`, `minecraftversion.cpp` -Каждая версия Minecraft описывается файлом `versions//.json` — в нём главный класс, -аргументы запуска, список библиотек, индекс ресурсов и требуемая версия Java. Файлы образуют -цепочку: профиль модлоадера наследуется от версии игры через поле `inheritsFrom`, а та, в свою -очередь, может наследоваться дальше. +Чтение описаний версий из `/versions//.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` | Абсолютный путь к `.jar`; может лежать у родительской версии | -| `javaMajor` | `int` | Требуемая мажорная версия Java; по умолчанию `8` | -| `jvmArgs` | `QStringList` | Аргументы JVM ещё с неподставленными подстановками вида `${...}` | -| `gameArgs` | `QStringList` | Аргументы игры, тоже с неподставленными подстановками | -| `libraries` | `QList` | Библиотеки версии с уже применёнными правилами | -| `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 приносит свою версию библиотеки, +она вытесняет ванильную. -Версии, реально установленные в `/versions`: есть и папка, и файл `.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) +## Правила и натив -Размер `/versions/` в байтах; `0`, если папки нет. Используется в предупреждении об -удалении — версия весит десятки мегабайт, и стоит показать, сколько освободится. +`rulesAllow(rules, features)` реализует алгоритм Mojang: правила проверяются по порядку, +действует последнее совпавшее; пустой список означает «разрешено». Правило совпадает, если подошли +`features`, `os.name`, `os.arch` и регулярное выражение `os.version` (сверяется с +`QSysInfo::productVersion()`). -#### bool remove(const QString &gameDir, const QString &versionId, QString \*error) +Нативные библиотеки встречаются в двух форматах: -Сносит `/versions/`. Отсутствие папки считается успехом. При неудаче возвращает -`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<QString> &features, QString \*error) +`osName()` возвращает `windows`, `osx` или `linux`. `osArch()` возвращает архитектуру из +`QSysInfo`, причём `i386` и `i686` сводятся к `x86`. -Читает версию и разворачивает всю цепочку `inheritsFrom` в один объект. Параметр `features` — -набор включённых возможностей, влияющих на применение правил (например, запрошен ли пользовательский -размер окна). +## Установленные версии -При ошибке возвращает объект, у которого `isValid()` даёт `false`, и заполняет `error`. - -#### bool rulesAllow(const QJsonArray &rules, const QSet<QString> &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/`, в которых есть `.json`, по алфавиту. +- `dependentsOf(gameDir, id)` — установленные профили с `inheritsFrom == id`. Без базовой версии они + не запустятся, поэтому их перечисляют в предупреждении об удалении. +- `installedSize(gameDir, id)` — размер папки в байтах. +- `remove(gameDir, id, &error)` — удаляет `versions/`. Сначала проверяет, что id не пустой, + не начинается с точки и не содержит слэшей, а итоговый путь лежит внутри `versions/`. Если папки + нет, это не ошибка. --- diff --git a/doc/cpp/modloader.md b/doc/cpp/modloader.md index fac7928..4b2310a 100644 --- a/doc/cpp/modloader.md +++ b/doc/cpp/modloader.md @@ -1,86 +1,43 @@ -# modloader.h — ModLoader +# modloader.h -## Обзор +Перечисление модлоадеров, запись одной версии модлоадера и перевод между перечислением и +строковым ключом. Только заголовок, `.cpp` нет. -`Minecraft_launcher` умеет ставить четыре модлоадера: Minecraft Forge, Fabric Loader, NeoForge и -Quilt Loader. Заголовок `modloader.h` — общий словарь для всей этой части проекта: перечисление -самих лоадеров, описание одной их сборки и функции перевода между перечислением и строковым -ключом. +## ModLoader -Лоадеры взаимоисключающи: игра запускается ровно с одним профилем в `/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/`. У 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<ModLoader> loaderFromKey(const QString &key) - -Обратный перевод: ключ в перечисление. Возвращает `std::nullopt` для неизвестного ключа, в том -числе для пустой строки — а пустая строка в сборке означает чистую ваниль без лоадера. Объявлена -`inline`. - -## Зависимости - -Подключает `QString`, `QUrl` и ``. От классов проекта не зависит и сам подключается -всюду, где речь идёт о модлоадерах. - -## Пример использования - -```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 | --- diff --git a/doc/cpp/zlibreference.md b/doc/cpp/zlibreference.md index 8c71b01..328f703 100644 --- a/doc/cpp/zlibreference.md +++ b/doc/cpp/zlibreference.md @@ -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/`, при +установке — в `/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. `/zlib/libz.so.1` — запуск из дерева сборки; +2. `/../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, ¬e) -## Зависимости +- 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, ¬e)) { - emit failed(note); - return; -} - -QProcess installer; -installer.setProcessEnvironment(env); -installer.start(javaPath, arguments); -``` +[ModLoaderInstaller](ModLoaderInstaller.md) пишет `note` в журнал установщика. Если `applyTo()` +вернула `false`, а установщик упал с ошибкой контрольных сумм, к ошибке добавляется прямая +подсказка про zlib-ng. --- diff --git a/doc/index.md b/doc/index.md index 91be0fe..f2683e2 100644 --- a/doc/index.md +++ b/doc/index.md @@ -1,164 +1,143 @@ -# Minecraft Launcher — справочник по исходному коду +# BiLauncher: устройство проекта -Десктопный лаунчер Minecraft на Qt 6. Интерфейс написан на QML, вся работа — авторизация, -скачивание версий, установка модлоадеров и Java, запуск игры — лежит в C++. +Лаунчер Minecraft на Qt 6. Интерфейс на QML. Всё остальное, то есть вход в аккаунт, загрузка +версий, установка модлоадеров и Java, смена сборок и запуск игры, написано на C++. -Документация разделена на две части: [QML-компоненты](#qml-компоненты) и -[C++-классы](#c-классы). +Здесь описано, как устроен код. Как собрать проект и выпустить релиз, рассказано в +[build-and-release.md](build-and-release.md). -## Как устроено приложение +## Общая схема -Слоёв четыре, и каждый общается только с соседними: +``` +QML (Main.qml и диалоги) + │ свойства, вызовы, сигналы + ▼ +LauncherBackend ──────────────── Loc (Localization) + │ владеет + ▼ +сервисы: каталоги, установщики, авторизация, BuildSwitcher, GameLauncher + │ + ▼ +сеть, файлы, дочерние процессы +``` -1. **QML.** [Main](qml/Main.md) — единственное настоящее окно и точка входа. Оно держит - единственный экземпляр `LauncherBackend` и раздаёт его вложенным диалогам через свойство - `backend`. Ни один QML-файл не обращается к сервисам напрямую. -2. **Фасад.** [LauncherBackend](cpp/LauncherBackend.md) — главный класс, видимый из QML. - Хранит профили, сборки и настройки, владеет двенадцатью сервисами и сводит их состояние к - свойствам, которые читает интерфейс. Каталоги он отдаёт уже сведёнными с локальным состоянием, - поэтому окна показывают статус, не считая ничего сами. -3. **Сервисы.** Каталоги (версий, модлоадеров, Java, сезонных сборок), установщики, службы - авторизации, переключатель сборок и запуск игры. Каждый занят одним делом и ничего не знает об - интерфейсе. -Особняком стоит [Localization](cpp/Localization.md) — второй и последний тип, видимый из QML. -Это синглтон `Loc`, через который проходят все тексты интерфейса: `Loc.t.домен.вид.имя` в QML и -`Loc::text("домен.вид.имя")` в C++. Он ни от чего не зависит и доступен всем слоям сразу. +1. **QML.** [Main.qml](qml/Main.md) — единственное окно приложения. Остальные окна — `Dialog` внутри + него. Main создаёт один `LauncherBackend` и передаёт его диалогам через `required property var + backend`. К сервисам QML не обращается. +2. **[LauncherBackend](cpp/LauncherBackend.md)** хранит профили, сборки и настройки, владеет + двенадцатью сервисами и следит, чтобы их операции не пересекались. Каталоги он отдаёт в QML + уже сведёнными с тем, что лежит на диске, поэтому окна сами ничего не считают. +3. **Сервисы.** Каждый занят одним делом и про интерфейс не знает. +4. **Внешний мир.** Серверы Mojang, Ely.by, Microsoft/Xbox, Adoptium, Fabric/Quilt/Forge/NeoForge и + свой сервер сезонных сборок. Файлы: `.minecraft` и папка лаунчера. Процессы: java с игрой, + установщики Forge/NeoForge, `tar`. -4. **Внешний мир.** Сеть (Mojang, Ely.by, Microsoft, Adoptium, файловый сервер сборок), файловая - система (`.minecraft` и папка лаунчера) и дочерние процессы (java, установщики модлоадеров, - `tar`). +Отдельно стоит [Localization](cpp/Localization.md), синглтон `Loc`. Через него идут все тексты +интерфейса: `Loc.t.домен.вид.имя` в QML и `Loc::text("домен.вид.имя")` в C++. Он ни от чего не +зависит, и обращаться к нему можно из любого слоя. -Почти весь код работает в потоке GUI: параллелизм даёт асинхронная сеть, а не потоки. Единственное -исключение — операции над содержимым `.minecraft` (гигабайты модов и миров), вынесенные в -отдельный поток к [BuildArchiveWorker](cpp/BuildArchiveWorker.md). +Почти всё работает в GUI-потоке. Параллельность даёт асинхронная сеть, а не потоки. Исключение — +файловые операции над `.minecraft` (это могут быть гигабайты модов и миров). Они вынесены в +отдельный поток в [BuildArchiveWorker](cpp/BuildArchiveWorker.md). -## QML-компоненты +## Основные понятия -| Компонент | Описание | -|-----------|----------| -| [Main](qml/Main.md) | Главное окно и точка входа: экран запуска, профили, плашка сообщений, панели прогресса и внутренние диалоги | -| [BuildsDialog](qml/BuildsDialog.md) | Пользовательские сборки: список слева, карточка редактирования справа | -| [VersionPickerDialog](qml/VersionPickerDialog.md) | Выбор версии Minecraft: категории, поиск, удаление скачанных версий | -| [JavaPickerDialog](qml/JavaPickerDialog.md) | Выбор сборки Java; подтверждение при необходимости сразу начинает загрузку | -| [SeasonalBuildsDialog](qml/SeasonalBuildsDialog.md) | Каталог готовых сезонных сборок таблицей и установка одной кнопкой | -| [MicrosoftLoginDialog](qml/MicrosoftLoginDialog.md) | Окно входа в аккаунт Microsoft; собирается только с Qt WebEngine | -| [LoaderRow](qml/LoaderRow.md) | Одна строка модлоадера в карточке сборки: чекбокс и список версий | -| [ProgressPanel](qml/ProgressPanel.md) | Плашка хода долгой операции в левом нижнем углу | -| [DarkCombo](qml/DarkCombo.md) | Выпадающий список в тёмном стиле окна | -| [LabelledField](qml/LabelledField.md) | Подпись и поле ввода одной колонкой | +**Профиль** — игрок и способ входа: офлайн, Ely.by или Microsoft. -## C++-классы +**Сборка** — версия игры, модлоадер (или без него), адрес сервера и собственное содержимое +`.minecraft`: моды, конфиги, миры. Активная сборка одна, её файлы разложены в `.minecraft`, у +остальных файлы лежат в архивах в папке лаунчера. Папки `versions`, `libraries`, `assets`, +`runtime` общие для всех сборок. См. [BuildSwitcher](cpp/BuildSwitcher.md). -### Точка входа +**Сезонная сборка** — готовая сборка, которую публикует команда на своём сервере. Ставится одной +кнопкой вместе с версией, модлоадером и Java, обновляется по номеру ревизии. Формат каталога +описан в [SeasonalBuildService](cpp/SeasonalBuildService.md). -| Файл | Описание | -|------|----------| -| [main.cpp](cpp/main.md) | Инициализация WebEngine, объект приложения, каталог переводов, стиль `Basic`, загрузка QML-модуля | +**Папка лаунчера** — `BiLauncher` рядом с `.minecraft`. Что в ней лежит, расписано в +[launcherpaths.h](cpp/launcherpaths.md). -### Фасад +## QML -| Класс | Описание | -|-------|----------| -| [Localization](cpp/Localization.md) | Синглтон `Loc`: все тексты интерфейса в одном файле `i18n/translations.json`, переключение языка на лету | -| [LauncherBackend](cpp/LauncherBackend.md) | Единственный тип, видимый из QML: 26 свойств, 42 вызываемых метода, состояние лаунчера и порядок работы всех сервисов | +| Файл | Что это | +|------|---------| +| [Main](qml/Main.md) | главное окно: экран запуска, профили, настройки, плашка сообщений, панели прогресса | +| [BuildsDialog](qml/BuildsDialog.md) | пользовательские сборки: список и карточка | +| [VersionPickerDialog](qml/VersionPickerDialog.md) | выбор версии Minecraft, удаление скачанных | +| [JavaPickerDialog](qml/JavaPickerDialog.md) | выбор и загрузка Java | +| [SeasonalBuildsDialog](qml/SeasonalBuildsDialog.md) | каталог сезонных сборок | +| [MicrosoftLoginDialog](qml/MicrosoftLoginDialog.md) | вход Microsoft во встроенном браузере; только с WebEngine | +| [LoaderRow](qml/LoaderRow.md) | строка модлоадера в карточке сборки | +| [ProgressPanel](qml/ProgressPanel.md) | плашка прогресса | +| [DarkCombo](qml/DarkCombo.md) | выпадающий список в стиле окна | +| [LabelledField](qml/LabelledField.md) | подпись и поле ввода | -### Авторизация и запуск +## C++ -| Класс | Описание | -|-------|----------| -| [AuthService](cpp/AuthService.md) | Вход через Ely.by и офлайн-режим; структура `AuthResult` | -| [MsaAuthService](cpp/MsaAuthService.md) | Вход через аккаунт Microsoft: OAuth2 → Xbox Live → XSTS → Minecraft Services | -| [GameLauncher](cpp/GameLauncher.md) | Сборка командной строки, распаковка нативных библиотек и запуск JVM | +### Вход и фасад + +| | | +|---|---| +| [main.cpp](cpp/main.md) | запуск приложения | +| [LauncherBackend](cpp/LauncherBackend.md) | всё, что видит QML | +| [Localization](cpp/Localization.md) | тексты интерфейса, `i18n/translations.json` | + +### Вход в аккаунт и запуск + +| | | +|---|---| +| [AuthService](cpp/AuthService.md) | Ely.by, офлайн, authlib-injector; структура `AuthResult` | +| [MsaAuthService](cpp/MsaAuthService.md) | Microsoft: OAuth2 → Xbox Live → XSTS → Minecraft Services | +| [GameLauncher](cpp/GameLauncher.md) | командная строка, нативные библиотеки, процесс игры | ### Версии игры -| Класс | Описание | -|-------|----------| -| [VersionManifestService](cpp/VersionManifestService.md) | Каталог версий Mojang с кэшем в папке лаунчера | -| [VersionInstaller](cpp/VersionInstaller.md) | Фоновая установка версии: jar, библиотеки, индекс ресурсов и сами ресурсы | +| | | +|---|---| +| [VersionManifestService](cpp/VersionManifestService.md) | каталог версий Mojang | +| [VersionInstaller](cpp/VersionInstaller.md) | установка версии: jar, библиотеки, ресурсы | +| [minecraftversion.h](cpp/minecraftversion.md) | чтение описаний версий, цепочка `inheritsFrom`, правила Mojang | ### Модлоадеры -| Класс | Описание | -|-------|----------| -| [ModLoaderVersionService](cpp/ModLoaderVersionService.md) | Списки версий Forge, Fabric, NeoForge и Quilt; совместимость заложена в структуру данных | -| [ModLoaderInstaller](cpp/ModLoaderInstaller.md) | Два пути установки под одним фасадом: готовое описание версии либо запуск `installer.jar` | +| | | +|---|---| +| [modloader.h](cpp/modloader.md) | перечисление модлоадеров и ключи | +| [ModLoaderVersionService](cpp/ModLoaderVersionService.md) | версии Forge, Fabric, NeoForge, Quilt | +| [ModLoaderInstaller](cpp/ModLoaderInstaller.md) | установка: готовый профиль или `installer.jar` | +| [zlibreference.h](cpp/zlibreference.md) | обход zlib-ng для установщиков Forge/NeoForge на Linux | ### Java -| Класс | Описание | -|-------|----------| -| [JavaRuntimeService](cpp/JavaRuntimeService.md) | Каталог сборок Mojang и Eclipse Temurin под текущую платформу | -| [JavaInstaller](cpp/JavaInstaller.md) | Установка сборки Java: архив Temurin либо дерево файлов Mojang | +| | | +|---|---| +| [javaruntime.h](cpp/javaruntime.md) | типы, папка `/java`, какая Java нужна какой версии игры | +| [JavaRuntimeService](cpp/JavaRuntimeService.md) | каталог Mojang и Temurin | +| [JavaInstaller](cpp/JavaInstaller.md) | установка: архив Temurin или дерево файлов Mojang | +| [javalocator.h](cpp/javalocator.md) | поиск java в системе | -### Сборки и их содержимое +### Сборки -| Класс | Описание | -|-------|----------| -| [BuildSwitcher](cpp/BuildSwitcher.md) | Порядок шагов смены активной сборки и восстановление после прерванной операции | -| [BuildArchiveWorker](cpp/BuildArchiveWorker.md) | Упаковка, очистка, распаковка и раскатка пака в отдельном потоке | +| | | +|---|---| +| [BuildSwitcher](cpp/BuildSwitcher.md) | смена активной сборки, раскатка пака, продолжение после падения | +| [BuildArchiveWorker](cpp/BuildArchiveWorker.md) | файловые операции в отдельном потоке | +| [SeasonalBuildService](cpp/SeasonalBuildService.md) | каталог сезонных сборок и его формат | +| [SeasonalPackDownloader](cpp/SeasonalPackDownloader.md) | загрузка архива сезонной сборки | +| [launcherpaths.h](cpp/launcherpaths.md) | все пути к данным лаунчера | -### Сезонные сборки +## Прочее в репозитории -| Класс | Описание | -|-------|----------| -| [SeasonalBuildService](cpp/SeasonalBuildService.md) | Каталог готовых сборок с файлового сервера | -| [SeasonalPackDownloader](cpp/SeasonalPackDownloader.md) | Загрузка архива сборки потоком с проверкой sha256 | - -### Общие типы и утилиты - -| Файл | Описание | -|------|----------| -| [minecraftversion.h](cpp/minecraftversion.md) | Структуры версии и библиотеки, чтение и разворачивание цепочки `inheritsFrom` | -| [javaruntime.h](cpp/javaruntime.md) | Виды сборок Java, папка `/java` и таблица требований версий игры | -| [modloader.h](cpp/modloader.md) | Перечисление модлоадеров, запись версии и перевод ключей | -| [launcherpaths.h](cpp/launcherpaths.md) | Все пути к данным лаунчера в одном месте | -| [javalocator.h](cpp/javalocator.md) | Поиск установленной в системе Java и выбор подходящей версии | -| [zlibreference.h](cpp/zlibreference.md) | Подмена zlib-ng эталонным zlib для установщиков Forge и NeoForge | - -## Сборка - -Требуется Qt 6.8 или новее. Обязательные модули: `Quick`, `QuickControls2`, `Core`, `CorePrivate`, -`Gui`, `Network`. `CorePrivate` нужен ради `QZipReader` и `QZipWriter` — ими распаковываются -нативные библиотеки LWJGL и архивы сборок; привязка к версии Qt из-за приватного модуля — -осознанный выбор, и предупреждение о ней в `CMakeLists.txt` отключено. - -`Qt6::WebEngineQuick` необязателен, и это принципиально: модуль ставится отдельной галочкой в -установщике Qt и тянет за собой WebChannel с Positioning, которых в типовой установке нет. Если -сделать его обязательным, у любого, кто их не поставил, проект перестанет конфигурироваться -целиком — вместе с офлайном и Ely.by. Когда модуль найден, определяется макрос -`LAUNCHER_HAS_WEBENGINE`, а [MicrosoftLoginDialog.qml](qml/MicrosoftLoginDialog.md) добавляется в -QML-модуль; без модуля лаунчер собирается и работает как обычно, только вход через Microsoft -сообщает, что эта сборка его не умеет. В QML различие видно через свойство -`backend.microsoftAvailable`. - -QML-модуль объявлен как `qt_add_qml_module` с URI `Minecraft_launcher`; точка входа — -`engine.loadFromModule("Minecraft_launcher", "Main")`. Туда же, в список `RESOURCES`, попадает -каталог переводов `i18n/translations.json`. - -### Тексты интерфейса - -Все подписи, сообщения и ошибки лежат в одном файле `i18n/translations.json` и достаются через -синглтон `Loc` — подробности, правила именования ключей и порядок добавления языка описаны в -[Localization](cpp/Localization.md). Штатные `.ts`/`.qm` не используются, `lupdate` и `lrelease` -в сборке не участвуют. Целостность каталога проверяет `python3 tools/check_translations.py`. - -### Каталог `zlib/` - -В репозитории лежат исходники обычного zlib версии 1.3.1 — это чужой upstream-код, и -документацией он не покрыт. Он нужен вот зачем. - -Установщики Forge и NeoForge сверяют sha1 каждого jar, который сами же и собирают, с эталоном из -`install_profile.json`. Эталон посчитан на обычном zlib, а дистрибутивы вроде CachyOS и Fedora -подставляют вместо него zlib-ng: сжатие корректное, но побайтово другое, поэтому установка падает -на любой версии игры сообщением «Processor failed, invalid outputs». Сменить Java не выйдет — -сборки OpenJDK под Linux берут `libz.so.1` из системы. - -Поэтому на Linux собирается цель `launcher_zlib_reference` из этих исходников, и готовая -библиотека подсовывается через `LD_PRELOAD` только процессу установщика — см. -[zlibreference.h](cpp/zlibreference.md). Сам лаунчер с ней не линкуется, системный zlib-ng -остаётся на месте. Исходники хранятся в репозитории, чтобы сборка не зависела от сети, а версия -была зафиксирована: от неё зависит побайтовый результат сжатия. +| Путь | Что это | +|------|---------| +| `i18n/translations.json` | все тексты интерфейса | +| `tools/check_translations.py` | проверка каталога переводов | +| `icons/` | иконки и скрипт, который их генерирует | +| `images/` | картинки интерфейса | +| `zlib/` | исходники zlib 1.3.1 (чужой код) | +| `packaging/` | установщик Windows, лицензии сторонних компонентов | +| `ci/` | скрипты подготовки раннеров | +| `.gitea/workflows/release.yml` | сборка релизов | +| `Screenshots/` | картинки для README | --- diff --git a/doc/qml/BuildsDialog.md b/doc/qml/BuildsDialog.md index 59719ee..507c304 100644 --- a/doc/qml/BuildsDialog.md +++ b/doc/qml/BuildsDialog.md @@ -1,178 +1,94 @@ -# BuildsDialog +# BuildsDialog.qml -## Обзор компонента +Окно пользовательских сборок. Слева список, справа карточка выбранной сборки: имя, сервер, версия +Minecraft, модлоадер. Отсюда сборку ставят, делают активной и удаляют. -`Minecraft_launcher` — десктопный лаунчер Minecraft на Qt Quick. Сборка в нём — это именованный -набор «версия игры + модлоадер + адрес сервера» вместе с собственным содержимым `.minecraft`: -модами, конфигами и мирами. Активная сборка одна, её содержимое лежит в `.minecraft`, остальные -хранятся в архивах и разворачиваются при переключении. +Сборка — это версия игры, модлоадер и адрес сервера плюс собственное содержимое `.minecraft` +(моды, конфиги, миры). Активная сборка одна, её файлы разложены в `.minecraft`, остальные лежат в +архивах. Подробнее в [BuildSwitcher](../cpp/BuildSwitcher.md). -`BuildsDialog` — окно управления этими сборками: слева список со сменой активной, справа карточка -выбранной — имя, сервер, версия Minecraft и модлоадеры. Отсюда же сборка ставится (скачивание -версии игры и модлоадера) и удаляется. - -Карточка сохраняет правки по ходу редактирования, отдельной кнопки «Сохранить» нет: иначе -появляется неочевидное несохранённое состояние, пока пользователь переключается между сборками в -левом списке. - -## Место в проекте и зависимости - -В начале файла объявлена `pragma ComponentBehavior: Bound`. Импортирует `QtQuick`, -`QtQuick.Controls 2.15` и `QtQuick.Layouts 2.15`. - -Использует три компонента из того же QML-модуля: - -- [LabelledField](LabelledField.md) — поля названия сборки и адреса сервера; -- [LoaderRow](LoaderRow.md) — по одной строке на каждый из четырёх модлоадеров; -- [VersionPickerDialog](VersionPickerDialog.md) — вложенное окно выбора версии Minecraft. - -Работает с C++-типом `LauncherBackend` (`launcherbackend.h`, `QML_ELEMENT` в модуле -`Minecraft_launcher`) через свойство `backend`. Читает свойства `customBuildNames`, -`activeBuildIndex`, `switching` и `busy`; вызывает `customBuildAt()`, `addCustomBuild()`, -`updateCustomBuild()`, `customBuildRemovalInfo()`, `removeCustomBuild()`, `checkInstallation()`, -`installCustomBuild()` и `refreshVersionCatalog()`; слушает сигнал `installedVersionsChanged`. - -Объявлен в `QML_FILES` модуля (`CMakeLists.txt`); использует ресурс `images/Trash.svg`. -Инстанцируется в [Main](Main.md). - -## Иерархия и роль - -Корневой тип — `Dialog`: модальный, 880×600 px, нулевой внутренний отступ, тёмный фон со -скруглением и акцентной рамкой. Пока идёт смена активной сборки (`backend.switching`), окно -закрывается только кнопкой: архивация и распаковка `.minecraft` не должны прерываться случайным -щелчком мимо. - -Содержимое — `RowLayout` из двух частей: - -- **Слева** (260 px) список сборок и строка «+ Новая сборка» под ним. Строка списка показывает имя, - пометку «активна» у активной сборки и корзину, появляющуюся при наведении. Вся колонка - выключается на время смены активной сборки. -- **Справа** карточка выбранной сборки внутри `Flickable` — она может не поместиться по высоте. - Карточка выключается и приглушается, когда сборка не выбрана или идёт переключение. - -Карточка сверху вниз: название, адрес сервера, поле версии Minecraft, панель модлоадеров и строка -состояния комплектности. Поле версии — не выпадающий список, а прямоугольник, который только -показывает выбор и открывает отдельное окно: версий около тысячи, и разбираться в них удобнее в -окне с категориями. - -Отдельно объявлен вложенный `Dialog` подтверждения удаления шириной 420 px с красной рамкой, -привязанный к тому же родителю, что и само окно. - -Подвал несёт три кнопки: «Установить», «Сделать активной» и «Закрыть». +Открывается из [Main](Main.md) по кнопке активной сборки. ## Свойства -| Свойство | Тип | По умолчанию | Обязательное | Описание | -|----------|-----|--------------|--------------|----------| -| `backend` | `var` | — | **Да** | Экземпляр `LauncherBackend` — хранилище сборок и исполнитель установки, удаления и переключения. | -| `editIndex` | `int` | `-1` | Нет | Индекс сборки, открытой в карточке справа. Значение `-1` означает, что карточка пуста и выключена. С активной сборкой не связан: активную меняет отдельная кнопка, чтобы случайный клик по списку не запускал архивацию `.minecraft`. | -| `loading` | `bool` | `false` | Нет | Признак того, что карточка сейчас заполняется данными сборки. Пока он выставлен, обработчики полей не должны писать обратно — иначе открытие сборки немедленно перезаписывало бы её. | +| Свойство | Что это | +|----------|---------| +| `backend` (required) | `LauncherBackend` | +| `editIndex` | сборка, открытая в карточке; −1 — ничего не открыто | +| `loading` | карточка сейчас заполняется данными сборки, обработчики полей ничего не записывают | -### Внутренняя панель модлоадеров +`editIndex` не связан с активной сборкой. Щелчок по строке списка только открывает карточку, а +активной сборку делает отдельная кнопка. Иначе случайный щелчок запускал бы архивацию +`.minecraft`. -Панель `loaderPanel` — это `Column` с четырьмя строками [LoaderRow](LoaderRow.md) и собственным -маленьким API. Лоадеры несовместимы между собой: игра запускается ровно с одним профилем -`versions/`, поэтому отметка одного снимает остальные, а «ничего не отмечено» — это чистая -ваниль. +## Как устроено окно -| Свойство панели | Тип | Описание | -|-----------------|-----|----------| -| `rows` | `var` | Массив из четырёх строк лоадеров в порядке показа: Minecraft Forge (`forge`), Fabric Loader (`fabric`), NeoForge (`neoforge`), Quilt Loader (`quilt`). | +Модальный `Dialog` 880×600. Пока идёт смена сборки (`backend.switching`), окно закрывается только +кнопкой, а список и карточка выключены. -Функции панели: `applyBuild(loader, loaderVersion)` раздаёт данные сборки всем строкам; -`selectedRow()` возвращает отмеченную строку или `null`; `keepOnly(row)` очищает все строки, -кроме переданной; `commitSelection()` сохраняет выбор в сборку, записывая ключ лоадера, его версию -и пустой `resolvedVersionId` — профиль появится только после установки, а до неё сборка -запускается на чистой ванили. +**Слева** (260 px) список сборок. У активной пометка «активна», при наведении появляется корзина. +Ниже строка «+ Новая сборка»: создаёт сборку с первым свободным именем вида «Сборка N» и сразу +открывает её. -## Сигналы +**Справа** карточка в `Flickable`, потому что может не поместиться по высоте: -Собственных сигналов компонент не объявляет: все изменения уходят прямо в бэкенд, и о них -остальной интерфейс узнаёт по его сигналам. +- название и адрес сервера ([LabelledField](LabelledField.md)); +- версия Minecraft. Это не выпадающий список, а поле, которое открывает + [VersionPickerDialog](VersionPickerDialog.md): версий около тысячи, и выбирать их удобнее в окне + с категориями и поиском; +- четыре строки [LoaderRow](LoaderRow.md): Forge, Fabric, NeoForge, Quilt; +- строка комплектности: «можно запускать» или «не хватает N файлов» и первый из них + (`checkInstallation()`). -## Методы +**Внизу** кнопки «Установить» (`installCustomBuild()`), «Сделать активной» (запись в +`activeBuildIndex`) и «Закрыть». Первые две выключены, пока `busy`. «Сделать активной» выключена +ещё и для уже активной сборки. Прогресс установки и смены сборки показывают панели главного окна. -#### selectBuild(int index) : void +## Сохранение по ходу -Открывает сборку с указанным индексом в карточке. Читает данные через `customBuildAt()`, -выставляет `loading` на время заполнения полей, раздаёт лоадеры панели через `applyBuild()` и в -конце обновляет строку состояния. +Отдельной кнопки «Сохранить» нет. Каждое поле записывается сразу после правки через +`commit(fields)` → `updateCustomBuild()`. Иначе пришлось бы как-то показывать несохранённые правки +при переключении между сборками в списке. -#### askRemove(int index) : void +Чтобы открытие сборки не записывало её саму в себя, на время `selectBuild()` выставляется +`loading`, и `commit()` в это время ничего не делает. -Спрашивает подтверждение перед удалением: оно необратимо и уносит с собой архив сборки. Запрашивает -`customBuildRemovalInfo()` и наполняет окно подтверждения именем сборки и тремя признаками — есть -ли у неё архив, активна ли она сейчас и последняя ли она. Индекс запоминается в самом окне -подтверждения, потому что к моменту ответа строка списка под курсором может быть уже другой. +## Модлоадеры -Окно подтверждения объясняет последствия по этим признакам: вместе со сборкой удалится её архив, и -моды, конфиги и миры восстановить будет нельзя; активная сборка владеет содержимым `.minecraft`, -оно будет очищено, а на его место развернётся следующая сборка; для последней сборки содержимое -`.minecraft` остаётся на месте. +`loaderPanel` держит четыре строки и следит, чтобы был отмечен не больше чем один модлоадер: игра +запускается с одним профилем из `versions/`. Ни одной отметки — ванильная сборка. -#### performRemove(int index) : void +- `applyBuild(loader, loaderVersion)` — раздать данные сборки строкам; +- `selectedRow()` — отмеченная строка или `null`; +- `keepOnly(row)` — снять отметки с остальных (на `userChecked` строки); +- `commitSelection()` — записать `loader`, `loaderVersion` и пустой `resolvedVersionId` (на + `changed` строки). Профиль модлоадера появится только после установки, а до этого сборка + запускается как ванильная. -Удаляет сборку через бэкенд и восстанавливает состояние карточки: если сборок не осталось, -сбрасывает `editIndex` в `-1`, иначе открывает соседнюю — ту же позицию или последнюю оставшуюся. +Строки получают `gameVersion` привязкой к выбранной версии, поэтому смена версии игры сама +перезапрашивает их списки. -#### commit(var fields) : void +## Удаление -Сохраняет часть полей сборки: передаёт `QVariantMap` в `updateCustomBuild()` и обновляет строку -состояния. Ничего не делает во время заполнения карточки (`loading`) и при пустом `editIndex` — -это и есть защита от записи при открытии сборки. +`askRemove(index)` берёт `customBuildRemovalInfo()` и открывает подтверждение. Текст зависит от +трёх признаков: -Вызывается по завершении правки каждого поля: имени, адреса сервера, версии Minecraft и выбора -модлоадера. +- `hasArchive` — вместе со сборкой удалится её архив, моды, конфиги и миры будет не вернуть; +- `active` — содержимое `.minecraft` принадлежит этой сборке, оно будет очищено, и на его место + развернётся следующая; +- `lastOne` — для последней сборки содержимое `.minecraft` остаётся как есть. -#### refreshStatus() : void +Индекс запоминается в самом окне подтверждения, потому что к моменту ответа под курсором может +оказаться другая строка. -Пересчитывает строку комплектности под карточкой. Спрашивает у бэкенда `checkInstallation()` и -показывает либо сообщение о готовности к запуску, либо число недостающих файлов вместе с первым из -них. При пустом `editIndex` очищает строку. +`performRemove(index)` удаляет сборку и открывает соседнюю (ту же позицию или последнюю), а если +сборок не осталось, очищает карточку. -#### newBuildName() : string +## Когда окно открывается -Придумывает имя для новой сборки: перебирает «Сборка 1», «Сборка 2» и так далее, пока не найдёт -свободное среди существующих имён. - -## Взаимодействие с другими компонентами - -**Со стороны родителя.** [Main](Main.md) задаёт `backend` и открывает окно. Начальное состояние -окно выбирает само в обработчике `onAboutToShow`: просит обновить каталог версий и открывает -активную сборку, а при пустом списке оставляет карточку выключенной. - -**Внутрь — к вложенным компонентам.** Поля [LabelledField](LabelledField.md) сообщают о правке -сигналом `editingFinished`, и карточка сразу вызывает `commit()` с обрезанным по краям значением. -Поле версии открывает [VersionPickerDialog](VersionPickerDialog.md) вызовом `openFor()` и получает -результат сигналом `versionChosen`; запись выбранной версии сама вызывает `commit()` через -обработчик изменения. Строки [LoaderRow](LoaderRow.md) получают `gameVersion` привязкой к -выбранной версии игры, поэтому смена версии Minecraft автоматически перезапрашивает списки версий -лоадеров. - -**Наружу — к бэкенду.** Кнопка «Установить» вызывает `installCustomBuild()`, кнопка «Сделать -активной» пишет в свойство `activeBuildIndex`. Обе выключены, пока лаунчер занят (`busy`), а -кнопка активации — ещё и когда выбранная сборка уже активна. Ход установки и переключения -показывает плашка [ProgressPanel](ProgressPanel.md) главного окна, а не это окно. - -**Обратная связь от бэкенда.** Окно подписано на `installedVersionsChanged` через `Connections`: -установка версии или модлоадера меняет комплектность сборки, и строка состояния должна это -заметить, не дожидаясь переоткрытия окна. - -## Пример использования - -```qml -BuildsDialog { - id: buildsDialog - parent: Overlay.overlay - anchors.centerIn: parent - backend: launcherBackend -} - -Button { - text: qsTr("Сборки") - onClicked: buildsDialog.open() -} -``` +`onAboutToShow` просит обновить каталог версий и открывает активную сборку (или первую). Через +`Connections` окно слушает `installedVersionsChanged` и обновляет строку комплектности: установка +могла закончиться, пока окно открыто. --- diff --git a/doc/qml/DarkCombo.md b/doc/qml/DarkCombo.md index ba8fa29..3c463cc 100644 --- a/doc/qml/DarkCombo.md +++ b/doc/qml/DarkCombo.md @@ -1,88 +1,15 @@ -# DarkCombo +# DarkCombo.qml -## Обзор компонента +`ComboBox` в тёмном оформлении окна: фон `#2a2a2a`, зелёная рамка при фокусе, выпадающая панель +высотой до 220 px с полосой прокрутки. -`Minecraft_launcher` — десктопный лаунчер Minecraft на Qt Quick. Всё окно оформлено вручную в -тёмной теме, а стиль Qt Quick Controls принудительно выставлен в `Basic` (`main.cpp`), потому что -нативные стили игнорируют пользовательские `contentItem` и `background`. Из-за этого каждый -стандартный элемент управления, попадающий в интерфейс, приходится переопределять самому. +Стрелка взята та же, что у списков профиля и версии на главном окне +(`images/Profile_Box/Asset_23/24.svg`), вместо двойного шеврона стиля `Basic`. -`DarkCombo` — как раз такое переопределение: выпадающий список в общем тёмном стиле окна. -Компонент не добавляет логики, он целиком про внешний вид — фон, рамку, стрелку, делегат строки и -всплывающую панель. К нему обращаются всюду, где нужен выпадающий список внутри диалогов: выбор -версии модлоадера, выбор Java, поля в настройках. +Новых свойств нет, используется как обычный `ComboBox`. Есть одно ограничение: делегат выводит +`modelData` как текст, поэтому модель должна быть списком строк. `textRole` не поддерживается. -## Место в проекте и зависимости - -Импортирует `QtQuick` и `QtQuick.Controls 2.15`; собственных C++-типов не использует. - -Объявлен в `QML_FILES` модуля `Minecraft_launcher` (см. `CMakeLists.txt`), поэтому доступен по -имени `DarkCombo` в любом файле того же модуля без явного импорта. - -Используется в [LoaderRow](LoaderRow.md) — список версий модлоадера — и дважды в [Main](Main.md): -в выпадающих списках профиля и сборки на главном окне. - -Компонент ссылается на ресурсы `images/Profile_Box/Asset_23.svg` и -`images/Profile_Box/Asset_24.svg` — они перечислены в `RESOURCES` того же QML-модуля, отдельного -подключения не требуют. - -## Иерархия и роль - -Корневой тип — `ComboBox` из Qt Quick Controls. Всё поведение (модель, `currentIndex`, -`activated`, `displayText`, клавиатурная навигация) наследуется без изменений; `DarkCombo` -переопределяет только четыре визуальных слота базового типа: - -| Слот | Что даёт `DarkCombo` | -|------|----------------------| -| `indicator` | стрелка из SVG-ресурса вместо двойного шеврона Basic-стиля; при открытом списке (`down`) картинка меняется | -| `contentItem` | текст текущего значения белым, с обрезкой справа многоточием и отступом под стрелку | -| `background` | тёмный прямоугольник со скруглением 6 px; рамка подсвечивается акцентным цветом при фокусе | -| `delegate` | строка списка: белый текст, подсветка фона у элемента под курсором | -| `popup` | всплывающая панель шириной с сам комбобокс, высотой не более 220 px, с вертикальным индикатором прокрутки | - -## Свойства - -Собственных свойств компонент не объявляет — доступен весь набор свойств `ComboBox` -(`model`, `currentIndex`, `currentText`, `displayText`, `editable` и прочие). - -Внутри `delegate` объявлены два обязательных свойства делегата, они относятся к строке списка, -а не к самому комбобоксу: - -| Свойство | Тип | По умолчанию | Обязательное | Описание | -|----------|-----|--------------|--------------|----------| -| `modelData` | `var` | — | **Да** | Значение строки модели; выводится текстом в строке списка. Модель ожидается плоской — списком строк, а не объектов. | -| `index` | `int` | — | **Да** | Позиция строки; сравнивается с `highlightedIndex` комбобокса, чтобы подсветить строку под курсором. | - -## Сигналы - -Собственных сигналов нет. Наследуются сигналы `ComboBox`, из которых на практике используется -`activated(int index)` — выбор строки пользователем (в отличие от `currentIndexChanged`, он не -срабатывает при программной смене значения). - -## Методы - -Собственных функций нет. - -## Взаимодействие с другими компонентами - -Компонент самодостаточен и ничего не знает ни о бэкенде, ни о родителе: модель приходит извне -через `model`, результат выбора родитель получает через унаследованный `activated`. Так, -[LoaderRow](LoaderRow.md) передаёт в `model` список подписей версий модлоадера и в обработчике -`activated` переводит индекс обратно в номер версии. - -Поскольку модель ожидается списком строк, вызывающий код обычно сам приводит массив объектов к -массиву подписей перед присваиванием. - -## Пример использования - -```qml -DarkCombo { - width: 200 - height: 32 - model: ["1.21.1", "1.20.6", "1.20.4"] - onActivated: (index) => console.log("выбрано:", model[index]) -} -``` +Используется в [LoaderRow](LoaderRow.md) и в диалогах [Main](Main.md). --- diff --git a/doc/qml/JavaPickerDialog.md b/doc/qml/JavaPickerDialog.md index 1880e95..8040ed7 100644 --- a/doc/qml/JavaPickerDialog.md +++ b/doc/qml/JavaPickerDialog.md @@ -1,148 +1,48 @@ -# JavaPickerDialog +# JavaPickerDialog.qml -## Обзор компонента +Окно выбора сборки Java. Устроено как [VersionPickerDialog](VersionPickerDialog.md): слева виды, +справа список с поиском. Отличие в том, что выбранная здесь сборка сразу и скачивается. Версии игры +качает сборка, а Java, которой нет на диске, игре ничем не поможет. -`Minecraft_launcher` — десктопный лаунчер Minecraft на Qt Quick. Разным версиям игры нужны разные -версии Java, и лаунчер умеет скачивать их сам: официальные сборки Mojang и сборки Temurin в двух -вариантах — JDK и JRE. Держать в голове, какая Java нужна какой версии игры, пользователь не -обязан. +Открывается из диалога настроек в [Main](Main.md). -`JavaPickerDialog` — окно выбора сборки Java: слева типы сборок, справа сами версии с поиском. -Устроено так же, как [VersionPickerDialog](VersionPickerDialog.md), с одним принципиальным -отличием: выбранное здесь ещё и качается. У версий игры загрузку начинает сама сборка, а сборка -Java, которой нет на диске, запуску ничем не поможет — поэтому кнопка подтверждения при -необходимости сразу ставит выбранную сборку в очередь на скачивание. +## Интерфейс -Окно также показывает, какая версия Java нужна выбранной версии игры, помечает сборки, которые -для неё слишком старые, и позволяет удалять скачанное прямо из списка. +| | | +|---|---| +| `backend` (required) | `LauncherBackend` | +| `selectedId` | выбранная сборка | +| `category` | `java` (Mojang), `jdk`, `jre` (Temurin) | +| `filterText`, `installedOnly` | поиск и «только скачанные» | +| `requiredMajor` | минимальная Java для версии игры активной сборки; 0 — версия не выбрана | +| `openFor(runtimeId, requiredMajor)` | открыть | +| `runtimeChosen(runtimeId)` | выбор подтверждён | -## Место в проекте и зависимости +## Как работает -В начале файла объявлена `pragma ComponentBehavior: Bound`. Импортирует `QtQuick`, -`QtQuick.Controls 2.15` и `QtQuick.Layouts 2.15`. +`openFor()` сбрасывает поиск, открывает категорию переданной сборки и просит бэкенд обновить +каталог Java. По «Отмене» ничего не меняется. -Работает с C++-типом `LauncherBackend` (`launcherbackend.h`, `QML_ELEMENT` в модуле -`Minecraft_launcher`), передаваемым снаружи в свойство `backend`. Читает свойства `javaCatalog` и -`javaCatalogLoading`, вызывает `refreshJavaCatalog()`, `installJavaRuntime()` и -`removeJavaRuntime()`. +Под категориями слева написано, какая Java нужна выбранной версии игры, чтобы не держать это в +голове. -Объявлен в `QML_FILES` модуля (`CMakeLists.txt`); использует ресурс `images/Trash.svg`. -Инстанцируется в диалоге настроек внутри [Main](Main.md) — сборка Java выбирается один раз для -всего лаунчера, а не отдельно для каждой сборки Minecraft. +`javaCatalog` приходит из C++ уже отсортированным: скачанное первым, потом новые версии сверху. +Окно только отбирает строки. -Каталог приходит из C++ уже отсортированным — новые сверху, скачанные первыми, — поэтому окно -только отбирает записи и порядок не трогает. Запись каталога содержит поля `id`, `label`, `kind` -(тип сборки), `major` (мажорная версия Java числом), `installed`, `downloadable`, `lts`, `sizeMb`, -`detail`, `coverage` и `search`. +В строке: версия, пометка LTS, какие версии игры она потянет (`coverage`), размер и галочка у +скачанных. Сборка ниже `requiredMajor` не прячется, а помечается красноватым и подписью «слишком +старая для этой версии». Она может понадобиться другой сборке. -## Иерархия и роль +Кнопка внизу называется «Скачать», если выбранной сборки нет на диске, и «Выбрать», если есть. +`acceptSelection()` испускает `runtimeChosen` и, если сборки нет на диске, вызывает +`installJavaRuntime()`. Ход загрузки показывает панель главного окна. -Корневой тип — `Dialog`: модальный, 720×480 px, нулевой внутренний отступ, закрывается по Escape и -щелчку мимо, оформление тёмное со скруглением и акцентной рамкой. +## Удаление -Содержимое — `RowLayout` из колонки типов шириной 170 px и области версий, разделённых линией. -Каждый пункт колонки типов показывает название и пояснение мелким шрифтом, а под списком типов — -подсказка о требовании выбранной версии игры, видимая только когда это требование известно. - -Область версий повторяет устройство окна выбора версии Minecraft: поле поиска сверху, флажок -«Только скачанные» снизу, `ListView` между ними. Строка списка выше обычной (44 px), потому что -содержит две строки текста: подпись сборки с меткой LTS и строку подробностей, где через точку -собраны описание, покрытие версий игры, размер в мегабайтах и — при необходимости — -предупреждение о том, что сборки не хватит. - -Пустое состояние объясняется текстом по центру и различает загрузку каталога, отсутствие -соединения и пустой результат поиска. - -## Свойства - -| Свойство | Тип | По умолчанию | Обязательное | Описание | -|----------|-----|--------------|--------------|----------| -| `backend` | `var` | — | **Да** | Экземпляр `LauncherBackend` — источник каталога Java, исполнитель установки и удаления. | -| `selectedId` | `string` | `""` | Нет | Идентификатор выбранной сборки. Окно открывается с текущей сборкой, и по «Отмене» выбор возвращается к ней. | -| `category` | `string` | `"java"` | Нет | Ключ активного типа сборок. Допустимые значения: `java` (сборки Mojang), `jdk` (Temurin с инструментами), `jre` (Temurin, только запуск). | -| `filterText` | `string` | `""` | Нет | Текст поиска; сравнивается в нижнем регистре с полем `search` записи внутри активного типа. | -| `installedOnly` | `bool` | `false` | Нет | Показывать только скачанные сборки. | -| `requiredMajor` | `int` | `0` | Нет | Мажорная версия Java, ниже которой выбранной версии игры не запуститься. Значение `0` означает, что версия игры не выбрана и предупреждать не о чем: подсказка в колонке типов скрывается, пометка «слишком старая» не ставится. | -| `categories` | `var` (только чтение) | список из трёх записей | Нет | Описание колонки типов: массив объектов с полями `key`, `title` и `hint`. Задаёт порядок пунктов и набор допустимых значений `category`. | -| `visibleEntries` | `var` (только чтение) | вычисляется | Нет | Отобранные строки каталога: записи активного типа, прошедшие флажок «только скачанные» и текст поиска. Модель списка. | -| `selectedEntry` | `var` (только чтение) | вычисляется | Нет | Полная запись выбранной сборки из каталога или `null`, если ничего не выбрано. Ищется по всему каталогу, а не по видимым строкам, поэтому выбор не теряется при смене фильтра. От неё зависят подпись в подвале и надпись на кнопке подтверждения. | - -## Сигналы - -#### runtimeChosen(string runtimeId) - -Сборка Java подтверждена — кнопкой, двойным щелчком по строке или клавишей Enter в поле поиска. В -параметре приходит идентификатор сборки. - -Обработчик записывает выбранную сборку в настройки лаунчера — сборка Java общая для всех сборок -Minecraft, а требование конкретной версии игры влияет только на пометки в списке. Сигнал испускается и для ещё не скачанной сборки — загрузка при -этом начинается сама, и обработчику ждать её завершения не нужно. - -## Методы - -#### openFor(string runtimeId, int required) : void - -Открывает окно на переданной сборке. Запоминает её в `selectedId`, выставляет `requiredMajor` из -второго аргумента (отсутствующее или нулевое значение означает «требование неизвестно»), очищает -поиск, переключается на тип именно этой сборки, просит бэкенд обновить каталог и прокручивает -список к выбранной строке. - -#### categoryOf(string runtimeId) : string - -Возвращает тип сборки по каталогу; для неизвестного идентификатора — `java`. - -#### indexOfSelected() : int - -Позиция выбранной сборки в `visibleEntries` или `-1`, если под текущим фильтром её не видно. - -#### revealSelected() : void - -Выставляет текущий индекс списка на выбранную сборку и прокручивает список так, чтобы строка -оказалась по центру. - -#### acceptSelection() : void - -Подтверждает выбор. Ничего не делает, если `selectedEntry` пуст. Иначе испускает -`runtimeChosen()`, а затем — если сборка не установлена, но доступна для скачивания, — вызывает -`installJavaRuntime()` у бэкенда и закрывает окно. Именно поэтому кнопка подтверждения называется -«Скачать» для отсутствующей сборки и «Выбрать» для уже скачанной. - -## Взаимодействие с другими компонентами - -**Со стороны родителя.** Вызывающий код задаёт `backend`, открывает окно вызовом `openFor()` с -текущей сборкой и требуемой мажорной версией Java (её отдаёт `requiredJavaMajor()` бэкенда) и -подписывается на `runtimeChosen()`. - -**Со стороны бэкенда.** `javaCatalog` и `javaCatalogLoading` — привязки, пересчитывающие модель и -текст пустого состояния при каждом обновлении каталога. `refreshJavaCatalog()` вызывается при -открытии окна, `installJavaRuntime()` — при подтверждении отсутствующей сборки, -`removeJavaRuntime()` — по щелчку на корзине в строке. Ход самой загрузки окно не показывает: за -это отвечает плашка [ProgressPanel](ProgressPanel.md) в главном окне. - -**Удаление.** Корзина в строке доступна только у скачанных сборок; сборки весят по двести -мегабайт, и удалять их нужно прямо здесь, иначе папка лаунчера растёт молча. Если удалена была -выбранная сборка, выбор снимается. - -**Слишком старые сборки.** Сборка ниже требования игры остаётся доступной для выбора, но -помечается в строке подробностей: она может пригодиться для другой сборки Minecraft. - -## Пример использования - -```qml -JavaPickerDialog { - id: javaPicker - x: (window.width - width) / 2 - y: (window.height - height) / 2 - backend: launcherBackend - onRuntimeChosen: (runtimeId) => settingsDialog.javaRuntimeId = runtimeId -} - -MouseArea { - anchors.fill: javaField - onClicked: javaPicker.openFor(settingsDialog.javaRuntimeId, - launcherBackend.requiredJavaMajor(launcherBackend.activeBuildIndex)) -} -``` +У скачанных сборок есть корзина, и удаление происходит **без подтверждения**: +`removeJavaRuntime()` вызывается сразу. Сборка весит около двухсот мегабайт, и без корзины здесь +папка лаунчера росла бы незаметно для пользователя. Если удалили сборку, выбранную в настройках, +бэкенд очистит `javaRuntime`. --- diff --git a/doc/qml/LabelledField.md b/doc/qml/LabelledField.md index 0c87099..4e6d79a 100644 --- a/doc/qml/LabelledField.md +++ b/doc/qml/LabelledField.md @@ -1,75 +1,16 @@ -# LabelledField +# LabelledField.qml -## Обзор компонента +Подпись и под ней поле ввода. Используется в настройках и в карточке сборки. -`Minecraft_launcher` — десктопный лаунчер Minecraft на Qt Quick с полностью самостоятельно -оформленным тёмным интерфейсом. В диалоге настроек и в редакторе сборок много однотипных полей -ввода: подпись сверху, поле под ней. `LabelledField` собирает эту пару в один компонент, чтобы -отступы, цвета и подсветка фокуса не переписывались в каждом месте заново. +| | | +|---|---| +| `label` | подпись | +| `placeholder` | текст-подсказка в пустом поле | +| `text` | алиас текста поля | +| `validator` | алиас валидатора поля | +| `editingFinished()` | правка закончена: поле потеряло фокус или нажат Enter | -Компонент нужен там, где пользователь вводит короткое значение: имя профиля, объём памяти, путь, -аргументы запуска. - -## Место в проекте и зависимости - -Импортирует `QtQuick` и `QtQuick.Controls 2.15`; C++-типы не используются. - -Объявлен в `QML_FILES` модуля `Minecraft_launcher` (`CMakeLists.txt`) и доступен по имени внутри -модуля без импорта. Применяется в диалоге настроек и в карточке сборки — см. [Main](Main.md) и -[BuildsDialog](BuildsDialog.md). - -## Иерархия и роль - -Корневой тип — `Column` с расстоянием 3 px между элементами. Колонка содержит два потомка: -`Text` с подписью (серый, 11 px) и `TextField` фиксированной высоты 32 px с тёмным фоном, -скруглением 6 px и рамкой, которая при фокусе поля меняет цвет на акцентный. - -Ширина поля привязана к ширине самой колонки, поэтому размер задаётся снаружи одним свойством -`width` корневого элемента. Высоту `Column` вычисляет сам. - -## Свойства - -| Свойство | Тип | По умолчанию | Обязательное | Описание | -|----------|-----|--------------|--------------|----------| -| `text` | `string` (алиас на `text` внутреннего поля) | `""` | Нет | Содержимое поля ввода. Работает в обе стороны: чтение возвращает введённое значение, запись подставляет новое. | -| `validator` | `var` (алиас на `validator` внутреннего поля) | `null` | Нет | Валидатор ввода — например `IntValidator` для числовых полей. Ограничивает то, что пользователь может набрать. | -| `label` | `string` | `""` | Нет | Текст подписи над полем. | -| `placeholder` | `string` | `""` | Нет | Подсказка, показываемая в пустом поле приглушённым цветом. | - -## Сигналы - -#### editingFinished() - -Проброшен из внутреннего `TextField`: срабатывает, когда правка закончена — поле потеряло фокус -или пользователь нажал Enter. Промежуточные нажатия клавиш сигнала не вызывают. - -Обработчик обычно сохраняет введённое значение: читает `text` и передаёт его в бэкенд или в -модель родительского диалога. Именно из-за этой семантики поля настроек сохраняются по завершении -правки, а не на каждый символ. - -## Методы - -Собственных функций нет. - -## Взаимодействие с другими компонентами - -Компонент не знает ни о бэкенде, ни о содержащем его диалоге. Родитель задаёт `label`, -`placeholder`, начальный `text` и при необходимости `validator`, а затем подписывается на -`editingFinished`, чтобы записать значение. Двусторонней привязки к бэкенду внутри компонента нет -— решение о том, когда и куда сохранять, целиком за родителем. - -## Пример использования - -```qml -LabelledField { - width: parent.width - label: "Оперативная память, МБ" - placeholder: "2048" - text: String(settings.memoryMb) - validator: IntValidator { bottom: 512; top: 32768 } - onEditingFinished: settings.memoryMb = parseInt(text) -} -``` +Ширина поля равна ширине компонента, высота 32 px. --- diff --git a/doc/qml/LoaderRow.md b/doc/qml/LoaderRow.md index ee3ab5f..6610c44 100644 --- a/doc/qml/LoaderRow.md +++ b/doc/qml/LoaderRow.md @@ -1,149 +1,52 @@ -# LoaderRow +# LoaderRow.qml -## Обзор компонента +Одна строка модлоадера в карточке [BuildsDialog](BuildsDialog.md): чекбокс, а если он отмечен, +выпадающий список версий под выбранную версию Minecraft. -`Minecraft_launcher` — десктопный лаунчер Minecraft на Qt Quick. Кроме чистой игры он умеет -ставить модлоадеры: Forge, Fabric, NeoForge и Quilt. В карточке сборки лоадер выбирается -чекбоксом, а под ним — конкретная версия лоадера. +Совместимость здесь не проверяется. `backend.loaderVersions()` отдаёт список уже под конкретную +версию игры, и несовместимых строк в нём нет. Если список пустой, значит, модлоадер эту версию не +поддерживает, и вместо пустого выпадающего списка показывается текст. -`LoaderRow` — одна такая строка: чекбокс с названием лоадера и, когда он отмечен, выпадающий -список версий именно под выбранную версию Minecraft. Компонент берёт на себя всю возню со -списком версий: подтягивает его из кэша, обновляет по сети, следит, чтобы выбранная версия -всегда существовала под текущую версию игры, и словами объясняет случай «лоадер эту версию игры -не поддерживает» вместо показа пустого списка. +## Интерфейс -Совместимость компонент не проверяет и проверять не должен: бэкенд отдаёт список, уже собранный -под конкретную версию игры, поэтому несовместимой строки в нём не бывает. Пустой список — это и -есть отсутствие поддержки. +| | | +|---|---| +| `backend` (required) | `LauncherBackend` | +| `loaderKey` (required) | `forge`, `fabric`, `neoforge`, `quilt` | +| `title` (required) | подпись у чекбокса | +| `gameVersion` | версия Minecraft из карточки; её смена сбрасывает выбор | +| `checked` | алиас чекбокса | +| `selectedVersion` | выбранная версия модлоадера | +| `entries` | строки `{version, label, recommended, stable}` | +| `applying` | строка заполняется из сборки; изменения в это время не считаются пользовательскими | +| `userChecked()` | пользователь отметил чекбокс. Карточка снимает отметки с остальных строк | +| `changed()` | отметка или версия изменились, карточке пора сохранить сборку | +| `applyBuild(loader, loaderVersion)` | заполнить из сохранённой сборки без `changed()` | -## Место в проекте и зависимости +Пока версия игры не выбрана, чекбокс выключен. -Импортирует `QtQuick` и `QtQuick.Controls 2.15`. +## applyEntries(list) -Использует компонент [DarkCombo](DarkCombo.md) из того же QML-модуля — выпадающий список версий в -тёмном стиле окна. +Единственное место, где меняются `entries` и `selectedVersion`. Через него проходят все три пути: +ответ из кэша, ответ сети и заполнение из сборки. Если выбранной версии в новом списке нет, она +заменяется первой строкой, а это самая новая версия для этой версии игры. Если при этом значение +изменилось и строка не в режиме `applying`, испускается `changed()`. -Работает с C++-типом `LauncherBackend` (`launcherbackend.h`, зарегистрирован через `QML_ELEMENT` -в модуле `Minecraft_launcher`), который передаётся снаружи в свойство `backend`. Из него -компонент вызывает `loaderVersions()`, `refreshLoaderVersions()` и `loaderVersionsLoading()`, а -также слушает сигнал `loaderVersionsChanged`. +Раньше проверку делал только путь из сети, и версия модлоадера, выбранная под прошлую версию игры, +доживала до сохранения сборки. -Объявлен в `QML_FILES` модуля `Minecraft_launcher` (`CMakeLists.txt`). Инстанцируется в -[BuildsDialog](BuildsDialog.md) — по одной строке на каждый поддерживаемый лоадер. +## Откуда берётся список -## Иерархия и роль +`reload()` сначала берёт то, что уже есть в кэше (`loaderVersions()`), чтобы список появился сразу, +а потом просит обновление (`refreshLoaderVersions()`). Ответ приходит в +`backend.loaderVersionsChanged(key, game)`. Строка сравнивает пару со своей и игнорирует чужие +ответы: от другого модлоадера или от прошлой версии игры. -Корневой тип — `Column` с расстоянием 4 px. Внутри три потомка, видимость которых -взаимоисключающая по нижней части: +Под пустым списком написано «загрузка версий…», пока идёт запрос +(`loaderVersionsLoading()`), и «не поддерживает версию N» после. -- `CheckBox` с полностью переопределённым индикатором (квадрат со скруглением и галочкой) и - подписью. Выключен, пока не выбрана версия игры. -- [DarkCombo](DarkCombo.md) со списком версий лоадера — виден, только когда чекбокс отмечен и - список непустой. -- Текстовая строка на месте списка — видна, когда чекбокс отмечен, а список пуст. Пока идёт - запрос, она серая и говорит о загрузке; когда запрос закончен, она красноватая и сообщает, что - лоадер не поддерживает выбранную версию Minecraft. - -Ширина внутренних элементов считается от ширины колонки, поэтому снаружи достаточно задать -`width`. - -## Свойства - -| Свойство | Тип | По умолчанию | Обязательное | Описание | -|----------|-----|--------------|--------------|----------| -| `backend` | `var` | — | **Да** | Экземпляр `LauncherBackend`. Через него запрашиваются и обновляются списки версий лоадера. | -| `loaderKey` | `string` | — | **Да** | Ключ лоадера, которым он опознаётся в бэкенде и в сохранённой сборке: `forge`, `fabric`, `neoforge`, `quilt`. | -| `title` | `string` | — | **Да** | Человекочитаемое название лоадера рядом с чекбоксом; оно же подставляется в сообщения о загрузке и об отсутствии поддержки. | -| `gameVersion` | `string` | `""` | Нет | Версия Minecraft, выбранная в карточке. Пустая строка выключает чекбокс. Смена значения сбрасывает выбранную версию лоадера и перезапрашивает список. | -| `checked` | `bool` (алиас на чекбокс) | `false` | Нет | Отмечен ли лоадер. Чтение даёт текущее состояние, запись переключает чекбокс программно — без сигнала `userChecked()`. | -| `selectedVersion` | `string` | `""` | Нет | Выбранная версия лоадера. Устанавливается только через `applyEntries()`, поэтому всегда либо пуста, либо присутствует в текущем списке. | -| `entries` | `var` | `[]` | Нет | Текущий список версий лоадера — массив записей, у каждой есть поля `version` (значение) и `label` (подпись для списка). Заполняется из бэкенда. | -| `applying` | `bool` | `false` | Нет | Признак того, что строку сейчас заполняет карточка данными сохранённой сборки. Пока он выставлен, изменения не считаются пользовательскими и сигнал `changed()` не испускается. | - -## Сигналы - -#### userChecked() - -Пользователь сам отметил чекбокс (не программная установка `checked`). Карточка сборки в ответ -снимает отметки с остальных строк лоадеров: одновременно в `.minecraft` может жить только один -лоадер. - -#### changed() - -Отметка или версия лоадера изменились и это изменение пользовательское. Обработчик — карточка -сборки — сохраняет сборку с новыми значениями. - -Сигнал сознательно не испускается, пока выставлен `applying`, то есть при заполнении строки из -уже сохранённой сборки: иначе загрузка карточки сразу же приводила бы к её перезаписи. - -## Методы - -#### versionIndex(string version) : int - -Возвращает позицию версии в текущем массиве `entries` или `-1`, если такой версии в списке нет. -Вспомогательная функция для синхронизации выбранного значения с выпадающим списком. - -#### applyEntries(var list) : void - -Единственное место, где меняются `entries` и `selectedVersion`. Через него проходят все три пути -получения списка — кэш, ответ сети и заполнение из сохранённой сборки, — потому что выбранная -версия обязана существовать в списке под текущую версию игры. - -Записывает новый список, а затем проверяет выбранную версию: если её в списке нет, подставляет -первую строку (список отсортирован новыми вперёд, поэтому первая — максимально доступная под эту -версию игры) или пустую строку для пустого списка. Если подстановка изменила значение и строка не -находится в режиме `applying`, испускает `changed()`. В конце синхронизирует `currentIndex` -выпадающего списка. - -#### reload() : void - -Перезапрашивает список версий. Если лоадер не отмечен или версия игры не выбрана, очищает список -через `applyEntries([])`. Иначе сначала берёт список из кэша бэкенда (`loaderVersions()`) — он -появляется мгновенно, — а затем просит обновление по сети (`refreshLoaderVersions()`), результат -которого придёт позже сигналом. - -#### applyBuild(string loader, string loaderVersion) : void - -Заполняет строку данными сохранённой сборки, не испуская `changed()`: на время работы выставляет -`applying`. Отмечает чекбокс, если ключ лоадера сборки совпадает с `loaderKey`, подставляет версию -из сборки как пожелание и вызывает `reload()`. Если под выбранную версию игры такой версии -лоадера нет, `applyEntries()` заменит её на максимально доступную. - -## Взаимодействие с другими компонентами - -**Что приходит извне.** Карточка сборки в [BuildsDialog](BuildsDialog.md) задаёт `backend`, -`loaderKey`, `title` и привязывает `gameVersion` к версии Minecraft, выбранной в карточке. -Заполнение сохранённой сборкой идёт вызовом `applyBuild()` снаружи. - -**Что уходит наружу.** По `userChecked()` карточка снимает отметки с остальных строк — набор -строк она держит в собственном списке. По `changed()` карточка сохраняет сборку, читая `checked` и -`selectedVersion`. - -**Бэкенд.** Компонент сам подписан на сигнал `loaderVersionsChanged(key, game)` через -`Connections`: пришедшее обновление принимается, только если ключ и версия игры совпадают с -текущими и чекбокс отмечен, — иначе ответ относится к другой строке или устарел. Текст в пустом -состоянии опрашивает `loaderVersionsLoading()`, чтобы отличать «ещё грузим» от «не поддерживается». - -**Реакция на смену версии игры.** Обработчик `onGameVersionChanged` сбрасывает `selectedVersion` и -вызывает `reload()`: сборка лоадера привязана к версии игры, поэтому под новой версией прежний -выбор недействителен. - -## Пример использования - -```qml -LoaderRow { - id: forgeRow - width: parent.width - - backend: launcherBackend - loaderKey: "forge" - title: "Forge" - gameVersion: buildCard.gameVersion - - onUserChecked: buildCard.keepOnly(forgeRow) - onChanged: buildCard.commitSelection() -} -``` +`applyBuild()` берёт версию из сборки как пожелание. Если под текущую версию игры её нет, +`applyEntries()` заменит её на самую новую. --- diff --git a/doc/qml/Main.md b/doc/qml/Main.md index 82e3e31..d5d9a2c 100644 --- a/doc/qml/Main.md +++ b/doc/qml/Main.md @@ -1,211 +1,131 @@ -# Main +# Main.qml -## Обзор компонента +Главное окно, оно же точка входа: `main.cpp` загружает его через +`engine.loadFromModule("BiLauncher", "Main")`. Корень — `Window` 1280×720. -`Minecraft_launcher` — десктопный лаунчер Minecraft на Qt Quick. `Main.qml` — его главное и -единственное настоящее окно: точка входа приложения, которую загружает `main.cpp` вызовом -`engine.loadFromModule("Minecraft_launcher", "Main")`. +Что в нём есть: -Окно совмещает четыре роли. Оно держит единственный экземпляр `LauncherBackend` — весь остальной -интерфейс получает его от главного окна. Оно рисует сам экран запуска: фоновая картинка, большая -кнопка игры по центру, выпадающий список профилей, кнопка активной сборки, кнопки папки модов, -настроек и сезонных сборок. Оно показывает обратную связь — всплывающую плашку сообщений и две -панели хода долгих операций. И наконец, оно объявляет диалоги, которые не вынесены в отдельные -файлы: создание и редактирование профиля, ввод кода двухфакторной аутентификации и настройки -запуска. +- единственный экземпляр `LauncherBackend` и все обработчики его сигналов; +- экран запуска: фон, кнопка игры по центру, список профилей, кнопка активной сборки, кнопки папки + модов, настроек и сезонных сборок; +- плашка сообщений и две панели прогресса ([ProgressPanel](ProgressPanel.md)); +- диалоги, которые не вынесены в отдельные файлы: новый профиль, редактирование профиля, код + двухфакторной аутентификации, настройки запуска. -## Место в проекте и зависимости +Отдельными файлами сделаны [BuildsDialog](BuildsDialog.md), +[SeasonalBuildsDialog](SeasonalBuildsDialog.md), [JavaPickerDialog](JavaPickerDialog.md) и +[MicrosoftLoginDialog](MicrosoftLoginDialog.md). Окно передаёт им бэкенд через свойство `backend`. -Импортирует `QtQuick`, `QtQuick.Layouts 2.15`, `QtQuick.Controls 2.15` и сам QML-модуль проекта -`Minecraft_launcher`, из которого приходит тип `LauncherBackend`. +Стиль Qt Quick Controls — `Basic` (выставлен в `main.cpp`), и почти у каждого контрола здесь своё +оформление. -Инстанцирует четыре компонента модуля: [ProgressPanel](ProgressPanel.md) (дважды), -[SeasonalBuildsDialog](SeasonalBuildsDialog.md), [BuildsDialog](BuildsDialog.md), -[JavaPickerDialog](JavaPickerDialog.md), а также [DarkCombo](DarkCombo.md) и -[LabelledField](LabelledField.md) внутри своих диалогов. -[MicrosoftLoginDialog](MicrosoftLoginDialog.md) создаётся динамически — см. ниже. +## Раскладка -Стиль Qt Quick Controls принудительно выставлен в `Basic` в `main.cpp`, потому что нативные стили -игнорируют пользовательские `contentItem` и `background`; поэтому в этом файле почти каждый -элемент управления переопределяет своё оформление вручную. +Всё стоит на якорях вокруг кнопки запуска: список профилей слева сверху от неё, кнопка активной +сборки справа сверху, кнопки папки и настроек под списком профилей. Кнопка сезонных сборок стоит в +правом нижнем углу, потому что остальные углы заняты. -Использует ресурсы из `RESOURCES` QML-модуля: фоновую картинку, три состояния кнопки запуска, по -три состояния кнопок папки и настроек, стрелки выпадающих списков, `images/Trash.svg` и -`images/Pencil.svg`. +Обе панели прогресса (загрузки и смены сборки) стоят в левом нижнем углу на одних и тех же якорях. +Одновременно они не появляются: пока лаунчер занят загрузкой, бэкенд не начнёт смену сборки, и +наоборот. Панель смены сборки отменить нельзя. -## Иерархия и роль +У заголовков и подвалов всех диалогов корнем служит `Item` с явным `implicitHeight`. У +`Rectangle` `implicitHeight` всегда 0, и `Dialog` не смог бы посчитать свою высоту. -Корневой тип — `Window` размером 1280×720 px, видимое при старте. Это не переиспользуемый -компонент, а точка входа приложения, поэтому раздел с примером использования здесь неприменим. +## Функции -Раскладка держится на якорях относительно центральной кнопки запуска: список профилей — слева -сверху от неё, кнопка активной сборки — справа сверху, кнопки папки и настроек — под списком -профилей. Кнопка сезонных сборок стоит в правом нижнем углу: это единственная свободная часть -окна, потому что панели хода работ висят слева, а всё остальное собрано вокруг кнопки запуска. +**`showToast(text, color, timeout)`** — показать сообщение в плашке. `timeout` в миллисекундах, +по умолчанию 4000. `0` значит «держать до следующего сообщения». Так показываются шаги запуска, +чтобы сообщение не исчезало посреди долгой операции. -Панели загрузки и смены сборки имеют одни и те же якоря — они взаимоисключающи по построению: -признак занятости бэкенда не даёт начать переключение во время установки и наоборот. Панель смены -сборки объявлена неотменяемой: отступать после очистки `.minecraft` некуда, операцию нужно довести -до конца. +**`openMicrosoftLogin(url)`** — открыть окно входа Microsoft. Окно создаётся при первом вызове +через `Qt.createComponent()` и хранится в `microsoftLoginDialog`. Объявить его прямо в файле +нельзя: `MicrosoftLoginDialog.qml` есть в модуле только в сборках с WebEngine, и в остальных +сломалось бы всё главное окно. Если компонент не создался, вход отменяется у бэкенда и показывается +сообщение. -Заголовки и подвалы всех диалогов сделаны на `Item` с явным `implicitHeight`, а не на -`Rectangle`: у прямоугольника `implicitHeight` равен нулю независимо от заданной высоты, и -`Dialog` не смог бы вычислить свою полную высоту. +**`formatMb(bytes)`** — байты в мегабайты с одним знаком после запятой, для панели загрузки. -## Свойства +## Сигналы бэкенда -| Свойство | Тип | По умолчанию | Обязательное | Описание | -|----------|-----|--------------|--------------|----------| -| `microsoftLoginDialog` | `var` | `null` | Нет | Созданный по требованию экземпляр окна входа Microsoft либо `null`, пока вход ни разу не запускался. Хранится в свойстве, чтобы окно создавалось один раз за сеанс. | +| Сигнал | Что делает окно | +|--------|-----------------| +| `launched` | зелёное сообщение «запущено» | +| `launchProgress` | сообщение без таймаута | +| `launchError` | красное сообщение на 8 секунд | +| `twoFactorRequired` | открывает диалог кода | +| `gameFinished` | сообщение о закрытии игры; если код не 0 или процесс упал, красное и с кодом | +| `microsoftLoginUrlReady` | `openMicrosoftLogin()` | +| `microsoftLoginSucceeded` | сообщение. Выбор в списке профилей не трогает: новый профиль уже выбран тем, кто его создал, а повторный вход мог быть не для последнего профиля | +| `microsoftLoginFailed` | красное сообщение на 8 секунд | +| `microsoftReloginRequired` | сразу `startMicrosoftLogin()` для этого профиля | +| `gameOutput` | `console.log` | +| `seasonalInstallFinished` | сообщение «сборка установлена, можно играть» | +| `javaRuntimeInstalled` | обновляет строку Java в настройках | -### Внутренние диалоги и их состояние +Кнопка запуска выключена, пока `busy` или `gameRunning`. Кнопка активной сборки выключена во время +игры и смены сборки. -`Main.qml` объявляет четыре диалога прямо в файле. Их свойства — часть состояния главного окна. +## Профили -**Диалог редактирования профиля** (`editProfileDialog`): +Выпадающий список профилей переделан целиком. Наверху всплывающей панели закреплена кнопка +«+ Добавить профиль», под ней список. У каждой строки при наведении появляются карандаш (открывает +редактирование через `openFor()`) и корзина (`removeProfile()`). -| Свойство | Тип | По умолчанию | Описание | -|----------|-----|--------------|----------| -| `editIndex` | `int` | `-1` | Индекс редактируемого профиля; `-1` — диалог не открыт ни для кого. | -| `msProfile` | `bool` | `false` | Открытый профиль имеет тип «Microsoft». | -| `msLinked` | `bool` | `false` | У профиля есть действующая сессия Microsoft — от этого зависит строка статуса и подпись кнопки входа. | -| `msName` | `string` | `""` | Ник, полученный при официальной авторизации. Показывается отдельным полем только для чтения, а не подменой поля логина: привязка сломалась бы первым же вводом в поле логина обычного профиля. | +Типы входа во всех списках идут в одном порядке: 0 — `offline`, 1 — `elyby`, 2 — `microsoft`. +Модель берётся из `Loc.t.profile.authTypes`, и порядок в каталоге важен, потому что код сравнивает +`currentIndex` с 1 и 2. Без WebEngine третий пункт отрезается через `.slice(0, 2)`. В окне +редактирования он остаётся, если профиль уже лицензионный: иначе в сборке без WebEngine такой +профиль молча стал бы офлайновым. -**Диалог двухфакторной аутентификации** (`twoFactorDialog`): +**Новый профиль.** `addProfile()`, новый профиль выбирается в списке. Если тип «Microsoft», сразу +начинается вход, потому что без него такой профиль бесполезен. Скрытые поля при сохранении не +читаются: в них мог остаться текст, набранный до смены типа. -| Свойство | Тип | По умолчанию | Описание | -|----------|-----|--------------|----------| -| `profileName` | `string` | `""` | Имя профиля, для которого запрошен код; подставляется в текст просьбы. | +**Редактирование.** Дополнительные свойства диалога: `editIndex`, `msProfile`, `msLinked` (есть ли +сессия Microsoft), `msName` (ник из аккаунта). Ник показывается отдельным полем только для чтения, +а не подставляется в поле логина. Иначе привязка оборвалась бы при первом же вводе в это поле у +обычного профиля. Кнопка входа сначала сохраняет профиль с типом `microsoft` через +`updateProfile()` и только потом вызывает `startMicrosoftLogin()`. Тип могли переключить только +что, и без этого токены достались бы профилю другого типа. -**Диалог настроек** (`settingsDialog`): +## Двухфакторная аутентификация -| Свойство | Тип | По умолчанию | Описание | -|----------|-----|--------------|----------| -| `javaRuntimeId` | `string` | `""` | Выбранная сборка Java из папки лаунчера. Живёт в свойстве, а не в поле ввода: её выбирают в отдельном окне, а записывается она только по «Сохранить». Пустая строка означает «искать Java в системе». | -| `javaRuntimeInfo` | `var` | `null` | Подробности выбранной сборки для строки поля. Не привязка: `javaRuntimeInfo()` — обычный вызов, и сам он не пересчитается, когда сборка докачается, поэтому значение обновляется по событиям. | +Диалог показывает имя профиля (`profileName`) и поле для кода. «Подтвердить» вызывает +`submitTwoFactorCode()`, «Отмена» — `cancelPendingLaunch()`. Если отмену не вызвать, запуск так и +повиснет. -Типы профилей во всех выпадающих списках кодируются одинаково: позиция 0 — `offline` -(офлайн, без пароля), 1 — `elyby` (Ely.by, с логином и паролем), 2 — `microsoft` (лицензия). -Третья позиция показывается, только когда лаунчер собран с Qt WebEngine; в диалоге редактирования -она показывается ещё и тогда, когда профиль уже сохранён как лицензионный — иначе в сборке без -WebEngine он молча стал бы офлайновым. +## Настройки -## Сигналы +`settingsDialog.load()` читает `settings()` и `detectedJava()`, раскладывает значения по полям и +открывает диалог. «Сохранить» собирает всё в одну карту и отдаёт в `updateSettings()`. -Собственных сигналов главное окно не объявляет. +- **Язык** стоит первым, потому что относится ко всему приложению, а не к запуску игры. В модели + переведённые подписи, рядом лежит непереводимый список `codes` (`system`, `ru`, `en`). Язык + применяется по «Сохранить» и сразу, без перезапуска. +- **Папка игры**, а под полем — путь, который получится на самом деле (`resolvedGameDir`). Путь + хранится в свойстве, а не записывается в текст напрямую, иначе подпись не обновилась бы при смене + языка. +- **Java.** Поле открывает [JavaPickerDialog](JavaPickerDialog.md) с текущим выбором и требованием + активной сборки (`requiredJavaMajor()`). Крестик сбрасывает выбор, и тогда Java ищется в системе. + Выбор хранится в `javaRuntimeId` и записывается только по «Сохранить». `javaRuntimeInfo` + обновляется при смене `javaRuntimeId` и по `javaRuntimeInstalled`, потому что + `javaRuntimeInfo()` — обычный вызов, а не привязка. Если сборка выбрана, запуск идёт ею, а путь + из соседнего поля остаётся запасным вариантом. +- Память, размер окна, аргументы JVM, полноэкранный режим. +- Ниже для справки список найденных в системе java. -## Методы - -#### showToast(string text, color color, int timeout) : void - -Показывает единую всплывающую плашку сообщений: через неё проходят сообщения о ходе запуска, -ошибки и статус игры. Задаёт текст и цвет фона и перезапускает таймер скрытия. - -Параметр `timeout` — время показа в миллисекундах. Пропущенное значение означает четыре секунды; -`0` означает «держать до следующего сообщения» — так показываются промежуточные шаги запуска, чтобы -сообщение не исчезало посреди долгой операции. - -#### openMicrosoftLogin(url) : void - -Открывает окно входа в аккаунт Microsoft на переданном адресе, создавая его при первом вызове. - -Окно создаётся по требованию, а не вместе с главным: `MicrosoftLoginDialog.qml` попадает в модуль -только в сборках с Qt WebEngine, и обычная декларация сломала бы всё главное окно в остальных. -Поэтому компонент загружается через `Qt.createComponent()`, и если он не готов — сборка собрана без -WebEngine, — вход отменяется у бэкенда, а пользователю показывается сообщение о том, что окно -недоступно. При успешном создании окну сразу передаётся бэкенд, а его сигнал `failed` -подключается к плашке сообщений. - -#### formatMb(bytes) : string - -Переводит байты в мегабайты с одним знаком после запятой. Используется в строке подробностей -панели загрузки. - -## Взаимодействие с другими компонентами - -### Бэкенд - -Единственный экземпляр `LauncherBackend` объявлен прямо в окне и передаётся всем вложенным -диалогам через их свойство `backend`. Главное окно — единственное место, где обрабатываются его -сигналы: - -| Сигнал бэкенда | Что делает главное окно | -|----------------|-------------------------| -| `launched(profileName, buildName, serverUrl)` | показывает зелёное сообщение о запуске | -| `launchProgress(message)` | показывает сообщение без таймаута — до следующего шага | -| `launchError(message)` | показывает ошибку на восемь секунд | -| `twoFactorRequired(profileName)` | открывает диалог ввода кода: Ely.by отклонил пароль с пометкой two factor, и код добирается здесь, чтобы продолжить прерванный запуск | -| `gameFinished(exitCode, crashed)` | сообщает о закрытии игры; аварийное завершение показывается красным вместе с кодом выхода | -| `microsoftLoginUrlReady(url)` | вызывает `openMicrosoftLogin()` | -| `microsoftLoginSucceeded(playerName)` | сообщает об успешном входе. Выбор в списке профилей при этом не трогается: новый профиль уже выбран тем, кто его создал, а повторный вход мог быть и не в последний профиль | -| `microsoftLoginFailed(message)` | показывает ошибку на восемь секунд | -| `microsoftReloginRequired(profileIndex)` | сразу начинает вход заново для этого профиля | -| `gameOutput(line)` | пишет строку в консоль | -| `seasonalInstallFinished(seasonalId, buildName)` | сообщает, что сезонная сборка установлена и её можно запускать | -| `javaRuntimeInstalled(runtimeId)` | обновляет подробности выбранной сборки Java в настройках | - -Привязки к свойствам бэкенда управляют доступностью интерфейса: кнопка запуска выключена, пока -лаунчер занят или игра уже идёт; кнопка активной сборки — пока идёт игра или переключение сборок; -подпись на ней берётся из `activeBuildName`, а список профилей — из `profileNames`. - -### Профили - -Выпадающий список профилей переопределён целиком: кнопка «+ Добавить профиль» закреплена сверху -всплывающей панели, под ней список, где у строки при наведении появляются карандаш и корзина. -Карандаш открывает диалог редактирования (`openFor()` заполняет его через `profileAt()`), корзина -вызывает `removeProfile()`. - -Создание профиля вызывает `addProfile()`, выбирает новый профиль в списке и, если тип — -«Microsoft», сразу начинает вход: такой профиль без входа бесполезен. Скрытые поля при сохранении -не читаются — в них мог остаться текст, набранный до переключения типа профиля. - -В диалоге редактирования кнопка входа перед вызовом `startMicrosoftLogin()` сначала сохраняет -профиль вызовом `updateProfile()` с типом `microsoft`: тип мог быть только что переключён, и без -этого бэкенд приписал бы токены профилю другого типа. - -### Запуск игры - -Кнопка запуска вызывает `launchGame()` с индексом выбранного профиля и индексом активной сборки. -Дальше всё идёт через сигналы бэкенда: промежуточные шаги — в плашку сообщений, запрос кода -двухфакторной аутентификации — в отдельный диалог, где подтверждение вызывает -`submitTwoFactorCode()`, а отмена — `cancelPendingLaunch()`. - -### Настройки - -Диалог настроек открывается методом `load()`, который читает `settings()` бэкенда и раскладывает -значения по полям, а также подставляет разрешённый путь папки игры и список найденных в системе -сборок Java (`detectedJava()`). Сохранение собирает все поля в один `QVariantMap` и передаёт его -в `updateSettings()`. - -Первым пунктом диалога идёт выбор языка интерфейса — настройка уровня приложения, поэтому она -стоит над параметрами запуска. Подписи в модели переводятся, а коды (`system`, `ru`, `en`) лежат -рядом отдельным списком `codes` и не переводятся. Применяется язык по кнопке «Сохранить», как и -всё остальное в этом диалоге, и сразу же, без перезапуска: см. [Localization](../cpp/Localization.md). - -Разрешённый путь папки игры хранится свойством `resolvedGameDir` диалога, а не присваивается -тексту напрямую — иначе подпись не пережила бы смену языка. - -Поле выбора сборки Java открывает [JavaPickerDialog](JavaPickerDialog.md), передавая текущий выбор -и требование активной сборки (`requiredJavaMajor()`); крестик справа сбрасывает выбор обратно на -поиск Java в системе. Само окно выбора объявлено рядом с настройками, а не внутри них: оно шире и +Окно выбора Java объявлено рядом с диалогом настроек, а не внутри него. Оно шире настроек и центрируется по окну лаунчера. -Выбранная сборка Java — общая настройка лаунчера: когда она задана, запуск идёт ею, а путь к Java -из соседнего поля остаётся запасным вариантом. +## Кнопки -### Тексты +- папка — `openMinecraftFolder()` (открывает `mods`); +- настройки — `settingsDialog.load()`; +- сезонные сборки — `seasonalDialog.openCatalog()`; +- активная сборка — `buildsDialog.open()`. -Все подписи, сообщения и подсказки окна берутся из синглтона `Loc`: `Loc.t.домен.вид.имя`. -Ни одного текстового литерала в разметке не осталось, `qsTr` не используется. Модель типов входа -в диалогах профиля — тоже ключ каталога (`Loc.t.profile.authTypes`), причём порядок значений -в нём значим: код сравнивает `currentIndex` с 1 и 2, а вариант без Microsoft получается из той же -модели через `.slice(0, 2)`. Подробности — в [Localization](../cpp/Localization.md). - -### Прочие кнопки - -Кнопка папки вызывает `openMinecraftFolder()`, кнопка настроек открывает диалог настроек, кнопка -сезонных сборок — [SeasonalBuildsDialog](SeasonalBuildsDialog.md) методом `openCatalog()`, кнопка -активной сборки — [BuildsDialog](BuildsDialog.md). +Все тексты окна берутся из `Loc.t`, литералов в разметке нет. --- diff --git a/doc/qml/MicrosoftLoginDialog.md b/doc/qml/MicrosoftLoginDialog.md index 1b5085f..ebb9a40 100644 --- a/doc/qml/MicrosoftLoginDialog.md +++ b/doc/qml/MicrosoftLoginDialog.md @@ -1,120 +1,45 @@ -# MicrosoftLoginDialog +# MicrosoftLoginDialog.qml -## Обзор компонента +Окно входа в аккаунт Microsoft. Работает так же, как официальный лаунчер: страница входа +открывается внутри приложения в `WebEngineView`, а лаунчер ждёт, когда браузер перейдёт на +`redirect_uri` с кодом в адресе. -`Minecraft_launcher` — десктопный лаунчер Minecraft на Qt Quick. Он поддерживает несколько -способов входа: офлайн-профиль, сервер Ely.by и учётную запись Microsoft. Последний путь требует -показать пользователю настоящую страницу входа Microsoft и дождаться, пока браузер уйдёт на -`redirect_uri` с кодом авторизации в адресе — ровно так же поступает официальный лаунчер. +Файл попадает в QML-модуль, только если CMake нашёл `Qt6::WebEngineQuick`. Поэтому +[Main](Main.md) не объявляет окно в разметке, а создаёт его через `Qt.createComponent()` при первом +входе (`openMicrosoftLogin()`). -`MicrosoftLoginDialog` — окно с этой страницей. Внутри него живёт `WebEngineView`; диалог следит -за сменой адреса, отдаёт перехваченный код бэкенду и закрывается. Собственной логики разбора -адреса у него нет — она в C++, чтобы правила совпадения совпадали с теми, по которым сервис сам -строит `redirect_uri`. +## Интерфейс -## Место в проекте и зависимости +| | | +|---|---| +| `backend` (required) | `LauncherBackend` | +| `openAt(url)` | открыть страницу входа | +| `failed(message)` | вход не удался. Окно ничего не знает о плашке сообщений главного окна и сообщает о неудаче сигналом | -Импортирует `QtQuick`, `QtQuick.Controls 2.15` и `QtWebEngine`. В начале файла объявлена -`pragma ComponentBehavior: Bound`. +## Как работает -Работает с C++-типом `LauncherBackend` (`launcherbackend.h`, `QML_ELEMENT`), который передаётся -снаружи в свойство `backend`. Вызывает у него `inspectMicrosoftRedirect()`, -`finishMicrosoftLogin()` и `cancelMicrosoftLogin()`. +На каждое `urlChanged` вызывается `handleUrl()`. Он передаёт адрес в +`backend.inspectMicrosoftRedirect()`. Разбор сделан в C++, чтобы правила совпадали с тем, как +[MsaAuthService](../cpp/MsaAuthService.md) строит `redirect_uri`. -**Особенность сборки.** Это единственный QML-файл проекта, который попадает в модуль условно. -`CMakeLists.txt` ищет `Qt6WebEngineQuick` через `find_package(... QUIET)`; модуль объявлен -необязательным сознательно — он ставится отдельной галочкой в установщике Qt и тянет за собой -WebChannel с Positioning, которых в типовой установке нет. Если модуль найден, файл добавляется в -`QML_FILES` и определяется макрос `LAUNCHER_HAS_WEBENGINE`; если нет — лаунчер собирается и -работает как прежде, только без входа через Microsoft. В QML это различие видно через свойство -`backend.microsoftAvailable`, и интерфейс не должен предлагать этот путь, когда оно ложно. +Когда адрес совпал: -Инстанцируется динамически из [Main](Main.md) — главное окно создаёт диалог по требованию, потому -что при сборке без WebEngine самого типа в модуле не существует. +- окно закрывается, `codeTaken` выставляется в `true`; +- если есть код, вызывается `backend.finishMicrosoftLogin(code)`; +- если кода нет, вызывается `backend.cancelMicrosoftLogin()` и испускается `failed` с текстом от + Microsoft или общим «вход не завершён». -## Иерархия и роль +`codeTaken` нужен потому, что `WebEngineView` может сообщить об одном и том же адресе несколько +раз, а отдавать код дважды нельзя. -Корневой тип — `Dialog` из Qt Quick Controls: модальный, 560×680 px, по центру родителя, с нулевым -внутренним отступом и `closePolicy: Popup.NoAutoClose` — окно нельзя закрыть щелчком мимо или -клавишей Escape, выход только через кнопку отмены или успешный вход. +Кнопка «Отмена» выставляет `codeTaken`, закрывает окно и вызывает `cancelMicrosoftLogin()`. Щелчок +мимо окна и Esc его не закрывают. -Оформление задано вручную: тёмный фон со скруглением и акцентной рамкой, заголовок с -разделительной линией, подвал с кнопкой «Отмена». +## Куки -Содержимое — `WebEngineView` во всю площадь с отступом 12 px и индикатор занятости по центру, -видимый на время загрузки страницы. Рядом объявлен `WebEngineProfilePrototype` без `storageName`: -профиль без имени хранилища означает профиль без диска, поэтому куки живут только пока работает -лаунчер и в общий браузер не попадают. За выбор аккаунта в пределах сессии отвечает параметр -`prompt=select_account` в адресе входа, который формирует бэкенд. - -## Свойства - -| Свойство | Тип | По умолчанию | Обязательное | Описание | -|----------|-----|--------------|--------------|----------| -| `backend` | `var` | — | **Да** | Экземпляр `LauncherBackend`. Разбирает перехваченный адрес и завершает или отменяет вход. | -| `codeTaken` | `bool` | `false` | Нет | Код авторизации уже отдан бэкенду. Защита от повторной обработки: `WebEngineView` успевает сообщить об изменении адреса несколько раз, и без этого признака код ушёл бы дважды. Сбрасывается в `openAt()` и принудительно выставляется при отмене. | - -## Сигналы - -#### failed(string message) - -Вход не завершён: адрес совпал с `redirect_uri`, но кода в нём нет — например, пользователь -отказался выдать разрешение, или Microsoft вернула ошибку. В параметре приходит текст ошибки от -бэкенда, а если его нет — сообщение по умолчанию о незавершённом входе. - -Окно ничего не знает про тосты главного окна, поэтому о неудаче сообщает сигналом. Обработчик в -[Main](Main.md) показывает это сообщение пользователю. К моменту испускания сигнала диалог уже -закрыт, а вход у бэкенда отменён — обработчику остаётся только уведомить. - -Отмена по кнопке сигнала не испускает: пользователь и так знает, что закрыл окно. - -## Методы - -#### openAt(string url) : void - -Открывает диалог на переданном адресе страницы входа. Сбрасывает `codeTaken`, загружает адрес в -`WebEngineView` и показывает окно. Адрес формирует бэкенд — в нём уже присутствуют `redirect_uri`, -идентификатор клиента и `prompt=select_account`. - -#### handleUrl(url) : void - -Обработчик смены адреса в `WebEngineView`; вызывать снаружи не нужно. Ничего не делает, если код -уже перехвачен. Иначе передаёт адрес в `backend.inspectMicrosoftRedirect()` и смотрит на поле -`matched` ответа: если адрес не является `redirect_uri`, обработка на этом заканчивается — это -обычная навигация по страницам входа. - -При совпадении выставляет `codeTaken`, закрывает окно и дальше расходится по двум путям: непустое -поле `code` уходит в `backend.finishMicrosoftLogin()`, иначе вход отменяется через -`backend.cancelMicrosoftLogin()` и испускается сигнал `failed()` с текстом из поля `error`. - -## Взаимодействие с другими компонентами - -**Со стороны главного окна.** [Main](Main.md) создаёт диалог динамически (функция -`openMicrosoftLogin()`), задаёт `backend`, вызывает `openAt()` с адресом от бэкенда и -подписывается на `failed()`, чтобы показать тост с ошибкой. Показывать ли кнопку входа через -Microsoft вообще, главное окно решает по `backend.microsoftAvailable`. - -**Со стороны бэкенда.** Диалог только доставляет код: `inspectMicrosoftRedirect()` — разбор -адреса, `finishMicrosoftLogin()` — продолжение обмена кода на токены, `cancelMicrosoftLogin()` — -сброс начатой сессии входа. Результат входа диалогу не возвращается: об успехе главное окно -узнаёт от бэкенда по его собственным сигналам, а диалог к этому моменту уже закрыт. - -**Кнопка отмены.** Выставляет `codeTaken`, закрывает окно и отменяет вход у бэкенда. Признак -ставится до закрытия, чтобы последний сигнал об изменении адреса при закрытии не был обработан. - -## Пример использования - -```qml -MicrosoftLoginDialog { - id: msLogin - parent: Overlay.overlay - backend: launcherBackend - onFailed: (message) => showToast(message, "#cc6666", 4000) -} - -// открывать только в сборке с Qt WebEngine -Component.onCompleted: if (launcherBackend.microsoftAvailable) msLogin.openAt(loginUrl) -``` +Профиль браузера создаётся через `WebEngineProfilePrototype` без `storageName`, то есть только в +памяти. Куки живут, пока работает лаунчер, и в общий браузер не попадают. Выбрать другой аккаунт +в пределах одного запуска позволяет `prompt=select_account` в адресе входа. --- diff --git a/doc/qml/ProgressPanel.md b/doc/qml/ProgressPanel.md index 0d31567..81a65f6 100644 --- a/doc/qml/ProgressPanel.md +++ b/doc/qml/ProgressPanel.md @@ -1,89 +1,23 @@ -# ProgressPanel +# ProgressPanel.qml -## Обзор компонента +Плашка прогресса долгой операции, 320×72, в левом нижнем углу главного окна. Там она не закрывает +кнопку запуска, сообщения по центру и кнопки папки и настроек. -`Minecraft_launcher` — десктопный лаунчер Minecraft на Qt Quick. Почти каждое действие в нём -долгое: скачивание версии игры, установка Java, распаковка и архивация папки `.minecraft` при -смене сборки, загрузка сезонной сборки. Лаунчер не блокирует окно на это время, поэтому ход -операции нужно показывать неотрывно от остального интерфейса. +В [Main](Main.md) их две: загрузка (версия, модлоадер, Java, пак) и смена сборки. -`ProgressPanel` — та самая плашка прогресса. Она размещается в левом нижнем углу главного окна, -где не перекрывает кнопку запуска, сообщение по центру и кнопки папки и настроек. Компонент -только отображает переданное состояние; сам он ничего не считает и ни за чем не следит. +## Интерфейс -## Место в проекте и зависимости +| | | +|---|---| +| `title` | заголовок, жирным | +| `status` | вторая строка, если нет `detail` | +| `fraction` | от 0 до 1. −1 — общий объём неизвестен, вместо процентов «…» | +| `detail` | вторая строка вместо `status`: мегабайты у загрузки, путь у архивации. Длинный текст обрезается посередине | +| `cancellable` | показывать ли крестик; по умолчанию `true` | +| `cancelRequested()` | нажат крестик | -Импортирует `QtQuick` и `QtQuick.Controls 2.15`; C++-типы напрямую не использует. - -Объявлен в `QML_FILES` модуля `Minecraft_launcher` (`CMakeLists.txt`), поэтому доступен по имени -внутри модуля без импорта. Инстанцируется в [Main](Main.md) — по одной плашке на вид долгой -операции. - -## Иерархия и роль - -Корневой тип — `Rectangle` фиксированного размера 320×72 px со скруглением 8 px, тёмной заливкой, -акцентной рамкой и лёгкой полупрозрачностью, чтобы плашка читалась поверх фонового изображения -окна. - -Внутри — пять элементов без внешних зависимостей: заголовок слева сверху, проценты справа сверху, -полоса прогресса (дорожка и заполнение с плавной анимацией ширины на 120 мс), строка подробностей -снизу и крестик отмены в правом нижнем углу с увеличенной областью нажатия. - -## Свойства - -| Свойство | Тип | По умолчанию | Обязательное | Описание | -|----------|-----|--------------|--------------|----------| -| `title` | `string` | `""` | Нет | Заголовок операции в левом верхнем углу, полужирным. Длинный текст обрезается справа многоточием. | -| `status` | `string` | `""` | Нет | Строка состояния внизу плашки. Показывается только когда `detail` пуст. | -| `fraction` | `double` | `-1` | Нет | Доля выполнения от `0` до `1`. Значение `-1` означает «итог ещё неизвестен»: вместо процентов выводится многоточие, а полоса остаётся пустой. | -| `detail` | `string` | `""` | Нет | Необязательная вторая строка подробностей — мегабайты у загрузки, путь у архивации. Если задана, вытесняет `status`. Длинный текст обрезается посередине, чтобы у пути были видны и начало, и конец. | -| `cancellable` | `bool` | `true` | Нет | Показывать ли крестик отмены. Ставится в `false` для операций, которые прерывать нельзя. | - -## Сигналы - -#### cancelRequested() - -Пользователь нажал крестик в правом нижнем углу. Сигнал сообщает только о намерении: плашка не -скрывает себя и не меняет своё состояние. - -Обработчик должен сам остановить операцию в бэкенде и убрать плашку с экрана — как правило, вызвав -соответствующий метод отмены у `LauncherBackend`; видимость плашки при этом снимется сама, потому -что она привязана к свойству занятости бэкенда. - -Сигнал не испускается при `cancellable: false` — в этом случае крестик скрыт. - -## Методы - -Собственных функций нет. - -## Взаимодействие с другими компонентами - -Все пять свойств плашки — точки внешней привязки. В [Main](Main.md) они связаны со свойствами -`LauncherBackend`: у загрузки версии это группа `downloading` / `downloadProgress` / -`downloadVersion` / `downloadStatus` / `downloadBytesDone` / `downloadBytesTotal`, у смены сборки — -`switching` / `switchProgress` / `switchStage` / `switchStatus`. Байты в мегабайты переводит -функция `formatMb()` главного окна, а не сама плашка. - -Видимостью плашки управляет родитель, обычно привязывая её к тому же признаку занятости, который -питает `fraction`. Сигнал `cancelRequested` родитель замыкает на метод отмены бэкенда. - -## Пример использования - -```qml -ProgressPanel { - anchors.left: parent.left - anchors.bottom: parent.bottom - anchors.margins: 16 - visible: backend.downloading - - title: qsTr("Загрузка Minecraft %1").arg(backend.downloadVersion) - status: backend.downloadStatus - fraction: backend.downloadProgress - detail: formatMb(backend.downloadBytesDone) + " / " + formatMb(backend.downloadBytesTotal) - - onCancelRequested: backend.cancelDownload() -} -``` +Панель только показывает данные. Видимость, значения и реакцию на отмену задаёт главное окно: у +загрузки крестик вызывает `backend.cancelDownload()`, у смены сборки крестика нет. --- diff --git a/doc/qml/SeasonalBuildsDialog.md b/doc/qml/SeasonalBuildsDialog.md index 970f411..a7f1de7 100644 --- a/doc/qml/SeasonalBuildsDialog.md +++ b/doc/qml/SeasonalBuildsDialog.md @@ -1,134 +1,55 @@ -# SeasonalBuildsDialog +# SeasonalBuildsDialog.qml -## Обзор компонента +Каталог сезонных сборок с сервера лаунчера. Таблица показывает всё, что нужно, чтобы решить, +ставить сборку или нет. Остальное делает одна кнопка: создаёт запись сборки, ставит версию игры, +модлоадер и Java и раскладывает файлы. Как это устроено, описано в +[LauncherBackend](../cpp/LauncherBackend.md#сезонные-сборки). -`Minecraft_launcher` — десктопный лаунчер Minecraft на Qt Quick. Кроме сборок, которые -пользователь собирает сам, он умеет ставить готовые сезонные сборки с сервера лаунчера: набор -модов под конкретную версию игры и модлоадер, подготовленный заранее и выдаваемый целиком. - -`SeasonalBuildsDialog` — окно этого каталога: таблица со всем, что нужно знать, чтобы решить, -ставить сборку или нет, и одна кнопка, которая делает всё остальное — заводит сборку, ставит -версию игры, модлоадер, Java и раскладывает файлы. Окно также показывает, что установленная -сборка устарела, и предлагает обновить её до свежей ревизии. - -Таблица собрана из строк `Row` с фиксированными колонками, а не из `TableView`: в проекте нет ни -одной модели `QAbstractItemModel`, а строки приходят готовыми `QVariantMap` — заводить ради семи -колонок отдельную модель незачем. - -## Место в проекте и зависимости - -В начале файла объявлена `pragma ComponentBehavior: Bound`. Импортирует `QtQuick` и -`QtQuick.Controls 2.15` — слои `QtQuick.Layouts` здесь не нужны, вся раскладка на якорях и -фиксированных ширинах колонок. - -Работает с C++-типом `LauncherBackend` (`launcherbackend.h`, `QML_ELEMENT` в модуле -`Minecraft_launcher`), передаваемым снаружи в свойство `backend`. Читает свойства -`seasonalCatalog`, `seasonalCatalogLoading`, `seasonalCatalogError`, `seasonalInstalling` и `busy`, -вызывает `refreshSeasonalCatalog()` и `installSeasonalBuild()`. - -Объявлен в `QML_FILES` модуля (`CMakeLists.txt`). Инстанцируется в [Main](Main.md). - -Строки каталога приходят из C++ уже отсортированными и сведёнными с локальными записями — окно -показывает статус, не считая ничего само. Запись содержит поля `id`, `name`, `description`, -`minecraftVersion`, `loaderTitle`, `loaderVersion`, `modCount`, `seasonStart`, `seasonEnd`, -`status`, `revision`, `installedRevision`, `updateAvailable`, `sizeBytes` и `serverUrl`. - -## Иерархия и роль - -Корневой тип — `Dialog`: модальный, 960×560 px, нулевой внутренний отступ, тёмный фон со -скруглением и акцентной рамкой. - -Политика закрытия зависит от состояния: пока идёт установка, окно закрывается только кнопкой -(`Popup.NoAutoClose`), потому что случайный щелчок мимо не должен спрятать единственную видимую -отмену; в остальное время работают Escape и щелчок мимо. - -Содержимое — шапка таблицы (`Row` с `Repeater` по `columns`), разделительная линия, список строк и -сообщение по центру для пустого состояния. Строка списка — `Rectangle` высотой 36 px с вложенным -`Row`, который повторяет тот же набор колонок; ширины берутся из общего описания `columns`, -поэтому шапка и строки не могут разъехаться. - -Подвал высотой 76 px несёт описание и размер выбранной сборки (они длинные и в таблицу не -помещаются, а решение принимается именно по ним) и две кнопки — обновления списка и установки. +Открывается из [Main](Main.md) кнопкой в правом нижнем углу (`openCatalog()`). ## Свойства -| Свойство | Тип | По умолчанию | Обязательное | Описание | -|----------|-----|--------------|--------------|----------| -| `backend` | `var` | — | **Да** | Экземпляр `LauncherBackend` — источник каталога сезонных сборок и исполнитель установки. | -| `selectedId` | `string` | `""` | Нет | Идентификатор выбранной строки. Хранится по `id`, а не по индексу: список обновляется под руками, и индекс после обновления указывал бы на другую сборку. | -| `columns` | `var` (только чтение) | список из семи колонок | Нет | Описание таблицы: массив объектов с полями `key` (поле записи), `title` (заголовок), `width` (ширина в пикселях) и `align` (выравнивание). Колонки: название, версия, загрузчик, число модов, начало и конец сезона, статус. Ширины собраны в одном месте, потому что их повторяют и шапка, и делегат строки. | -| `entries` | `var` (только чтение) | `backend.seasonalCatalog` | Нет | Строки каталога напрямую из бэкенда. Отбора и сортировки в окне нет. | -| `selectedEntry` | `var` (только чтение) | вычисляется | Нет | Полная запись выбранной сборки или `null`, если ничего не выбрано. От неё зависят подвал и доступность кнопки установки. | +| | | +|---|---| +| `backend` (required) | `LauncherBackend` | +| `selectedId` | выбранная строка, по `id` сборки | +| `columns` | описание колонок: ключ, заголовок, ширина, выравнивание | -## Сигналы +Выбранная строка хранится по `id`, а не по индексу: список может обновиться, пока окно открыто, +и индекс начал бы указывать на другую сборку. -Собственных сигналов компонент не объявляет. Результат работы окна виден через состояние бэкенда: -установка меняет список сборок и запускает загрузку, за ходом которой следит главное окно. +## Таблица -## Методы +Колонки: название, версия, модлоадер с версией, число модов, начало и конец сезона, статус +(«не установлена», «установлена», «доступно обновление»). Ширины заданы в одном месте, `columns`, +и их используют и шапка, и строки. -#### openCatalog() : void +Таблица собрана из `Row` с колонками фиксированной ширины, а не из `TableView`. В проекте нет ни +одной модели на `QAbstractItemModel`, строки приходят из C++ готовыми `QVariantMap`, и заводить +модель ради семи колонок не стоит. -Открывает окно. Обновление каталога происходит само в обработчике `onAboutToShow`, который просит -у бэкенда `refreshSeasonalCatalog(false)` — без принудительного обхода кэша: свежий кэш отвечает -без сети, поэтому вызов при каждом открытии ничего не стоит. +Строки приходят уже отсортированными и сведёнными с локальными записями: окно ничего не считает. -#### formatMb(bytes) : string +Пустой список и ошибка сервера выглядят одинаково пусто, поэтому причина пишется прямо на месте +строк: «загрузка…», «сборок пока нет» или текст `seasonalCatalogError` красным. -Переводит размер в байтах в строку с мегабайтами и одним знаком после запятой. Для нулевого или -отсутствующего значения возвращает пустую строку, чтобы размер просто не попал в строку подвала. +Под таблицей описание выбранной сборки, ревизия, размер архива и установленная ревизия, если она +есть. Описания длинные и в таблицу не помещаются, а решение принимают как раз по ним. -#### cellText(entry, string key) : string +## Кнопки -Возвращает текст ячейки для записи и ключа колонки. Для всех колонок это значение одноимённого -поля записи, приведённое к строке; исключение — колонка загрузчика, где название и версия -склеиваются в одну подпись, а при пустой версии остаётся только название. +- **Обновить список** — `refreshSeasonalCatalog(true)`, мимо кэша. Выключена, пока идёт загрузка. +- **Установить / Обновить** — `installSeasonalBuild(id)`. Подпись зависит от `updateAvailable`. + Выключена, пока лаунчер чем-то занят (`busy`): установка занимает и панель загрузки, и + `.minecraft` целиком. -#### installSelected() : void +Двойной щелчок по строке делает то же, что кнопка установки. -Ставит выбранную сборку. Ничего не делает, если строка не выбрана или лаунчер занят другой -операцией: установка занимает и панель загрузки, и `.minecraft` целиком, поэтому вторую начинать -нельзя. Иначе вызывает `installSeasonalBuild()` у бэкенда. +Каталог обновляется при каждом открытии окна (`refreshSeasonalCatalog(false)`). Свежий кэш отвечает +без сети. -Вызывается кнопкой установки и двойным щелчком по строке. - -## Взаимодействие с другими компонентами - -**Со стороны родителя.** [Main](Main.md) задаёт `backend` и открывает окно вызовом -`openCatalog()`. Обратной связи наружу через сигналы нет. - -**Со стороны бэкенда.** Всё содержимое таблицы — привязка к `seasonalCatalog`, поэтому обновление -каталога и изменение статуса установленной сборки перерисовывают окно сами. Пустое состояние -различает три случая по `seasonalCatalogLoading` и `seasonalCatalogError`: идёт загрузка, сборок -пока нет, произошла ошибка — её текст показывается прямо на месте строк, потому что пустой список -и ошибка выглядят одинаково пустыми. - -**Занятость.** Кнопка установки выключается по общему признаку `busy`, кнопка обновления списка — -по `seasonalCatalogLoading` (её подпись при этом меняется на «Обновление…»). Политика закрытия -окна завязана на `seasonalInstalling`. - -**Обновление ревизии.** Если у записи выставлен `updateAvailable`, статус в таблице подсвечивается -акцентным цветом, в подвале дописывается установленная ревизия, а кнопка установки называется -«Обновить». Отдельного пути обновления нет — это тот же вызов `installSeasonalBuild()`. - -**Ход установки.** Окно не показывает прогресс: за это отвечает плашка -[ProgressPanel](ProgressPanel.md) в главном окне, а отмена — метод `cancelSeasonalInstall()` -бэкенда. - -## Пример использования - -```qml -SeasonalBuildsDialog { - id: seasonalDialog - parent: Overlay.overlay - backend: launcherBackend -} - -Button { - text: qsTr("Сезонные сборки") - onClicked: seasonalDialog.openCatalog() -} -``` +Пока идёт установка, окно закрывается только кнопкой. Отменить установку можно на панели загрузки +главного окна, и случайный щелчок мимо окна не должен её закрыть. --- diff --git a/doc/qml/VersionPickerDialog.md b/doc/qml/VersionPickerDialog.md index 69da248..199f738 100644 --- a/doc/qml/VersionPickerDialog.md +++ b/doc/qml/VersionPickerDialog.md @@ -1,157 +1,58 @@ -# VersionPickerDialog +# VersionPickerDialog.qml -## Обзор компонента +Окно выбора версии Minecraft. Слева категории, справа список версий выбранной категории с поиском. -`Minecraft_launcher` — десктопный лаунчер Minecraft на Qt Quick. Каталог версий Mojang — это около -тысячи записей: релизы, снапшоты, старые беты и альфы. Раньше выбор версии был обычным выпадающим -списком на всю эту тысячу, и найти в нём, скажем, бету 1.7 можно было только поиском по точному -номеру. +Раньше на этом месте был выпадающий список на тысячу версий, и найти в нём, например, бету 1.7 +можно было только набрав точный номер. Категории делят каталог на части разумного размера, а поиск +работает внутри выбранной. -`VersionPickerDialog` заменил тот список отдельным окном: слева категории, справа сами версии с -поиском сверху. Категории делят каталог на обозримые части, а поиск работает внутри выбранной. -Помимо выбора окно умеет удалять уже скачанные версии — с предупреждением о последствиях, потому -что версия весит десятки мегабайт, а поверх неё могут стоять профили модлоадеров. +Используется в [BuildsDialog](BuildsDialog.md) для поля версии игры. -Окно открывается из карточки сборки, когда пользователь выбирает, на какой версии Minecraft -собирается играть. +## Интерфейс -## Место в проекте и зависимости +| | | +|---|---| +| `backend` (required) | `LauncherBackend` | +| `selectedId` | выбранная версия | +| `category` | `release`, `snapshot`, `old_beta`, `old_alpha` или `other` | +| `filterText` | строка поиска | +| `installedOnly` | показывать только скачанные | +| `openFor(versionId)` | открыть с этой версией | +| `versionChosen(versionId)` | выбор подтверждён | -В начале файла объявлена `pragma ComponentBehavior: Bound`. Импортирует `QtQuick`, -`QtQuick.Controls 2.15` и `QtQuick.Layouts 2.15`. +Сигнал называется не `accepted`, потому что такой сигнал у `Dialog` уже есть, и переопределить его +нельзя. -Работает с C++-типом `LauncherBackend` (`launcherbackend.h`, `QML_ELEMENT` в модуле -`Minecraft_launcher`), передаваемым снаружи в свойство `backend`. Читает у него свойства -`versionCatalog` и `catalogLoading`, вызывает `refreshVersionCatalog()`, `versionRemovalInfo()` и -`removeVersion()`. +## Как работает -Объявлен в `QML_FILES` модуля (`CMakeLists.txt`); использует ресурс `images/Trash.svg` из -`RESOURCES` того же модуля. Инстанцируется в [BuildsDialog](BuildsDialog.md). +`openFor()` сбрасывает поиск, переключается на категорию переданной версии (а не всегда на +релизы), просит бэкенд обновить каталог (`refreshVersionCatalog()`) и прокручивает список к +выбранной строке. Если версии нет в каталоге, окно открывается на релизах. По «Отмене» ничего не +меняется: результат уходит наружу только через `versionChosen`. -Каталог приходит из C++ уже отсортированным (новые сверху), поэтому окно только отбирает записи и -порядок не трогает. Каждая запись каталога — объект с полями `id` (идентификатор версии), `label` -(подпись строки), `category` (ключ категории), `installed` (скачана ли) и `search` -(предвычисленная строка для поиска в нижнем регистре). +`versionCatalog` приходит из C++ уже отсортированным, новые сверху. `visibleEntries` только +отбирает строки по категории, флажку «только скачанные» и подстроке в поле `search`, а порядок не +меняет. В «Прочие» попадают профили модлоадеров и всё, чего нет в манифесте Mojang. -## Иерархия и роль +С клавиатуры: фокус сразу в поиске, стрелки вверх и вниз двигают выбор, Enter подтверждает, Esc +закрывает. Мышью выбор подтверждается двойным щелчком или кнопкой. -Корневой тип — `Dialog`: модальный, 720×480 px, нулевой внутренний отступ, закрывается по Escape и -щелчку мимо. Оформление задано вручную — тёмный фон со скруглением и акцентной рамкой. +Если список пуст, на его месте написано почему: каталог ещё грузится, каталог не загрузился или по +запросу ничего нет. -Содержимое — `RowLayout` из двух частей, разделённых вертикальной линией: колонка категорий -шириной 150 px (`Repeater` по `categories`) и область версий. В области версий сверху поле поиска, -внизу флажок «Только установленные», между ними `ListView` со строками версий. Строка показывает -подпись, галочку для установленной версии и корзину удаления; галочка рядом со строкой — -единственное место, где видно, что именно скачано. +## Удаление версии -Отдельно объявлен вложенный `Dialog` подтверждения удаления шириной 420 px с красной рамкой. Он -привязан к тому же родителю, что и само окно, и центрируется в нём вручную, чтобы не оказаться -внутри области выбора. +У скачанных версий есть корзина. Версия занимает от десятков мегабайт, а это окно — единственное +место, где видно, что именно скачано. -Пустое состояние списка объясняется текстом по центру, который различает три случая: каталог ещё -грузится, каталог пуст (нет соединения) и по фильтру ничего не найдено. +`askRemove(versionId)` берёт `versionRemovalInfo()` и открывает подтверждение. В нём указаны +размер, профили модлоадеров, которые без этой версии не запустятся (`dependents`), и сборки, +которые на неё ссылаются (`builds`). id запоминается в окне подтверждения: к моменту ответа под +курсором может оказаться другая строка. -## Свойства - -| Свойство | Тип | По умолчанию | Обязательное | Описание | -|----------|-----|--------------|--------------|----------| -| `backend` | `var` | — | **Да** | Экземпляр `LauncherBackend` — источник каталога версий и исполнитель удаления. | -| `selectedId` | `string` | `""` | Нет | Идентификатор выбранной версии. Окно открывается с текущей версией, и по «Отмене» выбор возвращается к ней, потому что результат уходит наружу только через сигнал. Пустая строка — версия не выбрана, кнопка подтверждения выключена. | -| `category` | `string` | `"release"` | Нет | Ключ активной категории. Допустимые значения: `release` (релизы), `snapshot` (снапшоты), `old_beta` (беты), `old_alpha` (альфы), `other` (прочие — сюда попадают в том числе установленные профили модлоадеров). | -| `filterText` | `string` | `""` | Нет | Текст поиска. Сравнивается в нижнем регистре без учёта регистра с полем `search` записи каталога; поиск идёт внутри активной категории. | -| `installedOnly` | `bool` | `false` | Нет | Показывать только скачанные версии. | -| `categories` | `var` (только чтение) | список из пяти записей | Нет | Описание колонки категорий: массив объектов с полями `key` и переведённым `title`. Задаёт и порядок пунктов, и набор допустимых значений `category`. | -| `visibleEntries` | `var` (только чтение) | вычисляется | Нет | Отобранные строки каталога: записи активной категории, прошедшие флажок «только установленные» и текст поиска. Порядок наследуется от каталога. Служит моделью списка. | - -## Сигналы - -#### versionChosen(string versionId) - -Версия подтверждена — кнопкой «Выбрать», двойным щелчком по строке или клавишей Enter в поле -поиска. В параметре приходит идентификатор версии. - -Имя не `accepted()` сознательно: такой сигнал у `Dialog` уже есть и переопределить его нельзя. - -Обработчик — карточка сборки — записывает выбранную версию в сборку. К моменту вызова обработчика -окно уже закрыто. При отмене сигнал не испускается, поэтому снаружи ничего откатывать не нужно. - -## Методы - -#### openFor(string versionId) : void - -Открывает окно на переданной версии. Запоминает её в `selectedId`, очищает поиск, переключается на -категорию именно этой версии (а не всегда на релизы), просит бэкенд обновить каталог и -прокручивает список к выбранной строке. - -#### categoryOf(string versionId) : string - -Возвращает ключ категории версии по каталогу; для неизвестной версии — `release`. - -#### indexOfSelected() : int - -Позиция выбранной версии в `visibleEntries` или `-1`, если под текущим фильтром её не видно. - -#### revealSelected() : void - -Выставляет текущий индекс списка на выбранную версию и прокручивает список так, чтобы строка -оказалась по центру. Вызывается после каждой смены фильтра, категории или удаления. - -#### acceptSelection() : void - -Подтверждает выбор: испускает `versionChosen()` и закрывает окно. При пустом `selectedId` не -делает ничего. - -#### catalogHas(string versionId) : bool - -Есть ли версия в каталоге. Нужен после удаления: профиль модлоадера присутствовал в каталоге -только потому, что был установлен, и после удаления строка исчезает совсем. - -#### askRemove(string versionId) : void - -Спрашивает подтверждение перед удалением. Запрашивает у бэкенда `versionRemovalInfo()` и, если -версия действительно установлена, наполняет окно подтверждения: занимаемый объём, список -зависящих профилей модлоадеров и список сборок, которые эту версию используют. Идентификатор -запоминается в самом окне подтверждения, потому что к моменту ответа строка под курсором может -быть уже другой. - -Окно подтверждения объясняет три вещи: файлы удалятся из `versions/` и освободится столько-то -мегабайт; профили модлоадеров поверх этой версии без неё не запустятся; библиотеки и ресурсы в -`libraries/` и `assets/` общие для всех версий и остаются на месте. - -#### performRemove(string versionId) : void - -Удаляет версию через бэкенд. Если удалена была именно выбранная версия и её больше нет в каталоге, -снимает выбор. В конце обновляет позицию списка. - -## Взаимодействие с другими компонентами - -**Со стороны родителя.** [BuildsDialog](BuildsDialog.md) задаёт `backend`, открывает окно вызовом -`openFor()` с текущей версией сборки и подписывается на `versionChosen()`, чтобы записать выбор. -Никаких других точек входа у окна нет — прямая запись `selectedId` снаружи не предполагается. - -**Со стороны бэкенда.** `versionCatalog` и `catalogLoading` — привязки, от которых зависят и -модель списка, и текст пустого состояния: пришедшее обновление каталога пересчитывает -`visibleEntries` само. `refreshVersionCatalog()` вызывается при открытии окна, `removeVersion()` — -после подтверждения удаления. - -**Клавиатура.** Фокус при открытии уходит в поле поиска. Стрелки вверх и вниз двигают выбор по -списку функцией `step()`, Enter подтверждает выбор, Escape закрывает окно. - -## Пример использования - -```qml -VersionPickerDialog { - id: versionPicker - parent: Overlay.overlay - backend: launcherBackend - onVersionChosen: (versionId) => buildCard.gameVersion = versionId -} - -Button { - text: buildCard.gameVersion || qsTr("Выбрать версию") - onClicked: versionPicker.openFor(buildCard.gameVersion) -} -``` +`performRemove(versionId)` вызывает `removeVersion()`. Если удалили выбранный профиль модлоадера, +он пропадает из каталога совсем (он был в нём только потому, что установлен), и выбор +сбрасывается. ---