9 Commits
Author SHA1 Message Date
galeon 7ec4528292 Обновить README.md
tests / package-smoke-linux (push) Skipped
tests / package-smoke-windows (push) Skipped
tests / package-smoke-macos (push) Skipped
tests / macos (push) Successful in 1m21s
tests / linux (push) Successful in 7m35s
tests / windows (push) Successful in 7m35s
Signed-off-by: galeon <dertiellis@gmail.com>
2026-10-26 07:11:00 +00:00
galeon acd9334c16 tests hotfix
tests / windows (push) Failing after 14m37s
tests / linux (push) Successful in 2m11s
tests / macos (push) Successful in 1m14s
tests / package-smoke-linux (push) Skipped
tests / package-smoke-windows (push) Skipped
tests / package-smoke-macos (push) Skipped
2026-10-17 23:38:49 +03:00
galeon 3ac2156ef2 traslations fix
tests / windows (push) Failing after 6m19s
tests / linux (push) Successful in 2m12s
tests / macos (push) Failing after 1m15s
tests / package-smoke-linux (push) Skipped
tests / package-smoke-windows (push) Skipped
tests / package-smoke-macos (push) Skipped
release / create-release (push) Successful in 3s
release / build-linux (push) Successful in 2m17s
release / build-macos (push) Successful in 2m44s
release / build-windows (push) Successful in 6m27s
release / publish (push) Successful in 1s
Signed-off-by: galeon <dertiellis@gmail.com>
2026-10-14 09:32:17 +03:00
galeon 5d819129d6 Обновить .gitea/workflows/tests.yml
tests / package-smoke-linux (push) Skipped
tests / package-smoke-windows (push) Skipped
tests / package-smoke-macos (push) Skipped
tests / macos (push) Failing after 1m20s
tests / linux (push) Successful in 2m13s
tests / windows (push) Failing after 12m6s
Signed-off-by: galeon <dertiellis@gmail.com>
2026-10-13 06:01:40 +00:00
galeon 0dd6bef715 test fixes
tests / windows (push) Skipped
tests / macos (push) Skipped
tests / package-smoke-linux (push) Skipped
tests / package-smoke-windows (push) Skipped
tests / package-smoke-macos (push) Skipped
tests / linux (push) Successful in 1m59s
Signed-off-by: galeon <dertiellis@gmail.com>
2026-10-13 08:51:07 +03:00
galeon 41599fa82f new unit tests, hehe 67
tests / linux (push) Failing after 2m18s
tests / windows (push) Skipped
tests / macos (push) Skipped
tests / package-smoke-linux (push) Skipped
tests / package-smoke-windows (push) Skipped
tests / package-smoke-macos (push) Skipped
Signed-off-by: galeon <dertiellis@gmail.com>
2026-10-12 13:14:08 +03:00
galeon ebf8ff7869 release workflow fix
Signed-off-by: galeon <dertiellis@gmail.com>
2026-10-12 09:25:48 +03:00
galeon 443850196d fix bug with launcher version 2026-10-11 22:32:58 +03:00
galeon f10b654953 documentation rewrite
Signed-off-by: galeon <dertiellis@gmail.com>
2026-10-11 22:32:13 +03:00
108 changed files with 11014 additions and 4612 deletions

No files matched your search

-25
View File
@@ -1,16 +1,3 @@
# Сборка BiLauncher под Windows, Linux и macOS и публикация в Gitea Releases.
#
# push тега vX.Y.Z (или vX.Y.Z-rc1 → pre-release):
# create-release создаёт черновик релиза → три сборки прикладывают к нему файлы →
# publish добавляет SHA256SUMS.txt и снимает флаг черновика. Если какая-то сборка
# упала, релиз остаётся черновиком и пользователям не виден.
# Ручной запуск (workflow_dispatch): те же сборки, но результат — артефакты прогона,
# релиз не создаётся.
#
# Пути к Qt на раннерах задаются в их config.yaml (runner.envs): QT_ROOT — kit Qt,
# QT_TOOLS — папка Tools из Qt Online Installer (там CMake и Ninja).
# Секрет RELEASE_TOKEN — PAT с правом repository: write.
name: release
on:
@@ -56,7 +43,6 @@ jobs:
TOKEN: ${{ secrets.RELEASE_TOKEN }}
TAG: ${{ steps.meta.outputs.tag }}
run: |
# Описание релиза — раздел CHANGELOG.md для этой версии
base="${TAG#v}"; base="${base%%-*}"
notes=$(awk -v v="$base" '
/^## \[/ { if (on) exit; if (index($0, "[" v "]")) { on=1; next } }
@@ -85,12 +71,9 @@ jobs:
run: |
export PATH="$QT_TOOLS/CMake/bin:$QT_TOOLS/Ninja:$PATH"
rm -rf build AppDir dist
# LIBDIR=lib: zlibreference.cpp ищет libz рядом, в ../lib/BiLauncher,
# а на Ubuntu по умолчанию был бы lib/x86_64-linux-gnu.
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release \
-DCMAKE_PREFIX_PATH="$QT_ROOT" \
-DCMAKE_INSTALL_PREFIX=/usr -DCMAKE_INSTALL_LIBDIR=lib
# На ВМ 6 ГБ памяти: без ограничения 42 параллельных компилятора уходят в OOM.
cmake --build build -j 8
cmake --install build --prefix "$PWD/AppDir/usr"
@@ -142,7 +125,6 @@ jobs:
shell: powershell
run: |
$ErrorActionPreference = "Stop"
# Короткие пути: дерево WebEngine легко вылезает за MAX_PATH.
$build = "C:\r\bl-build"; $stage = "C:\r\bl-stage"; $dist = "$PWD\dist"
Remove-Item -Recurse -Force $build, $stage, $dist -ErrorAction SilentlyContinue
@@ -163,14 +145,10 @@ jobs:
& "$env:ISCC_DIR\ISCC.exe" /Q "/DAppVersion=$env:VERSION" "/DSourceDir=$stage" "/DOutputDir=$dist" packaging\windows\BiLauncher.iss
if ($LASTEXITCODE) { throw "iscc" }
# Portable: то же дерево, что ставит установщик, без vc_redist
# (в portable-варианте среда выполнения MSVC должна уже стоять в системе).
Remove-Item "$stage\bin\vc_redist.x64.exe" -ErrorAction SilentlyContinue
$portable = "C:\r\BiLauncher-$env:VERSION"
Remove-Item -Recurse -Force $portable -ErrorAction SilentlyContinue
Move-Item $stage $portable
# Системный bsdtar по полному пути: в PATH раннера раньше стоит GNU tar из Git,
# который не умеет zip и принимает «C:» за имя удалённого хоста.
& "$env:SystemRoot\System32\tar.exe" -a -c -f "$dist\BiLauncher-$env:VERSION-win64-portable.zip" -C C:\r "BiLauncher-$env:VERSION"
if ($LASTEXITCODE) { throw "zip" }
Remove-Item -Recurse -Force $portable
@@ -179,7 +157,6 @@ jobs:
$sums = Get-ChildItem $dist | ForEach-Object {
"{0} {1}" -f (Get-FileHash $_.FullName -Algorithm SHA256).Hash.ToLower(), $_.Name }
$b64 = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes(($sums -join "`n") + "`n"))
# Не Out-File: PowerShell 5.1 пишет UTF-8 с BOM, и имя первой переменной ломается.
[IO.File]::AppendAllText($env:GITHUB_OUTPUT, "sums=$b64`n")
- name: Загрузка в релиз
@@ -226,8 +203,6 @@ jobs:
- id: pack
name: Ad-hoc подпись и DMG
run: |
# Без Developer ID: ad-hoc подпись нужна, чтобы приложение вообще
# запускалось на Apple Silicon; Gatekeeper всё равно спросит подтверждение.
codesign --force --deep --sign - stage/BiLauncher.app
codesign --verify --deep --strict stage/BiLauncher.app
mkdir -p dmgroot dist
+183
View File
@@ -0,0 +1,183 @@
name: tests
on:
push:
branches: ["**"]
pull_request:
schedule:
- cron: "0 2 * * *"
workflow_dispatch:
concurrency:
group: tests-${{ github.ref }}
cancel-in-progress: true
jobs:
linux:
runs-on: linux
timeout-minutes: 90
env:
BILAUNCHER_REQUIRE_FAKE_INTERNET: "1"
steps:
- uses: actions/checkout@v4
- name: Сборка лаунчера и тестов
run: |
export PATH="$QT_TOOLS/CMake/bin:$QT_TOOLS/Ninja:$PATH"
launcher=""
if command -v ccache >/dev/null; then
launcher="-DCMAKE_C_COMPILER_LAUNCHER=ccache -DCMAKE_CXX_COMPILER_LAUNCHER=ccache"
fi
cmake -S . -B build-tests -G Ninja -DCMAKE_BUILD_TYPE=Debug \
-DBILAUNCHER_BUILD_TESTS=ON -DCMAKE_PREFIX_PATH="$QT_ROOT" $launcher
cmake --build build-tests -j 8
- name: Статические проверки (переводы, qmllint, CHANGELOG)
run: CMAKE="$QT_TOOLS/CMake/bin/cmake" python3 tests/scripts/static_checks.py build-tests
- name: Тесты
run: |
export PATH="$QT_TOOLS/CMake/bin:$PATH"
ctest --test-dir build-tests --output-on-failure -j 4 \
--output-junit "$PWD/build-tests/junit.xml"
- name: Отчёты
if: always()
uses: actions/upload-artifact@v4
with:
name: tests-linux
if-no-files-found: ignore
path: |
build-tests/junit.xml
build-tests/qmllint.log
build-tests/Testing/Temporary/LastTest.log
windows:
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || github.event_name == 'push'
runs-on: windows
timeout-minutes: 150
steps:
- uses: actions/checkout@v4
- name: Сборка и тесты
shell: powershell
run: |
$ErrorActionPreference = "Stop"
# Короткий путь: дерево WebEngine легко вылезает за MAX_PATH (см. release.yml).
$build = "C:\r\bl-tests"
Remove-Item -Recurse -Force $build -ErrorAction SilentlyContinue
$vs = & "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" -latest -products * `
-requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath
Import-Module "$vs\Common7\Tools\Microsoft.VisualStudio.DevShell.dll"
Enter-VsDevShell -VsInstallPath $vs -SkipAutomaticLocation -DevCmdArguments "-arch=x64 -host_arch=x64"
$env:PATH = "$env:QT_TOOLS\CMake_64\bin;$env:QT_TOOLS\Ninja;$env:PATH"
cmake -S . -B $build -G Ninja -DCMAKE_BUILD_TYPE=RelWithDebInfo -DBILAUNCHER_BUILD_TESTS=ON "-DCMAKE_PREFIX_PATH=$env:QT_ROOT"
if ($LASTEXITCODE) { throw "cmake configure" }
cmake --build $build
if ($LASTEXITCODE) { throw "cmake build" }
ctest --test-dir $build --output-on-failure --output-junit "$build\junit.xml"
$code = $LASTEXITCODE
New-Item -ItemType Directory -Force test-results | Out-Null
Copy-Item "$build\junit.xml" test-results\ -ErrorAction SilentlyContinue
if ($code) { throw "ctest" }
- name: Отчёты
if: always()
uses: actions/upload-artifact@v4
with:
name: tests-windows
if-no-files-found: ignore
path: test-results/
macos:
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || github.event_name == 'push'
runs-on: macos
timeout-minutes: 120
steps:
- uses: actions/checkout@v4
- name: Сборка и тесты
run: |
export PATH="$QT_TOOLS/CMake/CMake.app/Contents/bin:$QT_TOOLS/Ninja:$PATH"
cmake -S . -B build-tests -G Ninja -DCMAKE_BUILD_TYPE=Debug \
-DBILAUNCHER_BUILD_TESTS=ON -DCMAKE_PREFIX_PATH="$QT_ROOT"
cmake --build build-tests
ctest --test-dir build-tests --output-on-failure -j 4 \
--output-junit "$PWD/build-tests/junit.xml"
- name: Отчёты
if: always()
uses: actions/upload-artifact@v4
with:
name: tests-macos
if-no-files-found: ignore
path: build-tests/junit.xml
package-smoke-linux:
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
runs-on: linux
timeout-minutes: 90
steps:
- uses: actions/checkout@v4
- name: Release-сборка, установка, запуск
run: |
export PATH="$QT_TOOLS/CMake/bin:$QT_TOOLS/Ninja:$PATH"
rm -rf build-smoke smoke-stage
launcher=""
if command -v ccache >/dev/null; then
launcher="-DCMAKE_C_COMPILER_LAUNCHER=ccache -DCMAKE_CXX_COMPILER_LAUNCHER=ccache"
fi
cmake -S . -B build-smoke -G Ninja -DCMAKE_BUILD_TYPE=Release \
-DCMAKE_PREFIX_PATH="$QT_ROOT" \
-DCMAKE_INSTALL_PREFIX=/usr -DCMAKE_INSTALL_LIBDIR=lib $launcher
cmake --build build-smoke -j 8
cmake --install build-smoke --prefix "$PWD/smoke-stage/usr"
bash tests/scripts/smoke_installed.sh smoke-stage/usr
package-smoke-windows:
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
runs-on: windows
timeout-minutes: 150
steps:
- uses: actions/checkout@v4
- name: Release-сборка, установка, запуск
shell: powershell
run: |
$ErrorActionPreference = "Stop"
$build = "C:\r\bl-smoke"; $stage = "C:\r\bl-smoke-stage"
Remove-Item -Recurse -Force $build, $stage -ErrorAction SilentlyContinue
$vs = & "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" -latest -products * `
-requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath
Import-Module "$vs\Common7\Tools\Microsoft.VisualStudio.DevShell.dll"
Enter-VsDevShell -VsInstallPath $vs -SkipAutomaticLocation -DevCmdArguments "-arch=x64 -host_arch=x64"
$env:PATH = "$env:QT_TOOLS\CMake_64\bin;$env:QT_TOOLS\Ninja;$env:PATH"
cmake -S . -B $build -G Ninja -DCMAKE_BUILD_TYPE=Release "-DCMAKE_PREFIX_PATH=$env:QT_ROOT"
if ($LASTEXITCODE) { throw "cmake configure" }
cmake --build $build
if ($LASTEXITCODE) { throw "cmake build" }
cmake --install $build --prefix $stage
if ($LASTEXITCODE) { throw "cmake install" }
& powershell -ExecutionPolicy Bypass -File tests\scripts\smoke_installed.ps1 -Prefix $stage
if ($LASTEXITCODE) { throw "smoke" }
Remove-Item -Recurse -Force $build, $stage -ErrorAction SilentlyContinue
package-smoke-macos:
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
runs-on: macos
timeout-minutes: 120
steps:
- uses: actions/checkout@v4
- name: Release-сборка, установка, запуск
run: |
export PATH="$QT_TOOLS/CMake/CMake.app/Contents/bin:$QT_TOOLS/Ninja:$PATH"
rm -rf build-smoke smoke-stage
cmake -S . -B build-smoke -G Ninja -DCMAKE_BUILD_TYPE=Release -DCMAKE_PREFIX_PATH="$QT_ROOT"
cmake --build build-smoke
cmake --install build-smoke --prefix "$PWD/smoke-stage"
bash tests/scripts/smoke_installed.sh smoke-stage
+2
View File
@@ -64,3 +64,5 @@ build/
CMakeLists.txt.user*
stage/
dist/
build-tests/
build-*/
+8
View File
@@ -243,3 +243,11 @@ qt_generate_deploy_qml_app_script(
DEPLOY_USER_QML_MODULES_ON_UNSUPPORTED_PLATFORM
DEPLOY_TOOL_OPTIONS ${deploy_opts})
install(SCRIPT ${deploy_script})
# Автотесты: собираются только по запросу и в установку не попадают.
# Код лаунчера при этом не меняется — тесты компилируют те же исходники
# в отдельную статическую библиотеку, см. tests/CMakeLists.txt и doc/testing.md.
option(BILAUNCHER_BUILD_TESTS "Собрать автотесты (ctest)" OFF)
if(BILAUNCHER_BUILD_TESTS)
enable_testing()
add_subdirectory(tests)
endif()
+1 -1
View File
@@ -27,7 +27,7 @@
<img src="https://projects-live.galeonworx.com/galeon/BiLauncher/raw/branch/master/Screenshots/versionsSelect.png" alt="drawing" width="65%"/>
</p>
<h3>Сезонные сборки: централизованное управление сборками со стороны команды разработчика, полная автоматизация скачивания, установки и разрешения зависиомстей для конечных пользователей</h3>
<h3>Сезонные сборки: централизованное управление сборками со стороны команды разработчика, полная автоматизация скачивания, установки и разрешения зависимостей для конечных пользователей</h3>
<p float="left">
<img src="https://projects-live.galeonworx.com/galeon/BiLauncher/raw/branch/master/Screenshots/seasonal.png" alt="drawing" width="75%"/>
+4 -2
View File
@@ -23,10 +23,12 @@ RDIR="$HOME/act_runner"
# --- Компилятор и библиотеки ---------------------------------------------------
# libgl1-mesa-dev — заголовки OpenGL для Qt6::Gui; xcb-* и прочие — то, что
# Linux-деплой Qt и плагин xcb ищут при упаковке и запуске; libfuse2 — для
# запуска самого appimagetool (он тоже AppImage).
# запуска самого appimagetool (он тоже AppImage). ccache ускоряет прогоны
# автотестов (.gitea/workflows/tests.yml), xvfb нужен их дымовой проверке
# установленного дерева, если деплой Qt не положил плагин offscreen.
sudo apt-get update -q
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y -q \
build-essential patchelf file curl git xz-utils \
build-essential patchelf file curl git xz-utils ccache xvfb python3 \
libgl1-mesa-dev libfuse2 libfontconfig1 libdbus-1-3 \
libxkbcommon-x11-0 libxcb-cursor0 libxcb-icccm4 libxcb-image0 libxcb-keysyms1 \
libxcb-render-util0 libxcb-shape0 libxcb-xinerama0 libxcb-xkb1 \
+177
View File
@@ -0,0 +1,177 @@
# Сборка, упаковка и релизы
## Что нужно
- 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-модуль собирается в `<build>/qml/BiLauncher` (`OUTPUT_DIRECTORY`). Если оставить по
умолчанию, на Linux папка `BiLauncher` совпадёт по имени с исполняемым файлом (у него нет
расширения), и линковка упадёт с «Is a directory».
**`launcher_zlib_reference`** — только на Linux. Это обычный zlib 1.3.1 из каталога `zlib/`,
выходной файл `libz.so.1` в `<build>/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 в
`<bin>/../lib/BiLauncher`, а на Ubuntu по умолчанию `LIBDIR` был бы `lib/x86_64-linux-gnu`.
## Релизы
Релизы собирает Gitea Actions, `.gitea/workflows/release.yml`.
### Как выпустить версию
0. Запустить workflow `tests` вручную (Run workflow) и дождаться зелёного прогона на всех трёх
системах, включая дымовую проверку установленного дерева. `release.yml` от тестов не зависит,
поэтому этот шаг на совести выпускающего. См. [testing.md](testing.md).
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-<v>-x86_64.AppImage` и `BiLauncher-<v>-linux-x86_64.tar.gz`. Сборка
идёт с `-j 8`: на ВМ 6 ГБ памяти, и без ограничения компиляторы съедают её всю.
- **Windows**: `BiLauncher-<v>-win64-setup.exe` (Inno Setup) и
`BiLauncher-<v>-win64-portable.zip`. В portable нет `vc_redist`, среда выполнения MSVC должна
уже стоять в системе. Сборка идёт в `C:\r\...`, потому что дерево WebEngine легко выходит за
`MAX_PATH`. Zip делается системным `tar.exe` по полному пути: GNU tar из Git, который стоит в
`PATH` раньше, zip не умеет и принимает `C:` за имя удалённого хоста.
- **macOS**: `BiLauncher-<v>-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** — скрипта подготовки нет, раннер настроен вручную.
Те же раннеры гоняют автотесты (`.gitea/workflows/tests.yml`): Linux — на каждый push, все три
системы — ночью и по кнопке. Подробности — [testing.md](testing.md).
---
При создании этого документа использовался ИИ.
+58 -141
View File
@@ -1,170 +1,87 @@
# AuthService
## Обзор класса
`authservice.h`, `authservice.cpp`
`Minecraft_launcher` поддерживает три способа входа: офлайн-профиль без пароля, учётную запись
Ely.by и учётную запись Microsoft. `AuthService` закрывает первые два: это Yggdrasil-клиент Ely.by
плюс офлайн-режим. За третий отвечает [MsaAuthService](MsaAuthService.md).
Вход через Ely.by, офлайн-профиль и загрузка `authlib-injector`. Вход через Microsoft живёт
отдельно, в [MsaAuthService](MsaAuthService.md), но отдаёт результат в той же структуре
`AuthResult`. Поэтому запуск игры дальше идёт одинаково, как бы игрок ни вошёл.
Результат любого способа — структура `AuthResult`, объявленная в этом же заголовке. Она содержит
ровно то, что подставляется в аргументы запуска вида `${auth_*}`, поэтому дальше запуск игры идёт
по общему пути независимо от того, как пользователь вошёл.
Экземпляр создаёт и держит [LauncherBackend](LauncherBackend.md). Сервис ничего не пишет на диск,
кроме скачанного jar: токены между запусками сохраняет бэкенд в `profiles.json`.
Класс также умеет скачивать `authlib-injector` — библиотеку, которая перенаправляет обращения игры
к серверу авторизации на Ely.by.
## AuthResult
## Место в проекте и зависимости
Каждое поле соответствует подстановке в аргументах запуска.
Экземпляр создаётся и принадлежит [LauncherBackend](LauncherBackend.md); других владельцев нет.
Заголовок подключает [MsaAuthService](MsaAuthService.md) — ради общей структуры `AuthResult` — и
[GameLauncher](GameLauncher.md) через поля `LaunchOptions`, которые заполняются из результата
авторизации.
| Поле | Подстановка / смысл |
|------|---------------------|
| `ok` | вход удался |
| `twoFactorRequired` | Ely.by требует одноразовый код |
| `licenseMissing` | Microsoft: вход прошёл, но игры на аккаунте нет. Отдельный флаг, потому что повторный вход тут не поможет |
| `error` | текст ошибки, если `ok == false` |
| `playerName` | `${auth_player_name}` |
| `uuid` | `${auth_uuid}`, hex без дефисов |
| `accessToken` | `${auth_access_token}` |
| `clientToken` | `${clientid}` |
| `userType` | `${user_type}`: `legacy` (офлайн), `ELYBY`, `msa` |
| `refreshToken` | только Microsoft: продление сессии без пароля |
| `xuid` | только Microsoft: `${auth_xuid}` |
| `expiresAt` | только Microsoft: когда истекает `accessToken`, UTC |
Требования сборки: `Qt6::Core` (`QDateTime`, `QJsonObject`, `QString`) и `Qt6::Network`
(`QNetworkAccessManager`).
## Офлайн
## Иерархия и роль
`static AuthResult offline(nickname)` работает без сети. UUID считается так же, как в самом
Minecraft: MD5 от `OfflinePlayer:<ник>`, после чего выставляются биты версии 3 и варианта RFC 4122.
Значит, у одного ника всегда один UUID, и на офлайн-сервере игрок не теряет инвентарь. В
`accessToken` и `clientToken` записывается `"0"`.
Наследует `QObject`: даёт мета-объектную систему, сигнал `progress` и владение по родителю.
Виртуальных методов базового класса не переопределяет.
Пустой ник — ошибка.
## Публичные структуры
## Ely.by
### AuthResult
Сервер — `https://authserver.ely.by`, протокол Yggdrasil, JSON по HTTPS.
Результат авторизации — то, что подставляется в `${auth_*}` аргументы запуска.
`loginElyBy(login, password, clientToken, accessToken, callback)` старается не спрашивать пароль:
| Поле | Тип | Описание |
|------|-----|----------|
| `ok` | `bool` | Авторизация удалась |
| `twoFactorRequired` | `bool` | Ely.by отклонил пароль с пометкой two factor — нужен одноразовый код |
| `licenseMissing` | `bool` | Вход в Microsoft прошёл, но копии игры на аккаунте нет. Обрабатывается отдельно от прочих ошибок, потому что чинится только покупкой |
| `error` | `QString` | Текст ошибки, когда `ok` равен `false` |
| `playerName` | `QString` | Подставляется в `${auth_player_name}` |
| `uuid` | `QString` | Подставляется в `${auth_uuid}`; hex без дефисов |
| `accessToken` | `QString` | Подставляется в `${auth_access_token}` |
| `clientToken` | `QString` | Подставляется в `${clientid}` |
| `userType` | `QString` | Подставляется в `${user_type}`; принимает значения `legacy` (офлайн), `msa` (Microsoft) и `ELYBY` |
| `refreshToken` | `QString` | Только для аккаунтов Microsoft: продлевает сессию без ввода пароля |
| `xuid` | `QString` | Только для Microsoft; подставляется в `${auth_xuid}` |
| `expiresAt` | `QDateTime` | Только для Microsoft: UTC-время, когда протухает `accessToken` |
1. Если сохранённого `accessToken` нет, сразу `/auth/authenticate` с паролем.
2. Если есть, `/auth/validate`. Ответ 200 — токен рабочий, вход закончен.
3. Иначе `/auth/refresh`. Если продлить удалось, приходят новый токен, ник и UUID.
4. Если и это не вышло, `/auth/authenticate` с паролем.
## Псевдонимы типов
Если сервер недоступен (статус 0 и сетевая ошибка), цепочка обрывается с ошибкой, без попытки войти
по паролю.
`AuthService::Callback` — `std::function<void(const AuthResult &)>`. Все сетевые методы
асинхронные: колбэк вызывается ровно один раз и всегда в потоке GUI.
Второй фактор Ely.by не сообщает отдельным кодом. Сервис ищет в тексте ошибки подстроку
`two factor` и выставляет `twoFactorRequired`. Тогда бэкенд спрашивает код у игрока и вызывает
`loginElyByWithTotp(login, password, totp, clientToken, callback)`, который отправляет пароль в виде
`пароль:код`, как требует Ely.by.
## Публичные методы
`clientToken` генерируется один раз на профиль (`generateClientToken()`, случайный UUID) и хранится
вместе с ним: Yggdrasil привязывает выданный `accessToken` к этому значению.
#### explicit AuthService(QObject \*parent = nullptr)
По ходу работы испускается `progress(message)`. Бэкенд пересылает его в `launchProgress`.
Создаёт сервис и его `QNetworkAccessManager`. Конструктор помечен `explicit`.
## authlib-injector
#### static AuthResult offline(const QString &nickname)
Без него игра пойдёт проверять сессию на серверы Mojang, и токен Ely.by окажется недействительным.
Jar подключается к JVM через `-javaagent` (см. [GameLauncher](GameLauncher.md)).
Готовит результат для офлайн-профиля без единого сетевого запроса. UUID выводится из ника так же,
как это делает сам Minecraft в офлайне: `UUID.nameUUIDFromBytes(("OfflinePlayer:" + name)
.getBytes(UTF_8))`. Благодаря этому один и тот же ник всегда даёт один и тот же UUID, и прогресс
на сервере не теряется.
`ensureAuthlibInjector(targetDir, callback)`:
#### static QString generateClientToken()
- если `<targetDir>/authlib-injector.jar` уже есть, сразу отдаёт путь. Версия не проверяется, и
обновить файл можно, только удалив его;
- иначе читает `https://authlib-injector.yushi.moe/artifact/latest.json`, скачивает jar по
`download_url` и, если в манифесте есть `checksums.sha256`, сверяет хеш.
Случайный `clientToken` лаунчера. Генерируется один раз на профиль и хранится вместе с ним:
Yggdrasil связывает выданный `accessToken` именно с этим значением.
Колбэк получает либо путь, либо текст ошибки. `targetDir` — это `<root>/runtime`.
#### void loginElyBy(const QString &login, const QString &password, const QString &clientToken, const QString &accessToken, Callback callback)
## Как вызываются колбэки
Полный цикл входа в Ely.by: сначала проверка имеющегося токена, затем его продление, и только при
неудаче — авторизация по паролю. Пароль можно оставить пустым, если уже есть рабочий
`accessToken`, — тогда пользователю не придётся вводить его заново.
Все сетевые методы асинхронные, колбэк вызывается один раз в GUI-потоке. Если сервис удалить во
время запроса, менеджер сети уйдёт вместе с ним, и колбэк не вызовется вообще. Бэкенд к тому же
проверяет в каждом колбэке, не отменили ли запуск, пока шёл запрос.
По ходу работы испускает `progress` с описанием текущего шага. Результат приходит в `callback`
один раз; при ответе с пометкой двухфакторной аутентификации в нём выставлен
`twoFactorRequired`, и вызывающий код должен спросить у пользователя код и продолжить через
`loginElyByWithTotp()`.
#### void loginElyByWithTotp(const QString &login, const QString &password, const QString &totp, const QString &clientToken, Callback callback)
Повтор авторизации с одноразовым кодом двухфакторной аутентификации. Пароль и код объединяются в
одно поле в формате «пароль:код», как того требует Ely.by.
#### void ensureAuthlibInjector(const QString &targetDir, std::function&lt;void(const QString &path, const QString &error)&gt; callback)
Скачивает `authlib-injector` в `targetDir`, если его там ещё нет. Колбэк получает либо путь к
готовому jar, либо текст ошибки — заполнено всегда ровно одно из двух.
Библиотека нужна только для профилей Ely.by: она подключается к JVM аргументом `-javaagent` и
перенаправляет обращения игры к серверу авторизации.
## Сигналы
#### progress(const QString &message)
Описание текущего шага авторизации. Испускается по ходу всех сетевых операций.
Обработчик показывает сообщение пользователю: в главном окне лаунчера оно попадает в плашку
статуса и держится до следующего сообщения, потому что шаг может занять заметное время.
## Владение и время жизни
Класс наследует `QObject` и принимает `parent` в конструкторе — родитель его и удалит.
`QNetworkAccessManager` создаётся в конструкторе с сервисом в роли родителя и уничтожается вместе
с ним.
Колбэки захватываются по значению и живут до своего единственного вызова. Уничтожение сервиса во
время незавершённого запроса отменяет запрос вместе с менеджером сети — колбэк в этом случае не
вызывается, поэтому захватывать в него сырые указатели на объекты с меньшим временем жизни, чем у
сервиса, нельзя.
## Потокобезопасность
Только поток GUI. Все сетевые методы асинхронные, и их колбэки вызываются в том же потоке, в
котором создан сервис. Собственной синхронизации в классе нет.
## Взаимодействие с другими классами
`LauncherBackend` вызывает методы входа при запуске игры и переправляет сигнал `progress` в
интерфейс. Полученный `AuthResult` он раскладывает по полям `LaunchOptions`, которые уходят в
[GameLauncher](GameLauncher.md). Путь, возвращённый `ensureAuthlibInjector()`, попадает в поле
`authlibInjectorPath` тех же параметров запуска.
Сохранением токенов между запусками занимается `LauncherBackend`: сам сервис ничего не пишет на
диск, кроме скачанного jar.
## Внешнее взаимодействие
**Сеть, исходящие запросы.** Класс общается с сервером авторизации Ely.by через
`QNetworkAccessManager`. Формат — JSON поверх HTTPS, запросы инициирует всегда лаунчер. Внутренний
помощник `postJson()` разделяет три исхода: успешный ответ, ответ с кодом ошибки и транспортную
ошибку — последняя отдаётся отдельным параметром, чтобы отличить недоступную сеть от отказа
сервера.
Отдельным каналом идёт загрузка `authlib-injector` — обычная HTTPS-загрузка файла в
`targetDir`. Повторных попыток при неудаче класс не делает: решение о повторе принимает вызывающий
код.
Все сигналы и колбэки приходят в поток GUI.
## Пример использования
```cpp
auto *auth = new AuthService(this);
connect(auth, &AuthService::progress, this, &Backend::showStatus);
auth->loginElyBy(profile.login, profile.password,
profile.clientToken, profile.accessToken,
[this](const AuthResult &result) {
if (result.twoFactorRequired) {
emit twoFactorRequired(m_pendingProfileName);
return;
}
if (!result.ok) {
emit launchError(result.error);
return;
}
continueLaunch(result);
});
```
Повторных попыток при сетевых ошибках нет.
---
+33 -144
View File
@@ -1,165 +1,54 @@
# BuildArchiveWorker
## Обзор класса
`buildarchiveworker.h`, `buildarchiveworker.cpp`
Смена сборки в лаунчере — это перекладывание содержимого `.minecraft`: текущее упаковывается в
архив, папка вычищается, на её место распаковывается архив другой сборки. Речь о гигабайтах модов,
конфигов и миров, и гонять их в потоке GUI нельзя — окно замерзало бы на всё время смены.
Файловые операции над `.minecraft`: упаковать, очистить, распаковать, раскатать пак. В
`.minecraft` могут лежать гигабайты модов и миров, поэтому это единственная часть проекта,
которая работает не в GUI-потоке.
`BuildArchiveWorker` — исполнитель этих операций в отдельном потоке. Он умеет четыре вещи:
упаковать, вычистить, распаковать и докатить пак сезонной сборки поверх уже разложенного
содержимого.
Объект создаёт [BuildSwitcher](BuildSwitcher.md) без родителя, переносит в свой `QThread` и вызывает
слоты через `QMetaObject::invokeMethod(..., Qt::QueuedConnection)`. Работник не знает ни про
сборки, ни про журнал. Он получает пути и отвечает сигналами.
Класс намеренно ничего не знает ни о сборках, ни о путях лаунчера: он принимает готовые пути и
списки. Порядок шагов и восстановление после сбоя — дело [BuildSwitcher](BuildSwitcher.md).
ZIP читается и пишется через `QZipReader` и `QZipWriter` из `Qt6::CorePrivate`.
## Место в проекте и зависимости
## Слоты
Создаётся и целиком управляется [BuildSwitcher](BuildSwitcher.md), который же и переносит его в
собственный `QThread`. Больше к классу никто не обращается.
Каждый слот заканчивается одним `finished(ok, error)`. По ходу работы испускается
`progress(done, total, currentPath)` на каждый файл.
Требования сборки: `Qt6::Core` и `Qt6::CorePrivate` — последний нужен ради `QZipReader` и
`QZipWriter`, которыми читаются и пишутся архивы сборок.
### archive(gameDir, tempZipPath, excludeTop)
## Иерархия и роль
Упаковывает всё содержимое `gameDir`, кроме папок и файлов верхнего уровня из `excludeTop`.
Символьные ссылки пропускаются. Сжатие `AutoCompress`: Qt сам не сжимает то, что уже сжато.
При ошибке или отмене недописанный архив удаляется.
Наследует `QObject`: мета-объектная система, слоты, сигналы и возможность жить в отдельном потоке
через `moveToThread()`. Виртуальных методов базового класса не переопределяет.
### clear(gameDir, keepTop)
Объект живёт в своём `QThread` и вызывается только через очередь сигналов — прямых вызовов его
слотов из потока GUI быть не должно.
Удаляет всё на верхнем уровне `gameDir`, кроме `keepTop`. Символьная ссылка удаляется как файл,
то, на что она указывает, не трогается. Если `gameDir` нет, это успех. Отменить нельзя.
## Публичные методы
### restore(zipPath, gameDir)
#### explicit BuildArchiveWorker(QObject \*parent = nullptr)
Распаковывает архив в `gameDir`. Пути с `..`, абсолютные, с `\` или `:` останавливают распаковку.
Создаёт исполнителя. Конструктор помечен `explicit`.
### applyPack(zipPath, gameDir, removeRelative, forbiddenTop)
#### void requestCancel()
1. Сначала проверяет весь архив. Небезопасный путь или файл внутри общих папок (`forbiddenTop`:
`versions`, `libraries` и т. д.) — отказ до любых изменений. Пак не может подменить jar игры
или библиотеки.
2. Удаляет файлы из `removeRelative`. Опасные пути и пути в общих папках молча пропускаются. После
этого удаляются папки, которые стали пустыми.
3. Распаковывает пак, перезаписывая существующие файлы.
4. Испускает `packEntries(список файлов пака)`, потом `finished(true)`.
Просит прервать текущую операцию. Вызывается из потока GUI прямо во время работы — это
единственный метод класса, предназначенный для вызова снаружи рабочего потока.
## Отмена
Отмена не прерывает операцию мгновенно: признак проверяется между файлами. Хранится он в
`QAtomicInt`, поэтому запись из одного потока и чтение из другого безопасны без блокировок.
`requestCancel()` и `clearCancel()` выставляют атомарный флаг, его можно трогать из любого потока.
Флаг проверяется перед каждым файлом в `archive`, `restore` и `applyPack`.
#### void clearCancel()
Сбрасывает признак отмены перед началом новой операции.
## Публичные слоты
Все четыре слота вызываются через очередь сигналов и выполняются в рабочем потоке. Каждый
завершается сигналом `finished`.
#### void archive(const QString &gameDir, const QString &tempZipPath, const QStringList &excludeTop)
Упаковывает всё содержимое `gameDir` во временный файл `tempZipPath`. Элементы верхнего уровня,
перечисленные в `excludeTop`, в архив не попадают — так из архива сборки исключаются общие
каталоги лаунчера, которые не принадлежат ни одной сборке.
Запись идёт во временный файл, чтобы прерванная упаковка не оставила повреждённый архив на месте
настоящего.
#### void clear(const QString &gameDir, const QStringList &keepTop)
Удаляет из `gameDir` всё, кроме элементов верхнего уровня, перечисленных в `keepTop`. Выполняется
после упаковки, перед распаковкой другой сборки.
#### void restore(const QString &zipPath, const QString &gameDir)
Распаковывает архив сборки в `gameDir`.
#### void applyPack(const QString &zipPath, const QString &gameDir, const QStringList &removeRelative, const QStringList &forbiddenTop)
Докатывает пак сезонной сборки поверх уже разложенного содержимого. Сначала удаляет файлы из
`removeRelative` — те, что ушли из сборки в новой ревизии, — затем распаковывает архив с
перезаписью.
Того, чего нет ни в списке, ни в архиве, операция не касается: миры и скриншоты игрока остаются на
месте. Именно это отличает обновление сезонной сборки от её переустановки.
Параметр `forbiddenTop` — элементы верхнего уровня, которые паку трогать нельзя: общие каталоги
лаунчера. Проверяется здесь, а не только у издателя пака, потому что архив приезжает из сети.
## Сигналы
#### progress(int done, int total, const QString &currentPath)
Ход операции: сколько файлов обработано из скольких и какой обрабатывается сейчас. Испускается по
ходу всех четырёх операций.
Обработчик — `BuildSwitcher` — пересчитывает долю выполнения и обновляет панель прогресса. Сигнал
приходит в поток GUI через очередь, поэтому прямого доступа к состоянию рабочего потока у
обработчика нет.
#### finished(bool ok, const QString &error)
Операция завершена. При `ok == false` во втором параметре — текст ошибки.
Обработчик решает, что делать дальше: перейти к следующему шагу смены сборки или откатить уже
сделанное.
#### packEntries(const QStringList &entries)
Что именно принёс пак — относительными путями. Испускается только из `applyPack()`.
Список сохраняется в описании сезонной сборки: следующему обновлению он нужен, чтобы вычислить,
какие файлы из сборки ушли, и передать их в `removeRelative`.
## Владение и время жизни
Класс наследует `QObject` и принимает `parent`, но на практике родителя не имеет: объект,
перенесённый в другой поток через `moveToThread()`, не может иметь родителя в потоке GUI.
Ответственность за его удаление лежит на `BuildSwitcher`, который создаёт и поток, и исполнителя.
Удалять объект следует безопасным для потоков способом — не напрямую из потока GUI во время
работы. Уничтожение потока раньше исполнителя приведёт к обрыву незавершённой операции.
## Потокобезопасность
Класс рассчитан на жизнь в отдельном потоке. Все четыре слота выполняются в рабочем потоке;
вызывать их напрямую нельзя — только через очередь сигналов.
Единственная точка межпотокового взаимодействия — признак отмены в `QAtomicInt`:
`requestCancel()` и `clearCancel()` пишут его из потока GUI, а рабочий поток читает между файлами.
Другого разделяемого состояния у класса нет, поэтому иных блокировок не требуется.
## Взаимодействие с другими классами
Единственный собеседник — [BuildSwitcher](BuildSwitcher.md). Он создаёт поток и исполнителя,
переносит второй в первый, вызывает слоты через очередь сигналов и принимает `progress`,
`finished` и `packEntries`.
Пути и списки исключений `BuildSwitcher` берёт из [launcherpaths.h](launcherpaths.md) и из
описаний сборок; сам исполнитель к ним не обращается.
## Внешнее взаимодействие
**Файловая система.** Класс читает, пишет и удаляет файлы в папке игры и в каталоге архивов
сборок. Сетевых обращений и дочерних процессов у него нет: архив сезонной сборки к моменту вызова
`applyPack()` уже скачан [SeasonalPackDownloader](SeasonalPackDownloader.md).
Все операции выполняются в рабочем потоке, все сигналы приходят в поток GUI через очередь.
## Пример использования
```cpp
auto *thread = new QThread(this);
auto *worker = new BuildArchiveWorker; // без родителя: уедет в другой поток
worker->moveToThread(thread);
connect(thread, &QThread::finished, worker, &QObject::deleteLater);
thread->start();
connect(worker, &BuildArchiveWorker::progress, this, &Switcher::onProgress);
connect(worker, &BuildArchiveWorker::finished, this, &Switcher::onStepFinished);
worker->clearCancel();
QMetaObject::invokeMethod(worker, "archive", Qt::QueuedConnection,
Q_ARG(QString, gameDir),
Q_ARG(QString, tempZipPath),
Q_ARG(QStringList, excludeTop));
```
Файлы распаковываются через `QFile`, а не `QSaveFile`. Если операцию прервать, последний файл
может остаться недописанным. Поэтому `BuildSwitcher` и не даёт отменить шаги после архивации.
---
+72 -177
View File
@@ -1,210 +1,105 @@
# BuildSwitcher
## Обзор класса
`buildswitcher.h`, `buildswitcher.cpp`
`BuildSwitcher` меняет активную сборку: содержимое `.minecraft` уезжает в архив своей сборки,
папка чистится, на её место разворачивается архив выбранной. Он же докатывает пак сезонной
сборки поверх уже разложенного содержимого.
Смена активной сборки и раскатка пака сезонной сборки. Файлы двигает
[BuildArchiveWorker](BuildArchiveWorker.md) в отдельном потоке, а этот класс решает, в каком порядке
это делать, и ведёт журнал, по которому можно продолжить после падения.
Порядок шагов подчинён одному правилу: пока новый архив не записан целиком и не переименован на
место, из `.minecraft` не удаляется ничего. Отметка о начатом переключении пишется в
`builds/index.json` до первого разрушающего действия, поэтому обрыв питания или принудительное
завершение процесса всегда обнаружим на следующем запуске.
Экземпляр держит [LauncherBackend](LauncherBackend.md).
Сами файловые операции выполняет [BuildArchiveWorker](BuildArchiveWorker.md) в отдельном потоке;
`BuildSwitcher` — это порядок шагов, учёт состояния и восстановление после сбоя.
## Как устроены сборки на диске
## Место в проекте и зависимости
В `.minecraft` в каждый момент разложена одна сборка, активная. Содержимое остальных лежит в
архивах `<root>/builds/<id>/<дата>_<имя>.zip`, по одному на сборку.
Экземпляр создаётся и принадлежит [LauncherBackend](LauncherBackend.md). Класс создаёт и целиком
владеет своим `QThread` и объектом [BuildArchiveWorker](BuildArchiveWorker.md).
Общие для всех сборок папки и файлы никогда не архивируются и не удаляются (`sharedTopLevel()`):
Пути к папкам сборок и файлу состояния даёт [launcherpaths.h](launcherpaths.md)
(`buildStorageDir()`, `buildDir()`).
- `assets`
- `libraries`
- `versions`
- `launcher_profiles.json`
- `runtime`
Требования сборки: `Qt6::Core` (`QThread`, `QJsonObject`) и `Qt6::CorePrivate` — опосредованно,
через исполнителя.
Всё остальное (моды, конфиги, миры, скриншоты, `options.txt`) принадлежит сборке.
## Иерархия и роль
В папке сборки могут лежать и другие zip, но файлы с точкой в начале имени не считаются архивом
сборки. Это временные архивы (`.tmp-*.zip`) и скачанные паки сезонных сборок (`.pack-r*.zip`).
Наследует `QObject`: мета-объектная система, три сигнала и владение по родителю. Объявлен
виртуальный деструктор — класс обязан корректно остановить свой поток.
## switchTo(fromId, fromName, toId, toName, gameDir)
## Публичные методы
1. **Архивация.** Если прежней сборки нет (`fromId < 0`) или папки игры нет, шаг пропускается.
Иначе сначала проверяется свободное место: размер всего, что уйдёт в архив, плюс 256 МБ. Потом
архив пишется во временный `builds/<from>/.tmp-<uuid>.zip`. Если на этом шаге что-то пошло не
так, `.minecraft` не тронута.
2. **Фиксация архива.** Временный файл переименовывается в `<гггг-мм-дд>_<имя>.zip`, остальные
архивы этой сборки удаляются. В `index.json` записывается `state: "switching"` с `from` и `to`.
3. **Очистка.** Из `.minecraft` удаляется всё, кроме общего.
4. **Распаковка.** Если у новой сборки есть архив, он распаковывается. Если нет (новая сборка),
`.minecraft` остаётся пустой, только с общими папками.
5. В `index.json` записываются `state: "idle"` и `active`, испускается `finished(toId)`.
#### explicit BuildSwitcher(QObject \*parent = nullptr)
При ошибке приходит `failed(toId, message, gameDirIntact)`. `gameDirIntact` равен `true`, если
ошибка случилась до очистки. Бэкенд по нему выбирает текст: «ничего не изменилось» или «папка
игры разобрана».
Создаёт переключатель, рабочий поток и исполнителя, переносит второй в первый и запускает поток.
Конструктор помечен `explicit`.
`cancel()` работает только на шаге архивации. После него отступать уже некуда.
#### bool isRunning() const
## applyPack(buildId, buildName, packZip, removeRelative, note, gameDir)
Идёт ли сейчас переключение или раскатка пака.
Раскатка пака поверх активной сборки. Сначала в `index.json` пишется `state: "applying"` со всем,
что нужно для повтора: имя файла пака, имя сборки, список на удаление и `note`. Потом работник
удаляет файлы прошлой ревизии и распаковывает пак.
#### QString stage() const
После успеха пак удаляется, `index.json` возвращается в `idle`, а `lastPackEntries()` и
`lastPackNote()` содержат список разложенных файлов и `note`. Бэкенд забирает их в обработчике
`finished` и пишет `season.json`.
Текущий этап словами — заголовок панели прогресса.
`gameDirIntact` при ошибке раскатки равен `true`, только если работник не успел обработать ни одного
файла.
#### QString status() const
## После падения
Строка состояния: что обрабатывается сейчас.
При старте бэкенд вызывает `interruptedSwitchWarning()`. Если в `index.json` осталось
`switching` или `applying`, метод возвращает текст для пользователя, а `resumeInterrupted(gameDir)`
продолжает операцию:
#### double fraction() const
- `switching` — архив прежней сборки уже записан целиком, поэтому начинаем с очистки и
распаковываем сборку `to`. Начинать с архивации нельзя: заархивировалась бы полуразобранная
`.minecraft`;
- `applying` — раскатка повторяется с тем же паком и тем же списком на удаление. Если файла пака
уже нет, журнал просто сбрасывается в `idle`.
Доля выполнения от `0` до `1` либо `-1`, пока итог неизвестен.
Если программа упала во время самой архивации, в журнале ещё ничего нет, и продолжать нечего:
`.minecraft` цела, а временный архив удалится при следующем старте (`sweepTempArchives()` в
конструкторе).
#### static QStringList sharedTopLevel()
## Прочее
Общие для всех сборок каталоги верхнего уровня. Они не архивируются и не чистятся: принадлежат
лаунчеру, а не какой-либо сборке. Список передаётся исполнителю как `excludeTop` при упаковке и
как `keepTop` при очистке.
- `forgetBuild(id)` — удаляет запись о сборке из `index.json` и папку `builds/<id>` целиком.
- `hasArchive(id)` — есть ли у сборки архив.
- `isRunning()`, `stage()`, `status()` (текущий файл), `fraction()` — для панели прогресса.
Сигнал `progressChanged()` не ограничен по частоте и приходит на каждый обработанный файл.
#### void switchTo(int fromBuildId, const QString &fromName, int toBuildId, const QString &toName, const QString &gameDir)
### index.json
Переключает активную сборку. Проходит четыре этапа: упаковка текущего содержимого во временный
файл, фиксация архива переименованием на место, очистка папки игры и распаковка архива целевой
сборки.
`<root>/builds/index.json`:
Имена сборок нужны для текста в панели прогресса, идентификаторы — для путей к архивам.
#### void applyPack(int buildId, const QString &buildName, const QString &packZipPath, const QStringList &removeRelative, const QJsonObject &note, const QString &gameDir)
Докатывает пак сезонной сборки поверх содержимого `.minecraft`. Вызывается только для уже активной
сборки: пак ложится на то, что сейчас разложено, а не внутрь чужого архива.
Параметр `removeRelative` — файлы, ушедшие из сборки в новой ревизии; они удаляются перед
распаковкой. Всего остального операция не касается, поэтому миры и скриншоты игрока переживают
обновление.
Параметр `note` — непрозрачные данные вызывающей стороны. Они переживают перезапуск вместе с
отметкой о незавершённой операции и возвращаются через `lastPackNote()`, когда раскатка доиграна.
Сам переключатель в них не заглядывает.
#### QStringList lastPackEntries() const
Что принёс последний успешно раскатанный пак — относительными путями. Список нужен следующему
обновлению, чтобы вычислить, какие файлы из сборки ушли.
#### QJsonObject lastPackNote() const
Данные, с которыми пришёл последний успешно раскатанный пак, — те самые, что передавались в
`applyPack()`.
#### void cancel()
Отменяет текущую операцию. Отмена доходит до исполнителя через признак, проверяемый между
файлами.
Отменять имеет смысл только на этапе упаковки: после очистки `.minecraft` отступать некуда, и
операцию нужно довести до конца. Именно поэтому панель прогресса смены сборки объявлена
неотменяемой.
#### bool forgetBuild(int buildId)
Сборку удалили — убирает её архив и запись о нём. Возвращает `false`, если папку архива не удалось
удалить целиком.
#### bool hasArchive(int buildId) const
Есть ли у сборки сохранённый архив. Используется в тексте предупреждения об удалении: вместе со
сборкой пропадут её моды, конфиги и миры.
#### QString interruptedSwitchWarning() const
Незавершённое переключение с прошлого запуска. Возвращает пустую строку, если всё в порядке, иначе
— готовый текст для пользователя.
Проверяется при старте лаунчера: отметка в `builds/index.json` пишется до первого разрушающего
действия, поэтому прерванная операция обнаруживается всегда.
#### void resumeInterrupted(const QString &gameDir)
Доигрывает прерванное переключение: очистку и распаковку целевой сборки. Архив исходной сборки к
этому моменту уже записан — правило порядка шагов это гарантирует.
## Сигналы
#### progressChanged()
Изменились числа прогресса. Обработчик перечитывает `stage()`, `status()` и `fraction()`.
#### finished(int toBuildId)
Переключение или раскатка завершены успешно; в параметре — идентификатор сборки, ставшей активной.
Обработчик снимает признак переключения, обновляет активную сборку и, после раскатки пака,
забирает `lastPackEntries()` и `lastPackNote()`, чтобы записать их в описание сезонной сборки.
#### failed(int toBuildId, const QString &message, bool gameDirIntact)
Операция не удалась. Третий параметр — ключевой: он говорит, цела ли папка игры. Неудача на этапе
упаковки оставляет `.minecraft` нетронутой, неудача после очистки — нет, и сообщение пользователю
должно различать эти случаи.
## Владение и время жизни
Класс наследует `QObject` и принимает `parent` — родитель его и удалит.
Рабочий поток и исполнитель создаются в конструкторе и принадлежат переключателю. Исполнитель
родителя не имеет — объект, живущий в другом потоке, не может принадлежать объекту из потока GUI;
его удаление привязано к завершению потока. Деструктор обязан остановить поток и дождаться его
завершения, иначе рабочая операция переживёт своего владельца.
Временный архив на диске переживает аварийное завершение процесса; накопившиеся временные файлы
подчищаются при следующем запуске.
## Потокобезопасность
Сам переключатель живёт в потоке GUI: все его публичные методы вызываются оттуда. Тяжёлая работа
вынесена в отдельный поток к [BuildArchiveWorker](BuildArchiveWorker.md), общение с которым идёт
исключительно через очередь сигналов — прямых вызовов слотов исполнителя нет.
Единственный межпотоковый примитив — атомарный признак отмены внутри исполнителя.
## Взаимодействие с другими классами
`LauncherBackend` вызывает `switchTo()` при смене активной сборки, `applyPack()` — при установке
или обновлении сезонной сборки, `forgetBuild()` — при удалении сборки. Сигналы переключателя он
переправляет в свойства, которые читает QML: панель прогресса главного окна показывает `stage()`,
`status()` и `fraction()`, а признак переключения блокирует список сборок и кнопки в
[BuildsDialog](../qml/BuildsDialog.md).
При старте бэкенд спрашивает `interruptedSwitchWarning()` и, если операция была прервана,
предлагает доиграть её через `resumeInterrupted()`.
Архив сезонной сборки к моменту вызова `applyPack()` уже скачан
[SeasonalPackDownloader](SeasonalPackDownloader.md).
## Внешнее взаимодействие
**Файловая система.** Класс читает и пишет `builds/index.json` — отметку о состоянии переключения,
— управляет папками архивов сборок и подчищает временные файлы. Все операции с содержимым самой
папки игры делегированы исполнителю в рабочем потоке.
Сети и дочерних процессов у класса нет.
## Пример использования
```cpp
auto *switcher = new BuildSwitcher(this);
connect(switcher, &BuildSwitcher::progressChanged, this, &Backend::switchChanged);
connect(switcher, &BuildSwitcher::finished, this, &Backend::onSwitchFinished);
connect(switcher, &BuildSwitcher::failed, this,
[this](int, const QString &message, bool gameDirIntact) {
emit launchError(gameDirIntact
? message
: tr("%1. Папка игры осталась незавершённой.").arg(message));
});
// при старте лаунчера
const QString warning = switcher->interruptedSwitchWarning();
if (!warning.isEmpty()) {
showStatus(warning);
switcher->resumeInterrupted(gameDir);
```json
{
"state": "idle",
"active": 3,
"builds": {
"2": { "archive": "2026-10-09_Vanilla.zip", "name": "Vanilla", "savedAt": "..." }
}
}
```
Во время работы к нему добавляются `from`, `to`, а для раскатки ещё `pack`, `packName`,
`packRemove`, `packNote`. Пишется через `QSaveFile`.
Деструктор просит работника остановиться и ждёт поток до 5 секунд.
---
При создании этого документа использовался ИИ.
+77 -170
View File
@@ -1,199 +1,106 @@
# GameLauncher
## Обзор класса
`gamelauncher.h`, `gamelauncher.cpp`
`GameLauncher` — то, ради чего существует весь остальной лаунчер: он готовит и запускает JVM с
Minecraft. К моменту его вызова уже известно всё — версия разобрана, файлы скачаны, пользователь
авторизован, — и класс превращает это в командную строку и дочерний процесс.
Собирает командную строку, распаковывает нативные библиотеки и запускает JVM с игрой. К этому
моменту версия уже разобрана ([minecraftversion.h](minecraftversion.md)), файлы скачаны, а игрок
вошёл в аккаунт. Всё остальное приходит в структуре `LaunchOptions`.
Кроме самого запуска класс умеет проверять комплектность `.minecraft` и распаковывать нативные
библиотеки, без которых игра не стартует.
Экземпляр один, его держит [LauncherBackend](LauncherBackend.md). Одновременно может работать
только одна игра.
Один экземпляр — одна игра одновременно.
## LaunchOptions
## Место в проекте и зависимости
| Поле | По умолчанию | Что это |
|------|--------------|---------|
| `gameDir` | | папка игры, она же рабочий каталог процесса |
| `versionId` | | папка в `versions/` |
| `playerName`, `uuid`, `accessToken`, `userType`, `clientToken`, `xuid` | | из [AuthResult](AuthService.md) |
| `javaPath` | пусто | пусто — ищет [JavaLocator](javalocator.md) |
| `minMemoryMb`, `maxMemoryMb` | 512, 4096 | `-Xms`, `-Xmx`; 0 — не передавать |
| `extraJvmArgs` | | аргументы JVM из настроек |
| `windowWidth`, `windowHeight` | 0, 0 | 0 — не передавать размер |
| `fullscreen` | `false` | |
| `serverAddress` | пусто | `host[:port]`, сервер для автоподключения |
| `authlibInjectorPath` | пусто | путь к `authlib-injector.jar`; пусто — без него |
| `authlibInjectorApi` | `ely.by` | сервер авторизации для инжектора |
| `launcherName`, `launcherVersion` | `BiLauncher`, `LAUNCHER_VERSION` | бэкенд передаёт имя и версию из `QCoreApplication` |
Подключает [minecraftversion.h](minecraftversion.md): разобранная версия — половина входных данных
запуска, вторая половина приходит структурой `LaunchOptions` из этого же заголовка.
## launch(options, version, &error)
Экземпляр создаётся и принадлежит [LauncherBackend](LauncherBackend.md), который заполняет
`LaunchOptions` из настроек, выбранной сборки и результата авторизации.
1. Если игра уже идёт, отказ.
2. `missingFiles()`: если чего-то не хватает, отказ со списком.
3. Распаковка нативных библиотек в `<gameDir>/versions/<id>/natives`.
4. `JavaLocator::select()` с `version.javaMajor` и `options.javaPath`. На Windows вместо
`java.exe` запускается `javaw.exe`, чтобы не было консольного окна.
5. `QProcess` с рабочим каталогом `gameDir` и объединёнными stdout и stderr.
6. Ожидание старта до 15 секунд. Если процесс не стартовал, отказ.
Требования сборки: `Qt6::Core` (`QProcess`, `QStringList`) и `Qt6::CorePrivate` — последний нужен
ради `QZipReader`, которым распаковываются нативные библиотеки LWJGL. Зависимость от приватного
модуля привязывает проект к конкретной версии Qt; в `CMakeLists.txt` это осознанный выбор, и
предупреждение о нём отключено.
Если всё прошло, метод возвращает `true` и испускает `gameStarted(commandLine)`. В этой строке
значение после `--accessToken` заменено на `<скрыт>`, чтобы токен не попал в журнал. Других
мест, где мог бы оказаться токен (например, в `${auth_session}`), замена не касается.
## Иерархия и роль
Перед шагами 2–4 испускается `progress(message)`.
Наследует `QObject`: мета-объектная система, четыре сигнала и владение по родителю. Виртуальных
методов базового класса не переопределяет.
Вывод игры читается кусками и испускается построчно в `output(line)`. Когда процесс завершится,
придёт `gameFinished(exitCode, crashed)`, где `crashed` значит `QProcess::CrashExit`.
## Публичные структуры
`terminate()` вызывает `kill()`, а не `terminate()`. Игра не успеет ничего сохранить, это то же
самое, что снять процесс в диспетчере задач.
### LaunchOptions
## missingFiles(options, version, limit = 12)
Всё, что лаунчер знает к моменту нажатия кнопки запуска.
Проверяет, что на месте папка игры, клиентский jar, все библиотеки, индекс ресурсов и папка
`assets/objects`. Возвращает до `limit` путей. Если папки игры нет, в списке будет только она.
| Поле | Тип | По умолчанию | Описание |
|------|-----|--------------|----------|
| `gameDir` | `QString` | — | Папка `.minecraft` |
| `versionId` | `QString` | — | Папка в `versions`, которую запускаем |
| `playerName` | `QString` | — | Ник игрока из результата авторизации |
| `uuid` | `QString` | — | UUID игрока |
| `accessToken` | `QString` | — | Токен доступа |
| `userType` | `QString` | — | Тип учётной записи: `legacy`, `msa` или `ELYBY` |
| `clientToken` | `QString` | — | Токен клиента |
| `xuid` | `QString` | — | Идентификатор Xbox; пустое значение заменяется на `0`, как в офлайне |
| `javaPath` | `QString` | — | Путь к java; пустое значение означает «искать самим» |
| `minMemoryMb` | `int` | `512` | Значение `-Xms` |
| `maxMemoryMb` | `int` | `4096` | Значение `-Xmx` |
| `extraJvmArgs` | `QStringList` | — | Дополнительные аргументы JVM из настроек |
| `windowWidth` | `int` | `0` | Ширина окна игры; `0` — не передавать `--width` и `--height` |
| `windowHeight` | `int` | `0` | Высота окна игры |
| `fullscreen` | `bool` | `false` | Запускать в полноэкранном режиме |
| `serverAddress` | `QString` | — | `host[:port]` для автоматического захода на сервер |
| `authlibInjectorPath` | `QString` | — | Путь к `authlib-injector`; пустое значение — не подключать |
| `authlibInjectorApi` | `QString` | `ely.by` | Сервер авторизации, на который перенаправляется игра |
| `launcherName` | `QString` | `BiLauncher` | Имя лаунчера, которое видит игра |
| `launcherVersion` | `QString` | `1.0` | Версия лаунчера |
Размеры и хеши не проверяются, только наличие. Функция статическая, бэкенд вызывает её и для
подсказки в интерфейсе, и перед запуском.
## Публичные методы
## extractNatives(options, version, nativesDir, &error)
#### explicit GameLauncher(QObject \*parent = nullptr)
Для каждой библиотеки с `native == true` открывает jar через `QZipReader` (отсюда зависимость от
`Qt6::CorePrivate`) и вынимает файлы `.dll`, `.so`, `.so.N`, `.dylib`, `.jnilib`. Пропускаются
`META-INF/` и пути из `extract.exclude` описания версии. Все файлы кладутся в одну плоскую папку.
Если файл такого же размера уже лежит, его не переписывают.
Создаёт объект. Процесс игры при этом не запускается. Конструктор помечен `explicit`.
## buildArguments(options, version, nativesDir)
#### bool isRunning() const
Порядок аргументов:
Идёт ли сейчас игра. От этого зависит доступность кнопки запуска в интерфейсе.
1. `-javaagent:<authlib-injector>=ely.by` и `-Dauthlibinjector.side=client`, если инжектор задан;
2. `-Xms`, `-Xmx`, потом `extraJvmArgs`;
3. JVM-аргументы из описания версии;
4. аргумент логирования, если файл конфигурации логов скачан;
5. главный класс;
6. аргументы игры из описания версии;
7. `--width`/`--height`, если размер задан, а описание версии само его не передаёт;
8. `--fullscreen`;
9. сервер: `--quickPlayMultiplayer host:port` для версий, которые это понимают, иначе
`--server host --port port` (порт по умолчанию 25565).
#### static QStringList missingFiles(const LaunchOptions &options, const MinecraftVersion &version, int limit = 12)
Подстановки: `auth_player_name`, `auth_uuid`, `auth_access_token`, `auth_session`
(`token:<токен>:<uuid>`), `auth_xuid` (пусто → `0`), `clientid` (пусто → `0`), `user_type`,
`user_properties` (`{}`), `version_name`, `version_type`, `game_directory`, `assets_root`,
`assets_index_name`, `game_assets`, `natives_directory`, `library_directory`, `classpath`,
`classpath_separator`, `primary_jar`, `launcher_name`, `launcher_version`, `resolution_width`,
`resolution_height`.
Проверяет `.minecraft` на комплектность и возвращает описания недостающих файлов. Пустой список
означает, что всё на месте и сборку можно запускать.
Аргумент, в котором после подстановки остался `${`, выбрасывается целиком. Так отсекаются
переменные, о которых лаунчер не знает (например, связанные с демо-режимом).
Параметр `limit` ограничивает длину списка: перечислять все отсутствующие файлы у неустановленной
версии бессмысленно, важен сам факт и пара примеров.
У версий со старыми ресурсами (`legacy`, `pre-1.6`) `game_assets` указывает на
`assets/virtual/legacy`.
Метод статический, ничего не меняет и вызывается интерфейсом для строки состояния сборки.
Classpath — это все не-нативные библиотеки и в конце клиентский jar.
#### static QStringList buildArguments(const LaunchOptions &options, const MinecraftVersion &version, const QString &nativesDir)
### Forge 1.17+ и `-DignoreList`
Собирает аргументы ровно в том порядке, в котором их ждёт JVM: сначала аргументы JVM, затем главный
класс, затем аргументы игры. Подстановки вида `${...}` из версии заменяются значениями из
параметров запуска.
Метод статический и не имеет побочных эффектов, поэтому годится и для показа собранной командной
строки без запуска.
#### static bool extractNatives(const LaunchOptions &options, const MinecraftVersion &version, const QString &nativesDir, QString \*error)
Распаковывает файлы `.dll`, `.so` и `.dylib` из нативных библиотек в `<версия>/natives`. Учитывает
поле `extractExclude` каждой библиотеки — перечисленные там префиксы не распаковываются.
Возвращает `false` и заполняет `error` при неудаче. Без этого шага игра не стартует: LWJGL ищет
нативные библиотеки именно в этой папке.
#### bool launch(const LaunchOptions &options, const MinecraftVersion &version, QString \*error)
Полный цикл запуска: проверка комплектности, распаковка нативных библиотек, поиск java, старт
процесса.
Возвращает `false` и заполняет `error`, если что-то из перечисленного не удалось; `true` означает,
что процесс запущен — дальнейшая судьба игры приходит сигналами.
Путь к java берётся из `options.javaPath`, а при пустом значении ищется через
[JavaLocator](javalocator.md) с учётом требования версии.
#### void terminate()
Завершает процесс игры. Если игра не запущена, ничего не делает.
## Сигналы
#### progress(const QString &message)
Описание текущего шага подготовки: проверка файлов, распаковка нативных библиотек, поиск java.
Обработчик показывает сообщение пользователю — подготовка занимает заметное время.
#### output(const QString &line)
Одна строка вывода процесса игры. Обработчик пишет её в журнал; в лаунчере это вывод в консоль.
#### gameStarted(const QString &commandLine)
Процесс запущен; в параметре — собранная командная строка целиком. Удобно для диагностики: по ней
видно, с какими аргументами и какой java стартовала игра.
#### gameFinished(int exitCode, bool crashed)
Игра завершилась. `exitCode` — код выхода процесса, `crashed` отличает аварийное завершение от
обычного.
Обработчик снимает признак «игра идёт», возвращает доступность кнопки запуска и сообщает
пользователю итог: ненулевой код или выставленный `crashed` показываются как ошибка.
## Владение и время жизни
Класс наследует `QObject` и принимает `parent` — родитель его и удалит.
Процесс игры хранится в поле `m_process` и создаётся при запуске. Один экземпляр рассчитан ровно
на одну игру одновременно: повторный вызов `launch()` при работающем процессе не предусмотрен, и
вызывающий код обязан проверять `isRunning()`.
Дочерний процесс переживает уничтожение объекта не сам по себе — завершать игру перед выходом
должен вызывающий код через `terminate()`.
## Потокобезопасность
Только поток GUI. `QProcess` привязан к потоку, в котором создан, и все сигналы приходят туда же.
Статические методы (`missingFiles()`, `buildArguments()`, `extractNatives()`) состояния не имеют,
но выполняют файловый ввод-вывод и на большой версии могут заметно задержать вызывающий поток.
## Взаимодействие с другими классами
`LauncherBackend` собирает `LaunchOptions` из трёх источников: настроек лаунчера, описания
выбранной сборки и `AuthResult` от [AuthService](AuthService.md) или
[MsaAuthService](MsaAuthService.md). Разобранную версию он получает через
`VersionLoader::load()` из [minecraftversion.h](minecraftversion.md).
Все четыре сигнала бэкенд переправляет в QML: `progress` и `gameFinished` попадают в плашку
сообщений главного окна, `output` — в консоль, а `gameStarted` меняет признак `gameRunning`.
Статический `missingFiles()` вызывается отдельно от запуска — из метода проверки комплектности
сборки, результат которого показывает [BuildsDialog](../qml/BuildsDialog.md).
## Внешнее взаимодействие
**Дочерний процесс.** Класс запускает java через `QProcess`. Аргументы собираются
`buildArguments()`; для профилей Ely.by в них добавляется `-javaagent` с путём к
`authlib-injector`. Стандартный вывод процесса читается построчно и отдаётся сигналом `output`,
завершение — сигналом `gameFinished`. Направление обмена одностороннее: лаунчер запускает процесс
и читает его вывод, ничего не передавая обратно после старта.
Все сигналы процесса приходят в поток GUI.
## Пример использования
```cpp
auto *launcher = new GameLauncher(this);
connect(launcher, &GameLauncher::progress, this, &Backend::showStatus);
connect(launcher, &GameLauncher::gameFinished, this, &Backend::onGameFinished);
LaunchOptions options;
options.gameDir = settings.resolvedGameDir;
options.versionId = build.resolvedVersionId;
options.playerName = auth.playerName;
options.uuid = auth.uuid;
options.accessToken = auth.accessToken;
options.userType = auth.userType;
options.maxMemoryMb = settings.maxMemoryMb;
QString error;
if (!launcher->launch(options, version, &error))
emit launchError(error);
```
Forge собирает слой модулей из classpath и пропускает файлы, имя которых начинается с записи в
`-DignoreList`. В профиле Forge там записан `${version_name}.jar`. Официальный лаунчер кладёт в
classpath jar самого профиля, а у нас своего jar у профиля нет, и в classpath попадает
родительский, например `1.20.1.jar`. Если его имени нет в списке, он становится вторым модулем с
классами Minecraft, и игра падает с ошибкой вида `Modules minecraft and _1._20._1 export package
...`. Поэтому к `-DignoreList` дописывается имя клиентского jar, если его там ещё нет.
---
+40 -180
View File
@@ -1,200 +1,60 @@
# JavaInstaller
## Обзор класса
`javainstaller.h`, `javainstaller.cpp`
`JavaInstaller` ставит сборку Java в `<root>/java/<id>`. Как и
[ModLoaderInstaller](ModLoaderInstaller.md), он прячет за одним фасадом два разных пути.
Ставит одну сборку Java из каталога [JavaRuntimeService](JavaRuntimeService.md) в
`<root>/java/<id>/`. Сборки Temurin приходят архивом, сборки Mojang — деревом отдельных файлов,
поэтому путей установки два.
**Temurin** отдаёт один архив: лаунчер качает его, сверяет sha256 и распаковывает. **Mojang**
отдаёт манифест с деревом файлов: лаунчер качает файлы по отдельности, как это делает официальный
лаунчер.
Экземпляр держит [LauncherBackend](LauncherBackend.md). Геттеры прогресса такие же, как у других
установщиков: `label`, `stage`, `currentFile`, `bytesDone`, `bytesTotal`, `fraction`, плюс
`runtimeId`. Сигналы: `started(label)`, `progressChanged()` (не чаще раза в 100 мс),
`finished(runtimeId, javaPath)`, `failed(label, message)`, `canceled(label)`.
Набор геттеров прогресса повторяет [VersionInstaller](VersionInstaller.md): панель загрузки в
интерфейсе читает их одинаково, независимо от того, кто сейчас работает.
## install(entry)
## Место в проекте и зависимости
Если установка уже идёт, вызов молча игнорируется. Иначе:
Подключает [javaruntime.h](javaruntime.md): на вход установщик принимает запись каталога
`JavaRuntimeEntry`, а результат записывает через `JavaRuntimeStore`.
1. проверяется папка лаунчера;
2. **папка `<root>/java/<id>` удаляется целиком**, если она была, и создаётся заново. Повторная
установка — это всегда установка с нуля;
3. дальше одна из двух веток.
Экземпляр создаётся и принадлежит [LauncherBackend](LauncherBackend.md); записи каталога приходят
от [JavaRuntimeService](JavaRuntimeService.md).
### Temurin (`zip`, `tar.gz`)
Требования сборки: `Qt6::Core` (`QCryptographicHash`, `QSaveFile`, `QProcess`, `QTimer`),
`Qt6::CorePrivate` (`QZipReader` для распаковки zip) и `Qt6::Network`.
1. Архив качается в `<root>/cache/java/<имя файла>` через `QSaveFile`, sha256 считается на ходу и
сверяется с каталогом.
2. `zip` распаковывается через `QZipReader` по 24 записи за такт таймера, чтобы окно не
подвисало. Права из архива переносятся. Записи, которые ведут за пределы папки сборки,
останавливают установку.
3. `tar.gz` распаковывается системным `tar -xzf`. Он есть на Linux, macOS и Windows 10 и новее.
`QZipReader` tar не умеет, а держать ради этого ещё одну библиотеку незачем.
4. Архив удаляется.
## Иерархия и роль
Прогресс при загрузке считается в байтах, при распаковке zip — в записях. Для tar прогресса нет.
Наследует `QObject`: мета-объектная система, пять сигналов и владение по родителю. Объявлен
виртуальный деструктор — класс владеет незавершёнными загрузками, открытым архивом и процессом
распаковки.
### Mojang
## Публичные структуры
1. Качается `manifest.json` компонента. Его sha1 из каталога сейчас **не проверяется**.
2. Из `files` строится дерево: папки создаются сразу, ссылки откладываются, файлы ставятся в
очередь.
3. До 4 файлов качаются параллельно, sha1 каждого сверяется. При временной ошибке (таймаут,
5xx и т. п.) и при несовпадении хеша файл уходит в конец очереди без паузы. После третьей
неудачи установка падает. Файлам с `executable: true` ставится бит исполнения.
4. Ссылки создаются через `QFile::link`. Если не вышло (на Windows без прав разработчика), файл
копируется.
### JavaFileTask
### Конец
Один файл рантайма Mojang.
`JavaRuntimeStore::locateBinary()` находит `bin/java`. На Unix ему ставится бит исполнения.
`runtime.json` записывается последним: пока его нет, `JavaRuntimeStore` сборку не видит. Поэтому
недокачанная сборка в списке не появится, даже если лаунчер упал на середине.
| Поле | Тип | По умолчанию | Описание |
|------|-----|--------------|----------|
| `url` | `QUrl` | — | Откуда качать |
| `path` | `QString` | — | Абсолютный путь назначения |
| `sha1` | `QString` | — | Контрольная сумма файла |
| `size` | `qint64` | `0` | Размер в байтах |
| `executable` | `bool` | `false` | Файлу нужно выставить право на исполнение — иначе `bin/java` не запустится |
| `attempts` | `int` | `0` | Сколько попыток уже сделано |
При ошибке и при отмене папка сборки и скачанный архив удаляются.
### JavaActiveDownload
## cancel()
Файл в процессе скачивания: задача, сетевой ответ, открытый `QSaveFile`, накапливаемая
контрольная сумма и число принятых байт. Файлы пишутся потоком — рантайм весит около двухсот
мегабайт.
Структура `ZipExtraction`, хранящая состояние распаковки, объявлена вперёд и спрятана в
`.cpp`: так приватный заголовок `QZipReader` не расходится по проекту вместе с этим заголовком.
## Публичные методы
#### explicit JavaInstaller(QObject \*parent = nullptr)
Создаёт установщик, его `QNetworkAccessManager` и два таймера. Конструктор помечен `explicit`.
#### bool isRunning() const
Идёт ли установка прямо сейчас.
#### QString runtimeId() const
Идентификатор устанавливаемой сборки.
#### QString label() const
Подпись установки для интерфейса.
#### QString stage() const
Текущий этап словами: загрузка, проверка, распаковка.
#### QString currentFile() const
Файл, который обрабатывается сейчас.
#### qint64 bytesDone() const
Сколько байт уже получено. Байты считаются только на загрузке: на распаковке считать нечего, и
панель по нулевому итогу сама прячет мегабайты.
#### qint64 bytesTotal() const
Ожидаемый общий объём загрузки.
#### double fraction() const
Доля выполнения от `0` до `1` либо `-1`, пока итог неизвестен. На этапе распаковки доля считается
по числу обработанных записей архива или файлов дерева, а не по байтам.
#### void install(const JavaRuntimeEntry &entry)
Ставит сборку по записи каталога. Путь установки выбирается по полю `archive` записи: значения
`zip` и `tar.gz` ведут по пути Temurin, значение `mojang` — по пути манифеста.
По завершении установщик находит исполняемый файл java в распакованном дереве через
`JavaRuntimeStore::locateBinary()` и записывает описание сборки рядом с ней.
Одновременно ставится одна сборка; очереди у этого установщика нет.
#### void cancel()
Отменяет установку. Отмена проверяется между файлами и между кусками распаковки, поэтому
срабатывает не мгновенно, но без замораживания интерфейса.
## Сигналы
#### started(const QString &label)
Установка началась. Обработчик показывает панель прогресса.
#### progressChanged()
Изменились числа прогресса; испускается не чаще, чем позволяет внутренний таймер. Обработчик
перечитывает геттеры.
#### finished(const QString &runtimeId, const QString &javaPath)
Сборка установлена; во втором параметре — абсолютный путь к исполняемому файлу java.
Обработчик обновляет каталог: сборка становится помеченной как скачанная, а диалог настроек
перечитывает её описание.
#### failed(const QString &label, const QString &message)
Установка не удалась; в параметре — текст ошибки для пользователя.
#### canceled(const QString &label)
Установка отменена пользователем.
## Владение и время жизни
Класс наследует `QObject` и принимает `parent` — родитель его и удалит.
`QNetworkAccessManager` и оба таймера создаются в конструкторе с установщиком в роли родителя.
Владение внутренними ресурсами построено на RAII: скачиваемый архив хранится как
`std::unique_ptr<QSaveFile>`, состояние распаковки — как `std::unique_ptr<ZipExtraction>`,
активные загрузки — как `std::shared_ptr<JavaActiveDownload>` с собственным `QSaveFile` внутри.
Незавершённая запись отменяется вместе с уничтожением объекта, и испорченный файл не попадает на
место назначения.
Процесс `tar`, используемый для распаковки архивов `tar.gz`, создаётся по ходу работы и
завершается в деструкторе.
## Потокобезопасность
Только поток GUI. Отдельного потока у класса нет намеренно: распаковка zip идёт по кускам по
таймеру — держать поток GUI занятым на всю сотню мегабайт нельзя, а заводить поток ради одной
операции незачем. Распаковка `tar.gz` отдана внешнему процессу, который работает параллельно сам.
## Взаимодействие с другими классами
`LauncherBackend` вызывает `install()` с записью, полученной от
[JavaRuntimeService](JavaRuntimeService.md), и переправляет сигналы прогресса в те же свойства,
что и остальные установщики. Сигнал `finished` бэкенд переправляет в QML под собственным именем —
на него подписан диалог настроек, чтобы обновить строку выбранной сборки, когда та докачается.
Раскладку папки `<root>/java`, поиск исполняемого файла и запись описания обеспечивает
`JavaRuntimeStore` из [javaruntime.h](javaruntime.md).
## Внешнее взаимодействие
**Сеть, исходящие запросы.** Загрузка по HTTPS с серверов Adoptium или Mojang. Архив Temurin
скачивается одним запросом с проверкой sha256; дерево Mojang — множеством параллельных запросов,
каждый с проверкой sha1. Неудачная задача повторяется, счётчик попыток хранится в самой задаче.
**Дочерний процесс.** Архивы `tar.gz` распаковываются системным `tar` через `QProcess` —
собственного распаковщика для этого формата в Qt нет. Обмен односторонний: лаунчер запускает
процесс и ждёт его завершения.
**Файловая система.** После распаковки дерева Mojang применяются символические ссылки из
манифеста, а файлам с признаком `executable` выставляется право на исполнение.
Все сигналы приходят в поток GUI.
## Пример использования
```cpp
auto *javaInstaller = new JavaInstaller(this);
connect(javaInstaller, &JavaInstaller::finished, this,
[this](const QString &runtimeId, const QString &javaPath) {
m_settings.javaRuntime = runtimeId;
m_resolvedJavaPath = javaPath;
emit javaRuntimeInstalled(runtimeId);
});
const auto entry = javaCatalog->find(runtimeId);
if (entry)
javaInstaller->install(*entry);
```
Обрывает загрузки и убивает `tar`. `canceled` приходит, когда вернутся все активные запросы.
---
+45 -133
View File
@@ -1,155 +1,67 @@
# JavaRuntimeService
## Обзор класса
`javaruntimeservice.h`, `javaruntimeservice.cpp`
`JavaRuntimeService` — каталог сборок Java, которые лаунчер умеет скачать: качает, кэширует в
папке лаунчера и отдаёт из кэша, пока тот не устарел. Устроен так же, как
[VersionManifestService](VersionManifestService.md).
Каталог сборок Java, которые лаунчер может скачать. Устроен как
[VersionManifestService](VersionManifestService.md), но кэш хранится сутки и лежит в
`<root>/cache/java_runtimes.json`.
Источников два, и они дополняют друг друга. **Mojang** (категория «Java») — ровно тот рантайм,
которым запускает игру официальный лаунчер: версий немного, зато они заведомо совместимы.
**Eclipse Temurin** (категории JDK и JRE) — свежие сборки всех мажорных версий, включая те, до
которых Mojang ещё не дошёл.
Источников два:
Отдаются только сборки под текущие операционную систему и архитектуру: выбрать заведомо
неработающую нечем. Исключение — macOS на Apple Silicon, где под Java 8 и 16 сборок `aarch64` нет
вовсе и приходится брать `x64`, который работает через Rosetta.
- **Mojang** (в интерфейсе категория «Java») — те же рантаймы, с которыми игру запускает
официальный лаунчер. Их немного, зато с игрой они точно совместимы.
- **Eclipse Temurin** (JDK и JRE) — свежие сборки всех мажорных версий, в том числе тех, которых у
Mojang ещё нет.
## Место в проекте и зависимости
В каталог попадают только сборки под текущую ОС и архитектуру.
Подключает [javaruntime.h](javaruntime.md): перечисление `JavaRuntimeKind` и структура
`JavaRuntimeEntry` приходят оттуда.
Экземпляр держит [LauncherBackend](LauncherBackend.md), ставит сборки
[JavaInstaller](JavaInstaller.md).
Экземпляр создаётся и принадлежит [LauncherBackend](LauncherBackend.md). Записями каталога
пользуется [JavaInstaller](JavaInstaller.md).
## Как обновляется каталог
Путь к файлу кэша даёт `LauncherPaths::javaCatalogFile()` из [launcherpaths.h](launcherpaths.md).
Запросы идут параллельно. Каталог заменяется целиком, когда ответят все.
Требования сборки: `Qt6::Core` (`QDateTime`, `QHash`, `QSet`) и `Qt6::Network`
(`QNetworkAccessManager`).
1. `api.adoptium.net/v3/info/available_releases` — какие мажорные версии есть и какие из них LTS.
2. Для каждой мажорной версии `assets/feature_releases/<major>/ga` с фильтрами: ОС, архитектура,
HotSpot, обычная куча, vendor `eclipse`, три последних релиза. Из каждого берутся бинарники с
`image_type` = `jdk` или `jre` в виде `.zip` или `.tar.gz` по `https`.
3. `piston-meta.mojang.com/.../java-runtime/.../all.json` — раздел под текущую платформу
(`windows-x64`, `mac-os-arm64`, `linux` и т. п.), по одному варианту на компонент. Компонент
`minecraft-java-exe` пропускается: это не Java.
## Иерархия и роль
**Нет сборки под aarch64.** У Temurin под ARM нет Java 8 и 16. Если для aarch64 ничего не нашлось,
запрос повторяется для `x64`. На Mac с Apple Silicon такая сборка работает через Rosetta. Сейчас
повтор делается на любой ОС с aarch64, хотя комментарий в заголовке говорит только про macOS.
Наследует `QObject`: мета-объектная система, два сигнала и владение по родителю. Виртуальных
методов базового класса не переопределяет.
Id строк: `temurin-<jdk|jre>-<версия>-<арх>` и `mojang-<компонент>`, пропущенные через
`JavaRuntimeStore::sanitizeId()`.
## Псевдонимы типов
Версия Temurin 8 показывается как `8u<security>`, остальные — как `openjdk_version` без `-LTS`.
Мажорная версия у Mojang берётся из имени (`8u…` → 8, иначе первое число).
`JavaRuntimeService::Callback` — `std::function<void(bool ok, const QString &warning)>`. Как и в
остальных каталогах лаунчера, `ok == true` с непустым `warning` означает данные из устаревшего
кэша.
У сборок Mojang `size` равен 0: в каталоге указан только размер манифеста, а не рантайма.
## Публичные методы
Если какие-то запросы упали, а другие ответили, каталог всё равно обновляется, а предупреждения
склеиваются через `; `. Если не пришло ничего, но старые данные есть, колбэк получает `ok = true` и
предупреждение.
#### explicit JavaRuntimeService(QObject \*parent = nullptr)
Кэш помечается платформой (`mojangPlatform()`). Кэш с другой платформы не читается. Это возможно,
например, если папку лаунчера перенесли с другой машины.
Создаёт сервис и его `QNetworkAccessManager`. Кэш читается лениво. Конструктор помечен `explicit`.
## Методы
#### void ensureLoaded(Callback callback, bool forceRefresh = false)
- `ensureLoaded(callback, forceRefresh)` — как у манифеста версий.
- `entries(kind)` — строки одного вида, отсортированы по мажорной версии, потом по дате выхода,
новые сверху.
- `find(id)`.
- `bestFor(major)` — что скачать, если игре нужна Java `major`, а подходящей нет. Выбирается
наименьшая мажорная версия не ниже `major`. При равенстве порядок такой: Temurin JDK, потом
Mojang, потом Temurin JRE. JDK идёт первым, потому что установщикам Forge и NeoForge иногда нужны
инструменты из полного комплекта. Если и здесь равенство, берётся более свежая.
- `isRefreshing()`, `hasData()`.
Единственная точка входа. Свежий кэш означает отложенный вызов колбэка без сети; иначе запускается
обновление. Параметр `forceRefresh` обходит проверку свежести — так работает кнопка обновления
каталога.
Одно обновление складывается из нескольких запросов сразу к обоим источникам. Ответы приходят
вразнобой, поэтому они накапливаются, и каталог подменяется целиком только когда пришли все.
#### QList&lt;JavaRuntimeEntry&gt; entries(JavaRuntimeKind kind) const
Сборки одной категории: `Mojang`, `Jdk` или `Jre`. Новые версии идут первыми.
#### std::optional&lt;JavaRuntimeEntry&gt; find(const QString &id) const
Запись по идентификатору сборки; `std::nullopt`, если такой нет. Поиск идёт по внутреннему
указателю.
#### std::optional&lt;JavaRuntimeEntry&gt; bestFor(int major) const
Что скачать, если для версии игры нужна Java указанной мажорной версии, а подходящей в системе
нет.
Правила выбора: точное совпадение мажорной версии предпочтительнее более новой, а JDK
предпочтительнее JRE — установщики Forge и NeoForge иногда требуют инструментов из полного
комплекта. `std::nullopt` означает, что предложить нечего.
#### bool isRefreshing() const
Идут ли сейчас запросы. Признак остаётся истинным, пока не завершится последний из них.
#### bool hasData() const
Есть ли в каталоге хоть что-то — из сети или из кэша.
## Сигналы
#### catalogChanged()
Каталог заменён новыми данными. Испускается один раз за обновление, когда пришли ответы от всех
источников, а не по каждому из них.
Обработчик перечитывает `entries()` для нужных категорий и обновляет интерфейс.
#### refreshingChanged()
Изменился признак обновления. Обработчик показывает или убирает индикатор загрузки.
## Владение и время жизни
Класс наследует `QObject` и принимает `parent` — родитель его и удалит.
`QNetworkAccessManager` создаётся в конструкторе с сервисом в роли родителя.
Отложенные колбэки хранятся до завершения текущего обновления. Уничтожение сервиса с
незавершёнными запросами обрывает их вместе с менеджером сети, и колбэки не вызываются.
## Потокобезопасность
Только поток GUI. Чтение и запись кэша выполняются синхронно в вызывающем потоке.
## Взаимодействие с другими классами
`LauncherBackend` вызывает `ensureLoaded()` при открытии окна выбора Java и по кнопке обновления,
а сигналы переправляет в свойства, которые читает QML. Перед отдачей в интерфейс он сводит записи
каталога с уже установленными сборками из `JavaRuntimeStore` — окно выбора показывает статус, не
считая ничего само.
[JavaInstaller](JavaInstaller.md) получает запись каталога и по ней скачивает и распаковывает
сборку. `bestFor()` используется, когда для запуска не хватает Java и лаунчер должен сам
предложить, что поставить.
Требования версий игры к Java берутся из пространства имён `JavaRequirement` в
[javaruntime.h](javaruntime.md).
## Внешнее взаимодействие
**Сеть, исходящие запросы.** Класс обращается к двум внешним каталогам: API Adoptium (сборки
Temurin) и списку рантаймов Mojang. Формат обоих — JSON поверх HTTPS, разбор разделён на две
функции.
Запросов на одно обновление несколько: список сборок Temurin запрашивается по мажорным версиям,
и на macOS с Apple Silicon неудачный запрос сборки `aarch64` может быть переспрошен для `x64` —
это и есть тот самый случай Java 8 и 16.
Общий счётчик незавершённых запросов сводит их воедино: пока он не обнулился, каталог не
подменяется, а предупреждения от отдельных источников накапливаются.
Все сигналы и колбэки приходят в поток GUI.
## Пример использования
```cpp
auto *javaCatalog = new JavaRuntimeService(this);
connect(javaCatalog, &JavaRuntimeService::catalogChanged, this, &Backend::rebuildJavaCatalog);
javaCatalog->ensureLoaded([this, javaCatalog](bool ok, const QString &warning) {
if (!ok) {
emit javaCatalogError(warning);
return;
}
const auto best = javaCatalog->bestFor(version.javaMajor);
if (best)
emit suggestJavaInstall(best->id, best->version);
});
```
Сигналы: `catalogChanged()`, `refreshingChanged()`.
---
+310 -488
View File
@@ -1,531 +1,353 @@
# LauncherBackend
## Обзор класса
`launcherbackend.h`, `launcherbackend.cpp`
`LauncherBackend` — единственный класс проекта, видимый из QML, и центр всего приложения. Интерфейс
лаунчера ничего не знает ни о сети, ни о файлах, ни о процессах: он читает свойства этого класса,
вызывает его методы и слушает его сигналы.
Через этот класс QML работает со всем остальным кодом. Интерфейс читает его свойства, вызывает
методы и слушает сигналы, а к сервисам напрямую не обращается.
Сам бэкенд почти ничего не делает руками. Он владеет двенадцатью сервисами — авторизация, запуск
игры, каталоги версий, модлоадеров, Java и сезонных сборок, три установщика, загрузчик паков и
переключатель сборок — и отвечает за то, чтобы они работали в правильном порядке. Кроме того, он
хранит состояние лаунчера: профили игрока, пользовательские сборки и настройки запуска, которые
читает и пишет в файлы папки лаунчера.
Сам бэкенд почти ничего не скачивает и не запускает. Он хранит профили, сборки и настройки,
создаёт двенадцать сервисов и следит, чтобы они не мешали друг другу: нельзя ставить Java во время
смены сборки, удалять версию, пока она качается, или запускать игру посреди установки модлоадера.
Ещё он сводит каталоги из сети с тем, что лежит на диске, чтобы окнам не пришлось этого делать.
Ещё одна его задача — приводить данные к виду, удобному QML. Каталоги отдаются в интерфейс уже
сведёнными с локальным состоянием: строка версии знает, скачана ли она, строка сезонной сборки —
установлена ли и не устарела ли. Окно показывает статус, не считая ничего само.
Экземпляр один, его объявляет [Main.qml](../qml/Main.md). Во вложенные окна он передаётся через
`required property var backend`. Тип зарегистрирован через `QML_ELEMENT` в модуле `BiLauncher`;
синглтоном он не сделан.
## Место в проекте и зависимости
## Сервисы
Единственный экземпляр создаётся декларативно в [Main.qml](../qml/Main.md); в `main.cpp` он не
упоминается.
Все создаются в конструкторе с бэкендом в роли родителя.
Владеет двенадцатью сервисами, каждому из которых посвящена своя страница:
| Поле | Класс | Роль |
|------|-------|------|
| `m_auth` | [AuthService](AuthService.md) | вход через Ely.by и офлайн |
| Поле | Класс | Зачем |
|------|-------|-------|
| `m_auth` | [AuthService](AuthService.md) | Ely.by, офлайн, загрузка authlib-injector |
| `m_msa` | [MsaAuthService](MsaAuthService.md) | вход через Microsoft |
| `m_launcher` | [GameLauncher](GameLauncher.md) | запуск JVM с игрой |
| `m_launcher` | [GameLauncher](GameLauncher.md) | запуск JVM |
| `m_manifest` | [VersionManifestService](VersionManifestService.md) | каталог версий Mojang |
| `m_installer` | [VersionInstaller](VersionInstaller.md) | установка версии игры |
| `m_loaderMeta` | [ModLoaderVersionService](ModLoaderVersionService.md) | списки версий модлоадеров |
| `m_loaderMeta` | [ModLoaderVersionService](ModLoaderVersionService.md) | версии модлоадеров |
| `m_loaderInstaller` | [ModLoaderInstaller](ModLoaderInstaller.md) | установка модлоадера |
| `m_switcher` | [BuildSwitcher](BuildSwitcher.md) | смена активной сборки |
| `m_javaMeta` | [JavaRuntimeService](JavaRuntimeService.md) | каталог сборок Java |
| `m_switcher` | [BuildSwitcher](BuildSwitcher.md) | смена активной сборки, раскатка пака |
| `m_javaMeta` | [JavaRuntimeService](JavaRuntimeService.md) | каталог Java |
| `m_javaInstaller` | [JavaInstaller](JavaInstaller.md) | установка Java |
| `m_seasonalMeta` | [SeasonalBuildService](SeasonalBuildService.md) | каталог сезонных сборок |
| `m_packDownloader` | [SeasonalPackDownloader](SeasonalPackDownloader.md) | загрузка архива сезонной сборки |
Пути ко всем файлам состояния берутся из [launcherpaths.h](launcherpaths.md), описания версий — из
[minecraftversion.h](minecraftversion.md), словарь модлоадеров — из [modloader.h](modloader.md),
поиск системной Java — из [javalocator.md](javalocator.md).
`VersionInstaller` получает указатель на сервис манифеста, `ModLoaderInstaller` — на сервис версий
модлоадеров и на установщик версий. Владельцем этих объектов остаётся бэкенд.
Требования сборки: `Qt6::Core`, `Qt6::Gui`, `Qt6::Network`, `Qt6::CorePrivate` (опосредованно) и
`Qt6::Quick` — класс зарегистрирован в QML-модуле `Minecraft_launcher`, объявленном в
`CMakeLists.txt`.
## Что происходит при старте
## Иерархия и роль
Конструктор делает следующее, порядок важен:
Наследует `QObject`: мета-объектная система, свойства, сигналы и владение по родителю. Объявлен
виртуальный деструктор. Виртуальных методов базового класса не переопределяет.
1. `LauncherPaths::ensureRootExists()` создаёт папку лаунчера и проверяет, что в неё можно писать.
2. `loadSettings()` заполняет настройки значениями по умолчанию и накладывает сверху
`settings.json`.
3. `migrateLegacyBuildsFile()` переносит старый `versions.json` в `customBuilds.json`. Это должно
случиться до следующего шага, иначе поверх старых данных появится пустой файл.
4. `loadData()` читает профили и сборки и сортирует их по `id`.
5. `ensureDataFiles()` создаёт недостающие файлы.
6. Если `BuildSwitcher` нашёл журнал оборванного переключения, бэкенд сразу продолжает его через
`resumeInterrupted()`. Пользователю ничего не предлагается. Если начать переключение заново,
оно сначала заархивирует полуразобранную `.minecraft` поверх целого архива прежней сборки.
7. Подписка на сигналы сервисов.
## Свойства Q_PROPERTY
Все проблемы, найденные по пути (битый JSON, папка без права записи, пропущенные записи сборок),
копятся в `m_storageIssues` и уходят одним `launchError` через `QTimer::singleShot(0)`: к этому
моменту QML уже подписан на сигнал.
### Профили и сборки
## Файлы на диске
| Свойство | Тип | READ | WRITE | NOTIFY | Описание |
|----------|-----|------|-------|--------|----------|
| `profileNames` | `QStringList` | `profileNames` | — | `profilesChanged` | Имена профилей игрока в порядке добавления. Только для чтения; модель выпадающего списка профилей |
| `customBuildNames` | `QStringList` | `customBuildNames` | — | `customBuildsChanged` | Имена пользовательских сборок. Только для чтения; модель списка в окне сборок |
| `activeBuildIndex` | `int` | `activeBuildIndex` | `setActiveBuildIndex` | `activeBuildChanged` | Сборка, которую запускает кнопка игры. Хранится по идентификатору сборки, а не по индексу: удаление соседней записи не должно переназначать активную. Запись в свойство запускает смену сборки |
| `activeBuildName` | `QString` | `activeBuildName` | — | `activeBuildChanged` | Имя активной сборки для подписи на кнопке. Только для чтения |
| `installedVersions` | `QStringList` | `installedVersions` | — | `installedVersionsChanged` | Версии, реально установленные в папке игры. Только для чтения |
Все пути берутся из [launcherpaths.h](launcherpaths.md).
### Занятость и смена сборки
- `settings.json` — объект с настройками;
- `profiles.json` — массив профилей;
- `customBuilds.json` — массив сборок.
| Свойство | Тип | READ | WRITE | NOTIFY | Описание |
|----------|-----|------|-------|--------|----------|
| `gameRunning` | `bool` | `gameRunning` | — | `gameRunningChanged` | Игра запущена. Только для чтения; выключает кнопку запуска |
| `switching` | `bool` | `switching` | — | `switchChanged` | Идёт архивация или распаковка `.minecraft` при смене сборки. Отдельно от `busy`, потому что на это время блокируется ещё и список сборок. Только для чтения |
| `switchProgress` | `double` | `switchProgress` | — | `switchChanged` | Доля выполнения смены сборки от `0` до `1`; `-1` — итог неизвестен. Только для чтения |
| `switchStage` | `QString` | `switchStage` | — | `switchChanged` | Этап смены сборки словами. Только для чтения |
| `switchStatus` | `QString` | `switchStatus` | — | `switchChanged` | Строка состояния смены сборки. Только для чтения |
| `busy` | `bool` | `busy` | — | `busyChanged` | Лаунчер занят: пока идёт загрузка версии, кнопка запуска гаснет. Только для чтения |
| `microsoftAvailable` | `bool` | `microsoftAvailable` | — | — | Собран ли лаунчер с Qt WebEngine. Константное свойство: без WebEngine окно входа Microsoft показать нечем, и интерфейс не должен предлагать этот путь |
Если файла нет, это первый запуск. Если он не читается как JSON или в нём не тот тип (объект вместо
массива и наоборот), файл переименовывается в `<имя>.bak`, а на его месте создаётся пустой.
Данные из `.bak` можно потом достать руками.
### Каталог версий
Перед каждой записью снова вызывается `ensureRootExists()`: папку могли удалить, пока лаунчер
работал, и без этой проверки сохранения пропадали бы без ошибки. Пишется всё через обычный `QFile`,
кроме `builds/<id>/season.json`, для которого используется `QSaveFile`.
| Свойство | Тип | READ | WRITE | NOTIFY | Описание |
|----------|-----|------|-------|--------|----------|
| `versionCatalog` | `QVariantList` | `versionCatalog` | — | `versionCatalogChanged` | Объединённый список для выбора версии: установленные, разделитель, затем весь каталог Mojang. Строка содержит поля `id`, `label`, `category`, `installed` и `search`. Только для чтения |
| `catalogLoading` | `bool` | `catalogLoading` | — | `catalogLoadingChanged` | Идёт загрузка манифеста версий. Только для чтения |
### Каталог Java
| Свойство | Тип | READ | WRITE | NOTIFY | Описание |
|----------|-----|------|-------|--------|----------|
| `javaCatalog` | `QVariantList` | `javaCatalog` | — | `javaCatalogChanged` | Сборки Java для окна выбора: скачиваемые из сети плюс те, что уже лежат в папке лаунчера. Строка содержит поля `id`, `label`, `kind`, `major`, `installed`, `downloadable`, `lts`, `sizeMb`, `detail`, `coverage` и `search`. Только для чтения |
| `javaCatalogLoading` | `bool` | `javaCatalogLoading` | — | `javaCatalogLoadingChanged` | Идёт загрузка каталога Java. Только для чтения |
### Сезонные сборки
| Свойство | Тип | READ | WRITE | NOTIFY | Описание |
|----------|-----|------|-------|--------|----------|
| `seasonalCatalog` | `QVariantList` | `seasonalCatalog` | — | `seasonalCatalogChanged` | Готовые сборки с сервера. Строки уже сведены с локальными записями: окно показывает статус, не считая ничего само. Только для чтения |
| `seasonalCatalogLoading` | `bool` | `seasonalCatalogLoading` | — | `seasonalCatalogLoadingChanged` | Идёт загрузка каталога сезонных сборок. Только для чтения |
| `seasonalCatalogError` | `QString` | `seasonalCatalogError` | — | `seasonalCatalogChanged` | Текст ошибки обращения к серверу сборок; пусто — всё в порядке. Только для чтения |
| `seasonalInstalling` | `bool` | `seasonalInstalling` | — | `seasonalInstallingChanged` | Идёт установка сезонной сборки: окно не даёт начать вторую. Только для чтения |
### Загрузка
Одного сигнала на все свойства загрузки достаточно: установщик уже ограничивает частоту, а QML всё
равно перечитывает их разом.
| Свойство | Тип | READ | WRITE | NOTIFY | Описание |
|----------|-----|------|-------|--------|----------|
| `downloading` | `bool` | `downloading` | — | `downloadChanged` | Идёт какая-либо загрузка. Только для чтения |
| `downloadProgress` | `double` | `downloadProgress` | — | `downloadChanged` | Доля выполнения от `0` до `1`; `-1` — итог неизвестен. Только для чтения |
| `downloadVersion` | `QString` | `downloadVersion` | — | `downloadChanged` | Что именно качается — версия, модлоадер, сборка Java или пак. Только для чтения |
| `downloadStatus` | `QString` | `downloadStatus` | — | `downloadChanged` | Строка состояния загрузки. Только для чтения |
| `downloadBytesDone` | `qint64` | `downloadBytesDone` | — | `downloadChanged` | Принято байт. Только для чтения |
| `downloadBytesTotal` | `qint64` | `downloadBytesTotal` | — | `downloadChanged` | Ожидаемый объём в байтах; `0` — неизвестен. Только для чтения |
Панель загрузки одна на все четыре источника: свойства отдают числа того установщика или
загрузчика, который работает сейчас.
## Методы Q_INVOKABLE
Все перечисленные ниже методы вызываются из QML.
### Профили
#### void addProfile(const QString &name, const QString &login, const QString &password, const QString &authType = "offline")
Добавляет профиль игрока. Параметр `authType` принимает значения `offline`, `elyby` и `microsoft`.
Для офлайн-профиля пароль не нужен, для профиля Microsoft не нужны ни логин, ни пароль. Испускает
`profilesChanged`.
#### void updateProfile(int index, const QString &name, const QString &login, const QString &password, const QString &authType = "offline")
Перезаписывает профиль по индексу теми же полями. Испускает `profilesChanged`.
#### QVariantMap profileAt(int index) const
Данные профиля для диалога редактирования: имя, логин, пароль, тип, а также признак наличия
действующей сессии Microsoft и ник, полученный при официальной авторизации.
#### void removeProfile(int index)
Удаляет профиль. Испускает `profilesChanged`.
### Вход через Microsoft
#### void startMicrosoftLogin(int profileIndex)
Начинает вход в аккаунт Microsoft: испускает `microsoftLoginUrlReady` с адресом страницы входа.
Значение `-1` в `profileIndex` означает, что вход ещё не привязан к профилю: профиль создастся по
нику, который вернут Minecraft Services.
#### void finishMicrosoftLogin(const QString &code)
Завершает вход по коду авторизации, перехваченному окном браузера. Итог приходит сигналом
`microsoftLoginSucceeded` или `microsoftLoginFailed`.
#### void cancelMicrosoftLogin()
Сбрасывает начатую сессию входа. Вызывается, когда пользователь закрыл окно или адрес возврата
пришёл без кода.
#### QVariantMap inspectMicrosoftRedirect(const QString &url) const
Разбирает адрес, на который встроенное окно возвращается после входа. Возвращает карту с полями
`matched` (является ли адрес адресом возврата), `code` и `error`.
Разбор живёт в C++, чтобы правила совпадения не разъезжались с теми, по которым сервис сам строит
`redirect_uri`.
### Сборки
#### void addCustomBuild(const QString &name, const QString &serverUrl, const QString &minecraftVersion = QString())
Создаёт пользовательскую сборку. Испускает `customBuildsChanged`.
#### void updateCustomBuild(int index, const QVariantMap &fields)
Мержит в сборку только присланные ключи: `name`, `serverUrl`, `minecraftVersion`, `loader`,
`loaderVersion`, `resolvedVersionId`. Остальные поля остаются как были — это и позволяет карточке
сборки сохранять правки по одному полю за раз.
#### QVariantMap customBuildAt(int index) const
Данные сборки для карточки редактирования.
#### QVariantMap customBuildRemovalInfo(int index) const
Что именно потеряется при удалении сборки — для текста предупреждения. Возвращает имя сборки и три
признака: есть ли у неё архив, активна ли она сейчас и последняя ли она.
#### void removeCustomBuild(int index)
Удаляет сборку вместе с её архивом. Испускает `customBuildsChanged`.
#### void installCustomBuild(int index)
Докачивает то, чего не хватает выбранной сборке: версию игры и, если он выбран, модлоадер.
Вынесено отдельной кнопкой, потому что карточка сборки сохраняет правки по ходу редактирования и
установка не должна начинаться сама при каждой правке.
#### QStringList checkInstallation(int buildIndex) const
Проверка комплектности без запуска — для подсказки в интерфейсе. Пустой список означает, что
сборку можно запускать; иначе возвращаются описания недостающих файлов.
### Запуск игры
#### void launchGame(int profileIndex, int buildIndex)
Главная кнопка. Проверяет выбор профиля и версии, комплектность `.minecraft`, при необходимости
авторизуется и стартует игру.
Авторизация асинхронна, поэтому метод возвращается сразу; дальнейший ход виден по сигналам
`launchProgress`, `launched`, `launchError` и `twoFactorRequired`.
#### void submitTwoFactorCode(const QString &code)
Продолжает прерванный запуск, отдавая одноразовый код двухфакторной аутентификации. Вызывается
после сигнала `twoFactorRequired`.
#### void cancelPendingLaunch()
Отменяет запуск, остановленный на ожидании кода двухфакторной аутентификации.
#### void stopGame()
Завершает процесс игры.
### Каталог версий
#### void refreshVersionCatalog(bool force = false)
Обновляет каталог версий. Вызывается при открытии окна выбора версии: свежий кэш отвечает без
сети. Параметр `force` обходит проверку свежести.
#### bool isVersionInstalled(const QString &versionId) const
Установлена ли версия в папке игры.
#### void installVersion(const QString &versionId)
Ставит версию игры в фоне. Ход виден по свойствам загрузки.
#### QVariantMap versionRemovalInfo(const QString &versionId) const
Что потеряется при удалении версии — для текста предупреждения. Возвращает признак установки,
занимаемый объём в мегабайтах, список зависящих профилей модлоадеров и список сборок, которые эту
версию используют.
#### void removeVersion(const QString &versionId)
Удаляет файлы версии из `versions/`. Библиотеки и ресурсы остаются: они общие для всех версий.
Испускает `installedVersionsChanged`.
#### void cancelDownload()
Отменяет текущую загрузку.
### Модлоадеры
#### QVariantList loaderVersions(const QString &loaderKey, const QString &gameVersion) const
Версии модлоадера для выбранной версии игры. Возвращает список карт с полями `version`, `label`,
`recommended` и `stable`.
Несовместимых строк в списке нет — отбор заложен в сам источник данных, поэтому проверять
совместимость вызывающему коду не нужно. Пустой список означает, что лоадер эту версию игры не
поддерживает.
Параметр `loaderKey` принимает значения `forge`, `fabric`, `neoforge` и `quilt`.
#### void refreshLoaderVersions(const QString &loaderKey, const QString &gameVersion, bool force = false)
Запрашивает обновление списка версий лоадера. Результат приходит сигналом `loaderVersionsChanged`.
#### bool loaderVersionsLoading(const QString &loaderKey, const QString &gameVersion) const
Идёт ли сейчас запрос по этой паре. Позволяет интерфейсу отличить «ещё грузим» от «не
поддерживается».
#### void installLoaderForBuild(int index)
Ставит модлоадер, выбранный в сборке, и записывает получившийся профиль в `resolvedVersionId`.
Пароли Ely.by и токены лежат в `profiles.json` открытым текстом.
### Настройки
#### QVariantMap settings() const
Настройки запуска одной картой: `gameDir`, `javaPath`, `javaRuntime`, `minMemoryMb`,
`maxMemoryMb`, `jvmArgs`, `windowWidth`, `windowHeight`, `fullscreen`, `language` и вычисленный
`resolvedGameDir`.
#### void updateSettings(const QVariantMap &values)
Записывает настройки и сохраняет их на диск. Испускает `settingsChanged`.
Смену `language` после записи пробрасывает в [Localization](Localization.md) — порядок
«сохранили → переключили» гарантирует, что выбранный язык переживёт падение сразу после
переключения. Сам `Localization` в `settings.json` не пишет: файл ведёт только бэкенд.
#### QStringList detectedJava() const
Пути ко всем java, найденным в системе. Показывается справочной строкой в диалоге настроек.
### Сборки Java
#### void refreshJavaCatalog(bool force = false)
Обновляет каталог сборок Java. Вызывается при открытии окна выбора: свежий кэш отвечает без сети.
#### void installJavaRuntime(const QString &runtimeId)
Скачивает и распаковывает сборку Java. По завершении испускается `javaRuntimeInstalled`.
#### void removeJavaRuntime(const QString &runtimeId)
Удаляет скачанную сборку Java из папки лаунчера.
#### QVariantMap javaRuntimeInfo(const QString &runtimeId) const
Описание установленной сборки: подпись, версия, путь к java и признак установки. Пустая карта
означает, что сборки с таким идентификатором в папке лаунчера нет.
Метод не является привязкой и сам не пересчитывается, когда сборка докачается, — диалог настроек
обновляет его по сигналу `javaRuntimeInstalled`.
#### int requiredJavaMajor(int buildIndex) const
Минимальная мажорная версия Java для версии игры выбранной сборки; `0` — версия не выбрана.
Передаётся в окно выбора Java, чтобы пометить слишком старые сборки.
### Сезонные сборки
#### void refreshSeasonalCatalog(bool force = false)
Обновляет каталог сезонных сборок.
#### void installSeasonalBuild(const QString &seasonalId)
Ставит или обновляет сборку целиком одной цепочкой: запись сборки, версия игры, модлоадер, Java и
файлы.
Порядок шагов жёсткий: сначала сборка делается активной, затем ставится Java — она нужна
установщику Forge, — затем модлоадер, затем качается пак и только в конце его файлы раскатываются
поверх `.minecraft`. Раскатывать файлы имеет смысл только когда всё остальное на месте.
Строка каталога копируется на момент старта: обновление списка посреди установки не должно менять
то, что ставится. По завершении испускается `seasonalInstallFinished`.
#### void cancelSeasonalInstall()
Отменяет установку сезонной сборки.
### Папки
#### void openMinecraftFolder()
Открывает папку модов Minecraft в файловом менеджере системы.
#### void openGameFolder()
Открывает корневую папку игры в файловом менеджере системы.
## Сигналы
### Сигналы уведомления свойств
Эти сигналы объявлены как `NOTIFY` соответствующих свойств; обработчик перечитывает свойство.
`profilesChanged`, `customBuildsChanged`, `activeBuildChanged`, `switchChanged`,
`installedVersionsChanged`, `settingsChanged`, `gameRunningChanged`, `busyChanged`,
`versionCatalogChanged`, `catalogLoadingChanged`, `javaCatalogChanged`,
`javaCatalogLoadingChanged`, `seasonalCatalogChanged`, `seasonalCatalogLoadingChanged`,
`seasonalInstallingChanged` и `downloadChanged`.
#### loaderVersionsChanged(const QString &loaderKey, const QString &gameVersion)
Список версий модлоадера изменился. Параметры сужают событие до конкретной пары, поэтому
обработчик обязан сверить их со своим текущим состоянием: обновление может относиться к другой
строке лоадера или к прошлой версии игры. Именно так поступает [LoaderRow](../qml/LoaderRow.md).
### События установки
#### seasonalInstallFinished(const QString &seasonalId, const QString &buildName)
Сезонная сборка установлена и активна — можно запускать игру. Обработчик показывает сообщение
пользователю.
#### javaRuntimeInstalled(const QString &runtimeId)
Сборка Java установлена. Диалог настроек по этому сигналу обновляет подпись выбранной сборки, не
переоткрываясь.
### Запуск игры
#### launchProgress(const QString &message)
Описание текущего шага запуска. Обработчик показывает сообщение без таймаута: шаг может занять
заметное время, и сообщение должно держаться до следующего.
#### launched(const QString &profileName, const QString &buildName, const QString &serverUrl)
Игра запущена. Обработчик сообщает пользователю, какой профиль и какая сборка стартовали.
#### launchError(const QString &message)
Запуск не удался либо произошла ошибка, о которой нужно сказать пользователю. Через этот же сигнал
сообщается о проблемах записи файлов лаунчера.
#### twoFactorRequired(const QString &profileName)
Ely.by отклонил пароль с пометкой two factor. Обработчик открывает диалог ввода кода и передаёт
введённое значение в `submitTwoFactorCode()`; отказ должен вызвать `cancelPendingLaunch()`, иначе
запуск останется висеть в ожидании.
#### gameOutput(const QString &line)
Строка вывода процесса игры. Обработчик пишет её в журнал.
#### gameFinished(int exitCode, bool crashed)
Игра завершилась. Обработчик сообщает итог: ненулевой код или выставленный `crashed` показываются
как ошибка.
### Вход через Microsoft
#### microsoftLoginUrlReady(const QString &url)
Окну входа Microsoft: открыться на этом адресе. Обработчик создаёт окно (в сборке с Qt WebEngine)
и открывает его.
#### microsoftLoginSucceeded(const QString &playerName)
Вход выполнен. Обработчик сообщает об этом пользователю, но не трогает выбор в списке профилей:
новый профиль уже выбран тем, кто его создал, а повторный вход мог быть и не в последний профиль.
#### microsoftLoginFailed(const QString &message)
Вход не удался.
#### microsoftReloginRequired(int profileIndex)
Сессия профиля протухла настолько, что нужен повторный вход руками. Обработчик обычно сразу
вызывает `startMicrosoftLogin()` для этого профиля.
## Владение и время жизни
Класс наследует `QObject` и принимает `parent`. Экземпляр создаётся декларативно в QML, поэтому
временем его жизни управляет движок QML: объект живёт столько же, сколько главное окно.
Все двенадцать сервисов создаются в конструкторе с бэкендом в роли родителя и уничтожаются вместе
с ним. Порядок создания важен для двоих: [VersionInstaller](VersionInstaller.md) принимает в
конструктор сервис манифеста, а [ModLoaderInstaller](ModLoaderInstaller.md) — сервис версий
лоадеров и установщик версий; эти указатели не переходят во владение принимающей стороны.
Кэши каталогов помечены `mutable` и пересобираются лениво из константных геттеров: QML читает
свойства помногу раз за кадр, пока открыт список, и пересборка по каждому чтению обошлась бы
дорого.
## Потокобезопасность
Только поток GUI. Единственная работа в другом потоке — файловые операции над содержимым
`.minecraft`, и она полностью инкапсулирована в [BuildSwitcher](BuildSwitcher.md): сам бэкенд
общается с ним обычными сигналами и слотами.
## Доступ из QML
Класс зарегистрирован макросом `QML_ELEMENT` в модуле `Minecraft_launcher`, объявленном через
`qt_add_qml_module` в `CMakeLists.txt`. Имя типа в QML совпадает с именем класса —
`LauncherBackend`.
Из QML доступны все 26 свойств, все 42 метода `Q_INVOKABLE` и все сигналы, перечисленные выше.
Синглтоном тип не объявлен: экземпляр создаётся декларативно в [Main.qml](../qml/Main.md) и
передаётся во вложенные диалоги через их свойство `backend`. Все диалоги проекта объявляют его как
`required property var backend`.
Объект, созданный из QML, принадлежит движку QML — удалять его из C++ нельзя.
## Взаимодействие с другими классами
**Вниз, к сервисам.** Бэкенд подписан на сигналы всех двенадцати сервисов и сводит их к своим
свойствам. Четыре разных источника загрузки — установщик версий, установщик модлоадеров,
установщик Java и загрузчик паков — отображаются в одну группу свойств `download*`, поэтому панель
в интерфейсе не различает, кто работает; какой из источников показывать, бэкенд решает сам.
**Вверх, к QML.** Интерфейс не обращается ни к одному сервису напрямую. Каталоги отдаются уже
сведёнными с локальным состоянием: строка версии знает про `installed`, строка сезонной сборки —
про установленную ревизию и доступное обновление.
**Состояние на диске.** Профили, сборки и настройки читаются при создании и пишутся при каждом
изменении. Отсутствие файла — норма (первый запуск), а повреждённое содержимое отводится в файл с
расширением `.bak`, чтобы рабочий файл создался заново. Проблемы хранилища, замеченные на старте,
накапливаются и показываются одним сообщением, когда интерфейс уже подключился к сигналам.
Отдельно предусмотрена миграция: файл `versions.json` от прежней схемы именования переносится в
`customBuilds.json` при первом запуске после переименования.
**Восстановление после сбоя.** При старте бэкенд спрашивает у переключателя сборок, не было ли
прервано переключение, и предлагает доиграть его.
## Внешнее взаимодействие
Собственных сетевых обращений и дочерних процессов у класса нет: всё внешнее взаимодействие
делегировано сервисам — сеть у каталогов, установщиков и служб авторизации, процессы у
[GameLauncher](GameLauncher.md), [ModLoaderInstaller](ModLoaderInstaller.md) и
[JavaInstaller](JavaInstaller.md), файловые операции над `.minecraft` у
[BuildSwitcher](BuildSwitcher.md).
Единственное прямое обращение к системе — открытие папки игры в файловом менеджере методами
`openMinecraftFolder()` и `openGameFolder()`.
## Пример использования
Класс предназначен для создания из QML, а не из C++:
| Ключ | По умолчанию | Что это |
|------|--------------|---------|
| `gameDir` | пусто | папка игры; пусто — `LauncherPaths::defaultMinecraftDir()` |
| `javaPath` | пусто | путь к java, заданный руками |
| `javaRuntime` | пусто | id сборки Java из `<root>/java`; важнее `javaPath` |
| `minMemoryMb`, `maxMemoryMb` | 512, 4096 | `-Xms`, `-Xmx` |
| `jvmArgs` | пусто | дополнительные аргументы JVM одной строкой, делятся по пробелам |
| `windowWidth`, `windowHeight` | 0, 0 | размер окна игры; 0 — не передавать |
| `fullscreen` | `false` | |
| `language` | `system` | язык интерфейса, см. [Localization](Localization.md) |
| `activeBuildId` | 0 | `id` активной сборки |
| `seasonalBaseUrl` | `https://launcher-builds.galeonworx.com` | сервер сезонных сборок |
`seasonalBaseUrl` в интерфейсе нет. Он нужен, чтобы проверять установку против локального
HTTP-сервера. Хвостовые слэши отрезаются.
Имя и версия лаунчера для `${launcher_name}` и `${launcher_version}` в настройках не хранятся: они
берутся из `QCoreApplication`, то есть из `project(... VERSION)` в CMake. Ключи `launcherName` и
`launcherVersion`, оставшиеся в старых `settings.json`, при загрузке выбрасываются и пропадают из
файла при следующем сохранении.
`settings()` возвращает всю карту и добавляет к ней вычисленный `resolvedGameDir`.
`updateSettings()` вливает присланные ключи, сохраняет файл и испускает `settingsChanged`. Если
сменился язык, после записи вызывается `Localization::setLanguage()`: так выбор сохранится, даже если
лаунчер упадёт сразу после переключения. Если сменилась папка игры, перестраивается каталог версий.
## Профили
Профиль хранит имя, логин, пароль, тип входа (`offline`, `elyby`, `microsoft`; всё прочее читается
как `offline`) и полученные при входе данные: токены, UUID, ник с сервера, `xuid`, срок жизни токена.
- `addProfile(name, login, password, authType)` — добавить.
- `updateProfile(index, ...)` — перезаписать. Если у профиля Ely.by поменялись логин или пароль,
сохранённый токен сбрасывается. Если поменялся тип входа, сбрасывается всё, что было получено
при входе. Переименование профиля Microsoft сессию не трогает.
- `profileAt(index)` — `name`, `login`, `password`, `authType`, `resolvedName` и
`hasMicrosoftSession` (есть ли refresh-токен).
- `removeProfile(index)` — удалить.
Все испускают `profilesChanged`. Свойство `profileNames` отдаёт имена в порядке `id`.
## Сборки
Сборка — это имя, адрес сервера для автоподключения, версия Minecraft и, если выбран, модлоадер с
версией. После установки модлоадера в `resolvedVersionId` записывается id профиля в `versions/`,
который на самом деле запускается. У сезонных сборок есть ещё `seasonalId` и `seasonalRevision`.
Какую папку из `versions/` запускать, решает `launchVersionIdOf()`: сначала `resolvedVersionId`,
потом `minecraftVersion`, а если и её нет, то имя сборки. Последний вариант остался от старых
записей, у которых версия была записана в имени.
- `addCustomBuild(name, serverUrl, minecraftVersion)` — создать и сразу начать ставить версию, если
она указана и её нет на диске. Если версия пустая (так создаёт сборку кнопка «+ Новая сборка»),
ничего не ставится.
- `updateCustomBuild(index, fields)` — изменить только переданные ключи. Карточка сборки сохраняет
правки по одному полю, поэтому установка здесь не запускается. Если сменилась версия игры, а
`loaderVersion` в том же вызове не прислали, `loaderVersion` и `resolvedVersionId` сбрасываются;
сам `loader` остаётся.
- `customBuildAt(index)` — все поля плюс `launchVersionId`.
- `customBuildRemovalInfo(index)` — `name`, `hasArchive`, `active`, `lastOne` для текста
предупреждения.
- `removeCustomBuild(index)` — удалить запись и архив. Пока лаунчер занят или игра запущена,
отказывает. Если удалили активную, активной становится первая из оставшихся, и запускается
переключение.
- `installCustomBuild(index)` — поставить, чего не хватает. Для ванильной сборки это версия игры,
для сборки с модлоадером — модлоадер вместе с версией. Вызывает `installLoaderForBuild()`.
- `installLoaderForBuild(index)` — то же самое; по завершении записывает `resolvedVersionId`.
- `checkInstallation(index)` — список того, чего не хватает для запуска; пустой список значит, что
всё на месте.
### Активная сборка
`activeBuildIndex` хранится в настройках как `activeBuildId`, а не как индекс. Если этот `id`
пропал, активной считается первая сборка.
Запись в свойство запускает `BuildSwitcher::switchTo()`: содержимое `.minecraft` уезжает в архив
прежней сборки, на его место распаковывается архив новой. Смена отклоняется, если уже идёт другая
смена, запущена игра или работает любой установщик. `activeBuildId` меняется только в обработчике
`BuildSwitcher::finished`, поэтому после неудачи активной остаётся прежняя сборка.
Пока идёт смена, выставлено `switching`, а ход виден в `switchProgress` (от 0 до 1, или −1, пока
объём неизвестен), `switchStage` и `switchStatus`.
## Версии игры
- `versionCatalog` — плоский список строк `{id, type, category, label, installed, search}` в порядке
манифеста, новые сверху. `category` — `release`, `snapshot`, `old_beta`, `old_alpha` или `other`.
В конец добавлены установленные версии, которых нет в манифесте (профили модлоадеров), по
алфавиту, с категорией `other`. Разделы и поиск делает
[VersionPickerDialog](../qml/VersionPickerDialog.md).
- `catalogLoading` — идёт запрос манифеста.
- `refreshVersionCatalog(force)` — сбросить кэш строк и попросить манифест. Свежий кэш манифеста
отвечает без сети.
- `installedVersions`, `isVersionInstalled(id)` — что лежит в `versions/`.
- `installVersion(id)` — поставить; если эта версия уже ставится или стоит в очереди, ничего не
делает.
- `versionRemovalInfo(id)` — `id`, `installed`, `sizeMb`, `dependents` (профили модлоадеров поверх
этой версии), `builds` (имена сборок, которые на неё ссылаются).
- `removeVersion(id)` — удалить папку версии. Библиотеки и ресурсы общие, их не трогает. Пока
лаунчер занят или запущена игра, отказывает. Сборки, ссылающиеся на версию, не меняются:
ссылка на неустановленную версию — нормальное состояние.
Список строк каталога строится лениво и кэшируется: QML читает свойство много раз за кадр, пока
открыт список. То же сделано для каталогов Java и сезонных сборок.
## Модлоадеры
- `loaderVersions(loaderKey, gameVersion)` — список `{version, label, recommended, stable}` для этой
версии игры, новые сверху. Несовместимых строк в нём не бывает. Пустой список означает, что
модлоадер эту версию не поддерживает.
- `refreshLoaderVersions(loaderKey, gameVersion, force)` — обновить список. По окончании всегда
приходит `loaderVersionsChanged(loaderKey, gameVersion)`, даже если ответил кэш. Обработчик должен
сверить пару со своей: сигнал может относиться к другому модлоадеру или к прошлой версии игры.
- `loaderVersionsLoading(loaderKey, gameVersion)` — помогает отличить «ещё грузится» от «не
поддерживается».
Ключи: `forge`, `fabric`, `neoforge`, `quilt`.
Установщику модлоадера передаётся та же Java, что пойдёт в игру: сначала скачанная лаунчером
сборка, потом `javaPath`.
## Java
- `javaCatalog` — строки `{id, kind, major, version, label, detail, coverage, sizeMb, installed, lts,
downloadable, search}`. Сначала то, что уже скачано в `<root>/java`, потом каталог Mojang, Temurin
JDK и Temurin JRE.
- `javaCatalogLoading`, `refreshJavaCatalog(force)` — как у версий.
- `installJavaRuntime(id)` — скачать. Пока работает другой установщик или идёт смена сборки,
отказывает. По завершении приходит `javaRuntimeInstalled(id)`.
- `removeJavaRuntime(id)` — удалить из папки лаунчера. Если это была выбранная в настройках сборка,
`javaRuntime` очищается.
- `javaRuntimeInfo(id)` — `id`, `kind`, `major`, `version`, `label`, `javaPath`, `installed`.
Если сборка выбрана, но не скачана, `installed` будет `false` и `javaPath` пустым. Для пустого
`id` возвращается пустая карта. Метод не пересчитывается сам, поэтому окно настроек перечитывает
его по `javaRuntimeInstalled`.
- `requiredJavaMajor(buildIndex)` — минимальная Java для версии игры сборки, 0 если версия не
выбрана.
- `detectedJava()` — найденные в системе java строками вида `Java 17 — /путь`, для справки в
настройках.
## Запуск игры
`launchGame(profileIndex, buildIndex)` сначала проверяет всё, что можно проверить синхронно:
- не работает ни один установщик, не идёт другой запуск и игра не запущена;
- профиль и сборка выбраны, папка игры существует;
- версия сборки есть в `versions/`;
- описание версии читается (`VersionLoader::load`), и все нужные файлы на месте
(`GameLauncher::missingFiles`).
После этого выставляется `busy`, а сборка запоминается по `id`, а не по индексу: пока идёт вход,
её могут удалить в другом окне.
Дальше зависит от типа профиля:
- **офлайн** — ник из логина или имени профиля, UUID считается из ника;
- **Ely.by** — сначала докачивается `authlib-injector` в `<root>/runtime`, потом вход. Если Ely.by
просит второй фактор, `busy` снимается и приходит `twoFactorRequired(profileName)`. Окно должно
вызвать `submitTwoFactorCode(code)` или `cancelPendingLaunch()`, иначе запуск так и останется
в ожидании;
- **Microsoft** — если refresh-токена нет, запуск прерывается с `microsoftReloginRequired(index)`.
Если сохранённый токен Minecraft живёт ещё дольше двух минут, сеть не трогается. Иначе сессия
продлевается по refresh-токену. Если продлить не вышло, тоже `microsoftReloginRequired`. Если у
аккаунта нет лицензии, просто ошибка.
Полученные при входе ник, UUID и токены сохраняются в профиль. Java выбирается так: скачанная
лаунчером сборка из `javaRuntime`, иначе `javaPath`, иначе её ищет `GameLauncher`. Если
`javaRuntime` задан, но такой сборки на диске нет, об этом будет сообщение в `launchProgress`.
Ход виден по сигналам `launchProgress`, `launched(profileName, buildName, serverUrl)` и
`launchError`. `stopGame()` завершает процесс. Пока игра работает, `gameRunning` равно `true`;
вывод процесса приходит построчно в `gameOutput`, конец — в `gameFinished(exitCode, crashed)`.
## Вход через Microsoft из окна профиля
1. `startMicrosoftLogin(profileIndex)` испускает `microsoftLoginUrlReady(url)`. `-1` значит, что
профиля ещё нет и его нужно создать по нику из аккаунта. Без WebEngine сразу приходит
`microsoftLoginFailed`.
2. [MicrosoftLoginDialog](../qml/MicrosoftLoginDialog.md) открывает страницу и на каждую смену
адреса вызывает `inspectMicrosoftRedirect(url)`. Ответ: `{matched, code, error}`. Разбор сделан
в C++, чтобы правила совпадали с тем, как `MsaAuthService` строит `redirect_uri`.
3. Код уходит в `finishMicrosoftLogin(code)`. Итог приходит в `microsoftLoginSucceeded(playerName)`
или `microsoftLoginFailed(message)`.
`cancelMicrosoftLogin()` забывает начатый вход. Если окно закрыли, пока шёл обмен кода, результат
будет выброшен.
`microsoftAvailable` — константа: собран ли лаунчер с Qt WebEngine.
## Сезонные сборки
- `seasonalCatalog` — строки каталога с полями сервера (`id`, `name`, `minecraftVersion`, `loader`,
`loaderTitle`, `loaderVersion`, `modCount`, `seasonStart`, `seasonEnd` в виде `дд.мм.гггг`,
`sizeBytes`, `revision`, `description`, `serverUrl`) и локальным состоянием (`installed`,
`installedRevision`, `updateAvailable`, `status`). Сборка считается установленной, если есть
запись с таким `seasonalId`. Обновление доступно, если её ревизия меньше серверной.
- `seasonalCatalogLoading`, `seasonalCatalogError`.
- `refreshSeasonalCatalog(force)` — перед запросом заново читает `seasonalBaseUrl`.
- `installSeasonalBuild(id)` — поставить или обновить.
- `cancelSeasonalInstall()` — отменить.
- `seasonalInstalling` — идёт установка.
Установка — цепочка шагов. Каждый шаг либо запускает асинхронную работу и ждёт сигнала, либо
решает, что делать нечего, и сразу передаёт ход следующему (`seasonAdvance()` со сквозными `case`).
1. **Запись.** Если сборки с таким `seasonalId` нет, она создаётся. Если есть, в неё записываются
новые версия, модлоадер и адрес сервера. Ревизию пока не трогаем: её запишем, только когда файлы
лягут на диск, иначе оборванная установка выглядела бы законченной.
2. **Switching.** Сборка становится активной: пак раскатывается поверх текущей `.minecraft`.
3. **Java.** Нужная версия берётся из `javaMajor` каталога или из таблицы требований. Если
подходящая java уже есть (своя, системная или скачанная раньше), шаг пропускается. Иначе
загружается каталог Java и ставится лучшая сборка для этой версии. Java ставится раньше
модлоадера, потому что она нужна установщику Forge.
4. **Loader.** Для ванильной сборки ставится версия игры, для остальных — модлоадер. Если всё уже
установлено и укомплектовано, шаг пропускается.
5. **Downloading.** Архив качается в `builds/<id>/.pack-r<ревизия>.zip`. Точка в начале имени нужна,
чтобы `BuildSwitcher` не принял файл за архив сборки и не стёр его.
6. **Applying.** `BuildSwitcher::applyPack()` удаляет всё, что принесла прошлая ревизия (список из
`season.json`), и распаковывает новую. Миры и скриншоты в этот список не попадают, потому что
пак их не приносил.
После раскатки `recordSeasonalPack()` пишет `builds/<id>/season.json` (`seasonalId`, `revision`,
`sha256`, `installedAt`, `entries`) и проставляет ревизию в запись сборки. Это делается и тогда,
когда раскатка была доиграна после перезапуска лаунчера. Конец цепочки — `seasonalInstallFinished
(seasonalId, buildName)`.
При ошибке на любом шаге скачанный архив удаляется, и приходит `launchError`. На шагах Switching и
Applying отменить нельзя: `.minecraft` уже разобрана, операцию нужно довести до конца.
Строка каталога копируется в `m_season.entry` при старте. Если каталог обновится во время установки,
ставиться будет всё равно то, что выбрали.
## Панель загрузки
Источников загрузки четыре: установщик версий, установщик модлоадера, установщик Java и загрузчик
пака. Наружу они видны через одну группу свойств: `downloading`, `downloadProgress`,
`downloadVersion` (что качается), `downloadStatus` (`этап — файл`), `downloadBytesDone`,
`downloadBytesTotal`. Все обновляются одним сигналом `downloadChanged`.
Чьи числа показывать, решается так. Пак качается, только когда остальные уже закончили. Java не
качается одновременно с версией или модлоадером. Во время установки модлоадера доля берётся у
`VersionInstaller`, если он сейчас качает файлы версии, потому что его оценка точнее.
`cancelDownload()` останавливает первое, что работает, в порядке: модлоадер (он сам остановит
вложенную установку версии), версия, Java, пак.
`busy` равно `true`, пока идёт вход или запуск, работает любой установщик, идёт смена сборки или
качается пак. На это время в интерфейсе гаснет кнопка запуска.
## Папки
- `openMinecraftFolder()` открывает `<gameDir>/mods` (создаёт, если её нет).
- `openGameFolder()` открывает саму папку игры.
## Потоки
Весь класс работает в GUI-потоке. В отдельном потоке выполняются только файловые операции
`BuildSwitcher`, и этот поток скрыт внутри него.
## Пример
```qml
import QtQuick
import Minecraft_launcher
import BiLauncher
Window {
id: window
visible: true
LauncherBackend {
id: backend
onLaunchError: (message) => console.warn(message)
onTwoFactorRequired: (profileName) => twoFactorDialog.open()
onMicrosoftLoginUrlReady: (url) => window.openMicrosoftLogin(url)
}
Button {
+103 -94
View File
@@ -1,17 +1,15 @@
# Localization
`localization.h` / `localization.cpp`
`localization.h`, `localization.cpp`
Единственный источник всех текстов интерфейса. Каталог — [`i18n/translations.json`](#каталог),
он лежит в ресурсах и правится руками. Штатных `.ts`/`.qm` в проекте нет намеренно: `lupdate`,
`lrelease` и Linguist не нужны, а все переводы видны в одном файле.
Все тексты интерфейса на всех языках лежат в одном файле, `i18n/translations.json`, и читаются
через этот класс. Стандартные `.ts`/`.qm`, `lupdate`, `lrelease` и Linguist в проекте не
используются. Язык переключается без перезапуска.
Язык переключается на лету: при смене все привязки QML перевычисляются, перезапуск не требуется.
## Из QML
## Доступ из QML
Синглтон зарегистрирован декларативно (`QML_NAMED_ELEMENT(Loc)` + `QML_SINGLETON`) и доступен
в любом файле модуля без импорта.
Синглтон `Loc` (`QML_NAMED_ELEMENT(Loc)` + `QML_SINGLETON`) доступен в любом файле модуля без
импорта.
```qml
Text { text: Loc.t.settings.title }
@@ -19,31 +17,22 @@ Text { text: Loc.t.java.progress.downloading.arg(label) }
DarkCombo { model: Loc.t.profile.authTypes }
```
Точечный путь `Loc.t.a.b.c` — это обращение к вложенным объектам дерева, которое строится из
плоских ключей каталога разбиением по точке.
`Loc.t` — дерево JS-объектов, собранное из плоских ключей каталога разбиением по точке.
**Почему свойство, а не метод.** Вызов `Q_INVOKABLE` не регистрирует зависимость привязки, и
`text: Loc.t("ключ")` никогда бы не обновился при смене языка. Чтение `Q_PROPERTY` с сигналом
`NOTIFY` зависимость регистрирует: `languageChanged` перевычисляет все привязки, которые читали
`Loc.t`. Сегменты после `t` — обычные обращения к членам JS-объекта, отслеживать их не нужно,
потому что при смене языка дерево заменяется целиком.
Почему `t` сделано свойством, а не методом `Loc.t("ключ")`. Вызов `Q_INVOKABLE` не регистрирует
зависимость привязки, и текст не обновился бы при смене языка. Чтение `Q_PROPERTY` с `NOTIFY`
регистрирует, поэтому `languageChanged` перевычисляет все привязки, где читали `Loc.t`. Дальнейшие
`.settings.title` — обычные обращения к полям JS-объекта. Следить за ними не нужно, потому что при
смене языка дерево заменяется целиком.
Тип свойства — `QJSValue`, а не `QVariantMap`: `QVariantMap` пересобирался бы в новый JS-объект
при каждом чтении, а привязок в проекте полторы сотни. `QJSValue` строится один раз на смену
языка.
Тип свойства — `QJSValue`. С `QVariantMap` движок собирал бы новый JS-объект при каждом чтении, а
привязок в проекте около полутора сотен. `QJSValue` строится один раз на каждую смену языка.
Из тела JS-функции `Loc.t` читается так же — это просто чтение свойства:
Кроме `t` есть `language` (что выбрано: `system`, `ru`, `en`), `resolvedLanguage` (во что это
превратилось, всегда `ru` или `en`) и `availableLanguages()`. Последний сейчас не используется:
список языков в настройках [Main.qml](../qml/Main.md) задан руками (`stLanguage`).
```qml
onLaunched: (profileName, buildName) =>
window.showToast(Loc.t.launch.status.started.arg(profileName).arg(buildName), "#4b7a1f")
```
## Доступ из C++
Свободные функции, а не методы: их вызывают и из namespace-обёрток
([LauncherPaths](launcherpaths.md), [JavaLocator](javalocator.md),
[ZlibReference](zlibreference.md)), где никакого `QObject` нет.
## Из C++
```cpp
#include "localization.h"
@@ -53,29 +42,34 @@ emit launchProgress(Loc::text("java.progress.downloading").arg(label));
const QStringList kinds = Loc::list("profile.authTypes");
```
Ключа нет — возвращается сам ключ, а в отладочной сборке ещё и `qWarning`: строка вида
`launch.error.noProfile` в интерфейсе сразу бросается в глаза.
Это свободные функции, потому что их вызывают и из пространств имён без `QObject`
([launcherpaths.h](launcherpaths.md), [javalocator.h](javalocator.md),
[zlibreference.h](zlibreference.md)).
Если ключа нет, возвращается сам ключ, а в отладочной сборке ещё пишется `qWarning`. В интерфейсе
такую строку легко заметить.
### Потоки
`Loc::text()` и `Loc::list()` можно звать из любого потока. Каталог заполняется ровно один раз
в `load()`, который отрабатывает в `main()` до того, как [BuildSwitcher](BuildSwitcher.md)
создаст свой поток; дальше он только читается, а копирование `QString` из хэша безопасно само по
себе. Единственное, что меняется на ходу, — индекс текущего языка, и он `QAtomicInt`. В худшем
случае сообщение, которое собиралось в момент переключения, уедет на прежнем языке.
`Loc::text()` и `Loc::list()` можно вызывать из любого потока. Каталог заполняется один раз в
`load()`, до того как [BuildSwitcher](BuildSwitcher.md) запустит свой поток, и дальше только
читается. На ходу меняется лишь индекс текущего языка, а он `QAtomicInt`. В худшем случае строка,
собранная в момент переключения, окажется на прежнем языке.
`setLanguage()` и рассылка `languageChanged` — только поток GUI; это проверяется `Q_ASSERT`.
`setLanguage()` вызывается только из GUI-потока, это проверяет `Q_ASSERT`.
## Каталог
`i18n/translations.json` попадает в ресурсы через список `RESOURCES` в `qt_add_qml_module`,
поэтому читается по пути `:/qt/qml/Minecraft_launcher/i18n/translations.json`.
Файл попадает в ресурсы через `RESOURCES` в `qt_add_qml_module` и читается из
`:/qt/qml/BiLauncher/i18n/translations.json`.
```json
{
"_meta": {
"languages": ["ru", "en"],
"displayNames": { "ru": "Русский", "en": "English" }
"displayNames": { "ru": "Русский", "en": "English" },
"_readme": ["..."],
"_glossary": { "сборка": "build", "...": "..." }
},
"strings": {
"settings.title": { "ru": "Настройки запуска", "en": "Launch settings" },
@@ -88,77 +82,92 @@ const QStringList kinds = Loc::list("profile.authTypes");
}
```
Ключи плоские, языки рядом: забытый перевод виден на соседней строке, правка пары — один участок
файла, а третий язык добавляется колонкой без правок загрузчика.
Ключи плоские, языки записаны рядом. Так сразу видно, если перевод забыли, а новый язык
добавляется ещё одним полем без правок загрузчика. `_readme` и `_glossary` — подсказки для тех, кто
правит файл. Загрузчик их не читает.
**Имя ключа — `<домен>.<вид>.<имя>`**, сегменты в lowerCamelCase.
Значение — либо строка, либо список строк (для моделей выпадающих списков). Тип определяется по
первому языку.
- **Домен** — область, а не имя файла: `app`, `common`, `settings`, `profile`, `build`,
`seasonal`, `version`, `java`, `loader`, `auth` (с `auth.ely.*`, `auth.msa.*`), `launch`,
`game`, `switch`, `storage`, `zlib`.
- **Вид** — `title`, `label`, `button`, `placeholder`, `hint`, `header`; для сообщений `error`,
`progress`, `status`, `warning`.
- **Имя** описывает условие, а не формулировку, чтобы перевод не переименовывал ключ:
`gameRunning`, `downloadFailed`, `checksumMismatch`.
### Имена ключей
Литерал, который нужен в двух и более файлах, живёт в `common.*`.
`<домен>.<вид>.<имя>`, сегменты в lowerCamelCase.
Подстановки `%1`/`%2` сохраняются дословно: их одинаково понимают `QString::arg()` и
QML-овский `String.arg()`. **Набор `%N` в `ru` и `en` обязан совпадать** — порядок слов может
отличаться, состав нет. Множественного числа формат не поддерживает; в коде сейчас нет ни одной
строки, которой оно требуется.
- **Домен** — область интерфейса, а не файл: `app`, `common`, `settings`, `profile`, `build`,
`seasonal`, `version`, `java`, `loader`, `auth` (`auth.ely.*`, `auth.msa.*`), `launch`, `game`,
`switch`, `storage`, `zlib`.
- **Вид**: `title`, `label`, `button`, `placeholder`, `hint`, `header`, `tooltip`; для сообщений
`error`, `progress`, `status`, `warning`.
- **Имя** описывает ситуацию, а не формулировку: `gameRunning`, `downloadFailed`,
`checksumMismatch`. Тогда при правке перевода ключ не придётся переименовывать.
## Как добавить строку
Строка, которая нужна в двух и более местах, кладётся в `common.*`.
1. Добавить запись в `strings` файла `i18n/translations.json` — сразу с `ru` и `en`.
2. Сослаться на неё: `Loc.t.домен.вид.имя` в QML или `Loc::text("домен.вид.имя")` в C++.
3. Прогнать `python3 tools/check_translations.py`.
`%1`, `%2` пишутся как есть: их одинаково понимают `QString::arg()` и `String.arg()` в QML. Набор
`%N` в `ru` и `en` должен совпадать, порядок может быть разным. Множественного числа формат не
поддерживает, и пока ни одна строка его не требует.
## Как добавить язык
## Добавить строку
1. Дописать код в `_meta.languages` и название в `_meta.displayNames`.
2. Добавить колонку с этим кодом в каждую запись `strings`.
3. Добавить код в `stLanguage.codes` и пункт в модель комбобокса в [Main.qml](../qml/Main.md),
а также ключ `settings.language.<код>` с эндонимом (название языка не переводится — оно
одинаково во всех колонках).
4. При необходимости поправить `systemLanguage()` в `localization.cpp`: сейчас он выбирает
русский для русской системной локали и английский во всех остальных случаях.
1. Запись в `strings` сразу с `ru` и `en`.
2. `Loc.t.домен.вид.имя` в QML или `Loc::text("домен.вид.имя")` в C++.
3. `python3 tools/check_translations.py`.
## Выбор языка и его хранение
## Добавить язык
Ключ настройки — `language`, значения `"system"`, `"ru"`, `"en"`, по умолчанию `"system"`.
Хранится в `settings.json` рядом с остальными настройками; в интерфейсе — первым пунктом
диалога «Настройки запуска», применяется по кнопке «Сохранить».
1. Код в `_meta.languages`, название в `_meta.displayNames`.
2. Значение для этого кода в каждой записи `strings`.
3. Ключ `settings.language.<код>` с названием языка на нём самом, одинаковым во всех колонках.
4. Пункт в модели `stLanguage` и код в `stLanguage.codes` в [Main.qml](../qml/Main.md).
5. Если нужно, поправить `systemLanguage()`: сейчас при русской системной локали выбирается
русский, при любой другой — английский.
Круг замкнут в одну сторону, циклической зависимости нет:
## Где хранится выбранный язык
Ключ `language` в `settings.json`, по умолчанию `system`. Меняется в окне настроек и применяется
по кнопке «Сохранить».
```
main.cpp ──► Localization::load() ──► LauncherPaths::settingsFile() (только чтение, один раз)
LauncherBackend::updateSettings() ──► Localization::setLanguage() (в одну сторону)
main.cpp ──► Localization::load() ──► читает settings.json (один раз)
LauncherBackend::updateSettings() ──► Localization::setLanguage()
```
`Localization` ничего не знает про [LauncherBackend](LauncherBackend.md) — писать `settings.json`
по-прежнему может только он. Читать настройки самому приходится потому, что язык нужен раньше,
чем QML вычислит первую привязку, а бэкенд появляется только вместе с движком.
`settings.json` записывает только [LauncherBackend](LauncherBackend.md). Localization его только
читает, и только при старте: язык нужен раньше первой привязки QML, а бэкенд создаётся вместе с
движком. Обратной зависимости от бэкенда у Localization нет.
Неизвестное значение (файл правили руками) откатывается на `"system"` с предупреждением.
Отсутствующий или испорченный каталог — ошибка на старте: `main()` пишет причину и возвращает
`-1`. Файл вкомпилирован в бинарник, так что это может быть только ошибка сборки, а лаунчер,
у которого все подписи выглядят как точечные ключи, хуже, чем лаунчер, который сказал, почему
не запустился.
Неизвестный язык в файле (если его правили руками) заменяется на `system` с предупреждением.
**Известное ограничение.** Уже сложенные в поля C++ строки не перепереводятся:
Если каталог не найден или не разбирается, это ошибка сборки, потому что файл вкомпилирован в
бинарник. `main()` пишет причину в `qCritical` и возвращает `-1`.
### Что не переводится при смене языка
Строки, которые уже собраны и лежат в полях C++, остаются на старом языке:
`LauncherBackend::m_storageIssues` собирается при старте, а `stage()`/`status()` у
[BuildSwitcher](BuildSwitcher.md) заменяются на следующем тике прогресса. Все они
диагностические и короткоживущие.
[BuildSwitcher](BuildSwitcher.md) обновятся только на следующем шаге. Всё это короткоживущие
диагностические сообщения.
## Проверка
Отдельно: `GameLauncher` прячет токен в строке запуска за `<скрыт>`, и эта строка не берётся из
каталога.
`tools/check_translations.py` — только чтение, ненулевой код возврата при любой ошибке:
## Проверка каталога
- каждый `Loc::text("…")` и `Loc::list("…")` из C++ есть в каталоге и совпадает по типу значения;
- каждый путь `Loc.t.a.b.c` из QML разворачивается в существующий ключ;
- наборы ключей у всех языков совпадают, пустых значений нет, типы одинаковы;
- наборы `%N` совпадают по языкам, длины списков равны;
- не осталось ни одного `tr(`, `qsTr(` или `QCoreApplication::translate`;
- ключи, на которые никто не ссылается, — предупреждением.
`tools/check_translations.py` ничего не меняет и возвращает ненулевой код при любой ошибке.
Проверяет:
- у всех языков одинаковый набор ключей и типы, пустых значений нет, длины списков равны;
- набор `%N` совпадает по языкам;
- каждый `Loc::text("…")` и `Loc::list("…")` в C++ есть в каталоге с нужным типом;
- каждый `Loc.t.a.b.c` в QML ведёт к существующему ключу (последний сегмент может быть методом
JS, например `.arg`);
- нигде не осталось `tr(`, `qsTr(`, `QCoreApplication::translate`.
Ключи, на которые никто не ссылается, выводятся предупреждением.
Скрипт смотрит только файлы в корне репозитория. Если исходники разложат по подпапкам, его нужно
будет поправить.
---
При создании этого документа использовался ИИ.
+54 -167
View File
@@ -1,190 +1,77 @@
# ModLoaderInstaller
## Обзор класса
`modloaderinstaller.h`, `modloaderinstaller.cpp`
`ModLoaderInstaller` ставит модлоадер в `.minecraft`. Под одним фасадом он прячет два совершенно
разных пути установки.
Ставит модлоадер в `.minecraft`. Fabric и Quilt ставятся иначе, чем Forge и NeoForge, но снаружи у
них одинаковый интерфейс.
**Fabric и Quilt** отдают готовое описание версии: лаунчер кладёт его в
`versions/<id>/<id>.json` и передаёт дальше [VersionInstaller](VersionInstaller.md), который по
полю `inheritsFrom` сам поставит ванильную версию и библиотеки лоадера.
- **Fabric и Quilt** отдают готовое описание версии. Его достаточно положить в
`versions/<id>/<id>.json`, а остальное докачает [VersionInstaller](VersionInstaller.md).
- **Forge и NeoForge** при установке патчат клиентский jar, поэтому запускается их официальный
`installer.jar` без окна. После него проверяется, какой профиль появился в `versions/`.
**Forge и NeoForge** так не умеют: их установка — это патч клиентского jar. Поэтому лаунчер
запускает официальный `installer.jar` найденной Java в headless-режиме и смотрит, какой профиль
появился в `versions`. Процессу установщика при этом подсовывается эталонный zlib — см.
[ZlibReference](zlibreference.md).
Экземпляр держит [LauncherBackend](LauncherBackend.md). Геттеры прогресса устроены так же, как у
`VersionInstaller` (`label`, `stage`, `currentFile`, `bytesDone`, `bytesTotal`, `fraction`), и
панель загрузки читает их одинаково.
Оба пути начинаются одинаково: пока ванильная версия не скачана целиком, ставить лоадер некуда.
## install(gameDir, loader, gameVersion, loaderVersion, javaPreference)
Набор геттеров прогресса намеренно повторяет `VersionInstaller`: панель загрузки в интерфейсе
читает их одинаково, независимо от того, кто сейчас работает.
Если установка уже идёт или не указана версия, сразу приходит `failed`. Иначе:
## Место в проекте и зависимости
1. Список версий модлоадера через [ModLoaderVersionService](ModLoaderVersionService.md).
2. **Базовая версия игры.** Если её нет или не хватает файлов, она ставится через
`VersionInstaller`, и ход идёт дальше только после её `finished`. Без целой ванильной версии
модлоадер ставить некуда.
3. Дальше один из двух путей.
Подключает [modloader.h](modloader.md). В конструктор принимает
[ModLoaderVersionService](ModLoaderVersionService.md) — за адресом установщика — и
[VersionInstaller](VersionInstaller.md) — за базовой версией и за докачиванием того, что
`installer.jar` не положил.
### Fabric и Quilt
Экземпляр создаётся и принадлежит [LauncherBackend](LauncherBackend.md).
Описание скачивается из `meta.fabricmc.net/v2/versions/loader/<игра>/<лоадер>/profile/json`
(у Quilt `meta.quiltmc.org/v3/...`). Проверяется, что в нём есть безопасный `id` и что
`inheritsFrom` совпадает с выбранной версией игры. Файл сохраняется, и `VersionInstaller` получает
этот `id`.
Требования сборки: `Qt6::Core` (`QProcess`, `QFile`) и `Qt6::Network` (`QNetworkAccessManager`,
`QNetworkReply`).
### Forge и NeoForge
## Иерархия и роль
1. Ссылка на установщик берётся из записи `ModLoaderVersionService::find()`. Если записи нет,
значит, эта пара версий несовместима. Установщик качается в
`<root>/cache/loaders/<ключ>-<версия>-installer.jar`.
2. Если в `.minecraft` нет `launcher_profiles.json`, создаётся заглушка. Без неё установщик
отказывается работать: он считает, что официальный лаунчер не установлен.
3. Java берётся через `JavaLocator::select()` по `javaMajor` базовой версии. `javaPreference` — это
та же Java, что выбрана для игры.
4. Запоминается список профилей в `versions/`.
5. Запуск: `java -jar installer.jar --installClient <gameDir>`. Рабочий каталог — папка загрузок
модлоадеров. На Linux в окружение процесса подставляется эталонный zlib (см.
[zlibreference.h](zlibreference.md)).
6. Вывод установщика построчно уходит в `log(line)`, бэкенд показывает его в журнале. Полностью он
пишется в `<root>/cache/loaders/<ключ>-<версия>-install.log`, а последние 12 строк хранятся в
памяти для текста ошибки.
7. Через 15 минут процесс убивается.
Наследует `QObject`: мета-объектная система, шесть сигналов и владение по родителю. Объявлен
виртуальный деструктор — класс владеет процессом установщика и открытым файлом журнала.
После успешного завершения новый профиль ищется как разница списков до и после. Угадывать имя по
строке нельзя: у Forge в разные годы оно составлялось по-разному. Если новых папок нет (установщик
переписал существующую), ищется профиль, который наследует базовую версию и содержит в имени версию
модлоадера. Если новых папок несколько, берётся та, где есть версия модлоадера.
## Публичные методы
Найденный профиль передаётся `VersionInstaller`, который докачивает библиотеки, не положенные
установщиком. Когда он отчитается, приходит `finished(loaderKey, gameVersion, loaderVersion,
producedVersionId)`. Бэкенд записывает `producedVersionId` в `resolvedVersionId` сборки.
#### ModLoaderInstaller(ModLoaderVersionService \*meta, VersionInstaller \*versionInstaller, QObject \*parent = nullptr)
### Если установщик упал
Создаёт установщик поверх двух сервисов. Ни один из них не переходит во владение установщика —
оба обязаны пережить его.
Все папки, которые он успел создать в `versions/`, удаляются. В текст ошибки идут код выхода,
хвост журнала и путь к полному журналу.
#### bool isRunning() const
Если подменить zlib не удалось, а в журнале есть `Processor failed` или `invalid outputs`, к
сообщению добавляется прямая подсказка про zlib-ng. Почти наверняка дело в нём.
Идёт ли установка прямо сейчас.
## cancel()
#### QString label() const
Обрывает загрузку, убивает процесс установщика и удаляет то, что он создал. Если ход уже передан
`VersionInstaller`, отменяется и он, а `canceled` придёт после его отчёта.
Подпись текущей установки для интерфейса — название лоадера с версиями.
#### QString stage() const
Текущий этап словами.
#### QString currentFile() const
Файл, который обрабатывается сейчас.
#### qint64 bytesDone() const
Сколько байт уже получено.
#### qint64 bytesTotal() const
Ожидаемый общий объём; `0` — неизвестен.
#### double fraction() const
Доля выполнения от `0` до `1` либо `-1`, пока итог неизвестен. У пути с `installer.jar` доля
почти всё время равна `-1`: сколько работы осталось внутри чужого процесса, лаунчер не знает.
#### void install(const QString &gameDir, ModLoader loader, const QString &gameVersion, const QString &loaderVersion, const QString &javaPreference)
Ставит модлоадер. Параметр `javaPreference` — путь к java, указанный пользователем в настройках;
он проверяется первым, а при пустом или неподходящем значении java ищется сама. Java нужна даже на
пути Fabric и Quilt, потому что перед установкой лоадера скачивается базовая версия игры.
Перед запуском `installer.jar` установщик записывает заглушку `launcher_profiles.json`: без этого
файла официальные установщики Forge и NeoForge отказываются работать.
#### void cancel()
Отменяет установку. Если `installer.jar` уже успел отработать, за появившиеся в `versions` папки
отвечает лаунчер, и при отмене они убираются.
## Сигналы
#### started(const QString &label)
Установка началась. Обработчик показывает панель прогресса.
#### progressChanged()
Изменились числа прогресса. Обработчик перечитывает геттеры — тот же набор, что у
[VersionInstaller](VersionInstaller.md).
#### finished(const QString &loaderKey, const QString &gameVersion, const QString &loaderVersion, const QString &producedVersionId)
Модлоадер установлен. Последний параметр — идентификатор появившегося профиля `versions/<id>`.
Он важен именно для Forge: заранее этот идентификатор неизвестен, поэтому новый профиль ищется
разницей между списком версий до и после запуска установщика, а не угадыванием строки —
идентификаторы Forge отличаются по эпохам.
Обработчик записывает `producedVersionId` в сборку: именно этот профиль будет запускаться.
#### failed(const QString &label, const QString &message)
Установка не удалась. Текст ошибки по возможности объясняет причину: отдельно распознаётся случай,
когда в системе стоит zlib-ng, а подменить его было нечем — это почти наверняка причина
расхождения sha1 у Forge, и о ней стоит сказать прямым текстом.
#### canceled(const QString &label)
Установка отменена пользователем.
#### log(const QString &line)
Строка вывода процесса `installer.jar`. Обработчик пишет её в журнал; вывод также сохраняется в
файл, чтобы разбираться с неудачной установкой после закрытия лаунчера.
## Владение и время жизни
Класс наследует `QObject` и принимает `parent` — родитель его и удалит.
Указатели на `ModLoaderVersionService` и `VersionInstaller`, переданные в конструктор, **не
принадлежат** установщику: оба сервиса создаются раньше и живут дольше.
`QNetworkAccessManager` создаётся в конструкторе с установщиком в роли родителя; сетевой ответ,
процесс установщика и файл журнала создаются по ходу работы и закрываются в деструкторе.
Установка асинхронна и состоит из вложенных продолжений: `ensureBaseVersion()` принимает функцию,
которая будет вызвана после появления базовой версии. Уничтожение установщика посреди этой цепочки
обрывает её.
## Потокобезопасность
Только поток GUI. Дочерний процесс `installer.jar` работает параллельно, но общение с ним идёт
через сигналы `QProcess`, которые приходят в поток GUI.
## Взаимодействие с другими классами
`LauncherBackend` вызывает `install()` при установке сборки с модлоадером и переправляет сигналы
прогресса в те же свойства, что и у `VersionInstaller`, — панель загрузки не различает, кто
работает. По сигналу `finished` бэкенд записывает `producedVersionId` в поле сборки
`resolvedVersionId`, которое до установки пусто и означает запуск на чистой ванили.
[ModLoaderVersionService](ModLoaderVersionService.md) даёт адрес `installer.jar`,
[VersionInstaller](VersionInstaller.md) ставит базовую версию до начала работы и докачивает
недостающие библиотеки после неё, [ZlibReference](zlibreference.md) готовит окружение процесса.
## Внешнее взаимодействие
**Сеть, исходящие запросы.** Загрузка `installer.jar` по HTTPS для Forge и NeoForge. Для Fabric и
Quilt сеть используется только через сервис версий и `VersionInstaller`.
**Дочерний процесс.** `installer.jar` запускается java в headless-режиме через `QProcess`.
Окружение процесса готовится `ZlibReference::applyTo()`: на Linux с zlib-ng туда дописывается
`LD_PRELOAD` с эталонной библиотекой, иначе установщик Forge падает с сообщением «Processor
failed, invalid outputs» — он сверяет sha1 собранных им же jar-файлов с эталоном, посчитанным на
обычном zlib.
Вывод процесса читается построчно, отдаётся сигналом `log` и параллельно пишется в файл. Результат
установки определяется не кодом выхода, а появлением новой папки в `versions`.
Все сигналы приходят в поток GUI.
## Пример использования
```cpp
auto *loaderInstaller = new ModLoaderInstaller(loaderVersions, versionInstaller, this);
connect(loaderInstaller, &ModLoaderInstaller::finished, this,
[this](const QString &key, const QString &game,
const QString &loaderVersion, const QString &producedVersionId) {
m_build.resolvedVersionId = producedVersionId;
saveBuilds();
});
connect(loaderInstaller, &ModLoaderInstaller::failed, this,
[this](const QString &, const QString &message) { emit launchError(message); });
loaderInstaller->install(gameDir, ModLoader::Forge,
QStringLiteral("1.20.1"), QStringLiteral("47.4.0"),
settings.javaPath);
```
Деструктор убивает процесс установщика, если тот ещё работает.
---
+49 -130
View File
@@ -1,152 +1,71 @@
# ModLoaderVersionService
## Обзор класса
`modloaderversionservice.h`, `modloaderversionservice.cpp`
Каждый модлоадер публикует свой список версий, и у каждого он устроен по-своему. `ModLoaderVersionService`
приводит все четыре к одному виду: качает списки, кэширует в папке лаунчера и отдаёт из кэша, пока
тот не устарел. Устроен так же, как [VersionManifestService](VersionManifestService.md).
Списки версий Forge, Fabric, NeoForge и Quilt. Устроен так же, как
[VersionManifestService](VersionManifestService.md): качает, хранит копию в
`<root>/cache/loader_<ключ>.json` и отвечает из неё, пока она моложе 6 часов.
Главная особенность класса — в том, чего в нём нет: отдельной проверки совместимости с версией
игры. Совместимость заложена в структуру данных. Fabric и Quilt отдают список сразу под нужную
версию игры, а `maven-metadata` Forge и NeoForge раскладывается по версиям игры при разборе.
Версии игры, под которую сборок нет, соответствует пустой список — выбрать несовместимый лоадер
физически нечем.
Совместимость с версией игры отдельно не проверяется: данные с самого начала разложены по версиям
игры. Под версию, для которой сборок нет, лежит пустой список, и выбрать несовместимый модлоадер
нечем.
## Место в проекте и зависимости
Экземпляр держит [LauncherBackend](LauncherBackend.md). [ModLoaderInstaller](ModLoaderInstaller.md)
берёт отсюда ссылку на `installer.jar` через `find()`.
Подключает [modloader.h](modloader.md): перечисление `ModLoader` и структура `LoaderVersionEntry`
приходят оттуда.
## Откуда берутся данные
Экземпляр создаётся и принадлежит [LauncherBackend](LauncherBackend.md). Ссылку на него получает
[ModLoaderInstaller](ModLoaderInstaller.md) — из записи списка он берёт адрес `installer.jar`.
**Fabric и Quilt** спрашиваются отдельно под каждую версию игры:
`meta.fabricmc.net/v2/versions/loader/<игра>` и `meta.quiltmc.org/v3/versions/loader/<игра>`.
Признак стабильности берётся из ответа, а если его нет, угадывается по имени.
Путь к файлу кэша даёт `LauncherPaths::loaderCacheFile()` из [launcherpaths.h](launcherpaths.md).
**Forge** — один `maven-metadata.xml` на все версии игры. Строка `1.20.1-47.4.0` делится по первому
дефису. Параллельно запрашивается `promotions_slim.json`, оттуда берутся отметки recommended. Если
он не ответил, список всё равно сохраняется, только без отметок.
Требования сборки: `Qt6::Core` (`QDateTime`, `QHash`, `QSet`) и `Qt6::Network`
(`QNetworkAccessManager`).
**NeoForge** тоже из `maven-metadata.xml`, но версия игры в номере не записана. Её приходится
выводить: `21.1.248` → `1.21.1`, `20.4.80` → `1.20.4`, `21.0.x` → `1.21`. Для четырёхчастных
номеров вроде `25.1.0.1` правило другое: `25.1.0.1` → `25.1`. Вторым запросом читается старый
репозиторий `net/neoforged/forge` с первыми сборками NeoForge под 1.20.1, которые выходили под
именем артефакта `forge` и номерами в стиле Forge.
## Иерархия и роль
Ссылка на установщик собирается по схеме maven:
`<репозиторий>/<версия>/<артефакт>-<версия>-installer.jar`.
Наследует `QObject`: мета-объектная система, два сигнала и владение по родителю. Виртуальных
методов базового класса не переопределяет.
Версии внутри каждой версии игры отсортированы от новых к старым. Числа сравниваются как числа,
поэтому `47.10` новее `47.9`.
## Псевдонимы типов
## ensureLoaded(loader, gameVersion, callback, forceRefresh)
`ModLoaderVersionService::Callback` — `std::function<void(bool ok, const QString &warning)>`.
Как и у сервиса манифеста, `ok == true` с непустым `warning` означает, что данные отдали из
устаревшего кэша.
Логика та же, что у манифеста версий. Отличие в том, что свежесть у Fabric и Quilt считается для
каждой версии игры отдельно, а у Forge и NeoForge для модлоадера целиком. По этому же ключу
объединяются одновременные запросы.
## Публичные методы
- Пустая `gameVersion` — сразу `ok` и пустой список.
- При первом обращении к модлоадеру читается кэш, и если он есть, испускается
`versionsChanged(key, gameVersion)`.
- Если сеть не ответила, но какие-то данные по модлоадеру есть, колбэк получит `ok = true` и
`warning`.
- Если ответ не разобрался, хороший кэш не затирается.
#### explicit ModLoaderVersionService(QObject \*parent = nullptr)
`versionsChanged(loaderKey, gameVersion)` испускается после каждого успешного обновления.
`refreshingChanged()` — в начале и в конце запроса. `isRefreshing(loader, gameVersion)` отвечает,
идёт ли запрос по этому ключу.
Создаёт сервис и его `QNetworkAccessManager`. Кэши читаются лениво, при первом обращении к
конкретному лоадеру. Конструктор помечен `explicit`.
## Формат кэша
#### void ensureLoaded(ModLoader loader, const QString &gameVersion, Callback callback, bool forceRefresh = false)
Единственная точка входа. Свежий кэш означает отложенный вызов колбэка без сети; иначе запускается
один сетевой запрос на всех, кто успел попросить.
Свежесть считается по-разному в зависимости от лоадера: Fabric и Quilt спрашиваются по каждой
версии игры отдельно, поэтому отметка времени у них своя на каждую версию; Forge и NeoForge
приходят одним `maven-metadata` на все версии сразу, и отметка у них одна на весь лоадер.
Параметр `forceRefresh` обходит проверку свежести.
#### QList&lt;LoaderVersionEntry&gt; versions(ModLoader loader, const QString &gameVersion) const
Список сборок лоадера под конкретную версию игры. Новые сборки идут первыми, поэтому первая строка
— самая свежая; именно её интерфейс подставляет по умолчанию.
Пустой список означает, что лоадер эту версию игры не поддерживает.
#### bool isRefreshing(ModLoader loader, const QString &gameVersion) const
Идёт ли сейчас запрос по этой паре. Интерфейс по этому признаку отличает «ещё грузим» от «не
поддерживается» — оба случая выглядят пустым списком.
#### std::optional&lt;LoaderVersionEntry&gt; find(ModLoader loader, const QString &gameVersion, const QString &loaderVersion) const
Запись по версии лоадера; `std::nullopt`, если такой нет. Из неё установщик берёт ссылку на
`installer.jar`.
## Сигналы
#### versionsChanged(const QString &loaderKey, const QString &gameVersion)
Список версий изменился. Параметры сужают событие до конкретной пары: `loaderKey` принимает
значения `forge`, `fabric`, `neoforge`, `quilt`.
Обработчик должен сверить оба параметра со своим текущим состоянием и перечитать `versions()`,
только если они совпадают, — иначе обновление относится к другой строке лоадера. Именно так
поступает [LoaderRow](../qml/LoaderRow.md).
#### refreshingChanged()
Изменился признак сетевого обновления у какой-либо пары. Обработчик перечитывает
`isRefreshing()` для интересующей его пары.
## Владение и время жизни
Класс наследует `QObject` и принимает `parent` — родитель его и удалит.
`QNetworkAccessManager` создаётся в конструкторе с сервисом в роли родителя.
Отложенные колбэки хранятся по ключу запроса до завершения соответствующего обращения к сети.
Уничтожение сервиса с незавершённым запросом обрывает его, и накопленные колбэки не вызываются.
Ссылку на сервис держит установщик модлоадеров; уничтожать сервис раньше установщика нельзя.
## Потокобезопасность
Только поток GUI. Чтение и запись кэша выполняются синхронно в вызывающем потоке.
## Взаимодействие с другими классами
`LauncherBackend` оборачивает сервис тремя методами, доступными из QML: получить список, запросить
обновление и узнать, идёт ли загрузка. Сигнал `versionsChanged` он переправляет в QML под тем же
именем, поэтому строка лоадера в карточке сборки подписывается прямо на него.
[ModLoaderInstaller](ModLoaderInstaller.md) обращается к `find()` за адресом установщика перед
началом установки.
## Внешнее взаимодействие
**Сеть, исходящие запросы.** Класс обращается к четырём разным источникам метаданных, и форматы
ответов различаются: у Fabric и Quilt это JSON, у Forge и NeoForge — XML `maven-metadata`. Разбор
разделён на две функции соответственно.
Испорченный ответ разбирается в пустой результат, и хороший кэш им не затирается — это сознательное
решение: лучше показать вчерашний список, чем стереть его из-за сбоя на сервере.
При недоступной сети данные отдаются из устаревшего кэша с `ok == true` и заполненным `warning`.
Все сигналы и колбэки приходят в поток GUI.
## Пример использования
```cpp
auto *loaders = new ModLoaderVersionService(this);
connect(loaders, &ModLoaderVersionService::versionsChanged,
this, [this](const QString &key, const QString &game) {
if (key == loaderKey(ModLoader::Fabric) && game == m_gameVersion)
emit fabricVersionsChanged();
});
loaders->ensureLoaded(ModLoader::Fabric, QStringLiteral("1.21.1"),
[this, loaders](bool ok, const QString &warning) {
if (!ok) {
showStatus(warning);
return;
}
const auto list = loaders->versions(ModLoader::Fabric,
QStringLiteral("1.21.1"));
if (!list.isEmpty())
selectVersion(list.first().loaderVersion);
});
```json
{
"fetchedAt": "...",
"games": {
"1.20.1": { "fetchedAt": "...", "entries": [ { "loaderVersion": "47.4.0", ... } ] }
}
}
```
Верхний `fetchedAt` используется для Forge и NeoForge, `fetchedAt` внутри версии игры — для Fabric и
Quilt.
---
При создании этого документа использовался ИИ.
+57 -137
View File
@@ -1,160 +1,80 @@
# MsaAuthService
## Обзор класса
`msaauthservice.h`, `msaauthservice.cpp`
`MsaAuthService` — авторизация через учётную запись Microsoft, то есть вход с лицензионной копией
игры. Это та же цепочка, что и в официальном лаунчере: OAuth2 → Xbox Live → XSTS → Minecraft
Services → проверка лицензии.
Вход через аккаунт Microsoft, то есть с лицензионной копией игры. Цепочка такая же, как у
официального лаунчера: OAuth2 → Xbox Live → XSTS → Minecraft Services → проверка лицензии →
профиль. Результат приходит в `AuthResult` из [AuthService](AuthService.md).
Результат отдаётся тем же `AuthResult`, что и [AuthService](AuthService.md) для Ely.by и офлайна,
поэтому запуск игры дальше идёт по общему пути и ничего не знает о способе входа.
Окно входа сервис не показывает. Страницу Microsoft открывает
[MicrosoftLoginDialog](../qml/MicrosoftLoginDialog.md), а сервис даёт адрес этой страницы и умеет
узнать адрес возврата. Класс компилируется всегда, но без Qt WebEngine показать страницу нечем, и
[LauncherBackend](LauncherBackend.md) сразу отвечает ошибкой.
Класс не показывает окно входа сам: страницу Microsoft открывает
[MicrosoftLoginDialog](../qml/MicrosoftLoginDialog.md) на стороне QML, а сервис даёт ему адрес
страницы и разбирает адрес возврата.
## Чьё это приложение
## Место в проекте и зависимости
Сейчас используется `client_id` официального лаунчера Mojang (`00000000402b5328`). Это старое
приложение Live Connect, поэтому и адреса от `login.live.com`:
Подключает `authservice.h` — ради общей структуры `AuthResult`. Экземпляр создаётся и принадлежит
[LauncherBackend](LauncherBackend.md).
| Константа | Значение |
|-----------|----------|
| `kAuthorizeUrl` | `https://login.live.com/oauth20_authorize.srf` |
| `kTokenUrl` | `https://login.live.com/oauth20_token.srf` |
| `kRedirectUri` | `https://login.live.com/oauth20_desktop.srf` |
| `kScope` | `service::user.auth.xboxlive.com::MBI_SSL` |
| `kRpsTicketPrefix` | пусто |
Требования сборки: `Qt6::Core` (`QJsonObject`, `QString`, `QUrl`) и `Qt6::Network`
(`QNetworkAccessManager`).
Это временное решение. Чтобы перейти на своё приложение в Azure (с включённым «Allow public client
flows»), меняются эти шесть констант. Что именно подставить, написано в комментарии в начале
`msaauthservice.cpp`. Главное, что легко пропустить: у нового приложения в `RpsTicket` нужна
приставка `d=`.
Сам класс собирается всегда и не зависит от Qt WebEngine — от наличия WebEngine зависит только
окно, в котором показывается страница входа. Поэтому в сборке без WebEngine сервис существует, но
воспользоваться им нельзя: показать страницу нечем.
`clientId()` отдаёт этот id наружу. Бэкенд подставляет его в `${clientid}`, потому что официальный
лаунчер передаёт туда id приложения, а не случайный токен.
## Иерархия и роль
## Окно входа
Наследует `QObject`: мета-объектная система, сигнал `progress`, владение по родителю. Виртуальных
методов базового класса не переопределяет.
`authorizationUrl()` — адрес страницы входа с `client_id`, `response_type=code`, `redirect_uri`,
`scope` и `prompt=select_account`. Последний параметр нужен, чтобы при повторном входе можно было
выбрать другой аккаунт.
## Псевдонимы типов
`matchRedirect(url, &code, &error)` возвращает `true`, только если хост и путь совпадают с
`kRedirectUri`. Код или ошибка ищутся сначала в query, потом во фрагменте. В `error` попадает
`error_description`, а если его нет, то `error`. Эта проверка сделана в C++, а не в QML, чтобы она
всегда совпадала с тем адресом, который сервис сам передаёт в `redirect_uri`.
`MsaAuthService::Callback` — `std::function<void(const AuthResult &)>`. Колбэк вызывается ровно
один раз и в потоке GUI.
## Цепочка
## Публичные методы
`loginWithCode(code, callback)` меняет код на токен (`grant_type=authorization_code`).
`loginWithRefreshToken(refreshToken, callback)` делает то же по refresh-токену. Дальше путь общий:
#### explicit MsaAuthService(QObject \*parent = nullptr)
1. **Токен Microsoft.** Форма на `kTokenUrl`. Из ответа берутся `access_token` и `refresh_token`.
2. **Xbox Live.** `user.auth.xboxlive.com/user/authenticate`, `RpsTicket` = приставка + токен
Microsoft.
3. **XSTS.** `xsts.auth.xboxlive.com/xsts/authorize` для `rp://api.minecraftservices.com/`. При 401
код `XErr` переводится в понятный текст: нет профиля Xbox, страна не поддерживается, нужна
проверка возраста, детский аккаунт без разрешения взрослого. Отсюда же берутся `uhs` и `xid`
(он станет `xuid`).
4. **Minecraft Services.** `login_with_xbox` с `XBL3.0 x=<uhs>;<xsts>`. В ответе токен Minecraft и
`expires_in`, обычно сутки; от него считается `expiresAt`.
5. **Лицензия.** `entitlements/mcstore`. Результат только запоминается и сам вход не останавливает.
6. **Профиль.** `minecraft/profile`. Ответы 404 и 401 означают, что профиля нет: выставляется
`licenseMissing`. Текст ошибки зависит от шага 5. Если игра куплена, значит, игрок ещё не выбрал
ник. Если не куплена, значит, лицензии нет.
Создаёт сервис и его `QNetworkAccessManager`. Конструктор помечен `explicit`.
`userType` = `msa`, `clientToken` = id приложения.
#### static QString clientId()
Каждый шаг испускает `progress(message)` до отправки запроса. Таймаут на каждый запрос 20 секунд,
повторных попыток нет. Сетевая ошибка и отказ сервера дают разные тексты.
Идентификатор приложения лаунчера в Microsoft. Игра ждёт его в `${clientid}`: официальный лаунчер
подставляет туда именно идентификатор приложения, а не случайный токен сессии.
Refresh-токен Microsoft живёт намного дольше суточного токена Minecraft, поэтому при обычном
запуске окно входа не нужно: бэкенд вызывает `loginWithRefreshToken()`. Если Microsoft не вернул
новый refresh-токен, бэкенд оставляет старый.
#### static QUrl authorizationUrl()
## Колбэки
Адрес страницы входа для встроенного окна браузера. В адресе уже собраны идентификатор клиента,
`redirect_uri` и параметр выбора аккаунта — вызывающему коду достаточно открыть эту ссылку.
#### static bool matchRedirect(const QUrl &url, QString \*code, QString \*error)
Отличает адрес, на который Microsoft возвращает управление после входа, от остальной навигации
внутри окна. Возвращает `true` только для адреса возврата.
При совпадении заполняется ровно одно из двух: `code` — код авторизации при успешном входе, либо
`error` — текст отказа. Разбор адреса живёт здесь, а не в QML, потому что правила совпадения
обязаны совпадать с теми, по которым сервис сам строит `redirect_uri`.
#### void loginWithCode(const QString &code, Callback callback)
Полный вход по коду, полученному из окна браузера. Проходит всю цепочку: обмен кода на токен
Microsoft, аутентификация в Xbox Live, авторизация XSTS, вход в Minecraft Services, проверка
лицензии и получение профиля.
По ходу испускает `progress` с описанием текущего шага — цепочка длинная, и без обратной связи
вход выглядел бы зависанием. Результат приходит в `callback` один раз.
Если вход прошёл, но копии игры на аккаунте нет, в результате выставлен `licenseMissing`: этот
случай чинится только покупкой, поэтому обрабатывается отдельно от прочих ошибок.
#### void loginWithRefreshToken(const QString &refreshToken, Callback callback)
Продление сессии без участия пользователя. Refresh-токен Microsoft живёт куда дольше суточного
токена Minecraft, так что при повторном запуске лаунчера обычно хватает его, и окно входа
показывать не приходится.
Проходит ту же цепочку, начиная с обмена refresh-токена. Неудача означает, что токен окончательно
протух и нужен полноценный вход через окно.
## Сигналы
#### progress(const QString &message)
Описание текущего шага цепочки авторизации.
Обработчик показывает сообщение пользователю. Сигнал особенно важен для этого класса: шагов пять,
каждый — отдельный сетевой запрос, и между ними проходит заметное время.
## Владение и время жизни
Класс наследует `QObject` и принимает `parent` — родитель его и удалит. `QNetworkAccessManager`
создаётся в конструкторе с сервисом в роли родителя.
Каждый шаг цепочки вызывается из колбэка предыдущего, поэтому незавершённый вход держит цепочку
захваченных колбэков до своего конца. Уничтожение сервиса посреди цепочки обрывает её вместе с
менеджером сети, и колбэк не вызывается.
## Потокобезопасность
Только поток GUI. Все методы асинхронные, колбэки и сигналы приходят в поток, где создан сервис.
## Взаимодействие с другими классами
`LauncherBackend` создаёт сервис, отдаёт в QML адрес страницы входа, принимает от окна код
авторизации и вызывает `loginWithCode()`. Полученные `refreshToken` и `expiresAt` он сохраняет в
профиле, чтобы при следующем запуске обойтись `loginWithRefreshToken()`.
Заполненный `AuthResult` дальше раскладывается по полям `LaunchOptions` для
[GameLauncher](GameLauncher.md) — ровно так же, как результат от [AuthService](AuthService.md).
Со стороны QML вход выглядит так: `LauncherBackend` испускает сигнал с адресом страницы, главное
окно открывает [MicrosoftLoginDialog](../qml/MicrosoftLoginDialog.md), тот следит за навигацией и
возвращает код обратно в бэкенд.
## Внешнее взаимодействие
**Сеть, исходящие запросы.** Класс последовательно обращается к пяти внешним службам: конечной
точке OAuth2 Microsoft, Xbox Live, XSTS, Minecraft Services и профильной конечной точке Minecraft.
Формат — JSON поверх HTTPS, кроме первого шага, где тело запроса отправляется как форма
(`postForm()`); дальше используются `postJson()` и `getJson()` с токеном в заголовке
авторизации.
Все запросы инициирует лаунчер. Ответ каждого шага разбирается тремя исходами: успех, ошибка с
кодом состояния и транспортная ошибка — последняя приходит отдельным параметром, чтобы отличить
недоступную сеть от отказа службы.
Повторных попыток класс не делает. Все сигналы и колбэки приходят в поток GUI.
## Пример использования
```cpp
auto *msa = new MsaAuthService(this);
connect(msa, &MsaAuthService::progress, this, &Backend::showStatus);
// 1. открыть окно браузера на этом адресе
emit microsoftLoginUrlReady(MsaAuthService::authorizationUrl());
// 2. когда окно поймало адрес возврата
QString code, error;
if (MsaAuthService::matchRedirect(url, &code, &error) && !code.isEmpty()) {
msa->loginWithCode(code, [this](const AuthResult &result) {
if (result.licenseMissing) {
emit loginFailed(tr("На аккаунте нет копии Minecraft"));
return;
}
if (!result.ok) {
emit loginFailed(result.error);
return;
}
storeSession(result);
});
}
```
Колбэк вызывается один раз в GUI-потоке. Шаги вызываются из колбэков предыдущих. Если удалить
сервис посреди цепочки, она оборвётся вместе с менеджером сети, и колбэк не вызовется.
---
+78 -159
View File
@@ -1,175 +1,94 @@
# SeasonalBuildService
## Обзор класса
`seasonalbuildservice.h`, `seasonalbuildservice.cpp`
Кроме сборок, которые пользователь собирает сам, лаунчер умеет ставить готовые сезонные сборки с
собственного файлового сервера: набор модов под конкретную версию игры и модлоадер, подготовленный
заранее и выдаваемый целиком.
Каталог сезонных сборок, то есть готовых паков, которые команда публикует на своём файловом
сервере. Сервис читает `<baseUrl>/index.json`, хранит копию в
`<root>/cache/seasonal_builds.json` и отвечает из неё 15 минут.
`SeasonalBuildService` — каталог этих сборок: скачивает `index.json` с файлового сервера, кэширует
в папке лаунчера и отдаёт из кэша, пока тот не устарел. Устройство повторяет
[VersionManifestService](VersionManifestService.md) — включая то, что пустой разбор считается
испорченным ответом и хороший кэш им не затирается.
Экземпляр держит [LauncherBackend](LauncherBackend.md). Он же сводит строки каталога с
установленными сборками и ставит выбранную.
## Место в проекте и зависимости
## Адрес сервера
Экземпляр создаётся и принадлежит [LauncherBackend](LauncherBackend.md). Записи каталога
используются [SeasonalPackDownloader](SeasonalPackDownloader.md) — оттуда берётся адрес архива и
его контрольная сумма.
По умолчанию `https://launcher-builds.galeonworx.com`. Переопределяется ключом `seasonalBaseUrl` в
`settings.json`, например чтобы проверить установку на локальном HTTP-сервере. Бэкенд передаёт
адрес через `setBaseUrl()` при старте и перед каждым `refreshSeasonalCatalog()`.
Путь к файлу кэша даёт `LauncherPaths::seasonalCatalogFile()` из
[launcherpaths.h](launcherpaths.md).
Если адрес сменился, накопленные данные сбрасываются, а кэш с другим `baseUrl` не читается: ссылки
в нём ведут на старый сервер. Пустой адрес или адрес без хоста — сразу ошибка.
Требования сборки: `Qt6::Core` (`QDate`, `QDateTime`, `QHash`, `QUrl`) и `Qt6::Network`
(`QNetworkAccessManager`).
## Формат index.json
## Иерархия и роль
Это контракт с теми, кто публикует сборки.
Наследует `QObject`: мета-объектная система, два сигнала и владение по родителю. Виртуальных
методов базового класса не переопределяет.
## Публичные структуры
### SeasonalBuildEntry
Одна готовая сборка с сервера сезонных сборок.
| Поле | Тип | По умолчанию | Описание |
|------|-----|--------------|----------|
| `id` | `QString` | — | Идентификатор вида `season-5`; он же ключ, по которому сборка узнаётся среди локальных записей |
| `name` | `QString` | — | Название для интерфейса, например «Сезон 5: Пустоши» |
| `revision` | `int` | `0` | Растёт при каждой публикации; сравнение с установленной ревизией даёт признак доступного обновления |
| `minecraftVersion` | `QString` | — | Версия игры, например `1.20.1` |
| `loader` | `QString` | — | Ключ модлоадера: пустая строка (чистая ваниль), `forge`, `fabric`, `neoforge` или `quilt` |
| `loaderVersion` | `QString` | — | Версия модлоадера |
| `modCount` | `int` | `0` | Число модов в сборке; показывается колонкой в таблице |
| `seasonStart` | `QDate` | — | Начало сезона |
| `seasonEnd` | `QDate` | — | Конец сезона; невалидная дата означает, что сезон ещё не закончен |
| `serverUrl` | `QString` | — | Адрес игрового сервера — не файлового, с которого качается сборка |
| `javaMajor` | `int` | `0` | Требуемая версия Java; `0` означает «определять по версии игры» |
| `description` | `QString` | — | Описание сборки; показывается в подвале окна каталога |
| `archiveUrl` | `QUrl` | — | Адрес архива сборки |
| `archiveSize` | `qint64` | `0` | Размер архива в байтах |
| `archiveSha256` | `QString` | — | Контрольная сумма архива |
Метод `isValid()` возвращает `true`, когда заполнен `id`, `revision` больше нуля и `archiveUrl`
корректен.
## Псевдонимы типов
`SeasonalBuildService::Callback` — `std::function<void(bool ok, const QString &warning)>`.
Сочетание `ok == true` с непустым `warning` означает данные из устаревшего кэша.
## Публичные методы
#### explicit SeasonalBuildService(QObject \*parent = nullptr)
Создаёт сервис и его `QNetworkAccessManager`. Конструктор помечен `explicit`.
#### QList&lt;SeasonalBuildEntry&gt; builds() const
Текущий каталог сборок. Возвращает копию.
#### bool hasData() const
Есть ли в каталоге хоть что-то — из сети или из кэша.
#### bool isRefreshing() const
Идёт ли сейчас сетевое обновление.
#### QString lastError() const
Последняя ошибка обращения к серверу; пустая строка означает, что всё в порядке.
В отличие от остальных каталогов лаунчера, ошибка здесь хранится отдельным полем: пустой список и
ошибка выглядят одинаково пустыми, и окно каталога показывает причину прямо на месте строк.
#### std::optional&lt;SeasonalBuildEntry&gt; find(const QString &id) const
Запись по идентификатору сборки; `std::nullopt`, если такой нет.
#### void setBaseUrl(const QUrl &baseUrl)
Задаёт адрес сервера сборок. Адрес меняется из настроек, поэтому при смене хоста накопленные
данные и кэш сбрасываются: ссылки в них указывают на старый сервер и после смены недействительны.
#### QUrl baseUrl() const
Текущий адрес сервера сборок.
#### void ensureLoaded(Callback callback, bool forceRefresh = false)
Единственная точка входа. Свежий кэш означает отложенный вызов колбэка без сети; иначе запускается
один сетевой запрос на всех, кто успел попросить. Параметр `forceRefresh` обходит проверку
свежести — так работает кнопка обновления списка.
Окно каталога вызывает этот метод при каждом открытии без принудительного обновления: свежий кэш
отвечает без сети, поэтому вызов ничего не стоит.
## Сигналы
#### buildsChanged()
Каталог изменился — пришли новые данные или прочитан кэш. Обработчик перечитывает `builds()` и
обновляет таблицу.
#### refreshingChanged()
Изменился признак обновления. Обработчик показывает или убирает индикатор загрузки; в окне
каталога по нему же выключается кнопка обновления списка.
## Владение и время жизни
Класс наследует `QObject` и принимает `parent` — родитель его и удалит.
`QNetworkAccessManager` создаётся в конструкторе с сервисом в роли родителя.
Отложенные колбэки хранятся до завершения текущего запроса; уничтожение сервиса с незавершённым
запросом обрывает его, и колбэки не вызываются.
## Потокобезопасность
Только поток GUI. Чтение и запись кэша выполняются синхронно в вызывающем потоке.
## Взаимодействие с другими классами
`LauncherBackend` вызывает `ensureLoaded()` при открытии окна каталога и по кнопке обновления, а
сигналы переправляет в свойства для QML. Перед отдачей в интерфейс он сводит записи каталога с
локальными: строка таблицы уже содержит готовый статус и признак доступного обновления, поэтому
[SeasonalBuildsDialog](../qml/SeasonalBuildsDialog.md) ничего не считает сам.
Установка сезонной сборки начинается с `find()`: по записи бэкенд получает адрес архива и передаёт
его [SeasonalPackDownloader](SeasonalPackDownloader.md), а скачанный пак раскатывает
[BuildSwitcher](BuildSwitcher.md).
## Внешнее взаимодействие
**Сеть, исходящие запросы.** Класс скачивает `index.json` с файлового сервера сборок, адрес
которого задаётся через `setBaseUrl()`. Формат — JSON поверх HTTPS, запрос инициирует лаунчер.
Испорченный или пустой ответ не затирает хороший кэш. При недоступной сети данные отдаются из
устаревшего кэша с `ok == true` и заполненным `warning`; текст ошибки при этом попадает и в
`lastError()`.
Все сигналы и колбэки приходят в поток GUI.
## Пример использования
```cpp
auto *seasonal = new SeasonalBuildService(this);
seasonal->setBaseUrl(QUrl(settings.seasonalServer));
connect(seasonal, &SeasonalBuildService::buildsChanged, this, &Backend::rebuildSeasonalCatalog);
seasonal->ensureLoaded([this, seasonal](bool ok, const QString &warning) {
if (!ok) {
emit seasonalCatalogError(seasonal->lastError());
return;
```json
{
"formatVersion": 1,
"builds": [
{
"id": "season-5",
"name": "Сезон 5: Пустоши",
"revision": 3,
"minecraftVersion": "1.20.1",
"loader": "forge",
"loaderVersion": "47.4.0",
"modCount": 142,
"seasonStart": "2026-09-01",
"seasonEnd": "",
"serverUrl": "play.example.com:25565",
"javaMajor": 17,
"description": "…",
"archive": {
"url": "https://launcher-builds.galeonworx.com/season-5/r3.zip",
"size": 734003200,
"sha256": "…64 hex…"
}
}
if (!warning.isEmpty())
showStatus(warning);
});
]
}
```
| Поле | Обязательно | Правила |
|------|-------------|---------|
| `formatVersion` | да | только `1`. Другое значение — весь каталог отклоняется с понятным текстом (если версия больше, пользователю предложат обновить лаунчер) |
| `id` | да | `[a-z0-9][a-z0-9._-]{0,63}`. По нему лаунчер узнаёт уже установленную сборку, менять его нельзя |
| `name` | да | |
| `revision` | да | > 0. Увеличивается при каждой публикации. Если у игрока ревизия меньше, ему покажут «доступно обновление» |
| `minecraftVersion` | да | без `/`, `\`, `:`, не длиннее 128 символов |
| `loader` | нет | пусто (ваниль) или `forge`, `fabric`, `neoforge`, `quilt` |
| `loaderVersion` | если есть `loader` | |
| `modCount` | нет | для таблицы |
| `seasonStart`, `seasonEnd` | нет | ISO-даты. Пустой `seasonEnd` значит, что сезон идёт |
| `serverUrl` | нет | адрес игрового сервера для автоподключения |
| `javaMajor` | нет | 0 или нет поля — определить по версии игры |
| `description` | нет | показывается под таблицей |
| `archive.url` | да | `http` или `https`, **та же схема, хост и порт, что у `baseUrl`** |
| `archive.size` | да | ≥ 0 |
| `archive.sha256` | да | 64 hex-символа |
Записи, которые не проходят проверку, молча пропускаются. Если не осталось ни одной, это считается
испорченным ответом, и хороший кэш не затирается.
Требование «тот же хост» не даёт подложить в каталог ссылку на чужой сервер. Пак распаковывается
прямо в `.minecraft`, поэтому качать его откуда попало нельзя.
Пак — это zip, содержимое которого раскладывается в корень `.minecraft` (`mods/`, `config/` и т. п.).
Файлы в `versions/`, `libraries/`, `assets/`, `runtime/` и `launcher_profiles.json` запрещены, пак
с ними не установится. Подробнее — [BuildArchiveWorker](BuildArchiveWorker.md).
Порядок в каталоге: сначала идущие сезоны, потом по дате начала (новые сверху), потом по имени.
## Интерфейс
- `ensureLoaded(callback, forceRefresh)` — как у
[VersionManifestService](VersionManifestService.md), только кэш живёт 15 минут.
- `builds()`, `find(id)`, `hasData()`, `isRefreshing()`.
- `lastError()` — текст последней ошибки сервера или пустая строка. Окно сезонных сборок
показывает его на месте таблицы.
- Сигналы `buildsChanged()` (в том числе после неудачного запроса, чтобы обновился `lastError`) и
`refreshingChanged()`.
---
При создании этого документа использовался ИИ.
+19 -149
View File
@@ -1,161 +1,31 @@
# SeasonalPackDownloader
## Обзор класса
`seasonalpackdownloader.h`, `seasonalpackdownloader.cpp`
`SeasonalPackDownloader` скачивает один архив сезонной сборки в файл. Задача узкая и отдельная по
двум причинам: пак — это сотни мегабайт, поэтому он пишется потоком, а не держится в памяти; и его
sha256 обязательно сверяется, потому что распаковывать битую загрузку поверх рабочей `.minecraft`
нельзя.
Качает один архив сезонной сборки в файл и проверяет sha256. Пак весит сотни мегабайт, поэтому
пишется потоком через `QSaveFile`, а не собирается в памяти. Битый архив распаковывать поверх
рабочей `.minecraft` нельзя, поэтому файл появляется на диске только после совпадения хеша.
Распаковкой класс не занимается — это делает [BuildSwitcher](BuildSwitcher.md) в отдельном потоке
вместе с остальными операциями над содержимым папки игры.
Распаковывает пак [BuildSwitcher](BuildSwitcher.md). Экземпляр держит
[LauncherBackend](LauncherBackend.md), который и вызывает загрузку на шаге Downloading установки
сезонной сборки.
Набор геттеров прогресса повторяет [VersionInstaller](VersionInstaller.md) и
[JavaInstaller](JavaInstaller.md): панель загрузки в интерфейсе читает их одинаково, независимо от
того, кто сейчас работает.
## download(url, targetPath, sha256, expectedSize, label)
## Место в проекте и зависимости
- `targetPath` — `<root>/builds/<id>/.pack-r<ревизия>.zip`. Если файл уже есть, он будет
перезаписан: недокачанный пак с прошлой попытки не должен дожить до новой.
- `expectedSize` из каталога используется для прогресса, пока сервер не сообщил настоящий размер.
- Таймаут — 60 секунд без данных. Повторных попыток нет: если загрузка оборвалась, установку
нужно начать заново.
Экземпляр создаётся и принадлежит [LauncherBackend](LauncherBackend.md). Адрес архива, его размер
и контрольную сумму даёт запись каталога от [SeasonalBuildService](SeasonalBuildService.md).
Если загрузка уже идёт, сразу приходит `failed`.
Требования сборки: `Qt6::Core` (`QCryptographicHash`, `QSaveFile`, `QTimer`) и `Qt6::Network`
(`QNetworkAccessManager`, `QNetworkReply`).
Сигналы: `started(label)`, `progressChanged()` (не чаще раза в 100 мс), `finished(path)`,
`failed(label, message)`, `canceled(label)`. Геттеры прогресса такие же, как у других
установщиков.
## Иерархия и роль
Наследует `QObject`: мета-объектная система, пять сигналов и владение по родителю. Объявлен
виртуальный деструктор — класс владеет незавершённой загрузкой и открытым файлом.
## Публичные методы
#### explicit SeasonalPackDownloader(QObject \*parent = nullptr)
Создаёт загрузчик, его `QNetworkAccessManager` и таймер сглаживания прогресса. Конструктор помечен
`explicit`.
#### bool isRunning() const
Идёт ли загрузка прямо сейчас.
#### QString label() const
Подпись загрузки для интерфейса — как правило, название сезонной сборки.
#### QString stage() const
Текущий этап словами.
#### QString currentFile() const
Файл, который качается сейчас.
#### qint64 bytesDone() const
Сколько байт уже получено.
#### qint64 bytesTotal() const
Ожидаемый размер архива.
#### double fraction() const
Доля выполнения от `0` до `1` либо `-1`, пока итог неизвестен — например, когда сервер не сообщил
размер, а в записи каталога он не был указан.
#### void download(const QUrl &url, const QString &targetPath, const QString &sha256, qint64 expectedSize, const QString &label)
Скачивает архив по адресу `url` в `targetPath`, сверяя sha256 с переданным значением.
Файл `targetPath` перезаписывается: недокачанный пак с прошлой попытки не должен пережить новую.
Запись идёт через `QSaveFile`, поэтому на месте назначения файл появляется только целиком и только
после успешной проверки контрольной суммы.
Параметр `expectedSize` берётся из записи каталога и используется для расчёта доли выполнения,
пока сервер не сообщил размер сам.
#### void cancel()
Отменяет загрузку. Недокачанный файл на месте назначения не остаётся.
## Сигналы
#### started(const QString &label)
Загрузка началась. Обработчик показывает панель прогресса.
#### progressChanged()
Изменились числа прогресса; испускается не чаще, чем позволяет внутренний таймер, — иначе сигнал
на каждый принятый блок обошёлся бы дороже самой загрузки. Обработчик перечитывает геттеры.
#### finished(const QString &path)
Архив скачан и проверен; в параметре — путь к готовому файлу.
Обработчик передаёт этот путь [BuildSwitcher](BuildSwitcher.md) для раскатки поверх содержимого
`.minecraft`.
#### failed(const QString &label, const QString &message)
Загрузка не удалась: сеть недоступна, сервер ответил ошибкой или не сошлась контрольная сумма.
Последний случай особенно важен — он означает, что архив повреждён и распаковывать его нельзя.
#### canceled(const QString &label)
Загрузка отменена пользователем.
## Владение и время жизни
Класс наследует `QObject` и принимает `parent` — родитель его и удалит.
`QNetworkAccessManager` и таймер создаются в конструкторе с загрузчиком в роли родителя.
Скачиваемый файл хранится как `std::unique_ptr<QSaveFile>`: незавершённая запись отменяется вместе
с уничтожением объекта, и повреждённый архив не попадает на место назначения. Сетевой ответ
создаётся по ходу работы и закрывается в деструкторе.
## Потокобезопасность
Только поток GUI. Данные пишутся на диск блоками по мере поступления, поэтому длительных
синхронных операций в потоке нет.
## Взаимодействие с другими классами
`LauncherBackend` вызывает `download()` при установке или обновлении сезонной сборки, передавая
адрес и контрольную сумму из записи [SeasonalBuildService](SeasonalBuildService.md). Сигналы
прогресса он переправляет в те же свойства, что и остальные загрузчики, поэтому панель в главном
окне не различает, кто работает.
По сигналу `finished` бэкенд передаёт путь к архиву в `BuildSwitcher::applyPack()` вместе со
списком уходящих файлов из описания предыдущей ревизии.
## Внешнее взаимодействие
**Сеть, исходящие запросы.** Одна загрузка по HTTPS с файлового сервера сборок. Направление
одностороннее, тело ответа — двоичный архив.
Данные пишутся потоком через `QSaveFile` с одновременным подсчётом sha256; несовпадение суммы
приводит к сигналу `failed`, и файл на месте назначения не появляется. Повторных попыток класс не
делает: решение о повторе принимает пользователь.
Все сигналы приходят в поток GUI.
## Пример использования
```cpp
auto *packLoader = new SeasonalPackDownloader(this);
connect(packLoader, &SeasonalPackDownloader::progressChanged, this, &Backend::downloadChanged);
connect(packLoader, &SeasonalPackDownloader::finished, this, [this](const QString &path) {
m_switcher->applyPack(m_buildId, m_buildName, path,
m_previousEntries, m_note, m_gameDir);
});
connect(packLoader, &SeasonalPackDownloader::failed, this,
[this](const QString &, const QString &message) { emit launchError(message); });
packLoader->download(entry.archiveUrl, targetPath,
entry.archiveSha256, entry.archiveSize, entry.name);
```
При ошибке и несовпадении хеша файл удаляется. `cancel()` обрывает запрос, и `canceled` приходит
после его завершения.
---
+70 -205
View File
@@ -1,210 +1,75 @@
# VersionInstaller
## Обзор класса
Установить версию Minecraft — значит положить в `.minecraft` пять групп файлов: описание версии,
клиентский jar, библиотеки, индекс ресурсов и сами ресурсы. Последних — десятки тысяч мелких
файлов.
`VersionInstaller` делает это фоном, не блокируя интерфейс: складывает всё нужное в очередь
загрузок, качает несколько файлов параллельно, пишет их потоком на диск и по ходу сообщает
прогресс. Одна версия ставится за раз, остальные ждут в очереди.
Класс также разворачивает цепочку наследования: если у версии есть `inheritsFrom`, родительская
версия ставится перед ней.
## Место в проекте и зависимости
Подключает [minecraftversion.h](minecraftversion.md) — по разобранной версии он и понимает, что
качать. В конструктор принимает [VersionManifestService](VersionManifestService.md): оттуда
берётся адрес описания версии.
Экземпляр создаётся и принадлежит [LauncherBackend](LauncherBackend.md). На него же опирается
[ModLoaderInstaller](ModLoaderInstaller.md) — профиль модлоадера ставится поверх установленной
версии игры.
Требования сборки: `Qt6::Core` (`QCryptographicHash`, `QSaveFile`, `QQueue`, `QTimer`) и
`Qt6::Network` (`QNetworkAccessManager`, `QNetworkReply`).
## Иерархия и роль
Наследует `QObject`: мета-объектная система, пять сигналов и владение по родителю. Объявлен
виртуальный деструктор — класс владеет незавершёнными загрузками и обязан их закрыть.
## Публичные структуры
### DownloadTask
Один файл, который нужно положить в `.minecraft`.
| Поле | Тип | По умолчанию | Описание |
|------|-----|--------------|----------|
| `kind` | `DownloadTask::Kind` | `ClientJar` | Что это за файл |
| `url` | `QUrl` | — | Откуда качать |
| `path` | `QString` | — | Абсолютный путь назначения |
| `sha1` | `QString` | — | Контрольная сумма; пустая означает «не проверять» |
| `size` | `qint64` | `0` | Ожидаемый размер; `0` — неизвестен |
| `label` | `QString` | — | Что показать в панели прогресса |
| `attempts` | `int` | `0` | Сколько попыток уже сделано |
#### Перечисление DownloadTask::Kind
| Значение | Описание |
|----------|----------|
| `ClientJar` | Клиентский jar версии |
| `Library` | Библиотека из `libraries/` |
| `AssetIndex` | Индекс ресурсов |
| `AssetObject` | Один файл ресурсов |
| `LoggingConfig` | Конфигурация журналирования log4j |
### ActiveDownload
Файл в процессе скачивания: задача, сетевой ответ, открытый `QSaveFile`, накапливаемая
контрольная сумма и число принятых байт. Файлы пишутся потоком — держать десятки мегабайт в
памяти незачем, а нескольких параллельных загрузок хватило бы на сотни.
## Публичные методы
#### explicit VersionInstaller(VersionManifestService \*manifest, QObject \*parent = nullptr)
Создаёт установщик поверх сервиса манифеста. Сервис не переходит во владение установщика и обязан
пережить его. Конструктор помечен `explicit`.
#### bool isRunning() const
Идёт ли установка прямо сейчас.
#### QString versionId() const
Идентификатор версии, которая ставится в данный момент.
#### QString stage() const
Текущий этап установки словами — это же значение показывается в заголовке панели прогресса.
#### QString currentFile() const
Подпись файла, который качается сейчас.
#### qint64 bytesDone() const
Сколько байт уже получено, с учётом идущих загрузок.
#### qint64 bytesTotal() const
Ожидаемый общий объём. Растёт по ходу установки: полный размер ресурсов становится известен
только после разбора их индекса.
#### double fraction() const
Доля выполнения от `0` до `1` либо `-1`, пока итоговый объём неизвестен. Значение `-1` панель
прогресса показывает многоточием вместо процентов.
#### void install(const QString &gameDir, const QString &versionId)
Ставит версию в указанную папку игры. Если установка уже идёт, версия становится в очередь.
Порядок работы: разрешение записи манифеста, загрузка описания версии, подготовка списка задач,
скачивание, разворачивание индекса ресурсов и — для версий до 1.6 — раскладка ресурсов в плоскую
папку, которую те версии умеют читать.
#### bool isQueued(const QString &versionId) const
Стоит ли версия в очереди на установку. Позволяет не ставить одну и ту же версию дважды.
#### void cancel()
Отменяет текущую установку и очищает очередь. Незавершённые файлы не остаются на диске: они
пишутся через `QSaveFile` и фиксируются только целиком.
## Сигналы
#### started(const QString &versionId)
Установка версии началась. Обработчик показывает панель прогресса и выставляет признак занятости.
#### progressChanged()
Изменились числа прогресса. Испускается не чаще десяти раз в секунду: при тысячах мелких файлов
сигнал на каждый принятый блок обошёлся бы дороже самой загрузки.
Обработчик перечитывает `stage()`, `currentFile()`, `bytesDone()`, `bytesTotal()` и `fraction()`.
#### finished(const QString &versionId)
Версия установлена успешно. Обработчик убирает панель прогресса, обновляет список установленных
версий и пересчитывает комплектность сборок.
#### failed(const QString &versionId, const QString &message)
Установка не удалась; в параметре — текст ошибки для пользователя. Часть файлов при этом может
остаться на диске: повторная установка докачает недостающее.
#### canceled(const QString &versionId)
Установка отменена пользователем. В отличие от `failed`, ошибку показывать не нужно.
## Владение и время жизни
Класс наследует `QObject` и принимает `parent` — родитель его и удалит.
Указатель на `VersionManifestService`, переданный в конструктор, **не принадлежит** установщику:
сервис создаётся раньше и живёт дольше. `QNetworkAccessManager` и оба таймера создаются в
конструкторе с установщиком в роли родителя.
Активные загрузки хранятся как `std::shared_ptr<ActiveDownload>`, а файл внутри каждой — как
`std::unique_ptr<QSaveFile>`: незавершённая запись отменяется вместе с уничтожением объекта, и
испорченный файл не попадает на место назначения.
## Потокобезопасность
Только поток GUI, как и остальной сетевой код лаунчера. Параллелизм здесь — не потоки, а
несколько одновременных сетевых запросов в одном цикле событий. Раскладка ресурсов для старых
версий выполняется порциями по таймеру, чтобы не занимать поток надолго.
## Взаимодействие с другими классами
`LauncherBackend` вызывает `install()` при установке сборки и переправляет все пять сигналов в
свойства, которые читает QML: панель прогресса главного окна показывает `stage()`, `fraction()` и
байты, а `finished` обновляет список установленных версий.
[ModLoaderInstaller](ModLoaderInstaller.md) держит ссылку на установщик: профиль модлоадера
требует, чтобы базовая версия игры была уже на месте.
Разбор описания версии идёт через `VersionLoader::load()` из
[minecraftversion.h](minecraftversion.md).
## Внешнее взаимодействие
**Сеть, исходящие запросы.** Класс качает файлы с серверов Mojang через `QNetworkAccessManager`.
Направление одностороннее, протокол — HTTPS; описание версии и индекс ресурсов приходят как JSON,
остальное — двоичными файлами.
Каждый файл пишется потоком через `QSaveFile` с одновременным подсчётом sha1; несовпадение
контрольной суммы считается неудачей загрузки. Неудачная задача повторяется — счётчик попыток
хранится в самой задаче, — и только исчерпав попытки, приводит к сигналу `failed`.
Существующие файлы сверяются только по размеру: перехеширование сотен мегабайт при каждом
добавлении версии дороже, чем риск битого файла.
Все сигналы приходят в поток GUI.
## Пример использования
```cpp
auto *installer = new VersionInstaller(manifestService, this);
connect(installer, &VersionInstaller::progressChanged, this, [this, installer] {
emit downloadProgress(installer->fraction(), installer->currentFile());
});
connect(installer, &VersionInstaller::finished, this, &Backend::onVersionInstalled);
connect(installer, &VersionInstaller::failed, this, [this](const QString &id, const QString &message) {
emit launchError(tr("Не удалось установить %1: %2").arg(id, message));
});
if (!installer->isQueued(versionId))
installer->install(gameDir, versionId);
```
`versioninstaller.h`, `versioninstaller.cpp`
Ставит версию игры в `.minecraft`: описание версии, клиентский jar, библиотеки, индекс ресурсов,
сами ресурсы и конфиг логирования. Одновременно ставится одна версия, остальные ждут в очереди.
Всё работает в GUI-потоке на асинхронной сети.
Экземпляр держит [LauncherBackend](LauncherBackend.md). Ещё им пользуется
[ModLoaderInstaller](ModLoaderInstaller.md): перед установкой модлоадера нужно поставить базовую
версию, а после установки докачать библиотеки нового профиля.
## Интерфейс
- `install(gameDir, versionId)` — поставить в очередь. Если эта версия уже ставится или ждёт,
ничего не происходит. Если id содержит слэши, `.` или `..`, сразу приходит `failed`.
- `cancel()` — отменить текущую установку и очистить очередь.
- `isRunning()`, `isQueued(id)`, `versionId()`.
- Для панели прогресса: `stage()`, `currentFile()`, `bytesDone()`, `bytesTotal()`, `fraction()`
(от 0 до 1; −1, если общий объём неизвестен).
Сигналы: `started(id)`, `progressChanged()` (не чаще раза в 100 мс), `finished(id)`,
`failed(id, message)`, `canceled(id)`.
`started` испускается один раз на всю серию установок из очереди. `finished` испускается для
каждой версии.
## Как идёт установка
1. **Описание версии.** У [VersionManifestService](VersionManifestService.md) берётся ссылка на
`<id>.json`, файл скачивается, sha1 сверяется с манифестом, и файл сохраняется в
`versions/<id>/`. Если версии в манифесте нет (профиль модлоадера) или сеть недоступна, берётся
файл, который уже лежит на диске. Если нет и его, установка падает.
2. **Родитель.** Если в описании есть `inheritsFrom`, а родителя на диске нет, текущая версия
уступает место: в начало очереди встают сначала родитель, потом она сама. Глубина цепочки — до
10 звеньев.
3. **Список файлов.** `VersionLoader::load()` сводит цепочку, и из результата составляется список:
клиентский jar, все библиотеки, индекс ресурсов и конфиг логов. Ссылки не по `https` и
библиотеки без адреса пропускаются с предупреждением.
4. **Загрузка.** До 8 файлов одновременно. Каждый пишется потоком в `QSaveFile`, sha1 считается на
ходу. При временной ошибке (таймаут, обрыв, HTTP 429 или 5xx) и при несовпадении хеша файл
ставится обратно в начало очереди с паузой 400 мс, потом 800 мс. После третьей неудачи
установка падает. Другие ошибки сразу её прерывают. Таймаут на файл 30 секунд.
5. **Ресурсы.** Как только индекс ресурсов на диске, он разворачивается в задачи на загрузку из
`resources.download.minecraft.net/<xx>/<hash>`. До этого момента в общем объёме учитывается
`totalSize` из описания версии, а потом он заменяется точной суммой.
6. **Старые ресурсы.** У версий до 1.6 (`virtual`, `map_to_resources`, `assets` = `legacy` или
`pre-1.6`) игра ищет ресурсы не в `assets/objects`, а в обычной папке с именами файлов. Они
копируются в `assets/virtual/legacy/` или в `resources/` по 64 файла за такт таймера, чтобы
окно не подвисало.
7. **Проверка.** `GameLauncher::missingFiles()`. Если чего-то нет, это ошибка.
## Уже скачанные файлы
Существующий файл считается целым, если его размер совпадает с ожидаемым (или размер неизвестен).
Хеш не пересчитывается: перехеширование сотен мегабайт ресурсов при каждой установке обходится
дороже, чем редкий битый файл. Для будущей кнопки «Проверить файлы» есть флаг `m_verifyExisting`,
сейчас он всегда `false`.
Пропуск файлов идёт пачками по 256 за такт. Иначе установка версии, у которой уже есть все
ресурсы, повесила бы окно на перебор десятков тысяч записей.
## Ошибки и отмена
При ошибке все активные загрузки обрываются, недописанные файлы откатываются (`QSaveFile`), а
**очередь очищается**: остальные версии из неё тоже не поставятся. К тексту ошибки добавляются до
трёх накопленных предупреждений.
Отмена обрывает активные запросы и ждёт, пока все они вернутся. Только после этого приходит
`canceled`. Пока отмена не закончилась, `install()` отвечает отказом.
Деструктор блокирует сигналы и отменяет установку.
---
+45 -145
View File
@@ -1,167 +1,67 @@
# VersionManifestService
## Обзор класса
`versionmanifestservice.h`, `versionmanifestservice.cpp`
Каталог версий Minecraft — это манифест Mojang: около тысячи записей от альф 2010 года до
свежайших снапшотов. `VersionManifestService` отвечает за него целиком: скачивает манифест,
кэширует в папке лаунчера и отдаёт из кэша, пока тот не устарел.
Каталог версий Mojang. Скачивает `version_manifest_v2.json`, хранит его копию в папке лаунчера и
отвечает из неё, пока копия свежая.
Класс нужен двум потребителям: окну выбора версии, которому нужен весь список, и установщику,
которому по идентификатору версии нужна ссылка на её описание.
Экземпляр держит [LauncherBackend](LauncherBackend.md): из каталога он строит список для окна
выбора версии. [VersionInstaller](VersionInstaller.md) берёт из каталога ссылку на описание версии
через `find()`.
## Место в проекте и зависимости
## RemoteVersionEntry
Экземпляр создаётся и принадлежит [LauncherBackend](LauncherBackend.md). Ссылку на него получает
[VersionInstaller](VersionInstaller.md) — установщик берёт из манифеста адрес описания версии.
| Поле | Что это |
|------|---------|
| `id` | `1.21.8`, `25w33a`, `b1.7.3` |
| `type` | `release`, `snapshot`, `old_beta`, `old_alpha` |
| `url` | адрес `<id>.json` |
| `sha1` | хеш этого json |
| `releaseTime` | |
Путь к файлу кэша даёт `LauncherPaths::versionManifestFile()` из
[launcherpaths.h](launcherpaths.md).
Записи без `id` или с адресом не по `https` отбрасываются. Список отсортирован по `releaseTime`,
новые сверху. При одинаковом времени сортировка идёт по `id`.
Требования сборки: `Qt6::Core` (`QDateTime`, `QHash`, `QList`, `QUrl`) и `Qt6::Network`
(`QNetworkAccessManager`).
## ensureLoaded(callback, forceRefresh = false)
## Иерархия и роль
Через этот метод сервис получает данные; других способов нет.
Наследует `QObject`: мета-объектная система, два сигнала и владение по родителю. Виртуальных
методов базового класса не переопределяет.
1. При первом вызове читается кэш `<root>/cache/version_manifest.json`. Если он разобрался,
сразу испускается `versionsChanged`, и окно показывает список ещё до ответа сети.
2. Если данные моложе 6 часов и `forceRefresh` не задан, колбэк вызывается через
`QTimer::singleShot(0)` с `ok = true`. Сеть не трогается.
3. Иначе колбэк встаёт в очередь. Если запрос уже идёт, второй не отправляется.
## Публичные структуры
Ответ сети разбирается, кэш перезаписывается через `QSaveFile`, испускается `versionsChanged`, и
все колбэки из очереди получают результат.
### RemoteVersionEntry
Если сеть не ответила, но старые данные есть, колбэк получит `ok = true` и непустой `warning`:
работать можно, просто список, возможно, неполный. Если данных нет совсем, придёт `ok = false`.
Бэкенд показывает `warning` пользователю только во втором случае.
Одна строка манифеста Mojang.
Таймаут запроса 20 секунд.
| Поле | Тип | Описание |
|------|-----|----------|
| `id` | `QString` | Идентификатор версии: `1.21.8`, `25w33a`, `b1.7.3` |
| `type` | `QString` | Категория: `release`, `snapshot`, `old_beta` или `old_alpha` |
| `url` | `QUrl` | Адрес `<id>.json` с описанием версии |
| `sha1` | `QString` | Контрольная сумма самого описания |
| `releaseTime` | `QDateTime` | Дата выпуска; по ней список сортируется новыми вперёд |
## Остальное
Категории `type` — те же ключи, по которым окно выбора версии делит каталог на вкладки; версии, не
попавшие ни в одну из четырёх, интерфейс относит к категории «прочие».
- `versions()` — весь список.
- `find(id)` — запись по id (через хеш-таблицу, а не перебором).
- `hasData()`, `isRefreshing()`, `fetchedAt()`.
- `refreshingChanged` испускается в начале и в конце запроса. Бэкенд превращает его в
`catalogLoading`.
## Псевдонимы типов
## Формат кэша
`VersionManifestService::Callback` — `std::function<void(bool ok, const QString &warning)>`.
Сочетание `ok == true` с непустым `warning` означает особый случай: данные отдали, но из
устаревшего кэша — сеть недоступна, а показать что-то нужно.
## Публичные методы
#### explicit VersionManifestService(QObject \*parent = nullptr)
Создаёт сервис и его `QNetworkAccessManager`. Манифест при этом не читается — чтение кэша
откладывается до первого обращения. Конструктор помечен `explicit`.
#### QList&lt;RemoteVersionEntry&gt; versions() const
Текущий список версий. Возвращает копию; пустой список означает, что данных ещё нет.
#### bool hasData() const
Есть ли хоть какие-то данные — из сети или из кэша.
#### bool isRefreshing() const
Идёт ли сейчас сетевое обновление. Интерфейс показывает по этому признаку строку загрузки вместо
пустого списка.
#### QDateTime fetchedAt() const
Когда данные были получены. По этой отметке решается, устарел ли кэш.
#### std::optional&lt;RemoteVersionEntry&gt; find(const QString &id) const
Запись по идентификатору версии; `std::nullopt`, если такой версии в манифесте нет. Из неё
установщик берёт ссылку на описание версии. Поиск идёт по внутреннему указателю, а не перебором.
#### void ensureLoaded(Callback callback, bool forceRefresh = false)
Единственная точка входа. Свежий кэш означает отложенный вызов колбэка без обращения к сети; иначе
запускается один сетевой запрос на всех, кто успел попросить, — колбэки накапливаются и вызываются
все разом по его завершении.
Параметр `forceRefresh` обходит проверку свежести кэша: так работает кнопка принудительного
обновления.
Колбэк вызывается ровно один раз и всегда в потоке GUI, в том числе когда данные уже есть.
## Сигналы
#### versionsChanged()
Список версий изменился — пришли новые данные из сети или прочитан кэш.
Обработчик перечитывает `versions()` и обновляет интерфейс. В лаунчере на этот сигнал завязано
свойство каталога версий, которое читает окно выбора.
#### refreshingChanged()
Изменился признак сетевого обновления. Обработчик показывает или убирает индикатор загрузки.
## Владение и время жизни
Класс наследует `QObject` и принимает `parent` — родитель его и удалит.
`QNetworkAccessManager` создаётся в конструкторе с сервисом в роли родителя.
Отложенные колбэки хранятся в списке до завершения текущего запроса. Уничтожение сервиса с
незавершённым запросом обрывает его вместе с менеджером сети, и накопленные колбэки не
вызываются.
Ссылку на сервис держит установщик версий; уничтожать сервис раньше установщика нельзя.
## Потокобезопасность
Только поток GUI — так же, как [AuthService](AuthService.md). Чтение и запись кэша выполняются в
вызывающем потоке, поэтому первое обращение к манифесту делает короткую файловую операцию
синхронно.
## Взаимодействие с другими классами
`LauncherBackend` вызывает `ensureLoaded()` при открытии окна выбора версии и по кнопке
обновления, а сигналы `versionsChanged` и `refreshingChanged` переправляет в свойства, которые
читает QML. Список из `versions()` он сводит с установленными версиями и отдаёт в интерфейс уже
готовыми строками.
[VersionInstaller](VersionInstaller.md) обращается к `find()`, чтобы получить адрес описания
версии перед началом загрузки.
## Внешнее взаимодействие
**Сеть, исходящие запросы.** Класс скачивает манифест версий Mojang через
`QNetworkAccessManager`. Формат — JSON поверх HTTPS, запрос инициирует лаунчер.
Стратегия при недоступной сети встроена в контракт колбэка: если есть устаревший кэш, он
отдаётся с `ok == true` и заполненным `warning`, и интерфейс показывает список вместо ошибки.
Полное отсутствие данных даёт `ok == false`.
**Файловый кэш.** Манифест сохраняется в файл, путь к которому даёт
`LauncherPaths::versionManifestFile()`, вместе с отметкой времени получения.
Сигналы и колбэки приходят в поток GUI.
## Пример использования
```cpp
auto *manifest = new VersionManifestService(this);
connect(manifest, &VersionManifestService::versionsChanged, this, &Backend::rebuildCatalog);
manifest->ensureLoaded([this, manifest](bool ok, const QString &warning) {
if (!ok) {
emit catalogError(warning);
return;
}
if (!warning.isEmpty())
showStatus(warning); // список из устаревшего кэша
const auto entry = manifest->find(QStringLiteral("1.21.8"));
if (entry)
startDownload(entry->url);
});
```json
{
"fetchedAt": "2026-10-11T08:00:00Z",
"versions": [
{ "id": "1.21.8", "type": "release", "url": "https://...", "sha1": "...", "releaseTime": "..." }
]
}
```
Хранится только то, что нужно лаунчеру, а не весь манифест Mojang.
---
При создании этого документа использовался ИИ.
+40 -57
View File
@@ -1,76 +1,59 @@
# javalocator.h — JavaLocator
# JavaLocator
## Обзор
`javalocator.h`, `javalocator.cpp`
Для запуска Minecraft нужна Java подходящей версии: старым версиям игры — восьмая, новым —
семнадцатая или двадцать первая. Лаунчер умеет скачивать сборки Java сам (см.
[JavaInstaller](JavaInstaller.md)), но сначала стоит посмотреть, что уже есть на машине.
Поиск java, уже установленной на машине, и выбор подходящей под версию игры. Скачиванием
занимается [JavaInstaller](JavaInstaller.md), а здесь только смотрим, что есть.
`JavaLocator` отвечает за поиск: собирает все доступные java, определяет их версии и выбирает
подходящую под требование версии игры. Пространство имён используется при подготовке запуска и при
показе списка найденных Java в настройках.
Это пространство имён со свободными функциями. Его используют [GameLauncher](GameLauncher.md) при
запуске, [LauncherBackend](LauncherBackend.md) для списка в настройках и для шага Java в установке
сезонной сборки.
## Пространства имён
## findAll(gameDir)
`JavaLocator` группирует четыре функции поиска и выбора. Состояния нет, но результат определения
версии кэшируется внутри реализации.
Список путей к исполняемому `java` (`java.exe` на Windows) без повторов. Порядок такой:
## Функции
1. сборки, скачанные лаунчером в `<root>/java` (через `JavaRuntimeStore::installed()`);
2. всё, что найдётся рекурсивно в `<gameDir>/runtime`: туда кладёт Java официальный лаунчер;
3. `$JAVA_HOME/bin`;
4. `java` из `PATH`;
5. стандартные каталоги установки. На Windows это `Program Files`, `Program Files (x86)` и
`ProgramW6432` с подпапками `Java`, `Eclipse Adoptium`, `Eclipse Foundation`, `AdoptOpenJDK`,
`Amazon Corretto`, `Zulu`, `BellSoft`, `Microsoft`, `Semeru` и JBR из Android Studio. На macOS
это `/Library/Java/JavaVirtualMachines` и такая же папка в домашнем каталоге. На Linux —
`/usr/lib/jvm`, `/usr/java`, `/opt/java`, `/opt/jdk`.
#### QStringList findAll(const QString &gameDir)
В каждом каталоге установки проверяются `bin`, `Contents/Home/bin` (так устроены JDK на macOS) и
`jre/bin`.
Все java, которые удалось найти, без дублей и в порядке приоритета. Просматриваются четыре
источника: рантайм самого Minecraft внутри папки игры, переменная `JAVA_HOME`, переменная `PATH` и
стандартные каталоги установки JDK и JRE.
## majorVersion(javaPath)
Параметр `gameDir` — папка игры; из неё берётся первый источник.
Запускает `java -version` и достаёт мажорную версию из строки `version "..."`. Старая схема
`1.8.0_x` даёт 8. Если процесс не ответил за 5 секунд или вывод не разобрался, возвращается 0.
Результат показывается в диалоге настроек списком «что нашлось в системе».
Результат кэшируется в статической таблице на всё время работы лаунчера. Если java по тому же пути
обновить, лаунчер узнает об этом только после перезапуска. Кэш не защищён от гонок, поэтому
функцию можно вызывать только из одного потока, и в проекте это GUI-поток.
#### int majorVersion(const QString &javaPath)
## select(gameDir, requiredMajor, preferred, &error)
Мажорная версия java по её пути: 8, 17, 21 и так далее. Возвращает `0`, если запустить
исполняемый файл не удалось — путь неверен, файл не исполняемый или это не java.
Выбирает java для запуска.
Результат кэшируется: определение версии требует запуска процесса, а один и тот же путь
проверяется многократно.
- Если `preferred` задан, он идёт первым кандидатом. Это может быть путь к файлу или к домашней
папке JDK (тогда берётся `bin/java`). Если по этому пути ничего нет, функция сразу возвращает
ошибку и других кандидатов не смотрит: пользователь явно указал путь, и молча подменять его
было бы неправильно.
- Дальше добавляется всё из `findAll()`.
- Возвращается первый кандидат с версией не ниже `requiredMajor`. Если таких нет, в `error`
пишется, какая самая новая java нашлась и где.
#### QString windowlessVariant(const QString &javaPath)
Функция возвращает первую подходящую, а не самую новую. Поэтому скачанная лаунчером Java 17
выиграет у системной Java 21, если игре нужна 17.
Заменяет `java.exe` на `javaw.exe`, чтобы игра не открывала окно консоли. На Unix возвращает вход
без изменений — там разницы нет.
## windowlessVariant(javaPath)
#### QString select(const QString &gameDir, int requiredMajor, const QString &preferred, QString \*error)
Выбирает java не ниже `requiredMajor`. Если задан `preferred` — путь, указанный пользователем в
настройках, — он проверяется первым и, если подходит, побеждает. Иначе перебираются найденные
`findAll()` варианты.
При неудаче возвращает пустую строку и заполняет `error` — текст объясняет, что именно не нашлось:
подходящей версии нет вовсе или указанный пользователем путь не подошёл.
Функция запускает процессы для определения версий, поэтому может занять заметное время; вызывать
её в обработчике нажатия не стоит.
## Зависимости
Подключает `QString` и `QStringList`. От классов проекта не зависит.
## Пример использования
```cpp
QString error;
const QString java = JavaLocator::select(gameDir,
version.javaMajor,
settings.javaPath,
&error);
if (java.isEmpty()) {
emit launchError(error);
return;
}
process.start(JavaLocator::windowlessVariant(java), arguments);
```
На Windows заменяет `java.exe` на `javaw.exe` из той же папки, если он там есть. Тогда при запуске
игры не появляется консольное окно. На других системах возвращает путь без изменений.
---
+76 -135
View File
@@ -1,153 +1,94 @@
# javaruntime.h — JavaRuntime, JavaRuntimeStore и JavaRequirement
# javaruntime.h
## Обзор
`javaruntime.h`, `javaruntime.cpp`
`Minecraft_launcher` умеет скачивать Java сам: официальные сборки Mojang — те же, которыми игру
запускает официальный лаунчер, — и сборки Eclipse Temurin в вариантах JDK и JRE. Скачанные сборки
живут в папке лаунчера, по одной подпапке на сборку.
Общие типы для Java: виды сборок, строка каталога, запись об установленной сборке, работа с папкой
`<root>/java` и таблица «какая Java нужна какой версии игры».
Заголовок `javaruntime.h` — словарь этой части проекта. Он даёт перечисление видов сборок, две
структуры (строка каталога и уже установленная сборка), пространство имён для работы с папкой
`<root>/java` и таблицу требований версий игры к Java.
## Виды сборок
Заголовок подключают [JavaRuntimeService](JavaRuntimeService.md) (каталог),
[JavaInstaller](JavaInstaller.md) (скачивание и распаковка) и
[LauncherBackend](LauncherBackend.md) (отдача каталога в QML).
| `JavaRuntimeKind` | Ключ | Что это |
|-------------------|------|---------|
| `Mojang` | `java` | сборки, которые Mojang раздаёт для своего лаунчера |
| `Jdk` | `jdk` | Eclipse Temurin JDK |
| `Jre` | `jre` | Eclipse Temurin JRE |
## Типы
`javaKindKey()`, `javaKindFromKey()`, `javaKindTitle()` переводят между перечислением, ключом и
подписью для интерфейса. Ключ хранится в `runtime.json` установленной сборки и используется в QML.
| Имя | Вид | Описание |
|-----|-----|----------|
| `JavaRuntimeKind` | `enum class` | Откуда взялась сборка и что именно в ней лежит |
| `JavaRuntimeEntry` | `struct` | Строка каталога — то, что можно скачать |
| `InstalledJavaRuntime` | `struct` | Сборка, уже распакованная в папке лаунчера |
## JavaRuntimeEntry
### JavaRuntimeKind
Строка каталога [JavaRuntimeService](JavaRuntimeService.md), то есть то, что можно скачать.
| Значение | Ключ | Описание |
|----------|------|----------|
| `Mojang` | `java` | Тот же рантайм, которым игру запускает официальный лаунчер |
| `Jdk` | `jdk` | Eclipse Temurin JDK: компилятор и инструменты в комплекте |
| `Jre` | `jre` | Eclipse Temurin JRE: только то, что нужно для запуска |
| Поле | Что это |
|------|---------|
| `id` | `temurin-jdk-21.0.12.1_1`, `mojang-java-runtime-delta` |
| `kind` | |
| `major` | 8, 17, 21… |
| `version` | `21.0.12.1+1`, `21.0.7` |
| `component` | только у Mojang: `java-runtime-delta` и т. п. |
| `released` | |
| `url` | архив Temurin или `manifest.json` компонента Mojang |
| `checksum` | sha256 архива Temurin или sha1 манифеста Mojang |
| `size` | размер загрузки, 0 если неизвестен |
| `archive` | `zip`, `tar.gz` или `mojang` |
| `architecture` | `x64`, `aarch64`; на macOS может быть не родной (см. JavaRuntimeService) |
| `lts` | |
Эти же ключи служат именами категорий в окне выбора Java.
## InstalledJavaRuntime
### JavaRuntimeEntry
Скачанная сборка: `id`, `kind` (ключ), `major`, `version`, `javaPath` (абсолютный путь к
`java`/`java.exe`), `size` (сколько заняла на диске).
| Поле | Тип | Описание |
|------|-----|----------|
| `id` | `QString` | Идентификатор сборки, например `temurin-jdk-21.0.12.1_1` или `mojang-java-runtime-delta` |
| `kind` | `JavaRuntimeKind` | Вид сборки; по умолчанию `Jdk` |
| `major` | `int` | Мажорная версия: 8, 17, 21… |
| `version` | `QString` | Полная версия, например `21.0.12.1+1` или `21.0.7` |
| `component` | `QString` | Имя компонента вида `java-runtime-delta`; только у сборок Mojang |
| `released` | `QDateTime` | Дата выпуска; по ней каталог сортируется новыми вперёд |
| `url` | `QUrl` | Архив Temurin либо `manifest.json` компонента Mojang |
| `checksum` | `QString` | sha256 архива Temurin или sha1 манифеста Mojang |
| `size` | `qint64` | Размер загрузки в байтах; `0` — неизвестен |
| `archive` | `QString` | Формат: `zip`, `tar.gz` или `mojang` — последний означает не архив, а манифест с пофайловой загрузкой |
| `architecture` | `QString` | `x64` или `aarch64`; на macOS бывает и не родная архитектура |
| `lts` | `bool` | Версия с длительной поддержкой; помечается в списке |
## JavaRuntimeStore
Метод `isValid()` возвращает `true`, когда заполнен `id`, `major` больше нуля и `url` корректен.
Каждая сборка лежит в `<root>/java/<id>/`, а рядом с ней `runtime.json`:
### InstalledJavaRuntime
| Поле | Тип | Описание |
|------|-----|----------|
| `id` | `QString` | Идентификатор сборки, он же имя подпапки |
| `kind` | `QString` | Вид сборки строкой: `java`, `jdk` или `jre` |
| `major` | `int` | Мажорная версия |
| `version` | `QString` | Полная версия |
| `javaPath` | `QString` | Абсолютный путь к исполняемому файлу `java` или `java.exe` |
| `size` | `qint64` | Сколько сборка заняла на диске по итогам установки |
## Функции
### Перевод видов сборок
#### QString javaKindKey(JavaRuntimeKind kind)
Строковый ключ вида сборки: `java`, `jdk` или `jre`.
#### std::optional&lt;JavaRuntimeKind&gt; javaKindFromKey(const QString &key)
Обратный перевод; `std::nullopt` для неизвестного ключа.
#### QString javaKindTitle(JavaRuntimeKind kind)
Человекочитаемое название вида сборки для интерфейса.
### JavaRuntimeStore — папка `<root>/java`
Устройство папки: одна подпапка на сборку плюс её описание внутри. Отдельного индекса нет
намеренно — удалённую вручную папку не пришлось бы вычищать ещё и из общего файла.
#### QString dirFor(const QString &id)
Папка конкретной сборки внутри `<root>/java`.
#### QList&lt;InstalledJavaRuntime&gt; installed()
Всё, что лежит в `<root>/java` и на что нашлась java. Новые версии идут первыми.
#### std::optional&lt;InstalledJavaRuntime&gt; find(const QString &id)
Описание одной установленной сборки; `std::nullopt`, если такой нет.
#### QString locateBinary(const QString &rootDir)
Ищет `bin/java` в распакованном дереве. Раскладка отличается между поставщиками: у Temurin на macOS
это `Contents/Home/bin`, у Mojang — `jre.bundle/Contents/Home/bin`.
#### bool writeMeta(const InstalledJavaRuntime &runtime, QString \*error)
Записывает описание сборки в её папку. Вызывается по завершении установки. При неудаче возвращает
`false` и заполняет `error`.
#### bool remove(const QString &id, QString \*error)
Удаляет папку сборки целиком. При неудаче возвращает `false` и заполняет `error`.
#### QString sanitizeId(const QString &id)
Превращает идентификатор каталога в безопасное имя папки: всё, кроме букв, цифр, точки, дефиса и
подчёркивания, заменяется. Идентификатор приходит из сети, поэтому подставлять его в путь как есть
нельзя.
### JavaRequirement — какая Java нужна какой версии игры
Точный ответ лежит в `client.json` версии (поле `javaVersion.majorVersion`) и берётся оттуда при
запуске. Эта таблица нужна раньше — когда версия ещё не скачана, а подсказку в каталоге показать
надо.
#### int minimumFor(const QString &minecraftVersionId)
Минимальная мажорная версия Java для версии игры; `0`, если разобрать идентификатор версии не
удалось.
#### QString coverage(int javaMajor)
Подпись к строке каталога вида «Minecraft 1.20.5 и новее» — какие версии игры покрывает эта версия
Java.
## Зависимости
Подключает `QDateTime`, `QList`, `QString`, `QUrl` и `<optional>` — только Qt Core. От классов
проекта не зависит.
## Пример использования
```cpp
const auto runtime = JavaRuntimeStore::find(settings.javaRuntime);
if (runtime && QFileInfo::exists(runtime->javaPath)) {
javaPath = runtime->javaPath;
} else {
// сборка удалена вручную — возвращаемся к поиску в системе
javaPath = JavaLocator::select(gameDir, version.javaMajor, settings.javaPath, &error);
}
```json
{ "id": "...", "kind": "jdk", "major": 21, "version": "21.0.7", "javaPath": "bin/java", "size": 197132288 }
```
`javaPath` хранится относительно папки сборки, так что папку лаунчера можно переносить.
- `sanitizeId(id)` — всё, кроме букв, цифр, `.`, `-`, `_`, заменяется на `_`. Ведущие точки
убираются.
- `dirFor(id)` — `<root>/java/<sanitizeId(id)>`.
- `find(id)` — читает `runtime.json`. Возвращает пусто, если файла нет, `id` в нём другой или
`java` по указанному пути не существует. Поэтому недокачанная сборка без `runtime.json` в
списке не появится.
- `installed()` — всё, что нашлось, от новых мажорных версий к старым.
- `locateBinary(rootDir)` — ищет `bin/java` внутри распакованного дерева и берёт самый короткий
путь. Нужно потому, что у архивов разная вложенность: `jdk-21/bin`, `jdk-21/Contents/Home/bin`
и т. п.
- `writeMeta(runtime, &error)` — пишет `runtime.json` через `QSaveFile`.
- `remove(id, &error)` — удаляет папку сборки. Перед этим проверяет, что путь лежит внутри
`<root>/java/`.
## JavaRequirement
`minimumFor(minecraftVersionId)` — минимальная Java для версии игры:
| Версия игры | Java |
|-------------|------|
| до 1.16 включительно, беты, альфы, всё нераспознанное | 8 |
| 1.17 | 16 |
| 1.18 — 1.20.4 | 17 |
| 1.20.5 и новее | 21 |
| версии с новой нумерацией (первое число не 1, например `26.1`) | 21 |
| снапшоты `21wNNx` | 16 |
| снапшоты `22w`, `23w` | 17 |
| снапшоты `24w` и новее | 21 |
| снапшоты до `21w` | 8 |
Пустая строка даёт 0. Таблица нужна там, где описания версии ещё нет на диске: в окне выбора Java
и при установке сезонной сборки. При запуске игры используется `javaVersion.majorVersion` из
описания версии.
Профили модлоадеров (`fabric-loader-...`) по этой таблице дают 8, поэтому бэкенд передаёт сюда
`minecraftVersion` сборки, а не `resolvedVersionId`.
`coverage(javaMajor)` — подпись для окна выбора: какие версии игры эта Java потянет.
---
При создании этого документа использовался ИИ.
+61 -128
View File
@@ -1,144 +1,77 @@
# 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`, без точки, то есть видимая.
### Корневые каталоги
## BILAUNCHER_HOME
#### QString containerDir()
`homeOverride()` читает переменную окружения `BILAUNCHER_HOME`. Если в ней абсолютный путь, он
заменяет домашнюю папку (`~` и `%USERPROFILE%` в таблице выше) для всех путей лаунчера, включая
папку игры по умолчанию. Пустое или относительное значение не учитывается. Остальной Qt
(`QDir::homePath()`, `QStandardPaths`) эту переменную не видит.
Родительская папка, в которой лежит `.minecraft`, а рядом с ней — `BiLauncher`. От неё
отсчитываются и папка игры по умолчанию, и корень данных лаунчера.
Переменная нужна тестам и дымовой проверке на Windows: там `QDir::homePath()` спрашивает профиль
у системы и `USERPROFILE` игнорирует, так что иначе их нельзя увести из настоящего
`%APPDATA%\BiLauncher`. Новых возможностей она не даёт: кто управляет окружением процесса, на
Linux и macOS и так подменит `HOME`. Чтобы случайно оставленную переменную было видно, `main.cpp`
при старте пишет в лог предупреждение с итоговой папкой лаунчера.
#### QString rootDir()
Внутри `rootDir()`:
Корень данных лаунчера — `<containerDir>/BiLauncher`.
#### QString defaultMinecraftDir()
Стандартная папка игры. Используется, когда пользователь не задал свою в настройках.
#### bool ensureRootExists(QString *error = nullptr)
Создаёт папку лаунчера, если её ещё нет. Вызывается при каждом запуске и перед каждой записью.
Возвращает `false` и заполняет `error`, если папку не удалось создать или в неё не пишется. Все
остальные функции пространства имён только считают строки и в этом смысле не могут завершиться
неудачей — проверять доступность каталога нужно этой функцией.
### Файлы состояния
#### QString settingsFile()
Файл настроек запуска: папка игры, путь к Java, память, аргументы JVM, размер окна.
#### QString profilesFile()
Файл профилей игрока.
#### QString customBuildsFile()
`<root>/customBuilds.json` — пользовательские сборки.
#### QString legacyCustomBuildsFile()
`<root>/versions.json` — как сборки назывались до переименования. Читается один раз при миграции и
больше ни для чего не нужен.
### Сборки и их архивы
#### QString buildStorageDir()
`<root>/builds` — архивы содержимого `.minecraft`, по одному на сборку.
#### QString buildDir(int buildId)
`<root>/builds/<id>` — папка одной сборки: её архив и, у сезонных, скачанный пак с описанием
установленной ревизии.
#### QString seasonalStateFile(int buildId)
`<root>/builds/<id>/season.json` — какая ревизия сезонной сборки установлена и какие файлы она
принесла. Список файлов нужен, чтобы при обновлении убрать те, что из сборки ушли.
### Java
#### QString javaDir()
`<root>/java` — сборки Java, скачанные лаунчером. Каждая в своей подпапке, имя подпапки —
идентификатор сборки из каталога.
#### QString javaCatalogFile()
Слепок каталога доступных сборок Java с отметкой времени.
#### QString javaDownloadDir()
Каталог, куда качаются архивы Temurin до распаковки.
### Кэши и загрузки
#### QString cacheDir()
`<root>/cache` — данные, которые можно удалить без потерь.
#### QString versionManifestFile()
Слепок манифеста версий Mojang с отметкой времени.
#### QString seasonalCatalogFile()
Слепок каталога сезонных сборок с отметкой времени.
#### QString loaderCacheFile(const QString &loaderKey)
Слепок списка версий одного модлоадера. Параметр `loaderKey` принимает значения `forge`, `fabric`,
`neoforge` и `quilt` — те же ключи, что возвращает `loaderKey()` из [modloader.h](modloader.md).
#### QString loaderDownloadDir()
Каталог, куда качаются `installer.jar` модлоадеров.
#### QString runtimeDir()
Каталог, куда качается `authlib-injector` — библиотека, подменяющая сервер авторизации при входе
через Ely.by.
## Зависимости
Единственный подключаемый заголовок — `QString`. Пространство имён не зависит ни от одного класса
проекта, поэтому его можно подключать откуда угодно без риска циклических зависимостей.
## Пример использования
```cpp
QString error;
if (!LauncherPaths::ensureRootExists(&error)) {
qWarning() << "папка лаунчера недоступна:" << error;
return;
}
QFile file(LauncherPaths::customBuildsFile());
if (file.open(QIODevice::WriteOnly))
file.write(document.toJson());
```
BiLauncher/
├── settings.json settingsFile()
├── profiles.json profilesFile()
├── customBuilds.json customBuildsFile()
├── versions.json legacyCustomBuildsFile() — старое имя, читается только при миграции
├── builds/ buildStorageDir()
│ ├── index.json журнал BuildSwitcher
│ └── <id>/ buildDir(id): архив сборки, скачанный пак
│ └── season.json seasonalStateFile(id)
├── java/ javaDir(): скачанные сборки Java, каждая в <id>/
├── runtime/ runtimeDir(): authlib-injector.jar
└── cache/ cacheDir(): всё, что можно удалить без потерь
├── version_manifest.json versionManifestFile()
├── java_runtimes.json javaCatalogFile()
├── seasonal_builds.json seasonalCatalogFile()
├── loader_<ключ>.json loaderCacheFile(key)
├── loaders/ loaderDownloadDir(): installer.jar и журналы установщиков
└── java/ javaDownloadDir(): архивы Temurin до распаковки
```
Папка игры задаётся в настройках (`gameDir`). `defaultMinecraftDir()` используется, только если
там пусто.
## ensureRootExists(&error)
Создаёт `rootDir`, `runtime`, `cache`, `cache/loaders`, `builds`, `java` и `cache/java`, а затем
проверяет, что каждая из них действительно папка и в неё можно писать. `mkpath` возвращает `true`
и для существующей папки без прав на запись, и тогда, когда на месте папки лежит файл. Без этой
проверки сохранения потом молча не работали бы.
Вызывается при старте бэкенда, перед каждой записью JSON-файлов бэкенда, перед записью кэшей
каталогов и перед установками. Остальные функции только собирают строки и ошибиться не могут.
`launcherpaths.cpp` подключает `localization.h` ради текста ошибки, заголовок зависит только от
`QString`.
---
+28 -89
View File
@@ -1,96 +1,35 @@
# main.cpp — точка входа
# main.cpp
## Обзор
Точка входа. Объектов предметной области здесь нет: `LauncherBackend` создаёт сам
[Main.qml](../qml/Main.md), а `main()` только готовит приложение и загружает QML.
`Minecraft_launcher` — десктопный лаунчер Minecraft на Qt 6 Quick. Интерфейс написан на QML,
вся работа — авторизация, скачивание версий, установка модлоадеров и Java, запуск игры — лежит в
C++-классе [LauncherBackend](LauncherBackend.md) и его сервисах.
## По порядку
`main.cpp` — стартовая последовательность приложения: здесь при необходимости инициализируется
Qt WebEngine, создаётся объект приложения, читается каталог переводов, выбирается стиль
Qt Quick Controls, загружается QML-модуль и запускается цикл событий. Файл намеренно короткий:
ни одного объекта предметной области он не создаёт — всё, что нужно, QML заводит сам.
1. **WebEngine.** Если лаунчер собран с Qt WebEngine (`LAUNCHER_HAS_WEBENGINE`),
`QtWebEngineQuick::initialize()` вызывается до создания `QGuiApplication`. После создания
приложения общий контекст OpenGL уже не выставить.
2. **`QGuiApplication`**, а не `QApplication`: виджетов в проекте нет.
3. **Имя и версия.** `applicationName` = `BiLauncher`, `applicationVersion` = `LAUNCHER_VERSION`
(берётся из `project(... VERSION)` в CMake), организация `GaleonDev`, домен `galeonworx.com`. На
пути к данным лаунчера это не влияет, см. [launcherpaths.h](launcherpaths.md).
4. **Иконка окна.** На Windows и Linux в `QIcon` складываются все PNG из ресурса `:/icons/`
(16–512 и `@2x`). На macOS этот шаг пропущен: там иконку берут из `icon.icns` внутри бандла.
5. **`setDesktopFileName("bilauncher")`.** По этому имени окружения рабочего стола в Linux
связывают окно с `bilauncher.desktop` и показывают правильную иконку в панели.
6. **Тексты.** `Localization::instance().load()` читает `i18n/translations.json` и язык из
`settings.json`. Это нужно сделать до создания движка: язык должен быть известен до первой
привязки. Если каталог не загрузился, `main()` возвращает `-1` (см.
[Localization](Localization.md)).
7. **Стиль `Basic`.** Окна оформлены вручную, а нативный стиль Windows игнорирует свои
`contentItem` и `background` и засыпает журнал предупреждениями.
8. **Движок.** `QQmlApplicationEngine`. Если корневой объект не создался, приложение
завершается с `-1`. Соединение отложенное (`Qt::QueuedConnection`), чтобы выход произошёл уже в
цикле событий, а не посреди загрузки.
9. `engine.loadFromModule("BiLauncher", "Main")` — загрузка по URI модуля и имени типа.
10. `app.exec()`.
## Настройка приложения Qt
Создаётся `QGuiApplication` — не `QApplication`: интерфейс целиком на Qt Quick, виджеты не
используются, и модуль Qt Widgets в проект не подключён.
До создания приложения, при сборке с Qt WebEngine, вызывается `QtWebEngineQuick::initialize()`.
Порядок здесь принципиален: инициализация выставляет общий контекст OpenGL, а после создания
объекта приложения это уже не действует. Вызов обёрнут в условную компиляцию по макросу
`LAUNCHER_HAS_WEBENGINE`.
## Каталог переводов
Сразу после создания приложения вызывается `Localization::instance().load()` — он читает
`i18n/translations.json` из ресурсов и определяет язык интерфейса. Порядок важен: язык нужно
знать до того, как QML вычислит первую привязку, а [LauncherBackend](LauncherBackend.md),
который ведёт `settings.json`, появляется только вместе с движком — поэтому ключ `language`
[Localization](Localization.md) читает из файла сам.
Неудача — не повод продолжать: каталог вкомпилирован в бинарник, значит его отсутствие или
поломка означают ошибку сборки. `main()` пишет причину и возвращает `-1`.
## Стиль Qt Quick Controls
Стиль Qt Quick Controls принудительно выставляется в `Basic`
вызовом `QQuickStyle::setStyle()`. Причина в оформлении: всё окно лаунчера стилизовано вручную, а
нативный стиль Windows игнорирует пользовательские `contentItem` и `background` и сыплет
предупреждениями.
## Обработка командной строки
Аргументы командной строки не разбираются: `argc` и `argv` передаются в конструктор
`QGuiApplication` и дальше не используются. Ни `QCommandLineParser`, ни собственного разбора в
файле нет.
## Создание объектов верхнего уровня
В `main()` создаётся ровно два объекта.
| Объект | Тип | Роль |
|--------|-----|------|
| `app` | `QGuiApplication` | объект приложения и цикл событий |
| `engine` | `QQmlApplicationEngine` | загружает и исполняет QML-модуль лаунчера |
Экземпляр `LauncherBackend` здесь не создаётся: тип зарегистрирован через `QML_ELEMENT`, и главное
окно объявляет его само декларативно. Поэтому в `main.cpp` нет ни одного `#include` классов
предметной области.
## Связывание и подключения
Единственное подключение — обработка неудачи создания корневого объекта: сигнал
`QQmlApplicationEngine::objectCreationFailed` замыкается на лямбду, которая завершает приложение
с кодом `-1`. Соединение создаётся с типом `Qt::QueuedConnection` и с объектом `app` в роли
контекста, чтобы выход из приложения происходил уже внутри цикла событий, а не в разгар загрузки
QML.
Контекстные свойства не задаются, начальные свойства корневому объекту не передаются: связь между
QML и C++ идёт исключительно через зарегистрированный тип.
## Цикл событий
QML загружается вызовом `engine.loadFromModule("Minecraft_launcher", "Main")` — по URI модуля и
имени типа, а не по пути к файлу. Модуль объявлен в `CMakeLists.txt` через `qt_add_qml_module`, а
`Main` — это [Main.qml](../qml/Main.md), корневой элемент которого `Window` с `visible: true`,
поэтому окно показывается само.
Цикл событий запускается `app.exec()`, его результат возвращается из `main()` как код завершения
процесса.
## Зависимости
| Заголовок | Что даёт |
|-----------|----------|
| `QGuiApplication` | объект приложения и цикл событий для приложения без виджетов |
| `QQmlApplicationEngine` | загрузка QML-модуля и создание корневого объекта |
| `QQuickStyle` | выбор стиля Qt Quick Controls до загрузки QML |
| `QtWebEngineQuick` | инициализация WebEngine; подключается только при сборке с Qt WebEngine |
Модули сборки: `Qt6::Quick`, `Qt6::QuickControls2`, `Qt6::Core`, `Qt6::CorePrivate`, `Qt6::Gui`,
`Qt6::Network` и опционально `Qt6::WebEngineQuick`. Макрос `LAUNCHER_HAS_WEBENGINE` определяется в
`CMakeLists.txt` только тогда, когда `find_package` нашёл `Qt6WebEngineQuick`.
Аргументы командной строки не разбираются. Контекстных свойств нет: QML общается с C++ только
через зарегистрированные типы `LauncherBackend` и `Loc`.
---
+74 -107
View File
@@ -1,135 +1,102 @@
# minecraftversion.h — MinecraftVersion и VersionLoader
# minecraftversion.h
## Обзор
`minecraftversion.h`, `minecraftversion.cpp`
Каждая версия Minecraft описывается файлом `versions/<id>/<id>.json` — в нём главный класс,
аргументы запуска, список библиотек, индекс ресурсов и требуемая версия Java. Файлы образуют
цепочку: профиль модлоадера наследуется от версии игры через поле `inheritsFrom`, а та, в свою
очередь, может наследоваться дальше.
Чтение описаний версий из `<gameDir>/versions/<id>/<id>.json` и всё, что с ними связано: правила
Mojang, нативные библиотеки под текущую платформу, список установленного, удаление.
Заголовок `minecraftversion.h` даёт две вещи: структуры, описывающие версию в разобранном виде, и
пространство имён `VersionLoader` — чтение, разворачивание цепочки наследования и работа с
установленными версиями на диске.
Профиль модлоадера обычно содержит только отличия от базовой версии и ссылается на неё через
`inheritsFrom`. `VersionLoader::load()` проходит эту цепочку и сводит её в одну структуру
`MinecraftVersion`, с которой уже работают [GameLauncher](GameLauncher.md) и
[VersionInstaller](VersionInstaller.md).
Заголовок подключают [GameLauncher](GameLauncher.md) (собирает из версии командную строку),
[VersionInstaller](VersionInstaller.md) (по списку библиотек понимает, что качать) и
[LauncherBackend](LauncherBackend.md) (проверяет комплектность и удаляет версии).
## MinecraftLibrary
## Типы
Одна библиотека, к которой уже применены правила.
| Имя | Вид | Описание |
|-----|-----|----------|
| `MinecraftLibrary` | `struct` | Одна библиотека из `client.json` с уже разрешёнными правилами |
| `MinecraftVersion` | `struct` | `client.json`, «схлопнутый» по всей цепочке `inheritsFrom` |
| Поле | Что это |
|------|---------|
| `name` | Maven-координаты, например `org.lwjgl:lwjgl:3.3.1:natives-windows` |
| `path` | путь относительно `libraries/` |
| `url` | откуда качать; может быть пустым |
| `sha1`, `size` | для проверки; могут отсутствовать |
| `native` | распаковывается в `natives`, в classpath не идёт |
| `extractExclude` | префиксы путей внутри jar, которые не распаковываются |
### MinecraftLibrary
## MinecraftVersion
| Поле | Тип | Описание |
|------|-----|----------|
| `name` | `QString` | Maven-координаты, например `org.lwjgl:lwjgl:3.3.1:natives-windows` |
| `path` | `QString` | Путь относительно `.minecraft/libraries` |
| `url` | `QString` | Откуда качать, если файла нет; может быть пустым — тогда файл должен уже лежать на месте |
| `sha1` | `QString` | Контрольная сумма для проверки скачанного |
| `size` | `qint64` | Размер в байтах; `0` — неизвестен |
| `native` | `bool` | Библиотека распаковывается в `natives`, а не кладётся в classpath |
| `extractExclude` | `QStringList` | Префиксы путей внутри архива, которые не распаковываются |
| Поле | Что это |
|------|---------|
| `id` | имя папки в `versions/` |
| `mainClass` | |
| `type` | `release`, `snapshot` и т. д.; по умолчанию `release` |
| `assetIndexId` | id индекса ресурсов |
| `assetsKind` | поле `assets`: `legacy`, `pre-1.6` или тот же id |
| `clientJarPath` | абсолютный путь к клиентскому jar; часто это jar родителя |
| `javaMajor` | из `javaVersion.majorVersion`; по умолчанию 8 |
| `jvmArgs`, `gameArgs` | с ещё не подставленными `${...}` |
| `libraries` | |
| `loggingArgument`, `loggingConfigPath` | аргумент log4j и путь к его xml в `assets/log_configs` |
| `supportsQuickPlay` | среди аргументов игры есть `quickPlayMultiplayer` (1.20+) |
| `hasCustomResolutionArgs` | описание само передаёт `resolution_width` |
### MinecraftVersion
`isValid()` возвращает `true`, если заданы `id` и `mainClass`.
| Поле | Тип | Описание |
|------|-----|----------|
| `id` | `QString` | Имя папки в `versions`, оно же значение аргумента `--version` |
| `mainClass` | `QString` | Главный класс, который запускает java |
| `type` | `QString` | `release`, `snapshot` или `modified`; идёт в `--versionType` |
| `assetIndexId` | `QString` | Идентификатор индекса ресурсов для `--assetIndex` |
| `assetsKind` | `QString` | Поле `assets` версии: `legacy`, `pre-1.6` либо тот же идентификатор |
| `clientJarPath` | `QString` | Абсолютный путь к `<id>.jar`; может лежать у родительской версии |
| `javaMajor` | `int` | Требуемая мажорная версия Java; по умолчанию `8` |
| `jvmArgs` | `QStringList` | Аргументы JVM ещё с неподставленными подстановками вида `${...}` |
| `gameArgs` | `QStringList` | Аргументы игры, тоже с неподставленными подстановками |
| `libraries` | `QList<MinecraftLibrary>` | Библиотеки версии с уже применёнными правилами |
| `loggingArgument` | `QString` | Аргумент вида `-Dlog4j.configurationFile=${path}` |
| `loggingConfigPath` | `QString` | Абсолютный путь к xml-конфигурации журнала; пуст, если конфигурации нет |
| `supportsQuickPlay` | `bool` | Версия 1.20 и новее понимает `--quickPlayMultiplayer` — так лаунчер подключается к серверу сразу при запуске |
| `hasCustomResolutionArgs` | `bool` | Версия принимает аргументы размера окна |
## Как сводится цепочка
Метод `isValid()` возвращает `true`, когда заполнены и `id`, и `mainClass`: именно этим проверяется
успешность чтения версии.
`load(gameDir, versionId, features, &error)` идёт от запрошенной версии к родителям по
`inheritsFrom`. Ограничения: не больше 10 звеньев, без циклов. Если файл отсутствует или не
разбирается, это ошибка, и возвращается невалидный объект.
## Функции
Дальше поля применяются от корня к потомку:
Пространство имён `VersionLoader`.
- `mainClass`, `type`, `assets`, `assetIndex`, `javaVersion`, `logging` — потомок перекрывает
родителя;
- `minecraftArguments` (старый формат, одна строка) **заменяет** `gameArgs` целиком;
- `arguments.jvm` и `arguments.game` (новый формат) **дописываются** к тому, что дал родитель.
#### QStringList installedVersions(const QString &gameDir)
Библиотеки собираются в обратном порядке, от потомка к корню. Повторы убираются по ключу
`группа:артефакт[:классификатор]`, без версии. Поэтому если Forge приносит свою версию библиотеки,
она вытесняет ванильную.
Версии, реально установленные в `<gameDir>/versions`: есть и папка, и файл `<id>.json`. Одной
только папки недостаточно — она остаётся после неудачной установки.
Если среди JVM-аргументов нет `-cp`, значит это старая версия без блока `arguments.jvm`. Тогда
добавляются аргументы, которые официальный лаунчер подставляет сам: `-Djava.library.path`, бренд и
версия лаунчера, `-cp ${classpath}`.
#### QStringList dependentsOf(const QString &gameDir, const QString &versionId)
Клиентский jar берётся первый найденный по цепочке, от потомка к корню. Если его нет нигде,
указывается путь у корня. `GameLauncher::missingFiles()` потом скажет, что его не хватает.
Установленные профили, у которых `inheritsFrom` равен `versionId`. Без базовой версии они не
запустятся, поэтому список показывается пользователю перед удалением версии.
`features` нужен для правил вида `"features": {"has_custom_resolution": true}`. Бэкенд передаёт
`has_custom_resolution`, если в настройках задан размер окна.
#### qint64 installedSize(const QString &gameDir, const QString &versionId)
## Правила и натив
Размер `<gameDir>/versions/<id>` в байтах; `0`, если папки нет. Используется в предупреждении об
удалении — версия весит десятки мегабайт, и стоит показать, сколько освободится.
`rulesAllow(rules, features)` реализует алгоритм Mojang: правила проверяются по порядку,
действует последнее совпавшее; пустой список означает «разрешено». Правило совпадает, если подошли
`features`, `os.name`, `os.arch` и регулярное выражение `os.version` (сверяется с
`QSysInfo::productVersion()`).
#### bool remove(const QString &gameDir, const QString &versionId, QString \*error)
Нативные библиотеки встречаются в двух форматах:
Сносит `<gameDir>/versions/<id>`. Отсутствие папки считается успехом. При неудаче возвращает
`false` и заполняет `error`.
- старый: блок `natives` с классификатором под ОС, `${arch}` заменяется на `32` или `64`;
- новый (LWJGL 3.3+): отдельные библиотеки с классификатором `natives-<ос>[-<арх>]`.
Библиотеки и ресурсы в `libraries/` и `assets/` не трогаются: они общие для всех версий.
`pickNativesForCurrentArch()` оставляет для каждого артефакта один классификатор. Лучше всего
точный (`nativeClassifier()`, например `natives-macos-arm64`), потом общий для ОС
(`natives-macos`), потом какой попадётся.
#### MinecraftVersion load(const QString &gameDir, const QString &versionId, const QSet&lt;QString&gt; &features, QString \*error)
`osName()` возвращает `windows`, `osx` или `linux`. `osArch()` возвращает архитектуру из
`QSysInfo`, причём `i386` и `i686` сводятся к `x86`.
Читает версию и разворачивает всю цепочку `inheritsFrom` в один объект. Параметр `features` —
набор включённых возможностей, влияющих на применение правил (например, запрошен ли пользовательский
размер окна).
## Установленные версии
При ошибке возвращает объект, у которого `isValid()` даёт `false`, и заполняет `error`.
#### bool rulesAllow(const QJsonArray &rules, const QSet&lt;QString&gt; &features = {})
Стандартный алгоритм Mojang для блоков `rules`: правила применяются по порядку, побеждает последнее
совпавшее. Используется и для библиотек, и для аргументов запуска.
#### QString nativeClassifier()
Классификатор нативных библиотек для текущей машины: `natives-windows`, `natives-macos-arm64`,
`natives-linux` и подобные. По нему из списка библиотек отбираются те, что нужно распаковать.
#### QString osName()
Имя операционной системы в терминах Mojang — оно подставляется в правила и аргументы.
#### QString osArch()
Архитектура в терминах Mojang.
## Зависимости
Подключает `QJsonArray`, `QList`, `QSet`, `QString` и `QStringList` — только Qt Core. От классов
проекта не зависит.
## Пример использования
```cpp
QString error;
const MinecraftVersion version = VersionLoader::load(gameDir, versionId, features, &error);
if (!version.isValid()) {
emit launchError(error);
return;
}
QStringList classpath;
for (const MinecraftLibrary &library : version.libraries) {
if (!library.native)
classpath << gameDir + "/libraries/" + library.path;
}
classpath << version.clientJarPath;
```
- `installedVersions(gameDir)` — подпапки `versions/`, в которых есть `<id>.json`, по алфавиту.
- `dependentsOf(gameDir, id)` — установленные профили с `inheritsFrom == id`. Без базовой версии они
не запустятся, поэтому их перечисляют в предупреждении об удалении.
- `installedSize(gameDir, id)` — размер папки в байтах.
- `remove(gameDir, id, &error)` — удаляет `versions/<id>`. Сначала проверяет, что id не пустой,
не начинается с точки и не содержит слэшей, а итоговый путь лежит внутри `versions/`. Если папки
нет, это не ошибка.
---
+29 -72
View File
@@ -1,86 +1,43 @@
# modloader.h — ModLoader
# modloader.h
## Обзор
Перечисление модлоадеров, запись одной версии модлоадера и перевод между перечислением и
строковым ключом. Только заголовок, `.cpp` нет.
`Minecraft_launcher` умеет ставить четыре модлоадера: Minecraft Forge, Fabric Loader, NeoForge и
Quilt Loader. Заголовок `modloader.h` — общий словарь для всей этой части проекта: перечисление
самих лоадеров, описание одной их сборки и функции перевода между перечислением и строковым
ключом.
## ModLoader
Лоадеры взаимоисключающи: игра запускается ровно с одним профилем в `<gameDir>/versions`, поэтому в
сборке лаунчера хранится один ключ лоадера, а не набор.
`Forge`, `Fabric`, `NeoForge`, `Quilt`.
Заголовок подключают [ModLoaderVersionService](ModLoaderVersionService.md),
[ModLoaderInstaller](ModLoaderInstaller.md) и [LauncherBackend](LauncherBackend.md).
Модлоадеры взаимоисключающие: игра запускается с одним профилем из `versions/`, поэтому у сборки
может быть не больше одного модлоадера. [LoaderRow](../qml/LoaderRow.md) снимает отметку с
остальных, когда пользователь отмечает один.
## Типы
## Ключи и названия
| Имя | Вид | Описание |
|-----|-----|----------|
| `ModLoader` | `enum class` | Модлоадеры, которые лаунчер умеет ставить |
| `LoaderVersionEntry` | `struct` | Одна сборка модлоадера под конкретную версию игры |
| Значение | `loaderKey()` | `loaderTitle()` |
|----------|---------------|-----------------|
| `Forge` | `forge` | Minecraft Forge |
| `Fabric` | `fabric` | Fabric Loader |
| `NeoForge` | `neoforge` | NeoForge |
| `Quilt` | `quilt` | Quilt Loader |
### ModLoader
Ключ используется в QML, в имени файла кэша (`cache/loader_<ключ>.json`) и в поле `loader` записи
сборки в `customBuilds.json`. Если его поменять, старые сборки и кэши перестанут читаться.
| Значение | Ключ | Название | Описание |
|----------|------|----------|----------|
| `Forge` | `forge` | Minecraft Forge | Старейший загрузчик; ставится собственным установщиком, который собирает часть файлов на месте |
| `Fabric` | `fabric` | Fabric Loader | Лёгкий загрузчик; профиль версии формируется из метаданных без запуска установщика |
| `NeoForge` | `neoforge` | NeoForge | Ответвление Forge; ставится так же собственным установщиком |
| `Quilt` | `quilt` | Quilt Loader | Ответвление Fabric; ставится так же, как Fabric |
`loaderFromKey(key)` возвращает `std::nullopt` для любой другой строки, в том числе пустой. В
бэкенде это значит «ванильная сборка».
Перечисление объявлено как `enum class`, поэтому неявного приведения к целому нет.
Названия не переводятся: это имена собственные.
### LoaderVersionEntry
## LoaderVersionEntry
| Поле | Тип | Описание |
|------|-----|----------|
| `loaderVersion` | `QString` | Версия самого лоадера — например `47.4.0`, `0.19.3` или `21.1.248` |
| `gameVersion` | `QString` | Версия Minecraft, под которую эта сборка — например `1.20.1` |
| `versionId` | `QString` | Предсказанный идентификатор профиля `versions/<id>`. У Forge пуст: он выясняется только после работы установщика |
| `installerUrl` | `QUrl` | Адрес установщика; заполнен только у Forge и NeoForge |
| `recommended` | `bool` | Сборка помечена авторами как рекомендуемая; по умолчанию `false` |
| `stable` | `bool` | Сборка стабильна, а не тестовая; по умолчанию `true` |
## Функции
#### QString loaderKey(ModLoader loader)
Строковый ключ лоадера: `forge`, `fabric`, `neoforge` или `quilt`. Один и тот же ключ используется
в трёх местах — в интерфейсе, в имени файла кэша на диске и в поле `loader` файла
`customBuilds.json`, — поэтому менять его нельзя без миграции сохранённых сборок.
Объявлена `inline` в заголовке.
#### QString loaderTitle(ModLoader loader)
Человекочитаемое название лоадера для интерфейса: «Minecraft Forge», «Fabric Loader», «NeoForge»,
«Quilt Loader». Объявлена `inline`.
#### std::optional&lt;ModLoader&gt; loaderFromKey(const QString &key)
Обратный перевод: ключ в перечисление. Возвращает `std::nullopt` для неизвестного ключа, в том
числе для пустой строки — а пустая строка в сборке означает чистую ваниль без лоадера. Объявлена
`inline`.
## Зависимости
Подключает `QString`, `QUrl` и `<optional>`. От классов проекта не зависит и сам подключается
всюду, где речь идёт о модлоадерах.
## Пример использования
```cpp
const auto loader = loaderFromKey(build.loader);
if (!loader) {
// сборка без модлоадера — запускаем чистую ваниль
return;
}
qInfo() << "ставим" << loaderTitle(*loader)
<< "версии" << entry.loaderVersion
<< "под Minecraft" << entry.gameVersion;
```
| Поле | Что это |
|------|---------|
| `loaderVersion` | `47.4.0`, `0.19.3`, `21.1.248` |
| `gameVersion` | `1.20.1` |
| `versionId` | ожидаемое имя профиля в `versions/`. У Fabric и Quilt оно известно заранее (`fabric-loader-<версия>-<игра>`), у Forge и NeoForge пусто: имя становится известно только после работы установщика |
| `installerUrl` | ссылка на `installer.jar`, только у Forge и NeoForge |
| `recommended` | у Forge помечено как recommended в `promotions_slim.json` |
| `stable` | не альфа, не бета, не rc/pre |
---
+40 -53
View File
@@ -1,75 +1,62 @@
# zlibreference.h — ZlibReference
# zlibreference.h
## Обзор
`zlibreference.h`, `zlibreference.cpp`
`Minecraft_launcher` умеет ставить модлоадеры Forge и NeoForge, а их установщики — это Java-программы,
которые собирают часть jar-файлов прямо на машине пользователя и сверяют sha1 каждого собранного
файла с эталоном из `install_profile.json`.
Обход одной конкретной проблемы на Linux: Forge и NeoForge не ставятся на системах, где вместо
zlib стоит zlib-ng.
Эталон посчитан на обычном zlib. Дистрибутивы вроде CachyOS и Fedora подставляют вместо него
zlib-ng: сжатие корректное, но побайтово другое — поэтому установка падает на любой версии игры
сообщением «Processor failed, invalid outputs». Сменить Java не выйдет: сборки OpenJDK под Linux
берут `libz.so.1` из системы.
## В чём проблема
`ZlibReference` — обход этой проблемы. Рядом с лаунчером лежит собранный обычный zlib, и процессу
установщика он подсовывается через `LD_PRELOAD`. Системный zlib-ng при этом остаётся на месте:
подмена живёт ровно один процесс.
Установщики Forge и NeoForge собирают часть jar прямо на машине пользователя и сверяют sha1
каждого собранного файла с эталоном из `install_profile.json`. Эталон посчитан на обычном zlib.
CachyOS, Fedora и некоторые другие дистрибутивы ставят вместо него zlib-ng в режиме совместимости.
Он сжимает корректно, но результат побайтово другой. Поэтому установка падает на любой версии игры
с сообщением `Processor failed, invalid outputs`.
Пространство имён используется установщиком модлоадеров при подготовке окружения дочернего
процесса Java.
Другая Java не помогает: сборки OpenJDK под Linux берут `libz.so.1` из системы.
## Пространства имён
## Что сделано
`ZlibReference` группирует три функции: проверку системной библиотеки, поиск собранного эталона и
подготовку окружения процесса. Состояния нет.
В репозитории лежат исходники zlib 1.3.1 (каталог `zlib/`). На Linux CMake собирает из них цель
`launcher_zlib_reference` (`libz.so.1`). В дереве сборки она лежит в `<папка бинарника>/zlib/`, при
установке — в `<prefix>/lib/BiLauncher/`. Сам лаунчер с ней не линкуется. Библиотека
подкладывается через `LD_PRELOAD` только процессу установщика, системный zlib-ng не трогается.
Исходники лежат в репозитории, а не скачиваются при сборке, по двум причинам: сборка не зависит от
сети, и версия зафиксирована. От версии зависит побайтовый результат сжатия.
## Функции
#### bool systemIsZlibNg()
На Windows и macOS все три функции ничего не делают: `systemIsZlibNg()` возвращает `false`,
`bundledPath()` пустую строку, `applyTo()` — `true`.
Отвечает на вопрос, окажется ли `libz.so.1`, который достанется процессу java, библиотекой zlib-ng.
От ответа зависит, нужна ли подмена вообще: на системе с обычным zlib она бессмысленна.
### systemIsZlibNg()
#### QString bundledPath()
Открывает `libz.so.1` через `dlopen`, то есть ту же библиотеку, которую загрузчик выдаст процессу
java, и смотрит, есть ли `zlib-ng` в строке `zlibVersion()`. Результат вычисляется один раз за
запуск.
Путь к собранному рядом эталонному `libz.so.1` или пустая строка, если его нет. Библиотека
собирается целью `launcher_zlib_reference` в `CMakeLists.txt` из исходников каталога `zlib/`,
которые лежат в репозитории, чтобы сборка не зависела от сети, а версия была зафиксирована — от
неё зависит побайтовый результат сжатия. Собирается только на Linux: на Windows и macOS проблемы
подмены системного zlib нет.
### bundledPath()
#### bool applyTo(QProcessEnvironment &env, QString *note = nullptr)
Ищет эталонную библиотеку по порядку:
Дописывает `LD_PRELOAD` в переданное окружение, если подмена нужна. Изменяет `env` на месте.
1. `<applicationDirPath>/zlib/libz.so.1` — запуск из дерева сборки;
2. `<applicationDirPath>/../lib/BiLauncher/libz.so.1` — установленный пакет, tar.gz и AppImage;
3. `LAUNCHER_ZLIB_INSTALL_DIR/libz.so.1` — путь, заданный при конфигурации CMake.
Возвращает `true`, когда окружение готово, — в том числе в случае, когда подменять нечего:
система с обычным zlib или платформа, где вопрос не стоит. `false` означает, что подмена нужна, но
эталонной библиотеки нет на месте.
Второй вариант рассчитан на `CMAKE_INSTALL_LIBDIR=lib`. Поэтому CI на Ubuntu передаёт его явно,
иначе библиотека легла бы в `lib/x86_64-linux-gnu`.
Необязательный параметр `note` заполняется строкой, пригодной и для журнала, и для текста ошибки:
она объясняет, была ли подмена применена и почему.
### applyTo(env, &note)
## Зависимости
- zlib-ng в системе нет — подменять нечего, возвращает `true`, `note` пуст.
- zlib-ng есть, эталон найден — дописывает его путь в начало `LD_PRELOAD` (чужое содержимое
переменной сохраняется), возвращает `true`, в `note` пишет, что подставлено.
- zlib-ng есть, эталона нет — возвращает `false`, в `note` пишет предупреждение.
Подключает `QString`; `QProcessEnvironment` объявлен вперёд и используется только по ссылке.
Реализация опирается на макрос `LAUNCHER_ZLIB_INSTALL_DIR`, который `CMakeLists.txt` определяет на
Linux — это путь установки библиотеки в системе, помимо каталога рядом с исполняемым файлом.
## Пример использования
```cpp
QProcessEnvironment env = QProcessEnvironment::systemEnvironment();
QString note;
if (!ZlibReference::applyTo(env, &note)) {
emit failed(note);
return;
}
QProcess installer;
installer.setProcessEnvironment(env);
installer.start(javaPath, arguments);
```
[ModLoaderInstaller](ModLoaderInstaller.md) пишет `note` в журнал установщика. Если `applyTo()`
вернула `false`, а установщик упал с ошибкой контрольных сумм, к ошибке добавляется прямая
подсказка про zlib-ng.
---
+114 -132
View File
@@ -1,164 +1,146 @@
# 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), как устроены и запускаются автотесты — в
[testing.md](testing.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) | типы, папка `<root>/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, папка `<root>/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` | проверка каталога переводов |
| `tests/` | автотесты, см. [testing.md](testing.md) |
| `icons/` | иконки и скрипт, который их генерирует |
| `images/` | картинки интерфейса |
| `zlib/` | исходники zlib 1.3.1 (чужой код) |
| `packaging/` | установщик Windows, лицензии сторонних компонентов |
| `ci/` | скрипты подготовки раннеров |
| `.gitea/workflows/release.yml` | сборка релизов |
| `.gitea/workflows/tests.yml` | автотесты на тех же раннерах |
| `Screenshots/` | картинки для README |
---
+64 -148
View File
@@ -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/<id>`, поэтому отметка одного снимает остальные, а «ничего не отмечено» — это чистая
ваниль.
## Как устроено окно
| Свойство панели | Тип | Описание |
|-----------------|-----|----------|
| `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` и обновляет строку комплектности: установка
могла закончиться, пока окно открыто.
---
+8 -81
View File
@@ -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).
---
+33 -133
View File
@@ -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`.
---
+10 -69
View File
@@ -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.
---
+37 -134
View File
@@ -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()` заменит её на самую новую.
---
+97 -177
View File
@@ -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`, литералов в разметке нет.
---
+30 -105
View File
@@ -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` в адресе входа.
---
+15 -81
View File
@@ -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()`, у смены сборки крестика нет.
---
+35 -114
View File
@@ -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()
}
```
Пока идёт установка, окно закрывается только кнопкой. Отменить установку можно на панели загрузки
главного окна, и случайный щелчок мимо окна не должен её закрыть.
---
+40 -139
View File
@@ -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()`. Если удалили выбранный профиль модлоадера,
он пропадает из каталога совсем (он был в нём только потому, что установлен), и выбор
сбрасывается.
---
+170
View File
@@ -0,0 +1,170 @@
# Автотесты
Тесты лежат в `tests/` и собираются только по опции `BILAUNCHER_BUILD_TESTS`. В обычную сборку и в
релизы они не попадают, код лаунчера ради них не менялся.
## Как запустить
```bash
cmake -S . -B build-tests -G Ninja -DCMAKE_BUILD_TYPE=Debug -DBILAUNCHER_BUILD_TESTS=ON -DCMAKE_PREFIX_PATH=$HOME/Qt/6.11.1/macos
```
```bash
cmake --build build-tests
```
```bash
ctest --test-dir build-tests --output-on-failure -j 6
```
Отдельный слой — по метке: `ctest --test-dir build-tests -L unit` (ещё `net`, `backend`, `qml`).
Отдельный тест — напрямую, с фильтром по функции:
```bash
build-tests/tests/net/tst_versioninstaller installsModernVersionCompletely
```
Статические проверки (переводы, qmllint, раздел CHANGELOG для текущей версии):
```bash
python3 tests/scripts/static_checks.py build-tests
```
В Qt Creator проект с тестами открывается как обычно: включите `BILAUNCHER_BUILD_TESTS` в настройках
CMake, тесты появятся в панели «Тесты».
## Что покрыто
| Метка | Тест | Что проверяет |
|-------|------|---------------|
| unit | `tst_launcherpaths` | раскладка папки лаунчера, `ensureRootExists` и её отказ, если на месте папки файл |
| unit | `tst_javaruntime` | ключи видов Java, `JavaRuntimeStore`, какая Java нужна какой версии игры |
| unit | `tst_modloader` | ключи и названия модлоадеров |
| unit | `tst_localization` | переключение языков, запасной язык, отсутствующий ключ, язык из `settings.json` |
| unit | `tst_minecraftversion` | правила Mojang, загрузка json версии, `inheritsFrom`, нативы, удаление версий |
| unit | `tst_authoffline` | офлайн-UUID как у сервера в offline-mode |
| unit | `tst_msaredirect` | разбор адреса возврата после входа Microsoft |
| unit | `tst_gamelauncher` | командная строка игры, нативы, запуск, падение и остановка процесса |
| unit | `tst_javalocator` | версия java по `-version`, выбор подходящей, поиск в папке лаунчера |
| unit | `tst_zlibreference` | `LD_PRELOAD` с эталонным zlib на Linux |
| unit | `tst_buildarchiveworker` | архив, очистка, восстановление, раскатка пака, zip-slip, отмена |
| unit | `tst_buildswitcher` | переключение сборок туда и обратно, продолжение после обрыва, пак |
| net | `tst_fakeinternet` | самопроверка перехвата HTTPS (см. ниже) |
| net | `tst_versionmanifestservice` | каталог Mojang: разбор, кеш, офлайн, общие запросы |
| net | `tst_seasonalbuildservice` | каталог сезонных сборок: проверки полей, формат, кеш по адресу |
| net | `tst_modloaderversionservice` | Fabric, Quilt, Forge (с рекомендованными), NeoForge обе схемы |
| net | `tst_javaruntimeservice` | Adoptium и Mojang, частичные отказы, кеш, запасной x64 на ARM |
| net | `tst_authservice` | Ely.by: пароль, 2FA, продление токена, authlib-injector |
| net | `tst_msaauthservice` | вся цепочка Microsoft → Xbox → XSTS → Minecraft, коды ошибок XSTS |
| net | `tst_versioninstaller` | установка версии целиком, повторы, контрольные суммы, отмена, очередь |
| net | `tst_seasonalpackdownloader` | загрузка пака: sha256, обрыв, отмена |
| net | `tst_javainstaller` | Temurin zip и tar.gz, дерево Mojang, zip-slip, отмена |
| net | `tst_modloaderinstaller` | Fabric по сети, Forge через подставную java, откат после сбоя |
| backend | `tst_launcherbackend` | всё, что зовёт QML: профили, сборки, настройки, миграция, битые файлы, каталог версий, запуск офлайн / Ely.by с 2FA / Microsoft, Java, сезонная сборка от каталога до раскатки обновления |
| qml | `tst_qml` | Qt Quick Test: `DarkCombo`, `LabelledField`, `ProgressPanel`, `LoaderRow`, диалоги с настоящим `LauncherBackend` |
| qml | `tst_qml_smoke` | `Main.qml` поднимается, как в `main.cpp`, без единого предупреждения QML, в том числе после смены языка |
Ночью и перед релизом ещё собирается Release, ставится через `cmake --install` и запускается на
10 секунд (`tests/scripts/smoke_installed.sh` / `.ps1`). Это ловит то, чего не видят тесты:
недоложенные деплоем модули Qt и плагины, ссылки на библиотеки вне дерева, неподписанный бандл.
## Как тесты изолированы
**Файлы.** `LauncherPaths` строит пути от домашней папки. Общий `main` тестов
(`tests/common/testenv.h`) до создания приложения направляет `BILAUNCHER_HOME` (а заодно `HOME`
и `USERPROFILE`) на временную папку, и каждый процесс работает в своей, на всех ОС, параллельно.
`BILAUNCHER_HOME` нужна ради Windows: там `QDir::homePath()` берёт профиль у системы и `HOME`
с `USERPROFILE` не читает. Дочерние процессы наследуют переменную. Если папка лаунчера или папка
игры по умолчанию всё же оказалась вне песочницы, процесс сразу завершается с кодом 2 и ничего не
трогает. Дымовая проверка установленной сборки на Windows (`smoke_installed.ps1`) тоже запускает
лаунчер с `BILAUNCHER_HOME` на временную папку.
**Сеть.** Адреса серверов в сервисах зашиты константами, а свои `QNetworkAccessManager` они
создают сами. Зато прокси и SSL-настройки у Qt общие на процесс, и код лаунчера их не трогает.
`FakeInternet` (`tests/common/fakeinternet.h`) ставит прокси на локальный порт и отключает
проверку сертификата. Каждое https-соединение приходит к нему как `CONNECT host:443`, переводится
в TLS с тестовым сертификатом из `tests/fixtures/tls/` и обслуживается по таблице маршрутов.
Запрос на адрес без маршрута получает 404 и попадает в `unmatched()`. При старте сервер проверяет
себя сам. Если TLS-бэкенд Qt не умеет серверный режим, сетевые тесты пропускаются с понятным
сообщением, а в CI на Linux (`BILAUNCHER_REQUIRE_FAKE_INTERNET=1`) это ошибка.
`FakeMojang` (`tests/common/fakemojang.h`) публикует через `FakeInternet` крошечную, но устроенную
как настоящая версию игры: json, `client.jar`, библиотеки, натив под текущую ОС, индекс ресурсов,
сами ресурсы, конфиг логирования.
**Java.** `fake_java` (`tests/fake_java/main.cpp`, без Qt) копируется в нужную папку как
`bin/java` (`java.exe`/`javaw.exe`). На `-version` она отвечает версией из `fake-java.cfg` рядом с
собой, а при обычном запуске пишет аргументы в файл, печатает заданные строки, создаёт файлы
(так изображается установщик Forge) и выходит с нужным кодом либо падает.
**Код лаунчера.** `tests/CMakeLists.txt` собирает все `.cpp` из корня, кроме `main.cpp`, в
статическую библиотеку-QML-модуль `bilauncher_testlib`. Новый файл попадёт в тесты сам. QML-файлы
берутся тоже все, ресурсы — из строк `RESOURCES` корневого `CMakeLists.txt`. Тестовая библиотека
собирается без Qt WebEngine, так что `backend.microsoftAvailable` в тестах всегда `false`.
## Найденные проблемы (QEXPECT_FAIL)
Тест, который описывает правильное поведение, но пока падает, помечен `QEXPECT_FAIL`. В отчёте он
выглядит как `XFAIL` и прогон не валит. Когда ошибку исправят, тест станет `XPASS` и прогон упадёт:
тогда пометку нужно снять.
| Тест | Проблема |
|------|----------|
| `tst_versioninstaller::retryOfLastFileIsNotLost` | если временный сбой (503, таймаут) или неверная контрольная сумма пришлись на последний файл, `retryOrFail()` откладывает повтор на 400 мс, а `pump()` сразу видит пустую очередь и завершает установку ошибкой «файлы отсутствуют» |
| `tst_modloaderversionservice::neoForgeLegacyArtifactArrivesFirst` | если ответ старого артефакта `net.neoforged:forge` приходит раньше основного, основной затирает слитые записи (`state->merged = parsed`), и версии NeoForge для 1.20.1 пропадают |
| `tst_buildarchiveworker::dotFilesSurviveRoundTrip` | `QZipReader` срезает ведущую точку в именах записей, и после переключения сборок `.fabric/`, `.mixin.out/` возвращаются как `fabric/`, `mixin.out/` |
| `tst_javainstaller::cancelDuringDownload` | `JavaInstaller::cancel()` шлёт `canceled` дважды: `abort()` синхронно доводит ответ до обработчика, который уже шлёт сигнал |
| `tst_gamelauncher::extractNatives` | битый jar с нативами не считается ошибкой: `QZipReader::isReadable()` проверяет только, что файл открылся |
Ещё одна особенность, которую тесты обходят: синглтон `Localization` запоминает JS-движок, который
его создал. Второй `QQmlApplicationEngine` после уничтожения первого оставляет этот указатель
висячим, поэтому дымовой тест держит один движок на весь прогон, как и приложение.
## CI: `.gitea/workflows/tests.yml`
Тесты идут на тех же host-раннерах, что собирают релизы. Отдельные машины и Docker не нужны:
Qt, CMake и Ninja там уже есть, пути берутся из `QT_ROOT` и `QT_TOOLS` в `config.yaml` раннера.
| Событие | Что запускается |
|---------|-----------------|
| push в любую ветку, pull request | Linux: сборка с тестами, статические проверки, все тесты |
| ночью (02:00 UTC) и вручную (Run workflow) | Linux, Windows и macOS, плюс дымовая проверка установленного дерева на всех трёх |
Windows и macOS на каждый коммит не запускаются: вместимость у раннеров 1, и часовая сборка с
WebEngine задерживала бы релизы. Перед выпуском версии запустите workflow вручную и дождитесь
зелёного прогона. `release.yml` от тестов не зависит.
Новый push в ту же ветку отменяет незаконченный прогон (`concurrency`). Отчёт JUnit
(`junit.xml`), лог qmllint и полный лог ctest прикладываются к прогону как артефакты.
На Linux-раннере полезен `ccache`: с ним повторная сборка занимает минуты. `ci/setup-runner-linux.sh`
ставит его вместе с `xvfb`, который нужен дымовой проверке, если деплой Qt не положил плагин
`offscreen`.
**qmllint.** В коде уже есть 53 предупреждения, а код ради тестов не правится. Поэтому проверка
работает как храповик: она падает, только если предупреждений стало больше числа в
`tests/qmllint-baseline.txt`. Если часть исправили, опустите число.
## Как добавить тест
1. Создайте `tests/<слой>/tst_<имя>.cpp` с классом на QtTest и в конце файла напишите
`BILAUNCHER_TEST_MAIN(Класс)` (или `BILAUNCHER_GUI_TEST_MAIN`, если нужен `QGuiApplication`).
Затем `#include "tst_<имя>.moc"`.
2. Добавьте имя в список в `tests/<слой>/CMakeLists.txt`.
3. В `init()` вызывайте `TestEnv::resetLauncherRoot()`, папку игры берите из
`TestEnv::freshDir("game")`.
4. Для сети поднимите `FakeInternet` в `initTestCase()` через `BILAUNCHER_START_FAKE_INTERNET(m_net)`
и опишите маршруты: `m_net.route("https://host/path", FakeResponse::json(...))`. Для префикса
поставьте `*` в конце, для ошибок, задержек и обрывов есть `FakeResponse::error`, `delayMs`,
`hang`, `drop`, `truncateTo`.
5. Крупные ответы серверов кладите в `tests/fixtures/`. Если в json нужны имя ОС, архитектура или
классификатор нативов, используйте подстановки `%OS%`, `%OTHER%`, `%ARCH%`, `%NATIVE%` и
`Fixtures::readTemplate()`.
У moc есть ограничение: сырые строки `R"(...)"` и длинные вложенные инициализаторы `{{...}}`,
разбитые на строки, внутри файла с `Q_OBJECT` сбивают его разбор («Not a signal or slot
declaration» или пустой `.moc`). Такие данные выносите в фикстуры или собирайте по шагам.
---
При создании этого документа использовался ИИ.
+6 -1
View File
@@ -8,6 +8,7 @@
#include <QFileInfo>
#include <QHash>
#include <QProcess>
#include <QRegularExpression>
#include <QtCore/private/qzipreader_p.h>
namespace {
@@ -46,7 +47,11 @@ bool isNativeLibraryFile(const QString &fileName)
for (const QString &suffix : suffixes)
if (fileName.endsWith(suffix, Qt::CaseInsensitive))
return true;
return fileName.contains(QStringLiteral(".so.")); // libfoo.so.1
// Версионированные so (libfoo.so.1, libfoo.so.1.2.3). Хвост — строго цифры,
// иначе в натив попадают служебные файлы LWJGL вроде liblwjgl.so.sha1 / .so.git.
static const QRegularExpression versionedSo(QStringLiteral("\\.so(\\.\\d+)+$"),
QRegularExpression::CaseInsensitiveOption);
return versionedSo.match(fileName).hasMatch();
}
} // namespace
+1 -1
View File
@@ -34,7 +34,7 @@ struct LaunchOptions
QString authlibInjectorApi = QStringLiteral("ely.by");
QString launcherName = QStringLiteral("BiLauncher");
QString launcherVersion = QStringLiteral("1.0");
QString launcherVersion = QStringLiteral(LAUNCHER_VERSION);
};
class GameLauncher : public QObject
+1 -1
View File
@@ -9,7 +9,7 @@
"en": "English"
},
"_readme": [
"Единственный файл со всеми текстами интерфейса. Правится руками.",
"Файл локализации",
"Ключ: <домен>.<вид>.<имя>, сегменты в lowerCamelCase.",
"Домены: app, common, settings, profile, build, seasonal, version, java,",
"loader, auth, launch, game, switch, storage, zlib.",
+13 -5
View File
@@ -18,6 +18,7 @@
#include "seasonalbuildservice.h"
#include "seasonalpackdownloader.h"
#include <QCoreApplication>
#include <QDate>
#include <QDateTime>
#include <QDesktopServices>
@@ -433,7 +434,10 @@ void LauncherBackend::addCustomBuild(const QString &name,
m_builds.append(build);
saveCustomBuilds();
emit customBuildsChanged();
maybeInstall(launchVersionIdOf(m_builds.last()));
// Без версии игры ставить нечего: launchVersionIdOf() вернул бы имя сборки,
// и установщик пошёл бы искать в манифесте «Сборка 1».
if (!minecraftVersion.trimmed().isEmpty())
maybeInstall(minecraftVersion.trimmed());
}
void LauncherBackend::updateProfile(int index,
@@ -1463,8 +1467,8 @@ void LauncherBackend::continueLaunch(const AuthResult &auth)
options.windowHeight = settingInt(m_settings, QStringLiteral("windowHeight"), 0);
options.fullscreen = m_settings.value(QStringLiteral("fullscreen")).toBool();
options.serverAddress = build.serverUrl.trimmed();
options.launcherName = m_settings.value(QStringLiteral("launcherName")).toString();
options.launcherVersion = m_settings.value(QStringLiteral("launcherVersion")).toString();
options.launcherName = QCoreApplication::applicationName();
options.launcherVersion = QCoreApplication::applicationVersion();
const QString extra = m_settings.value(QStringLiteral("jvmArgs")).toString().trimmed();
if (!extra.isEmpty())
@@ -2543,8 +2547,6 @@ void LauncherBackend::loadSettings()
{QStringLiteral("fullscreen"), false},
// Язык интерфейса: "system" — по системной локали.
{QStringLiteral("language"), QStringLiteral("system")},
{QStringLiteral("launcherName"), QStringLiteral("BiLauncher")},
{QStringLiteral("launcherVersion"), QStringLiteral("1.0")},
{QStringLiteral("activeBuildId"), 0},
// Сервер готовых сборок. В интерфейс не вынесен: менять его
// приходится только при отладке против локального сервера.
@@ -2553,6 +2555,12 @@ void LauncherBackend::loadSettings()
const QJsonObject stored = readObjectFile(LauncherPaths::settingsFile());
for (auto it = stored.constBegin(); it != stored.constEnd(); ++it)
m_settings.insert(it.key(), it.value().toVariant());
// Имя и версия лаунчера раньше хранились здесь со значением "1.0" и не
// обновлялись вместе с программой. Теперь они берутся из QCoreApplication,
// а старые ключи вычищаются из файла при следующем сохранении.
m_settings.remove(QStringLiteral("launcherName"));
m_settings.remove(QStringLiteral("launcherVersion"));
}
void LauncherBackend::saveSettings()
+20 -3
View File
@@ -3,21 +3,38 @@
#include <QDir>
#include <QFileInfo>
#include <QtGlobal>
namespace {
const QString kFolderName = QStringLiteral("BiLauncher");
// Домашняя папка, от которой строятся все пути (см. LauncherPaths::homeOverride()).
QString homeBase()
{
const QString overridden = LauncherPaths::homeOverride();
return overridden.isEmpty() ? QDir::homePath() : overridden;
}
} // namespace
QString LauncherPaths::homeOverride()
{
// Относительный путь зависел бы от текущей папки процесса — такой не принимаем.
const QString value = QDir::fromNativeSeparators(qEnvironmentVariable("BILAUNCHER_HOME"));
if (value.isEmpty() || !QDir::isAbsolutePath(value))
return QString();
return QDir::cleanPath(value);
}
QString LauncherPaths::containerDir()
{
#if defined(Q_OS_WIN)
return QDir::homePath() + QStringLiteral("/AppData/Roaming");
return homeBase() + QStringLiteral("/AppData/Roaming");
#elif defined(Q_OS_MACOS)
return QDir::homePath() + QStringLiteral("/Library/Application Support");
return homeBase() + QStringLiteral("/Library/Application Support");
#else // Linux и прочие Unix-системы
return QDir::homePath();
return homeBase();
#endif
}
+8
View File
@@ -4,6 +4,14 @@
namespace LauncherPaths {
// Все пути строятся от домашней папки пользователя (QDir::homePath()). Переменная
// окружения BILAUNCHER_HOME с абсолютным путём подменяет её — только для путей
// лаунчера (папка лаунчера и папка игры по умолчанию), остальной Qt её не видит.
// Нужна тестам и дымовой проверке на Windows: там QDir::homePath() спрашивает
// профиль у системы и HOME/USERPROFILE игнорирует. Пустое или относительное
// значение не учитывается. Возвращает подменённую папку или пустую строку.
QString homeOverride();
QString containerDir();
QString rootDir();
QString defaultMinecraftDir();
+7
View File
@@ -1,5 +1,8 @@
#include "launcherpaths.h"
#include "localization.h"
#include <QDebug>
#include <QDir>
#include <QGuiApplication>
#include <QIcon>
#include <QQmlApplicationEngine>
@@ -33,6 +36,10 @@ int main(int argc, char *argv[])
QGuiApplication::setDesktopFileName(QStringLiteral("bilauncher"));
// Случайно оставленная BILAUNCHER_HOME увела бы данные в чужую папку — пусть это будет видно в логе.
if (!LauncherPaths::homeOverride().isEmpty())
qWarning().noquote() << "BILAUNCHER_HOME: данные лаунчера в" << QDir::toNativeSeparators(LauncherPaths::rootDir());
if (!Localization::instance().load())
return -1;
+128
View File
@@ -0,0 +1,128 @@
# Автотесты BiLauncher. Включаются опцией BILAUNCHER_BUILD_TESTS (корневой CMakeLists.txt).
#
# Код лаунчера не меняется ради тестов. Те же исходники собираются ещё раз в
# статическую библиотеку-QML-модуль bilauncher_testlib (URI BiLauncher): её
# линкуют все тесты — и C++-тесты сервисов, и Qt Quick Test для QML.
# Подробности — doc/testing.md.
find_package(Qt6 REQUIRED COMPONENTS Test QuickTest Network Qml Quick QuickControls2)
set(CMAKE_AUTOMOC ON)
# --- Исходники лаунчера -----------------------------------------------------------
# Все .cpp/.h из корня, кроме main.cpp: новый файл попадёт в тесты сам.
file(GLOB LAUNCHER_TEST_CPP CONFIGURE_DEPENDS "${PROJECT_SOURCE_DIR}/*.cpp" "${PROJECT_SOURCE_DIR}/*.h")
list(FILTER LAUNCHER_TEST_CPP EXCLUDE REGEX "/main\\.cpp$")
# QML-файлы модуля. MicrosoftLoginDialog.qml нужен только со сборкой WebEngine;
# тестовая библиотека собирается без него (как сборка без Qt WebEngine), так что
# backend.microsoftAvailable в тестах всегда false.
file(GLOB LAUNCHER_TEST_QML RELATIVE "${PROJECT_SOURCE_DIR}" CONFIGURE_DEPENDS "${PROJECT_SOURCE_DIR}/*.qml")
list(REMOVE_ITEM LAUNCHER_TEST_QML MicrosoftLoginDialog.qml)
# Ресурсы модуля берём прямо из корневого CMakeLists.txt (строки «RESOURCES …»),
# чтобы список не пришлось вести в двух местах.
file(READ "${PROJECT_SOURCE_DIR}/CMakeLists.txt" _root_cmake)
string(REGEX MATCHALL "\n[ \t]*RESOURCES[^\n]*" _resource_lines "${_root_cmake}")
set(LAUNCHER_TEST_RESOURCES "")
foreach(_line IN LISTS _resource_lines)
string(REGEX REPLACE "^[ \t\n]*RESOURCES[ \t]+" "" _line "${_line}")
string(REGEX REPLACE "[ \t]+" ";" _files "${_line}")
list(APPEND LAUNCHER_TEST_RESOURCES ${_files})
endforeach()
list(REMOVE_ITEM LAUNCHER_TEST_RESOURCES "")
# Файлы QML-модуля лежат в корне проекта, а не в tests/: для qt_add_qml_module
# им нужны пути относительно текущего каталога, поэтому копируем их в дерево сборки.
set(_module_dir "${CMAKE_CURRENT_BINARY_DIR}/module_src")
set(_module_qml "")
foreach(_file IN LISTS LAUNCHER_TEST_QML LAUNCHER_TEST_RESOURCES)
configure_file("${PROJECT_SOURCE_DIR}/${_file}" "${_module_dir}/${_file}" COPYONLY)
endforeach()
foreach(_file IN LISTS LAUNCHER_TEST_QML)
list(APPEND _module_qml "${_module_dir}/${_file}")
set_source_files_properties("${_module_dir}/${_file}" PROPERTIES QT_RESOURCE_ALIAS "${_file}")
endforeach()
set(_module_res "")
foreach(_file IN LISTS LAUNCHER_TEST_RESOURCES)
list(APPEND _module_res "${_module_dir}/${_file}")
set_source_files_properties("${_module_dir}/${_file}" PROPERTIES QT_RESOURCE_ALIAS "${_file}")
endforeach()
qt_add_library(bilauncher_testlib STATIC)
qt_add_qml_module(bilauncher_testlib
URI BiLauncher
OUTPUT_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}/qml/BiLauncher"
SOURCES ${LAUNCHER_TEST_CPP}
QML_FILES ${_module_qml}
RESOURCES ${_module_res}
NO_LINT
)
target_include_directories(bilauncher_testlib PUBLIC "${PROJECT_SOURCE_DIR}")
target_compile_definitions(bilauncher_testlib PUBLIC
LAUNCHER_VERSION="${PROJECT_VERSION}"
)
target_link_libraries(bilauncher_testlib PUBLIC
Qt6::Quick Qt6::QuickControls2 Qt6::Qml Qt6::Svg Qt6::Core Qt6::CorePrivate Qt6::Gui Qt6::Network
)
# --- Подставная java --------------------------------------------------------------
add_executable(fake_java fake_java/main.cpp)
set_target_properties(fake_java PROPERTIES CXX_STANDARD 17 CXX_STANDARD_REQUIRED ON
AUTOMOC OFF MACOSX_BUNDLE FALSE WIN32_EXECUTABLE FALSE)
# --- Общая обвязка тестов --------------------------------------------------------
add_library(bilauncher_testsupport STATIC
common/testenv.h common/testenv.cpp
common/fixtures.h common/fixtures.cpp
common/fakeinternet.h common/fakeinternet.cpp
common/fakemojang.h common/fakemojang.cpp
common/nettest.h
)
# Designated initializers (VersionSpec{.id = ...}) — C++20.
target_compile_features(bilauncher_testsupport PUBLIC cxx_std_20)
target_include_directories(bilauncher_testsupport PUBLIC common)
target_compile_definitions(bilauncher_testsupport PUBLIC
BILAUNCHER_FIXTURES_DIR="${CMAKE_CURRENT_SOURCE_DIR}/fixtures"
BILAUNCHER_SOURCE_DIR="${PROJECT_SOURCE_DIR}"
BILAUNCHER_FAKE_JAVA="$<TARGET_FILE:fake_java>"
)
target_link_libraries(bilauncher_testsupport PUBLIC bilauncher_testlib Qt6::Test Qt6::Network Qt6::CorePrivate)
add_dependencies(bilauncher_testsupport fake_java)
# Окружение всех тестов: без окон, без GPU, без кеша QML на диске.
set(BILAUNCHER_TEST_ENV
"QT_QPA_PLATFORM=offscreen"
"QT_QUICK_BACKEND=software"
"QML_DISABLE_DISK_CACHE=1"
"QT_LOGGING_RULES=qt.network.ssl.warning=false"
)
# bilauncher_add_test(<имя> LABEL <метка> SOURCES ... [TIMEOUT сек] [GUI])
function(bilauncher_add_test name)
cmake_parse_arguments(ARG "GUI" "LABEL;TIMEOUT" "SOURCES" ${ARGN})
add_executable(${name} ${ARG_SOURCES})
set_target_properties(${name} PROPERTIES MACOSX_BUNDLE FALSE WIN32_EXECUTABLE FALSE)
target_link_libraries(${name} PRIVATE bilauncher_testsupport)
if(ARG_GUI)
target_link_libraries(${name} PRIVATE bilauncher_testlibplugin Qt6::QuickTest)
endif()
add_test(NAME ${name} COMMAND ${name})
if(NOT ARG_TIMEOUT)
set(ARG_TIMEOUT 120)
endif()
set_tests_properties(${name} PROPERTIES
LABELS "${ARG_LABEL}"
TIMEOUT ${ARG_TIMEOUT}
ENVIRONMENT "${BILAUNCHER_TEST_ENV}")
if(WIN32)
# DLL Qt — из установки Qt, без windeployqt для каждого теста.
set_property(TEST ${name} APPEND PROPERTY ENVIRONMENT_MODIFICATION
"PATH=path_list_prepend:$<TARGET_FILE_DIR:Qt6::Core>")
endif()
endfunction()
add_subdirectory(unit)
add_subdirectory(net)
add_subdirectory(backend)
add_subdirectory(qml)
+2
View File
@@ -0,0 +1,2 @@
# LauncherBackend целиком: всё, что вызывает QML, с настоящими сервисами под ним.
bilauncher_add_test(tst_launcherbackend LABEL backend TIMEOUT 300 SOURCES tst_launcherbackend.cpp)
+708
View File
@@ -0,0 +1,708 @@
// Интеграционные тесты LauncherBackend — того, что видит QML. Сервисы под ним
// настоящие; сеть — FakeInternet, java — подставная, файлы — в песочнице.
#include "testenv.h"
#include "fakemojang.h"
#include "fixtures.h"
#include "nettest.h"
#include "javaruntime.h"
#include "launcherbackend.h"
#include "launcherpaths.h"
#include "localization.h"
#include <QDir>
#include <QGuiApplication>
#include <QJsonArray>
#include <QJsonDocument>
#include <QSignalSpy>
#include <memory>
namespace {
const QString kSeasonalIndex = QStringLiteral("https://launcher-builds.galeonworx.com/index.json");
const QString kElyAuth = QStringLiteral("https://authserver.ely.by/auth/authenticate");
const QString kInjectorManifest = QStringLiteral("https://authlib-injector.yushi.moe/artifact/latest.json");
const QString kInjectorJar = QStringLiteral("https://authlib-injector.yushi.moe/artifact/53/authlib-injector.jar");
bool waitFor(const std::function<bool()> &condition, int ms = 30000)
{
return QTest::qWaitFor(condition, ms);
}
QStringList strings(const QSignalSpy &spy, int arg = 0)
{
QStringList out;
for (const QList<QVariant> &call : spy)
out << call.value(arg).toString();
return out;
}
} // namespace
class TestLauncherBackend : public QObject
{
Q_OBJECT
FakeInternet m_net;
QString m_game;
void writeSettings(const QJsonObject &extra = {})
{
QJsonObject settings{{"gameDir", m_game}, {"language", "en"}};
for (auto it = extra.begin(); it != extra.end(); ++it)
settings.insert(it.key(), it.value());
Fixtures::writeJson(LauncherPaths::settingsFile(), settings);
}
static std::unique_ptr<LauncherBackend> make() { return std::make_unique<LauncherBackend>(); }
// Ждём, пока backend освободится от фоновых операций.
static bool waitIdle(LauncherBackend &backend, int ms = 30000)
{
return waitFor([&] { return !backend.busy() && !backend.downloading() && !backend.switching(); }, ms);
}
// Установить версию через backend и дождаться результата.
static bool installVersion(LauncherBackend &backend, const QString &id)
{
backend.installVersion(id);
return waitFor([&] { return backend.isVersionInstalled(id) && !backend.downloading(); });
}
QString fakeJava(const QString &name, const QStringList &config = {})
{
return Fixtures::installFakeJava(m_game + "/../" + name, "17.0.8", config);
}
private slots:
void initTestCase()
{
BILAUNCHER_START_FAKE_INTERNET(m_net);
}
void init()
{
TestEnv::resetLauncherRoot();
m_net.reset();
m_net.setFallback(FakeResponse::error(503));
m_game = TestEnv::freshDir(QStringLiteral("game"));
writeSettings();
Localization::instance().setLanguage(QStringLiteral("en"));
}
// --- данные и настройки ----------------------------------------------------
void freshStartCreatesDataFiles()
{
QFile::remove(LauncherPaths::settingsFile());
auto backend = make();
QSignalSpy errors(backend.get(), &LauncherBackend::launchError);
QTest::qWait(50);
QVERIFY2(errors.isEmpty(), qPrintable(strings(errors).join('\n')));
QVERIFY(QFile::exists(LauncherPaths::settingsFile()));
QVERIFY(QFile::exists(LauncherPaths::profilesFile()));
QVERIFY(QFile::exists(LauncherPaths::customBuildsFile()));
const QVariantMap s = backend->settings();
QCOMPARE(s.value("maxMemoryMb").toInt(), 4096);
QCOMPARE(s.value("minMemoryMb").toInt(), 512);
QCOMPARE(s.value("language").toString(), QStringLiteral("system"));
QCOMPARE(s.value("resolvedGameDir").toString(), QDir::toNativeSeparators(LauncherPaths::defaultMinecraftDir()));
QVERIFY(backend->profileNames().isEmpty());
QCOMPARE(backend->activeBuildIndex(), -1);
QVERIFY(!backend->busy());
QVERIFY(!backend->microsoftAvailable()); // тестовая сборка — без WebEngine
Localization::instance().setLanguage(QStringLiteral("en"));
}
void profilesCrudAndPersistence()
{
{
auto backend = make();
QSignalSpy changed(backend.get(), &LauncherBackend::profilesChanged);
backend->addProfile("Steve", "steve", QString());
backend->addProfile("Ely", "ely@example.com", "pw", "elyby");
backend->addProfile("Odd", "odd", QString(), "something-unknown");
QCOMPARE(changed.count(), 3);
QCOMPARE(backend->profileNames(), QStringList({"Steve", "Ely", "Odd"}));
QCOMPARE(backend->profileAt(1).value("authType").toString(), QStringLiteral("elyby"));
QCOMPARE(backend->profileAt(2).value("authType").toString(), QStringLiteral("offline"));
QVERIFY(backend->profileAt(9).isEmpty());
backend->updateProfile(0, "Steve2", "steve2", QString(), "offline");
backend->removeProfile(2);
backend->removeProfile(42); // мимо — без последствий
QCOMPARE(backend->profileNames(), QStringList({"Steve2", "Ely"}));
}
const QJsonArray stored = QJsonDocument::fromJson(Fixtures::readFile(LauncherPaths::profilesFile())).array();
QCOMPARE(stored.size(), 2);
QVERIFY(!stored.at(0).toObject().value("clientToken").toString().isEmpty());
QVERIFY(stored.at(0).toObject().value("id").toInt() != stored.at(1).toObject().value("id").toInt());
auto reloaded = make();
QCOMPARE(reloaded->profileNames(), QStringList({"Steve2", "Ely"}));
QCOMPARE(reloaded->profileAt(1).value("login").toString(), QStringLiteral("ely@example.com"));
}
void elyTokenDroppedWhenCredentialsChange()
{
Fixtures::write(LauncherPaths::profilesFile(), QJsonDocument(QJsonArray{QJsonObject{
{"id", 1}, {"name", "Ely"}, {"login", "a"}, {"password", "p"}, {"authType", "elyby"},
{"clientToken", "c"}, {"accessToken", "tok"}, {"uuid", "u"}, {"resolvedName", "Nick"}}}).toJson());
auto backend = make();
backend->updateProfile(0, "Ely renamed", "a", "p", "elyby"); // только имя — токен живёт
QCOMPARE(QJsonDocument::fromJson(Fixtures::readFile(LauncherPaths::profilesFile()))
.array().at(0).toObject().value("accessToken").toString(), QStringLiteral("tok"));
backend->updateProfile(0, "Ely renamed", "a", "new-password", "elyby");
const QJsonObject p = QJsonDocument::fromJson(Fixtures::readFile(LauncherPaths::profilesFile())).array().at(0).toObject();
QVERIFY(p.value("accessToken").toString().isEmpty());
QVERIFY(p.value("resolvedName").toString().isEmpty());
QCOMPARE(p.value("clientToken").toString(), QStringLiteral("c"));
}
void customBuildsAndActiveSwitch()
{
auto backend = make();
QSignalSpy errors(backend.get(), &LauncherBackend::launchError);
backend->addCustomBuild("Alpha", "play.example.com");
backend->addCustomBuild("Beta", QString());
QCOMPARE(backend->customBuildNames(), QStringList({"Alpha", "Beta"}));
QCOMPARE(backend->activeBuildIndex(), 0); // настройка пуста — активна первая
QCOMPARE(backend->activeBuildName(), QStringLiteral("Alpha"));
QVERIFY(!backend->downloading()); // без версии игры ставить нечего
// Правка версии сбрасывает выбранную под старую версию сборку модлоадера.
backend->updateCustomBuild(0, {{"minecraftVersion", "1.20.1"}, {"loader", "fabric"},
{"loaderVersion", "0.16.10"}, {"resolvedVersionId", "fabric-loader-0.16.10-1.20.1"}});
QCOMPARE(backend->customBuildAt(0).value("launchVersionId").toString(), QStringLiteral("fabric-loader-0.16.10-1.20.1"));
backend->updateCustomBuild(0, {{"minecraftVersion", "1.21.1"}});
QVariantMap alpha = backend->customBuildAt(0);
QCOMPARE(alpha.value("loader").toString(), QStringLiteral("fabric"));
QVERIFY(alpha.value("loaderVersion").toString().isEmpty());
QVERIFY(alpha.value("resolvedVersionId").toString().isEmpty());
QCOMPARE(alpha.value("launchVersionId").toString(), QStringLiteral("1.21.1"));
QCOMPARE(backend->customBuildAt(1).value("launchVersionId").toString(), QStringLiteral("Beta")); // по имени
QCOMPARE(backend->requiredJavaMajor(0), 21);
QCOMPARE(backend->requiredJavaMajor(5), 0);
// Переключение активной сборки уносит содержимое .minecraft в архив.
Fixtures::write(m_game + "/mods/alpha.jar", "a");
QSignalSpy active(backend.get(), &LauncherBackend::activeBuildChanged);
backend->setActiveBuildIndex(1);
QVERIFY(waitIdle(*backend));
QCOMPARE(backend->activeBuildIndex(), 1);
QVERIFY(active.count() > 0);
QVERIFY(!QFile::exists(m_game + "/mods/alpha.jar"));
QCOMPARE(backend->settings().value("activeBuildId").toInt(), Fixtures::readJsonFile(LauncherPaths::settingsFile()).value("activeBuildId").toInt());
const QVariantMap info = backend->customBuildRemovalInfo(0);
QCOMPARE(info.value("name").toString(), QStringLiteral("Alpha"));
QVERIFY(info.value("hasArchive").toBool());
QVERIFY(!info.value("active").toBool());
// Удаление активной — активной становится первая из оставшихся, её архив разворачивается.
backend->removeCustomBuild(1);
QVERIFY(waitIdle(*backend));
QCOMPARE(backend->customBuildNames(), QStringList{"Alpha"});
QCOMPARE(backend->activeBuildIndex(), 0);
QCOMPARE(Fixtures::readFile(m_game + "/mods/alpha.jar"), QByteArray("a"));
QVERIFY2(errors.isEmpty(), qPrintable(strings(errors).join('\n')));
}
void settingsPersistAndGameDirChange()
{
Fixtures::writeJson(LauncherPaths::settingsFile(),
QJsonObject{{"gameDir", m_game}, {"language", "en"},
{"launcherName", "old"}, {"launcherVersion", "1.0"}});
const QString other = TestEnv::freshDir(QStringLiteral("other-game"));
{
auto backend = make();
QSignalSpy installedChanged(backend.get(), &LauncherBackend::installedVersionsChanged);
QSignalSpy settingsChanged(backend.get(), &LauncherBackend::settingsChanged);
backend->updateSettings({{"maxMemoryMb", 2048}, {"jvmArgs", "-XX:+UseZGC"}});
QCOMPARE(installedChanged.count(), 0);
backend->updateSettings({{"gameDir", other}});
QCOMPARE(installedChanged.count(), 1);
QCOMPARE(settingsChanged.count(), 2);
QCOMPARE(backend->settings().value("resolvedGameDir").toString(), QDir::toNativeSeparators(other));
}
const QJsonObject stored = Fixtures::readJsonFile(LauncherPaths::settingsFile());
QCOMPARE(stored.value("maxMemoryMb").toInt(), 2048);
QVERIFY(!stored.contains("launcherName")); // устаревшие ключи вычищены
QVERIFY(!stored.contains("launcherVersion"));
auto reloaded = make();
QCOMPARE(reloaded->settings().value("jvmArgs").toString(), QStringLiteral("-XX:+UseZGC"));
}
void languageSettingSwitchesLocalization()
{
auto backend = make();
backend->updateSettings({{"language", "ru"}});
QCOMPARE(Localization::instance().language(), QStringLiteral("ru"));
backend->updateSettings({{"language", "en"}});
QCOMPARE(Localization::instance().language(), QStringLiteral("en"));
}
void legacyBuildsFileIsMigrated()
{
Fixtures::write(LauncherPaths::legacyCustomBuildsFile(), QJsonDocument(QJsonArray{
QJsonObject{{"id", 3}, {"name", "Old build"}, {"serverUrl", "mc.example.com"}, {"versionId", "1.12.2"}}}).toJson());
auto backend = make();
QCOMPARE(backend->customBuildNames(), QStringList{"Old build"});
QCOMPARE(backend->customBuildAt(0).value("minecraftVersion").toString(), QStringLiteral("1.12.2"));
QVERIFY(QFile::exists(LauncherPaths::customBuildsFile()));
QVERIFY(!QFile::exists(LauncherPaths::legacyCustomBuildsFile()));
QVERIFY(QFile::exists(LauncherPaths::legacyCustomBuildsFile() + ".migrated"));
// Новые сборки получают id после старых.
backend->addCustomBuild("New", QString());
const QJsonArray builds = QJsonDocument::fromJson(Fixtures::readFile(LauncherPaths::customBuildsFile())).array();
QCOMPARE(builds.at(1).toObject().value("id").toInt(), 4);
}
void corruptFilesAreSetAsideAndReported()
{
Fixtures::write(LauncherPaths::profilesFile(), "{ this is not json");
Fixtures::write(LauncherPaths::customBuildsFile(), "{\"object\": \"not an array\"}");
auto backend = make();
QSignalSpy errors(backend.get(), &LauncherBackend::launchError);
QVERIFY(errors.wait(2000)); // сообщение откладывается до подключения интерфейса
const QString message = errors.constFirst().constFirst().toString();
QVERIFY(message.contains(QDir::toNativeSeparators(LauncherPaths::profilesFile() + ".bak")));
QVERIFY(QFile::exists(LauncherPaths::profilesFile() + ".bak"));
QVERIFY(QFile::exists(LauncherPaths::customBuildsFile() + ".bak"));
// Свежие файлы созданы заново и валидны.
QVERIFY(QJsonDocument::fromJson(Fixtures::readFile(LauncherPaths::profilesFile())).isArray());
QVERIFY(QJsonDocument::fromJson(Fixtures::readFile(LauncherPaths::customBuildsFile())).isArray());
QVERIFY(backend->profileNames().isEmpty());
}
void unusableBuildRecordsAreSkipped()
{
Fixtures::write(LauncherPaths::customBuildsFile(), QJsonDocument(QJsonArray{
QJsonObject{{"id", 1}, {"name", " "}}, QJsonObject{{"id", 2}, {"name", "Good"}}}).toJson());
auto backend = make();
QSignalSpy errors(backend.get(), &LauncherBackend::launchError);
QVERIFY(errors.wait(2000));
QCOMPARE(backend->customBuildNames(), QStringList{"Good"});
}
// --- версии ------------------------------------------------------------------
void versionCatalogInstallAndRemove()
{
FakeMojang mojang(m_net);
mojang.publish({.id = "1.20.1"});
mojang.publish({.id = "1.19.4", .releaseTime = "2023-03-14T12:00:00+00:00"});
Fixtures::write(m_game + "/versions/my-pack/my-pack.json", "{\"mainClass\":\"x\"}");
auto backend = make();
QSignalSpy errors(backend.get(), &LauncherBackend::launchError);
backend->refreshVersionCatalog();
QVERIFY(waitFor([&] { return backend->versionCatalog().size() == 3; }));
const QVariantList catalog = backend->versionCatalog();
QCOMPARE(catalog.at(0).toMap().value("id").toString(), QStringLiteral("1.20.1"));
QCOMPARE(catalog.at(0).toMap().value("category").toString(), QStringLiteral("release"));
QVERIFY(!catalog.at(0).toMap().value("installed").toBool());
QCOMPARE(catalog.at(2).toMap().value("id").toString(), QStringLiteral("my-pack")); // неизвестная манифесту
QCOMPARE(catalog.at(2).toMap().value("category").toString(), QStringLiteral("other"));
QSignalSpy installed(backend.get(), &LauncherBackend::installedVersionsChanged);
QVERIFY(installVersion(*backend, "1.20.1"));
QVERIFY(installed.count() > 0);
QVERIFY(backend->installedVersions().contains("1.20.1"));
QVERIFY(backend->versionCatalog().at(0).toMap().value("installed").toBool());
backend->addCustomBuild("Uses 1.20.1", QString(), "1.20.1");
const QVariantMap info = backend->versionRemovalInfo("1.20.1");
QVERIFY(info.value("installed").toBool());
QVERIFY(info.value("sizeMb").toInt() >= 1);
QCOMPARE(info.value("builds").toStringList(), QStringList{"Uses 1.20.1"});
backend->removeVersion("1.20.1");
QVERIFY(!backend->isVersionInstalled("1.20.1"));
QVERIFY(!QDir(m_game + "/versions/1.20.1").exists());
QVERIFY2(errors.isEmpty(), qPrintable(strings(errors).join('\n')));
}
void addingBuildWithVersionStartsDownload()
{
FakeMojang mojang(m_net);
mojang.publish({.id = "1.20.1"});
auto backend = make();
backend->addCustomBuild("Auto", QString(), "1.20.1");
QVERIFY(backend->downloading());
QCOMPARE(backend->downloadVersion(), QStringLiteral("1.20.1"));
QVERIFY(waitFor([&] { return backend->isVersionInstalled("1.20.1") && !backend->downloading(); }));
QVERIFY(backend->checkInstallation(0).isEmpty());
}
void checkInstallationMessages()
{
auto backend = make();
QCOMPARE(backend->checkInstallation(0), QStringList{Loc::text("common.status.noBuildSelected")});
backend->addCustomBuild("Missing", QString());
QStringList issues = backend->checkInstallation(0);
QCOMPARE(issues.size(), 1);
QVERIFY(!issues.constFirst().isEmpty());
}
void downloadFailureIsReported()
{
FakeMojang mojang(m_net);
mojang.publish({.id = "1.20.1"});
mojang.overrideFile("versions/1.20.1/1.20.1.jar", FakeResponse::error(404));
auto backend = make();
QSignalSpy errors(backend.get(), &LauncherBackend::launchError);
backend->installVersion("1.20.1");
QVERIFY(errors.wait(30000));
QVERIFY(errors.constFirst().constFirst().toString().startsWith(
Loc::text("version.error.downloadFailed").arg("1.20.1", QString()).section(u'%', 0, 0)));
QVERIFY(waitIdle(*backend));
}
// --- запуск --------------------------------------------------------------
void launchValidationErrors()
{
auto backend = make();
QSignalSpy errors(backend.get(), &LauncherBackend::launchError);
backend->launchGame(0, 0);
QCOMPARE(errors.constLast().constFirst().toString(), Loc::text("launch.error.noProfile"));
backend->addProfile("Steve", "steve", QString());
backend->launchGame(0, 0);
QCOMPARE(errors.constLast().constFirst().toString(), Loc::text("launch.error.noBuild"));
backend->addCustomBuild("B", QString());
backend->launchGame(0, 0);
QCOMPARE(errors.constLast().constFirst().toString(),
Loc::text("launch.error.noVersionsInstalled").arg(QDir::toNativeSeparators(m_game + "/versions")));
QVERIFY(!backend->busy());
}
void offlineLaunchRunsJava()
{
FakeMojang mojang(m_net);
mojang.publish({.id = "1.20.1"});
const QString argsFile = m_game + "/../game-args.txt";
const QString java = fakeJava("jdk-launch", {"args_file=" + argsFile, "stdout=[Render thread/INFO]: Setting user: Steve"});
writeSettings({{"javaPath", java}, {"maxMemoryMb", 3072}, {"jvmArgs", "-Dtest.flag=1"}});
auto backend = make();
QVERIFY(installVersion(*backend, "1.20.1"));
backend->addProfile("Steve", "steve", QString());
backend->addCustomBuild("Server build", "play.example.com:25570", "1.20.1");
QVERIFY(waitIdle(*backend));
QSignalSpy launched(backend.get(), &LauncherBackend::launched);
QSignalSpy finished(backend.get(), &LauncherBackend::gameFinished);
QSignalSpy output(backend.get(), &LauncherBackend::gameOutput);
QSignalSpy errors(backend.get(), &LauncherBackend::launchError);
backend->launchGame(0, 0);
QVERIFY2(waitFor([&] { return launched.count() + errors.count() > 0; }), "нет ни launched, ни launchError");
QVERIFY2(errors.isEmpty(), qPrintable(strings(errors).join('\n')));
QCOMPARE(launched.constFirst().at(0).toString(), QStringLiteral("Steve"));
QCOMPARE(launched.constFirst().at(1).toString(), QStringLiteral("Server build"));
QVERIFY(waitFor([&] { return finished.count() == 1; }));
QVERIFY(!backend->gameRunning());
QVERIFY(!backend->busy());
QVERIFY(strings(output).contains("[Render thread/INFO]: Setting user: Steve"));
const QStringList args = QString::fromLocal8Bit(Fixtures::readFile(argsFile)).split('\n', Qt::SkipEmptyParts);
QVERIFY(args.contains("-Xmx3072M"));
QVERIFY(args.contains("-Dtest.flag=1"));
QVERIFY(args.contains("net.minecraft.client.main.Main"));
const qsizetype server = args.indexOf("--server");
QVERIFY(server > 0);
QCOMPARE(args.at(server + 1), QStringLiteral("play.example.com"));
QCOMPARE(args.at(server + 3), QStringLiteral("25570"));
}
void elyLaunchWithTwoFactor()
{
FakeMojang mojang(m_net);
mojang.publish({.id = "1.20.1"});
m_net.route(kInjectorManifest, FakeResponse::json(QJsonObject{{"download_url", kInjectorJar}}));
m_net.route(kInjectorJar, FakeResponse::bytes("injector"));
m_net.route(kElyAuth, [](const FakeRequest &request) {
const QJsonObject in = QJsonDocument::fromJson(request.body).object();
if (in.value("password").toString() == QLatin1String("pw:654321"))
return FakeResponse::json(QJsonObject{{"accessToken", "ely-token"},
{"selectedProfile", QJsonObject{{"id", "aaaa-bbbb"}, {"name", "ElyNick"}}}});
return FakeResponse::json(QJsonObject{{"errorMessage", "Account protected with two factor auth."}}, 401);
});
const QString argsFile = m_game + "/../ely-args.txt";
writeSettings({{"javaPath", fakeJava("jdk-ely", {"args_file=" + argsFile})}});
auto backend = make();
QVERIFY(installVersion(*backend, "1.20.1"));
backend->addProfile("Ely", "ely@example.com", "pw", "elyby");
backend->addCustomBuild("B", QString(), "1.20.1");
QVERIFY(waitIdle(*backend));
QSignalSpy twoFactor(backend.get(), &LauncherBackend::twoFactorRequired);
QSignalSpy launched(backend.get(), &LauncherBackend::launched);
QSignalSpy errors(backend.get(), &LauncherBackend::launchError);
backend->launchGame(0, 0);
QVERIFY(waitFor([&] { return twoFactor.count() + errors.count() > 0; }));
QVERIFY2(errors.isEmpty(), qPrintable(strings(errors).join('\n')));
QCOMPARE(twoFactor.constFirst().constFirst().toString(), QStringLiteral("Ely"));
QVERIFY(!backend->busy()); // ждём ввода кода
backend->submitTwoFactorCode(" ");
QCOMPARE(errors.constLast().constFirst().toString(), Loc::text("auth.ely.error.enterCode"));
errors.clear();
backend->submitTwoFactorCode("654321");
QVERIFY(waitFor([&] { return launched.count() + errors.count() > 0; }));
QVERIFY2(errors.isEmpty(), qPrintable(strings(errors).join('\n')));
QTRY_VERIFY_WITH_TIMEOUT(QFile::exists(argsFile), 15000);
QTRY_VERIFY_WITH_TIMEOUT(!backend->gameRunning(), 15000);
const QStringList args = QString::fromLocal8Bit(Fixtures::readFile(argsFile)).split('\n', Qt::SkipEmptyParts);
QVERIFY(args.constFirst().startsWith("-javaagent:"));
QVERIFY(args.constFirst().endsWith("=ely.by"));
QVERIFY(args.contains("ElyNick"));
QVERIFY(args.contains("ely-token"));
// Токен и ник запомнены — в следующий раз пароль не понадобится.
const QJsonObject profile = QJsonDocument::fromJson(Fixtures::readFile(LauncherPaths::profilesFile())).array().at(0).toObject();
QCOMPARE(profile.value("accessToken").toString(), QStringLiteral("ely-token"));
QCOMPARE(profile.value("resolvedName").toString(), QStringLiteral("ElyNick"));
QCOMPARE(profile.value("uuid").toString(), QStringLiteral("aaaabbbb"));
}
void microsoftProfileWithFreshTokenLaunchesOffline()
{
FakeMojang mojang(m_net);
mojang.publish({.id = "1.20.1"});
const QString argsFile = m_game + "/../ms-args.txt";
writeSettings({{"javaPath", fakeJava("jdk-ms", {"args_file=" + argsFile})}});
Fixtures::write(LauncherPaths::profilesFile(), QJsonDocument(QJsonArray{QJsonObject{
{"id", 1}, {"name", "MS"}, {"authType", "microsoft"}, {"clientToken", "c"},
{"accessToken", "mc-token"}, {"refreshToken", "r"}, {"uuid", "069a79f444e94726a5befca90e38aaf5"},
{"resolvedName", "Notch"}, {"xuid", "253"}, {"userType", "msa"},
{"tokenExpiry", QDateTime::currentDateTimeUtc().addSecs(3600).toString(Qt::ISODate)}}}).toJson());
auto backend = make();
QVERIFY(installVersion(*backend, "1.20.1"));
backend->addCustomBuild("B", QString(), "1.20.1");
QVERIFY(waitIdle(*backend));
const int requestsBefore = m_net.requests().size();
QSignalSpy launched(backend.get(), &LauncherBackend::launched);
QSignalSpy errors(backend.get(), &LauncherBackend::launchError);
backend->launchGame(0, 0);
QVERIFY(waitFor([&] { return launched.count() + errors.count() > 0; }));
QVERIFY2(errors.isEmpty(), qPrintable(strings(errors).join('\n')));
QCOMPARE(m_net.requests().size(), requestsBefore); // токен жив — в Microsoft не ходили
QTRY_VERIFY_WITH_TIMEOUT(QFile::exists(argsFile), 15000);
const QStringList args = QString::fromLocal8Bit(Fixtures::readFile(argsFile)).split('\n', Qt::SkipEmptyParts);
QVERIFY(args.contains("Notch"));
QVERIFY(args.contains("mc-token"));
}
void microsoftProfileWithoutSessionAsksRelogin()
{
FakeMojang mojang(m_net);
mojang.publish({.id = "1.20.1"});
Fixtures::write(LauncherPaths::profilesFile(), QJsonDocument(QJsonArray{QJsonObject{
{"id", 1}, {"name", "MS"}, {"authType", "microsoft"}}}).toJson());
auto backend = make();
QVERIFY(installVersion(*backend, "1.20.1"));
backend->addCustomBuild("B", QString(), "1.20.1");
QVERIFY(waitIdle(*backend));
QSignalSpy relogin(backend.get(), &LauncherBackend::microsoftReloginRequired);
QSignalSpy errors(backend.get(), &LauncherBackend::launchError);
backend->launchGame(0, 0);
QCOMPARE(relogin.count(), 1);
QCOMPARE(relogin.constFirst().constFirst().toInt(), 0);
QCOMPARE(errors.constLast().constFirst().toString(), Loc::text("auth.msa.error.profileNeedsLogin").arg("MS"));
QVERIFY(!backend->busy());
}
void microsoftLoginNeedsWebEngine()
{
auto backend = make();
QSignalSpy failed(backend.get(), &LauncherBackend::microsoftLoginFailed);
backend->startMicrosoftLogin(-1);
QCOMPARE(failed.count(), 1);
QCOMPARE(failed.constFirst().constFirst().toString(), Loc::text("auth.msa.error.noWebEngine"));
const QVariantMap redirect = backend->inspectMicrosoftRedirect(
"https://login.live.com/oauth20_desktop.srf?code=abc");
QVERIFY(redirect.value("matched").toBool());
QCOMPARE(redirect.value("code").toString(), QStringLiteral("abc"));
QVERIFY(!backend->inspectMicrosoftRedirect("https://example.com/").value("matched").toBool());
}
// --- Java ----------------------------------------------------------------
void javaCatalogInstallSelectRemove()
{
const QByteArray zip = Fixtures::zipBytes({{QStringLiteral("jdk-21/bin/") + Fixtures::javaExecutableName(), "bin"}});
m_net.route("https://api.adoptium.net/v3/info/available_releases",
FakeResponse::json(QJsonObject{{"available_lts_releases", QJsonArray{21}}, {"available_releases", QJsonArray{21}}}));
m_net.route("https://api.adoptium.net/v3/assets/feature_releases/*", [zip](const FakeRequest &request) {
const QString arch = request.queryItems().queryItemValue("architecture");
const QJsonObject binary{
{"architecture", arch}, {"image_type", "jdk"},
{"package", QJsonObject{{"name", "jdk.zip"}, {"link", "https://github.com/adoptium/x/jdk.zip"},
{"checksum", Fixtures::sha256(zip)}, {"size", zip.size()}}}};
const QJsonArray releases{QJsonObject{
{"release_name", "jdk-21.0.2+13"}, {"timestamp", "2025-01-20T00:00:00Z"},
{"binaries", QJsonArray{binary}},
{"version_data", QJsonObject{{"major", 21}, {"openjdk_version", "21.0.2+13-LTS"}}}}};
return FakeResponse::json(QJsonDocument(releases).toJson());
});
m_net.route("https://github.com/adoptium/x/jdk.zip", FakeResponse::bytes(zip));
auto backend = make();
QSignalSpy installedSpy(backend.get(), &LauncherBackend::javaRuntimeInstalled);
backend->refreshJavaCatalog();
QVERIFY(waitFor([&] { return !backend->javaCatalog().isEmpty() && !backend->javaCatalogLoading(); }));
const QVariantMap row = backend->javaCatalog().constFirst().toMap();
QCOMPARE(row.value("major").toInt(), 21);
QVERIFY(row.value("lts").toBool());
QVERIFY(!row.value("installed").toBool());
const QString id = row.value("id").toString();
QVERIFY(!backend->javaRuntimeInfo(id).value("installed").toBool());
backend->installJavaRuntime(id);
QVERIFY(waitFor([&] { return installedSpy.count() == 1; }));
QCOMPARE(installedSpy.constFirst().constFirst().toString(), id);
QVERIFY(backend->javaRuntimeInfo(id).value("installed").toBool());
QVERIFY(backend->javaCatalog().constFirst().toMap().value("installed").toBool());
// Выбранная сборка Java попадает в настройки, а при удалении настройка очищается.
backend->updateSettings({{"javaRuntime", id}});
const QString javaPath = backend->javaRuntimeInfo(id).value("javaPath").toString();
QVERIFY(backend->detectedJava().join('\n').contains(javaPath));
backend->removeJavaRuntime(id);
QVERIFY(backend->settings().value("javaRuntime").toString().isEmpty());
QVERIFY(!backend->javaRuntimeInfo(id).value("installed").toBool());
QSignalSpy errors(backend.get(), &LauncherBackend::launchError);
backend->installJavaRuntime("no-such-runtime");
QCOMPARE(errors.constLast().constFirst().toString(), Loc::text("java.error.buildNotInCatalog").arg("no-such-runtime"));
}
// --- сезонные сборки --------------------------------------------------------
void seasonalBuildEndToEnd()
{
FakeMojang mojang(m_net);
mojang.publish({.id = "1.20.1"});
// Java 17 уже скачана лаунчером — шаг Java пропускается.
InstalledJavaRuntime runtime;
runtime.id = QStringLiteral("temurin-jdk-17-test");
runtime.kind = QStringLiteral("jdk");
runtime.major = 17;
runtime.version = QStringLiteral("17.0.8");
runtime.javaPath = Fixtures::installFakeJava(JavaRuntimeStore::dirFor(runtime.id), "17.0.8");
QVERIFY(JavaRuntimeStore::writeMeta(runtime, nullptr));
const auto publishSeason = [&](int revision, const QMap<QString, QByteArray> &files) {
const QByteArray pack = Fixtures::zipBytes(files);
const QString url = QStringLiteral("https://launcher-builds.galeonworx.com/packs/season-6-r%1.zip").arg(revision);
m_net.route(url, FakeResponse::bytes(pack));
const QJsonObject entry{
{"id", "season-6"}, {"name", "Season 6"}, {"revision", revision}, {"minecraftVersion", "1.20.1"},
{"loader", ""}, {"loaderVersion", ""}, {"modCount", files.size()}, {"seasonStart", "2026-01-01"},
{"serverUrl", "season6.example.com"}, {"javaMajor", 17},
{"archive", QJsonObject{{"url", url}, {"size", pack.size()}, {"sha256", Fixtures::sha256(pack)}}}};
m_net.route(kSeasonalIndex, FakeResponse::json(QJsonObject{{"formatVersion", 1}, {"builds", QJsonArray{entry}}}));
};
publishSeason(1, {{"mods/a.jar", "a1"}, {"mods/old.jar", "old"}, {"config/c.toml", "c=1"}});
auto backend = make();
QSignalSpy errors(backend.get(), &LauncherBackend::launchError);
QSignalSpy done(backend.get(), &LauncherBackend::seasonalInstallFinished);
backend->refreshSeasonalCatalog();
QVERIFY(waitFor([&] { return backend->seasonalCatalog().size() == 1; }));
QVariantMap row = backend->seasonalCatalog().constFirst().toMap();
QCOMPARE(row.value("status").toString(), Loc::text("seasonal.status.notInstalled"));
QCOMPARE(row.value("loaderTitle").toString(), Loc::text("loader.label.vanilla"));
QCOMPARE(row.value("seasonStart").toString(), QStringLiteral("01.01.2026"));
backend->installSeasonalBuild("season-6");
QVERIFY(backend->seasonalInstalling());
QVERIFY(backend->busy());
QVERIFY(waitFor([&] { return done.count() + errors.count() > 0; }, 60000));
QVERIFY2(errors.isEmpty(), qPrintable(strings(errors).join('\n')));
QCOMPARE(done.constFirst().at(0).toString(), QStringLiteral("season-6"));
QVERIFY(!backend->seasonalInstalling());
QVERIFY(waitIdle(*backend));
// Сборка заведена, активна, версия игры стоит, файлы пака разложены, ревизия записана.
QCOMPARE(backend->customBuildNames(), QStringList{"Season 6"});
QCOMPARE(backend->activeBuildIndex(), 0);
QVERIFY(backend->checkInstallation(0).isEmpty());
QCOMPARE(Fixtures::readFile(m_game + "/mods/a.jar"), QByteArray("a1"));
QCOMPARE(Fixtures::readFile(m_game + "/config/c.toml"), QByteArray("c=1"));
const QVariantMap build = backend->customBuildAt(0);
QCOMPARE(build.value("seasonalId").toString(), QStringLiteral("season-6"));
QCOMPARE(build.value("seasonalRevision").toInt(), 1);
QCOMPARE(build.value("serverUrl").toString(), QStringLiteral("season6.example.com"));
const int buildId = Fixtures::readJsonFile(LauncherPaths::settingsFile()).value("activeBuildId").toInt();
const QJsonObject state = Fixtures::readJsonFile(LauncherPaths::seasonalStateFile(buildId));
QCOMPARE(state.value("revision").toInt(), 1);
QCOMPARE(state.value("entries").toArray().size(), 3);
QCOMPARE(backend->seasonalCatalog().constFirst().toMap().value("status").toString(),
Loc::text("seasonal.status.installed"));
// Ревизия 2: мод old.jar из сборки убрали, a.jar обновили. Пользовательский мир не трогаем.
Fixtures::write(m_game + "/saves/MyWorld/level.dat", "mine");
publishSeason(2, {{"mods/a.jar", "a2"}, {"config/c.toml", "c=2"}});
backend->refreshSeasonalCatalog(true);
QVERIFY(waitFor([&] { return backend->seasonalCatalog().constFirst().toMap().value("updateAvailable").toBool(); }));
done.clear();
backend->installSeasonalBuild("season-6");
QVERIFY(waitFor([&] { return done.count() + errors.count() > 0; }, 60000));
QVERIFY2(errors.isEmpty(), qPrintable(strings(errors).join('\n')));
QCOMPARE(Fixtures::readFile(m_game + "/mods/a.jar"), QByteArray("a2"));
QVERIFY(!QFile::exists(m_game + "/mods/old.jar"));
QCOMPARE(Fixtures::readFile(m_game + "/saves/MyWorld/level.dat"), QByteArray("mine"));
QCOMPARE(backend->customBuildNames().size(), 1); // та же запись, а не вторая
QCOMPARE(backend->customBuildAt(0).value("seasonalRevision").toInt(), 2);
// Служебный файл пака за собой не оставляем.
QVERIFY(QDir(LauncherPaths::buildDir(buildId)).entryList({".pack-*"}, QDir::Files | QDir::Hidden).isEmpty());
}
void seasonalPackChecksumFailureAborts()
{
FakeMojang mojang(m_net);
mojang.publish({.id = "1.20.1"});
InstalledJavaRuntime runtime;
runtime.id = QStringLiteral("temurin-jdk-17-test2");
runtime.kind = QStringLiteral("jdk");
runtime.major = 17;
runtime.version = QStringLiteral("17.0.8");
runtime.javaPath = Fixtures::installFakeJava(JavaRuntimeStore::dirFor(runtime.id), "17.0.8");
QVERIFY(JavaRuntimeStore::writeMeta(runtime, nullptr));
const QString url = QStringLiteral("https://launcher-builds.galeonworx.com/packs/bad.zip");
m_net.route(url, FakeResponse::bytes("not what was promised"));
const QJsonObject entry{
{"id", "bad-season"}, {"name", "Bad"}, {"revision", 1}, {"minecraftVersion", "1.20.1"},
{"loader", ""}, {"javaMajor", 17},
{"archive", QJsonObject{{"url", url}, {"size", 21}, {"sha256", QString(64, u'0')}}}};
m_net.route(kSeasonalIndex, FakeResponse::json(QJsonObject{{"formatVersion", 1}, {"builds", QJsonArray{entry}}}));
auto backend = make();
QSignalSpy errors(backend.get(), &LauncherBackend::launchError);
backend->refreshSeasonalCatalog();
QVERIFY(waitFor([&] { return backend->seasonalCatalog().size() == 1; }));
backend->installSeasonalBuild("bad-season");
QVERIFY(waitFor([&] { return !backend->seasonalInstalling(); }, 60000));
const QString all = strings(errors).join('\n');
QVERIFY2(all.contains(Loc::text("seasonal.error.checksumMismatch")), qPrintable(all));
QVERIFY(!backend->busy());
QCOMPARE(backend->customBuildAt(0).value("seasonalRevision").toInt(), 0); // не отмечена установленной
backend->installSeasonalBuild("unknown-id");
QCOMPARE(errors.constLast().constFirst().toString(), Loc::text("seasonal.error.notInCatalog").arg("unknown-id"));
}
};
BILAUNCHER_GUI_TEST_MAIN(TestLauncherBackend)
#include "tst_launcherbackend.moc"
+417
View File
@@ -0,0 +1,417 @@
#include "fakeinternet.h"
#include "fixtures.h"
#include <QEventLoop>
#include <QHostAddress>
#include <QNetworkAccessManager>
#include <QNetworkReply>
#include <QJsonDocument>
#include <QJsonObject>
#include <QNetworkProxy>
#include <QSslConfiguration>
#include <QSslKey>
#include <QSslSocket>
#include <QTimer>
struct FakeInternet::Connection
{
QSslSocket *socket = nullptr;
QByteArray buffer;
QString tunnelHost; // непусто — внутри CONNECT-туннеля (https)
};
namespace {
QByteArray reasonPhrase(int status)
{
switch (status) {
case 200: return "OK";
case 201: return "Created";
case 204: return "No Content";
case 301: return "Moved Permanently";
case 302: return "Found";
case 304: return "Not Modified";
case 400: return "Bad Request";
case 401: return "Unauthorized";
case 403: return "Forbidden";
case 404: return "Not Found";
case 410: return "Gone";
case 429: return "Too Many Requests";
case 500: return "Internal Server Error";
case 502: return "Bad Gateway";
case 503: return "Service Unavailable";
default: return "Status";
}
}
} // namespace
QUrl FakeRequest::url() const
{
QUrl result;
result.setScheme(scheme);
result.setHost(host);
result.setPath(path);
if (!query.isEmpty())
result.setQuery(query);
return result;
}
FakeResponse FakeResponse::json(const QByteArray &body, int status)
{
FakeResponse response;
response.status = status;
response.body = body;
response.contentType = "application/json";
return response;
}
FakeResponse FakeResponse::json(const QJsonObject &object, int status)
{
return json(QJsonDocument(object).toJson(QJsonDocument::Compact), status);
}
FakeResponse FakeResponse::text(const QByteArray &body, int status)
{
FakeResponse response;
response.status = status;
response.body = body;
response.contentType = "text/plain; charset=utf-8";
return response;
}
FakeResponse FakeResponse::bytes(const QByteArray &body)
{
FakeResponse response;
response.body = body;
return response;
}
FakeResponse FakeResponse::error(int status, const QByteArray &body)
{
FakeResponse response = text(body.isEmpty() ? reasonPhrase(status) : body, status);
return response;
}
FakeInternet::FakeInternet(QObject *parent)
: QTcpServer(parent)
{
m_fallback = FakeResponse::error(404, "fake internet: no route");
}
FakeInternet::~FakeInternet()
{
stop();
}
bool FakeInternet::tlsAvailable(QString *why)
{
if (!QSslSocket::supportsSsl()) {
if (why)
*why = QStringLiteral("в этой сборке Qt нет рабочего TLS-бэкенда");
return false;
}
return true;
}
bool FakeInternet::start(QString *error)
{
if (m_started)
return true;
if (!tlsAvailable(error))
return false;
m_certificate = Fixtures::read(QStringLiteral("tls/cert.pem"));
m_key = Fixtures::read(QStringLiteral("tls/key.pem"));
if (!listen(QHostAddress::LocalHost, 0)) {
if (error)
*error = errorString();
return false;
}
QNetworkProxy::setApplicationProxy(
QNetworkProxy(QNetworkProxy::HttpProxy, QStringLiteral("127.0.0.1"), serverPort()));
QSslConfiguration ssl = QSslConfiguration::defaultConfiguration();
ssl.setPeerVerifyMode(QSslSocket::VerifyNone);
QSslConfiguration::setDefaultConfiguration(ssl);
m_started = true;
// Самопроверка: TLS-бэкенд может не уметь серверный режим (бывает на
// отдельных сборках Qt под Windows/macOS). Лучше узнать сразу и внятно,
// чем получить десятки непонятных падений в тестах сервисов.
const QString probe = QStringLiteral("https://selftest.fake-internet.test/ping");
route(probe, FakeResponse::text("pong"));
QNetworkAccessManager manager;
QNetworkReply *reply = manager.get(QNetworkRequest(QUrl(probe)));
QEventLoop loop;
QTimer::singleShot(10000, &loop, &QEventLoop::quit);
connect(reply, &QNetworkReply::finished, &loop, &QEventLoop::quit);
if (!reply->isFinished())
loop.exec();
const bool ok = reply->isFinished() && reply->error() == QNetworkReply::NoError
&& reply->readAll() == "pong";
const QString why = reply->isFinished() ? reply->errorString() : QStringLiteral("таймаут 10 с");
delete reply;
reset();
if (!ok) {
stop();
if (error)
*error = QStringLiteral("TLS через локальный прокси не заработал (%1), TLS-бэкенд: %2")
.arg(why, QSslSocket::activeBackend());
return false;
}
return true;
}
void FakeInternet::stop()
{
if (!m_started)
return;
// Никуда наружу и после остановки: прокси на закрытый порт.
close();
m_started = false;
}
void FakeInternet::route(const QString &pattern, const Handler &handler)
{
unroute(pattern);
const QUrl url(pattern.endsWith(u'*') ? pattern.chopped(1) : pattern);
Route route;
route.pattern = pattern;
route.host = url.host().toLower();
route.path = url.path().isEmpty() ? QStringLiteral("/") : url.path();
route.prefix = pattern.endsWith(u'*');
route.handler = handler;
m_routes << route;
}
void FakeInternet::route(const QString &pattern, const FakeResponse &response)
{
route(pattern, [response](const FakeRequest &) { return response; });
}
void FakeInternet::unroute(const QString &pattern)
{
m_routes.removeIf([&](const Route &route) { return route.pattern == pattern; });
}
void FakeInternet::reset()
{
m_routes.clear();
m_requests.clear();
m_unmatched.clear();
m_fallback = FakeResponse::error(404, "fake internet: no route");
}
QList<FakeRequest> FakeInternet::requests() const
{
return m_requests;
}
int FakeInternet::hits(const QString &url) const
{
const QUrl wanted(url);
int count = 0;
for (const FakeRequest &request : m_requests) {
if (request.host.compare(wanted.host(), Qt::CaseInsensitive) == 0
&& request.path == wanted.path())
++count;
}
return count;
}
QStringList FakeInternet::unmatched() const
{
return m_unmatched;
}
QStringList FakeInternet::requestedUrls() const
{
QStringList result;
for (const FakeRequest &request : m_requests)
result << request.url().toString();
return result;
}
void FakeInternet::incomingConnection(qintptr descriptor)
{
auto *socket = new QSslSocket(this);
if (!socket->setSocketDescriptor(descriptor)) {
delete socket;
return;
}
auto *connection = new Connection;
connection->socket = socket;
connect(socket, &QSslSocket::readyRead, this, [this, connection] { onReadyRead(connection); });
connect(socket, &QSslSocket::sslErrors, socket, [socket](const QList<QSslError> &) {
socket->ignoreSslErrors();
});
// Удаляем отложенно: disconnected может прийти синхронно посреди processBuffer().
connect(socket, &QSslSocket::disconnected, this, [socket, connection] {
socket->deleteLater();
QObject::connect(socket, &QObject::destroyed, [connection] { delete connection; });
});
}
void FakeInternet::onReadyRead(Connection *connection)
{
connection->buffer += connection->socket->readAll();
processBuffer(connection);
}
void FakeInternet::processBuffer(Connection *connection)
{
for (;;) {
const qsizetype headerEnd = connection->buffer.indexOf("\r\n\r\n");
if (headerEnd < 0)
return;
const QList<QByteArray> lines = connection->buffer.left(headerEnd).split('\n');
const QList<QByteArray> requestLine = lines.value(0).trimmed().split(' ');
if (requestLine.size() < 2) {
connection->socket->abort();
return;
}
FakeRequest request;
request.method = requestLine.at(0);
for (qsizetype i = 1; i < lines.size(); ++i) {
const QByteArray line = lines.at(i).trimmed();
const qsizetype colon = line.indexOf(':');
if (colon > 0)
request.headers.insert(line.left(colon).trimmed().toLower(),
line.mid(colon + 1).trimmed());
}
const qsizetype bodySize = request.headers.value("content-length", "0").toLongLong();
if (connection->buffer.size() < headerEnd + 4 + bodySize)
return; // тело ещё не дошло
request.body = connection->buffer.mid(headerEnd + 4, bodySize);
connection->buffer.remove(0, headerEnd + 4 + bodySize);
const QByteArray target = requestLine.at(1);
if (request.method == "CONNECT" && connection->tunnelHost.isEmpty()) {
QString host = QString::fromLatin1(target);
host = host.left(host.lastIndexOf(u':')).toLower();
connection->tunnelHost = host;
connection->socket->write("HTTP/1.1 200 Connection Established\r\n\r\n");
connection->socket->flush();
startTls(connection);
continue;
}
if (!connection->tunnelHost.isEmpty()) {
request.scheme = QStringLiteral("https");
request.host = connection->tunnelHost;
const QUrl relative(QString::fromLatin1(target));
request.path = relative.path(QUrl::FullyDecoded);
request.query = relative.query(QUrl::FullyEncoded);
} else {
// Простой http через прокси — адрес в absolute-form.
const QUrl absolute(QString::fromLatin1(target));
request.scheme = absolute.scheme();
request.host = absolute.host().toLower();
request.path = absolute.path(QUrl::FullyDecoded);
request.query = absolute.query(QUrl::FullyEncoded);
}
if (request.path.isEmpty())
request.path = QStringLiteral("/");
dispatch(connection, request);
}
}
void FakeInternet::startTls(Connection *connection)
{
QSslSocket *socket = connection->socket;
socket->setLocalCertificate(QSslCertificate(m_certificate, QSsl::Pem));
socket->setPrivateKey(QSslKey(m_key, QSsl::Rsa, QSsl::Pem));
socket->setPeerVerifyMode(QSslSocket::VerifyNone);
socket->startServerEncryption();
}
const FakeInternet::Route *FakeInternet::match(const FakeRequest &request) const
{
// Последний добавленный маршрут главнее: тест может перекрыть общий частным.
for (qsizetype i = m_routes.size() - 1; i >= 0; --i) {
const Route &route = m_routes.at(i);
if (route.host != request.host)
continue;
if (route.prefix ? request.path.startsWith(route.path) : request.path == route.path)
return &route;
}
return nullptr;
}
void FakeInternet::dispatch(Connection *connection, FakeRequest request)
{
m_requests << request;
const QString url = request.url().toString();
FakeResponse response;
if (const Route *route = match(request)) {
response = route->handler(request);
} else {
m_unmatched << url;
qWarning("FakeInternet: нет маршрута для %s %s", request.method.constData(),
qPrintable(url));
response = m_fallback;
}
const bool keepAlive = request.header("connection").toLower() != "close";
QPointer<QTcpSocket> socket(connection->socket);
if (response.hang)
return;
if (response.delayMs > 0) {
QTimer::singleShot(response.delayMs, this,
[this, socket, response, keepAlive] { respond(socket, response, keepAlive); });
} else {
respond(socket, response, keepAlive);
}
emit requestServed(url);
}
void FakeInternet::respond(QPointer<QTcpSocket> socket, const FakeResponse &response, bool keepAlive)
{
if (!socket || socket->state() != QAbstractSocket::ConnectedState)
return;
if (response.drop) {
socket->abort();
return;
}
QByteArray head = "HTTP/1.1 " + QByteArray::number(response.status) + ' '
+ reasonPhrase(response.status) + "\r\n";
head += "Content-Type: " + response.contentType + "\r\n";
head += "Content-Length: " + QByteArray::number(response.body.size()) + "\r\n";
for (const auto &[name, value] : response.headers)
head += name + ": " + value + "\r\n";
const bool close = !keepAlive || response.truncateTo >= 0;
head += close ? "Connection: close\r\n" : "Connection: keep-alive\r\n";
head += "\r\n";
socket->write(head);
if (response.truncateTo >= 0) {
socket->write(response.body.left(response.truncateTo));
socket->flush();
socket->disconnectFromHost();
// Schannel (Windows) на disconnectFromHost шлёт только TLS close_notify и держит
// TCP открытым, ожидая ответного close_notify, — клиент тогда висит до таймаута.
// Обрыв на то и обрыв: если сокет не закрылся сам, рвём соединение.
QTimer::singleShot(200, socket, [socket] {
if (socket && socket->state() != QAbstractSocket::UnconnectedState)
socket->abort();
});
return;
}
socket->write(response.body);
if (close)
socket->disconnectFromHost();
}
+136
View File
@@ -0,0 +1,136 @@
#pragma once
// «Фейковый интернет» для тестов сетевых сервисов без правки их кода.
//
// Сервисы лаунчера ходят на захардкоженные https-адреса через собственные
// QNetworkAccessManager. Прокси и SSL-конфигурация по умолчанию у Qt общие
// на весь процесс, поэтому start():
// - ставит QNetworkProxy::setApplicationProxy() на локальный порт;
// - отключает проверку сертификата в QSslConfiguration::defaultConfiguration().
// Дальше каждое https-соединение приходит сюда как CONNECT host:443; сокет
// переводится в TLS с тестовым самоподписанным сертификатом и обслуживается
// как обычный HTTP/1.1 — по таблице маршрутов (host + path).
//
// Запрос на неизвестный адрес получает 404 и попадает в unmatched(): тест
// может проверить, что код не ушёл куда-то, куда не ожидали.
#include <QByteArray>
#include <QHash>
#include <QList>
#include <QMutex>
#include <QObject>
#include <QPair>
#include <QPointer>
#include <QString>
#include <QStringList>
#include <QTcpServer>
#include <QUrl>
#include <QUrlQuery>
#include <functional>
class QSslSocket;
class QTcpSocket;
struct FakeRequest
{
QByteArray method;
QString scheme; // https | http
QString host;
QString path;
QString query; // без «?»
QHash<QByteArray, QByteArray> headers; // имена в нижнем регистре
QByteArray body;
QUrl url() const;
QUrlQuery queryItems() const { return QUrlQuery(query); }
QUrlQuery formItems() const { return QUrlQuery(QString::fromUtf8(body)); }
QByteArray header(const QByteArray &name) const { return headers.value(name.toLower()); }
};
struct FakeResponse
{
int status = 200;
QByteArray body;
QByteArray contentType = "application/octet-stream";
QList<QPair<QByteArray, QByteArray>> headers;
int delayMs = 0; // ответить с задержкой
bool hang = false; // не отвечать вовсе (для отмены и таймаутов)
bool drop = false; // оборвать соединение без ответа
int truncateTo = -1; // объявить полный размер, отдать столько байт и оборвать
static FakeResponse json(const QByteArray &body, int status = 200);
static FakeResponse json(const class QJsonObject &object, int status = 200);
static FakeResponse text(const QByteArray &body, int status = 200);
static FakeResponse bytes(const QByteArray &body);
static FakeResponse error(int status, const QByteArray &body = {});
};
class FakeInternet : public QTcpServer
{
Q_OBJECT
public:
using Handler = std::function<FakeResponse(const FakeRequest &)>;
explicit FakeInternet(QObject *parent = nullptr);
~FakeInternet() override;
// Слушает 127.0.0.1 и перенаправляет на себя весь трафик процесса.
// При ошибке пишет причину в error (например, нет TLS-бэкенда с поддержкой сервера).
bool start(QString *error = nullptr);
void stop();
// Есть ли TLS вообще (без него сетевые тесты пропускаются).
static bool tlsAvailable(QString *why = nullptr);
// Маршрут: "https://host/path" — точное совпадение пути (query не учитывается),
// "https://host/prefix/*" — всё, что начинается с prefix/.
void route(const QString &pattern, const Handler &handler);
void route(const QString &pattern, const FakeResponse &response);
void unroute(const QString &pattern);
// Ответ на всё, что не подошло ни к одному маршруту (по умолчанию 404).
void setFallback(const FakeResponse &response) { m_fallback = response; }
// Сбросить маршруты и журнал.
void reset();
QList<FakeRequest> requests() const;
// Сколько раз был запрошен адрес "https://host/path" (query не учитывается).
int hits(const QString &url) const;
QStringList unmatched() const;
QStringList requestedUrls() const;
signals:
void requestServed(const QString &url);
protected:
void incomingConnection(qintptr descriptor) override;
private:
struct Connection;
struct Route
{
QString pattern;
QString host;
QString path;
bool prefix = false;
Handler handler;
};
void onReadyRead(Connection *connection);
void processBuffer(Connection *connection);
void dispatch(Connection *connection, FakeRequest request);
void respond(QPointer<QTcpSocket> socket, const FakeResponse &response, bool keepAlive);
void startTls(Connection *connection);
const Route *match(const FakeRequest &request) const;
QList<Route> m_routes;
FakeResponse m_fallback;
QList<FakeRequest> m_requests;
QStringList m_unmatched;
QByteArray m_certificate;
QByteArray m_key;
bool m_started = false;
};
+194
View File
@@ -0,0 +1,194 @@
#include "fakemojang.h"
#include "fixtures.h"
#include "minecraftversion.h"
#include <QJsonArray>
#include <QJsonDocument>
namespace {
const QString kMeta = QStringLiteral("https://piston-meta.mojang.com/v1/packages/");
const QString kData = QStringLiteral("https://piston-data.mojang.com/v1/objects/");
const QString kLibraries = QStringLiteral("https://libraries.minecraft.net/");
const QString kResources = QStringLiteral("https://resources.download.minecraft.net/");
QJsonObject download(const QString &url, const QByteArray &data, const QString &path = {})
{
QJsonObject object{{"url", url}, {"sha1", Fixtures::sha1(data)}, {"size", data.size()}};
if (!path.isEmpty())
object.insert("path", path);
return object;
}
} // namespace
FakeMojang::FakeMojang(FakeInternet &net)
: m_net(net)
{
}
QString FakeMojang::manifestUrl()
{
return QStringLiteral("https://launchermeta.mojang.com/mc/game/version_manifest_v2.json");
}
QString FakeMojang::addObject(const QString &relativePath, const QByteArray &data)
{
QString url;
if (relativePath.startsWith(QLatin1String("libraries/")))
url = kLibraries + relativePath.mid(int(sizeof("libraries/") - 1));
else if (relativePath.startsWith(QLatin1String("assets/objects/")))
url = kResources + relativePath.mid(int(sizeof("assets/objects/") - 1));
else
url = kData + Fixtures::sha1(data) + u'/' + relativePath.section(u'/', -1);
m_files.insert(relativePath, {url, data});
m_net.route(url, FakeResponse::bytes(data));
return url;
}
void FakeMojang::overrideFile(const QString &relativePath, const FakeResponse &response)
{
m_net.route(m_files.value(relativePath).url, response);
}
QString FakeMojang::fileUrl(const QString &relativePath) const
{
return m_files.value(relativePath).url;
}
void FakeMojang::publish(const VersionSpec &spec)
{
QHash<QString, QByteArray> expected;
QJsonObject json{{"id", spec.id}, {"type", spec.type}, {"releaseTime", spec.releaseTime}};
if (!spec.inheritsFrom.isEmpty()) {
json.insert("inheritsFrom", spec.inheritsFrom);
json.insert("mainClass", "net.fabricmc.loader.impl.launch.knot.KnotClient");
const QString libPath = QStringLiteral("net/fabricmc/fabric-loader/0.16.10/fabric-loader-0.16.10.jar");
const QByteArray libData = "fabric-loader-" + spec.id.toUtf8();
const QString url = addObject("libraries/" + libPath, libData);
json.insert("libraries", QJsonArray{QJsonObject{
{"name", "net.fabricmc:fabric-loader:0.16.10"},
{"downloads", QJsonObject{{"artifact", download(url, libData, libPath)}}}}});
expected.insert("libraries/" + libPath, libData);
} else {
json.insert("mainClass", "net.minecraft.client.main.Main");
json.insert("arguments", QJsonObject{
{"game", QJsonArray{"--username", "${auth_player_name}", "--version", "${version_name}",
"--gameDir", "${game_directory}", "--assetsDir", "${assets_root}",
"--assetIndex", "${assets_index_name}", "--uuid", "${auth_uuid}",
"--accessToken", "${auth_access_token}", "--userType", "${user_type}",
"--versionType", "${version_type}"}},
{"jvm", QJsonArray{"-Djava.library.path=${natives_directory}", "-cp", "${classpath}"}}});
json.insert("javaVersion", QJsonObject{{"component", "java-runtime-gamma"}, {"majorVersion", spec.javaMajor}});
// client.jar
const QString clientPath = "versions/" + spec.id + u'/' + spec.id + ".jar";
const QByteArray clientData = "client-jar-of-" + spec.id.toUtf8();
const QString clientUrl = addObject(clientPath, clientData);
json.insert("downloads", QJsonObject{{"client", download(clientUrl, clientData)}});
expected.insert(clientPath, clientData);
// Обычная библиотека и натив под текущую платформу.
const QString native = VersionLoader::nativeClassifier();
const QString libPath = QStringLiteral("com/mojang/brigadier/1.1.8/brigadier-1.1.8.jar");
const QByteArray libData = "brigadier";
const QString nativePath = QStringLiteral("org/lwjgl/lwjgl/3.3.1/lwjgl-3.3.1-%1.jar").arg(native);
const QByteArray nativeData = Fixtures::zipBytes({{"liblwjgl.so", "so"}, {"lwjgl.dll", "dll"},
{"liblwjgl.dylib", "dylib"}});
const QString libUrl = addObject("libraries/" + libPath, libData);
const QString nativeUrl = addObject("libraries/" + nativePath, nativeData);
json.insert("libraries", QJsonArray{
QJsonObject{{"name", "com.mojang:brigadier:1.1.8"},
{"downloads", QJsonObject{{"artifact", download(libUrl, libData, libPath)}}}},
QJsonObject{{"name", "org.lwjgl:lwjgl:3.3.1:" + native},
{"downloads", QJsonObject{{"artifact", download(nativeUrl, nativeData, nativePath)}}},
{"rules", QJsonArray{QJsonObject{{"action", "allow"},
{"os", QJsonObject{{"name", VersionLoader::osName()}}}}}}}});
expected.insert("libraries/" + libPath, libData);
expected.insert("libraries/" + nativePath, nativeData);
// Ассеты.
const QString indexId = spec.legacyAssets ? QStringLiteral("legacy") : spec.id + "-assets";
QJsonObject objects;
qint64 total = 0;
for (const QString &name : {QStringLiteral("minecraft/sounds/click.ogg"), QStringLiteral("icons/icon_16x16.png")}) {
const QByteArray data = (name + "@" + spec.id).toUtf8();
const QString hash = Fixtures::sha1(data);
const QString objectPath = "assets/objects/" + hash.left(2) + u'/' + hash;
addObject(objectPath, data);
objects.insert(name, QJsonObject{{"hash", hash}, {"size", data.size()}});
expected.insert(objectPath, data);
if (spec.legacyAssets)
expected.insert("assets/virtual/legacy/" + name, data);
total += data.size();
}
QJsonObject index{{"objects", objects}};
if (spec.legacyAssets)
index.insert("virtual", true);
const QByteArray indexData = QJsonDocument(index).toJson(QJsonDocument::Compact);
const QString indexPath = "assets/indexes/" + indexId + ".json";
const QString indexUrl = addObject(indexPath, indexData);
QJsonObject assetIndex = download(indexUrl, indexData);
assetIndex.insert("id", indexId);
assetIndex.insert("totalSize", total);
json.insert("assetIndex", assetIndex);
json.insert("assets", indexId);
expected.insert(indexPath, indexData);
// Конфиг логирования.
const QByteArray logData = "<Configuration/>";
const QString logPath = QStringLiteral("assets/log_configs/client-1.12.xml");
const QString logUrl = addObject(logPath, logData);
QJsonObject logFile = download(logUrl, logData);
logFile.insert("id", "client-1.12.xml");
json.insert("logging", QJsonObject{{"client", QJsonObject{
{"argument", "-Dlog4j.configurationFile=${path}"},
{"file", logFile}, {"type", "log4j2-xml"}}}});
expected.insert(logPath, logData);
}
const QByteArray jsonData = QJsonDocument(json).toJson(QJsonDocument::Compact);
m_versionJson.insert(spec.id, jsonData);
expected.insert("versions/" + spec.id + u'/' + spec.id + ".json", jsonData);
m_expected.insert(spec.id, expected);
m_net.route(versionJsonUrl(spec.id), FakeResponse::json(jsonData));
m_specs.removeIf([&](const VersionSpec &s) { return s.id == spec.id; });
m_specs << spec;
routeManifest();
}
QString FakeMojang::versionJsonUrl(const QString &id) const
{
return kMeta + Fixtures::sha1(m_versionJson.value(id)) + u'/' + id + ".json";
}
QByteArray FakeMojang::versionJson(const QString &id) const
{
return m_versionJson.value(id);
}
void FakeMojang::routeManifest()
{
QJsonArray versions;
for (const VersionSpec &spec : std::as_const(m_specs)) {
if (!spec.listed)
continue;
versions << QJsonObject{{"id", spec.id},
{"type", spec.type},
{"url", versionJsonUrl(spec.id)},
{"sha1", Fixtures::sha1(m_versionJson.value(spec.id))},
{"releaseTime", spec.releaseTime},
{"time", spec.releaseTime}};
}
m_net.route(manifestUrl(), FakeResponse::json(QJsonObject{{"versions", versions}}));
}
QHash<QString, QByteArray> FakeMojang::expectedFiles(const QString &id) const
{
return m_expected.value(id);
}
+64
View File
@@ -0,0 +1,64 @@
#pragma once
// Миниатюрный Mojang для FakeInternet: манифест версий, json версий, client.jar,
// библиотеки (в т.ч. натив под текущую ОС), индекс ассетов, сами ассеты и
// конфиг логирования. Содержимое крошечное, но устроено как настоящее —
// VersionInstaller и GameLauncher работают с ним без поблажек.
#include "fakeinternet.h"
#include <QByteArray>
#include <QHash>
#include <QJsonObject>
#include <QString>
#include <QStringList>
class FakeMojang
{
public:
struct VersionSpec
{
QString id;
QString type = QStringLiteral("release");
QString inheritsFrom; // непусто — «дочерняя» версия без своих файлов
bool legacyAssets = false; // индекс с virtual: true (как у 1.6 и старше)
int javaMajor = 17;
QString releaseTime = QStringLiteral("2023-06-12T13:25:51+00:00");
bool listed = true; // попадает ли в манифест
};
explicit FakeMojang(FakeInternet &net);
// Публикует версию: json + все файлы, на которые он ссылается.
void publish(const VersionSpec &spec);
// Переопубликовать манифест (после publish всех версий вызывается автоматически).
void routeManifest();
// Подменить ответ на конкретный файл (по относительному пути внутри .minecraft,
// например "libraries/com/mojang/brigadier/1.1.8/brigadier-1.1.8.jar").
void overrideFile(const QString &relativePath, const FakeResponse &response);
// Что должно лечь на диск после установки: относительный путь → содержимое.
QHash<QString, QByteArray> expectedFiles(const QString &id) const;
// URL json версии и URL конкретного файла.
QString versionJsonUrl(const QString &id) const;
QString fileUrl(const QString &relativePath) const;
QByteArray versionJson(const QString &id) const;
static QString manifestUrl();
private:
struct File
{
QString url;
QByteArray data;
};
QString addObject(const QString &relativePath, const QByteArray &data);
FakeInternet &m_net;
QList<VersionSpec> m_specs;
QHash<QString, QByteArray> m_versionJson; // id → json
QHash<QString, QHash<QString, QByteArray>> m_expected; // id → (путь → данные)
QHash<QString, File> m_files; // относительный путь → файл
};
+204
View File
@@ -0,0 +1,204 @@
#include "fixtures.h"
#include "minecraftversion.h"
#include <QBuffer>
#include <QCryptographicHash>
#include <QDir>
#include <QDirIterator>
#include <QFile>
#include <QFileInfo>
#include <QProcess>
#include <QTemporaryDir>
#include <QtCore/private/qzipreader_p.h>
#include <QtCore/private/qzipwriter_p.h>
QString Fixtures::path(const QString &relative)
{
return QStringLiteral(BILAUNCHER_FIXTURES_DIR) + u'/' + relative;
}
QByteArray Fixtures::read(const QString &relative)
{
QFile file(path(relative));
if (!file.open(QIODevice::ReadOnly))
qFatal("Нет фикстуры %s", qPrintable(file.fileName()));
return file.readAll();
}
QJsonObject Fixtures::readJson(const QString &relative)
{
return QJsonDocument::fromJson(read(relative)).object();
}
QString Fixtures::otherOsName()
{
return VersionLoader::osName() == QLatin1String("windows") ? QStringLiteral("linux")
: QStringLiteral("windows");
}
QByteArray Fixtures::applyTemplate(QByteArray text)
{
text.replace("%OS%", VersionLoader::osName().toUtf8());
text.replace("%OTHER%", otherOsName().toUtf8());
text.replace("%ARCH%", VersionLoader::osArch().toUtf8());
text.replace("%NATIVE%", VersionLoader::nativeClassifier().toUtf8());
return text;
}
QByteArray Fixtures::readTemplate(const QString &relative)
{
return applyTemplate(read(relative));
}
bool Fixtures::write(const QString &filePath, const QByteArray &data)
{
QDir().mkpath(QFileInfo(filePath).absolutePath());
QFile file(filePath);
if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate))
return false;
return file.write(data) == data.size();
}
bool Fixtures::writeJson(const QString &filePath, const QJsonObject &object)
{
return write(filePath, QJsonDocument(object).toJson(QJsonDocument::Indented));
}
QByteArray Fixtures::readFile(const QString &filePath)
{
QFile file(filePath);
if (!file.open(QIODevice::ReadOnly))
return {};
return file.readAll();
}
QJsonObject Fixtures::readJsonFile(const QString &filePath)
{
return QJsonDocument::fromJson(readFile(filePath)).object();
}
QByteArray Fixtures::zipBytes(const QMap<QString, QByteArray> &entries)
{
QBuffer buffer;
buffer.open(QIODevice::WriteOnly);
{
QZipWriter writer(&buffer);
writer.setCompressionPolicy(QZipWriter::AutoCompress);
for (auto it = entries.cbegin(); it != entries.cend(); ++it) {
if (it.key().endsWith(u'/'))
writer.addDirectory(it.key());
else
writer.addFile(it.key(), it.value());
}
writer.close();
}
return buffer.data();
}
bool Fixtures::makeZip(const QString &zipPath, const QMap<QString, QByteArray> &entries)
{
return write(zipPath, zipBytes(entries));
}
QStringList Fixtures::zipEntries(const QString &zipPath)
{
QZipReader reader(zipPath);
QStringList result;
for (const QZipReader::FileInfo &info : reader.fileInfoList()) {
if (info.isFile)
result << info.filePath;
}
result.sort();
return result;
}
bool Fixtures::makeTarGz(const QString &archivePath, const QMap<QString, QByteArray> &entries)
{
QTemporaryDir staging;
if (!staging.isValid())
return false;
QStringList tops;
for (auto it = entries.cbegin(); it != entries.cend(); ++it) {
if (!write(staging.path() + u'/' + it.key(), it.value()))
return false;
const QString top = it.key().section(u'/', 0, 0);
if (!tops.contains(top))
tops << top;
}
QDir().mkpath(QFileInfo(archivePath).absolutePath());
QProcess tar;
tar.setWorkingDirectory(staging.path());
#if defined(Q_OS_WIN)
// Системный bsdtar по полному пути: GNU tar из Git, который бывает раньше в PATH,
// принимает «C:» за имя удалённого хоста (та же история в release.yml).
const QString program = qEnvironmentVariable("SystemRoot") + QStringLiteral("\\System32\\tar.exe");
#else
const QString program = QStringLiteral("tar");
#endif
tar.start(program,
QStringList{QStringLiteral("-czf"), QDir::toNativeSeparators(archivePath)} + tops);
return tar.waitForFinished(30000) && tar.exitStatus() == QProcess::NormalExit
&& tar.exitCode() == 0;
}
QString Fixtures::sha1(const QByteArray &data)
{
return QString::fromLatin1(QCryptographicHash::hash(data, QCryptographicHash::Sha1).toHex());
}
QString Fixtures::sha256(const QByteArray &data)
{
return QString::fromLatin1(QCryptographicHash::hash(data, QCryptographicHash::Sha256).toHex());
}
QStringList Fixtures::listTree(const QString &dir)
{
QStringList result;
QDirIterator it(dir, QDir::Files | QDir::Hidden | QDir::NoDotAndDotDot,
QDirIterator::Subdirectories);
const QDir base(dir);
while (it.hasNext())
result << base.relativeFilePath(it.next());
result.sort();
return result;
}
QString Fixtures::javaExecutableName()
{
#if defined(Q_OS_WIN)
return QStringLiteral("java.exe");
#else
return QStringLiteral("java");
#endif
}
void Fixtures::configureFakeJava(const QString &javaPath, const QString &version,
const QStringList &config)
{
QStringList lines{QStringLiteral("version=") + version};
lines += config;
write(QFileInfo(javaPath).absolutePath() + QStringLiteral("/fake-java.cfg"),
lines.join(u'\n').toUtf8() + '\n');
}
QString Fixtures::installFakeJava(const QString &dir, const QString &version,
const QStringList &config)
{
const QString binDir = dir + QStringLiteral("/bin");
QDir().mkpath(binDir);
const QString java = binDir + u'/' + javaExecutableName();
QFile::remove(java);
if (!QFile::copy(QStringLiteral(BILAUNCHER_FAKE_JAVA), java))
qFatal("Не удалось скопировать fake_java в %s", qPrintable(java));
QFile::setPermissions(java, QFile::permissions(java) | QFileDevice::ExeOwner
| QFileDevice::ExeGroup | QFileDevice::ExeOther);
#if defined(Q_OS_WIN)
const QString javaw = binDir + QStringLiteral("/javaw.exe");
QFile::remove(javaw);
QFile::copy(java, javaw);
#endif
configureFakeJava(java, version, config);
return java;
}
+60
View File
@@ -0,0 +1,60 @@
#pragma once
// Фикстуры и мелкие помощники для подготовки файлов в песочнице.
#include <QByteArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QMap>
#include <QString>
#include <QStringList>
namespace Fixtures {
// Абсолютный путь к tests/fixtures/<relative>.
QString path(const QString &relative);
// Содержимое фикстуры; при отсутствии файла тест падает через qFatal.
QByteArray read(const QString &relative);
QJsonObject readJson(const QString &relative);
// Фикстура-шаблон: %OS%, %OTHER%, %ARCH%, %NATIVE% заменяются на значения
// текущей платформы (имя ОС в терминах Mojang, «чужая» ОС, архитектура,
// классификатор нативов LWJGL) — один json годится для всех трёх ОС.
QByteArray readTemplate(const QString &relative);
QByteArray applyTemplate(QByteArray text);
QString otherOsName();
// Пишет файл, создавая недостающие папки.
bool write(const QString &path, const QByteArray &data);
bool writeJson(const QString &path, const QJsonObject &object);
QByteArray readFile(const QString &path);
QJsonObject readJsonFile(const QString &path);
// Zip из пар «путь внутри архива → содержимое» (через QZipWriter, как в коде лаунчера).
bool makeZip(const QString &zipPath, const QMap<QString, QByteArray> &entries);
QByteArray zipBytes(const QMap<QString, QByteArray> &entries);
// Список файлов в zip — как его видит QZipReader (ведущие «.» и «/» он срезает).
QStringList zipEntries(const QString &zipPath);
// tar.gz через системный tar (есть и на Windows 10+, и на Unix).
bool makeTarGz(const QString &archivePath, const QMap<QString, QByteArray> &entries);
QString sha1(const QByteArray &data);
QString sha256(const QByteArray &data);
// Все файлы под папкой (относительные пути, «/» в качестве разделителя), отсортированы.
QStringList listTree(const QString &dir);
// Подставная java (см. tests/fake_java). Копирует бинарник в <dir>/bin/java[.exe]
// (на Windows ещё и javaw.exe) и пишет рядом fake-java.cfg из строк config.
// Возвращает путь к java.
QString installFakeJava(const QString &dir, const QString &version,
const QStringList &config = {});
// Переписать fake-java.cfg у уже установленной подставной java.
void configureFakeJava(const QString &javaPath, const QString &version,
const QStringList &config);
QString javaExecutableName();
} // namespace Fixtures
+39
View File
@@ -0,0 +1,39 @@
#pragma once
// Помощники для тестов сетевых сервисов.
#include "fakeinternet.h"
#include <QString>
#include <QtTest/QTest>
// В initTestCase(): поднять FakeInternet или пропустить весь тест-класс.
// С BILAUNCHER_REQUIRE_FAKE_INTERNET=1 (так в CI на Linux) недоступность —
// ошибка, а не пропуск: иначе сломанный перехват тихо выключил бы все сетевые тесты.
#define BILAUNCHER_START_FAKE_INTERNET(net) \
do { \
QString startError_; \
if (!(net).start(&startError_)) { \
const QString message_ = QStringLiteral("FakeInternet недоступен: ") + startError_; \
if (qEnvironmentVariableIntValue("BILAUNCHER_REQUIRE_FAKE_INTERNET")) \
QFAIL(qPrintable(message_)); \
QSKIP(qPrintable(message_)); \
} \
} while (false)
// Результат колбэка вида (bool ok, QString warning).
struct CallResult
{
int calls = 0;
bool ok = false;
QString warning;
auto callback()
{
return [this](bool okValue, const QString &warningValue) {
++calls;
ok = okValue;
warning = warningValue;
};
}
};
+115
View File
@@ -0,0 +1,115 @@
#include "testenv.h"
#include "launcherpaths.h"
#include "localization.h"
#include <QDir>
#include <QFile>
#include <QTemporaryDir>
#include <cstdio>
#include <cstdlib>
#if defined(Q_OS_WIN)
#include <qt_windows.h>
#include <stdlib.h> // _wputenv_s
#endif
namespace {
QTemporaryDir *g_sandbox = nullptr;
[[noreturn]] void abortSetup(const QString &message)
{
const QString line = QStringLiteral("TestEnv: ") + message + u'\n';
#if defined(Q_OS_WIN)
// В живую консоль пишем UTF-16 напрямую: кодовая страница консоли тогда не важна.
const HANDLE handle = GetStdHandle(STD_ERROR_HANDLE);
DWORD mode = 0;
if (handle != INVALID_HANDLE_VALUE && GetConsoleMode(handle, &mode)) {
DWORD written = 0;
WriteConsoleW(handle, reinterpret_cast<const wchar_t *>(line.utf16()),
DWORD(line.size()), &written, nullptr);
std::exit(2);
}
#endif
// Вывод в трубу (ctest, CI) — UTF-8, как его и читает ctest. Локальная 8-битная
// кодировка Windows (cp1252/cp1251) превращала кириллицу в «???».
std::fputs(line.toUtf8().constData(), stderr);
std::fflush(stderr);
std::exit(2);
}
} // namespace
void TestEnv::prepareSandbox()
{
g_sandbox = new QTemporaryDir(QDir::tempPath() + QStringLiteral("/bilauncher-test-XXXXXX"));
if (!g_sandbox->isValid())
abortSetup(QStringLiteral("не удалось создать временную папку"));
const QString home = QDir::toNativeSeparators(g_sandbox->path());
setEnvPath("HOME", home);
setEnvPath("USERPROFILE", home);
// LauncherPaths: на Windows QDir::homePath() HOME/USERPROFILE не читает, а эту
// переменную лаунчер читает везде. Дочерние процессы получают её по наследству.
setEnvPath("BILAUNCHER_HOME", home);
// Чужая Java из окружения машины не должна влиять на выбор Java в тестах.
qunsetenv("JAVA_HOME");
std::atexit([] {
delete g_sandbox;
g_sandbox = nullptr;
});
}
void TestEnv::finishSetup()
{
QCoreApplication::setApplicationName(QStringLiteral("BiLauncher"));
QCoreApplication::setOrganizationName(QStringLiteral("GaleonDev"));
const QString root = QDir::cleanPath(LauncherPaths::rootDir());
const QString sandbox = QDir::cleanPath(g_sandbox->path());
const QString game = QDir::cleanPath(LauncherPaths::defaultMinecraftDir());
// Ни одна папка лаунчера не должна указывать на настоящий профиль: resetLauncherRoot()
// стирает rootDir() целиком.
for (const QString &path : {root, game}) {
if (!path.startsWith(sandbox + u'/'))
abortSetup(QStringLiteral("путь лаунчера %1 вне песочницы %2 — отказываюсь запускаться")
.arg(QDir::toNativeSeparators(path), QDir::toNativeSeparators(sandbox)));
}
if (!Localization::instance().load())
abortSetup(QStringLiteral("не загрузился каталог переводов"));
Localization::instance().setLanguage(QStringLiteral("en"));
}
void TestEnv::setEnvPath(const char *name, const QString &path)
{
// На Windows qputenv() пишет в ANSI-кодировке и испортил бы, например,
// кириллическое имя пользователя во временном пути.
#if defined(Q_OS_WIN)
_wputenv_s(reinterpret_cast<const wchar_t *>(QString::fromLatin1(name).utf16()),
reinterpret_cast<const wchar_t *>(path.utf16()));
#else
qputenv(name, QFile::encodeName(path));
#endif
}
QString TestEnv::homeDir()
{
return g_sandbox->path();
}
QString TestEnv::freshDir(const QString &name)
{
const QString path = g_sandbox->path() + QStringLiteral("/work/") + name;
QDir(path).removeRecursively();
QDir().mkpath(path);
return path;
}
void TestEnv::resetLauncherRoot()
{
QDir(LauncherPaths::rootDir()).removeRecursively();
}
+55
View File
@@ -0,0 +1,55 @@
#pragma once
// Общая обвязка тестов: песочница для домашней папки и main для QtTest.
//
// LauncherPaths строит все пути от домашней папки. Каждый тестовый процесс до
// создания QCoreApplication заводит свою временную папку и направляет туда
// BILAUNCHER_HOME (её LauncherPaths читает на всех ОС), а заодно HOME и
// USERPROFILE (их читает QDir::homePath() на Linux и macOS). На Windows
// QDir::homePath() переменные окружения игнорирует, поэтому без BILAUNCHER_HOME
// там не обойтись. Реальные папка лаунчера и .minecraft машины, на которой гоняются
// тесты, не трогаются: если путь лаунчера всё же оказался вне песочницы,
// процесс сразу завершается с кодом 2.
#include <QCoreApplication>
#include <QString>
#include <QtTest/QTest>
namespace TestEnv {
// До QCoreApplication: временная папка и подмена BILAUNCHER_HOME/HOME/USERPROFILE.
void prepareSandbox();
// После QCoreApplication: проверка, что пути лаунчера ушли в песочницу,
// загрузка переводов, английский язык (тексты ошибок не зависят от локали машины).
void finishSetup();
// Переменная окружения с путём, без потерь символов вне ANSI-кодировки на Windows.
void setEnvPath(const char *name, const QString &path);
// Корень песочницы — домашняя папка, от которой LauncherPaths строит пути в тестах.
QString homeDir();
// Свежая пустая папка внутри песочницы (для .minecraft, архивов и т. п.).
QString freshDir(const QString &name);
// Стирает папку лаунчера (LauncherPaths::rootDir()) — между тестами.
void resetLauncherRoot();
} // namespace TestEnv
#define BILAUNCHER_TEST_MAIN_IMPL(AppType, TestObject) \
int main(int argc, char *argv[]) \
{ \
TestEnv::prepareSandbox(); \
AppType app(argc, argv); \
TestEnv::finishSetup(); \
TestObject tc; \
QTEST_SET_MAIN_SOURCE_PATH \
return QTest::qExec(&tc, argc, argv); \
}
#define BILAUNCHER_TEST_MAIN(TestObject) BILAUNCHER_TEST_MAIN_IMPL(QCoreApplication, TestObject)
// Тесту с GUI нужно самому подключить <QGuiApplication>.
#define BILAUNCHER_GUI_TEST_MAIN(TestObject) BILAUNCHER_TEST_MAIN_IMPL(QGuiApplication, TestObject)
+138
View File
@@ -0,0 +1,138 @@
// Подставная «java» для тестов. Без Qt, чтобы её копию можно было положить
// в любую папку и запустить без поиска библиотек Qt.
//
// Поведение задаётся файлом fake-java.cfg рядом с исполняемым файлом,
// по строке «ключ=значение»:
// version=21.0.2 что печатать на «-version» (в stderr, как настоящая java)
// exit=0 код выхода обычного запуска
// sleep_ms=0 сколько «работать» перед выходом
// args_file=<путь> куда записать аргументы, по одному на строку
// dump_env=<ИМЯ> дописать в args_file строку «env:ИМЯ=значение» (можно несколько)
// stdout=<строка> что напечатать в stdout (можно несколько)
// create=<путь>\t<текст> создать файл с текстом; относительный путь — от рабочей папки
// crash=1 завершиться аварийно (abort)
#include <chrono>
#include <cstdlib>
#include <filesystem>
#include <fstream>
#include <iostream>
#include <iterator>
#include <string>
#include <thread>
#include <vector>
#if defined(_WIN32)
#include <windows.h>
#elif defined(__APPLE__)
#include <mach-o/dyld.h>
#endif
namespace fs = std::filesystem;
namespace {
fs::path executablePath(const char *argv0)
{
#if defined(_WIN32)
wchar_t buffer[MAX_PATH * 4];
const DWORD size = GetModuleFileNameW(nullptr, buffer, DWORD(std::size(buffer)));
return fs::path(std::wstring(buffer, size));
#elif defined(__APPLE__)
char buffer[4096];
uint32_t size = sizeof(buffer);
if (_NSGetExecutablePath(buffer, &size) == 0)
return fs::canonical(buffer);
return fs::absolute(argv0);
#else
std::error_code ec;
const fs::path self = fs::read_symlink("/proc/self/exe", ec);
return ec ? fs::absolute(argv0) : self;
#endif
}
} // namespace
int main(int argc, char *argv[])
{
std::string version = "17.0.8";
int exitCode = 0;
int sleepMs = 0;
bool crash = false;
std::string argsFile;
std::vector<std::string> dumpEnv;
std::vector<std::string> stdoutLines;
std::vector<std::pair<std::string, std::string>> creates;
std::ifstream config(executablePath(argv[0]).parent_path() / "fake-java.cfg");
for (std::string line; std::getline(config, line);) {
if (!line.empty() && line.back() == '\r')
line.pop_back();
const auto eq = line.find('=');
if (eq == std::string::npos)
continue;
const std::string key = line.substr(0, eq);
const std::string value = line.substr(eq + 1);
if (key == "version")
version = value;
else if (key == "exit")
exitCode = std::atoi(value.c_str());
else if (key == "sleep_ms")
sleepMs = std::atoi(value.c_str());
else if (key == "crash")
crash = value == "1";
else if (key == "args_file")
argsFile = value;
else if (key == "dump_env")
dumpEnv.push_back(value);
else if (key == "stdout")
stdoutLines.push_back(value);
else if (key == "create") {
const auto tab = value.find('\t');
creates.emplace_back(value.substr(0, tab),
tab == std::string::npos ? std::string() : value.substr(tab + 1));
}
}
for (int i = 1; i < argc; ++i) {
if (std::string(argv[i]) == "-version") {
std::cerr << "openjdk version \"" << version << "\" 2024-01-16\n"
<< "OpenJDK Runtime Environment (fake)\n";
return 0;
}
}
if (!argsFile.empty()) {
// binary: в текстовом режиме Windows превратит '\n' в "\r\n", и тесты увидят «-jar\r».
std::ofstream out(argsFile, std::ios::trunc | std::ios::binary);
for (int i = 1; i < argc; ++i)
out << argv[i] << '\n';
for (const std::string &name : dumpEnv) {
const char *value = std::getenv(name.c_str());
out << "env:" << name << '=' << (value ? value : "") << '\n';
}
}
for (const auto &[path, text] : creates) {
const fs::path target(path);
std::error_code ec;
if (target.has_parent_path())
fs::create_directories(target.parent_path(), ec);
std::ofstream(target, std::ios::trunc | std::ios::binary) << text;
}
for (const std::string &line : stdoutLines)
std::cout << line << '\n';
std::cout.flush();
if (sleepMs > 0)
std::this_thread::sleep_for(std::chrono::milliseconds(sleepMs));
if (crash) {
#if defined(_WIN32)
// QProcess считает аварией коды 0x8xxxxxxx–0xCxxxxxxx; abort() дал бы код 3.
TerminateProcess(GetCurrentProcess(), 0xC0000005);
#endif
std::abort();
}
return exitCode;
}
+9
View File
@@ -0,0 +1,9 @@
[
{"loader": {"separator": ".", "build": 9, "maven": "net.fabricmc:fabric-loader:0.16.9", "version": "0.16.9", "stable": true},
"intermediary": {"maven": "net.fabricmc:intermediary:1.20.1", "version": "1.20.1", "stable": true}},
{"loader": {"separator": ".", "build": 10, "maven": "net.fabricmc:fabric-loader:0.16.10", "version": "0.16.10", "stable": true},
"intermediary": {"maven": "net.fabricmc:intermediary:1.20.1", "version": "1.20.1", "stable": true}},
{"loader": {"separator": "+", "build": 1, "maven": "net.fabricmc:fabric-loader:0.17.0-beta.1", "version": "0.17.0-beta.1", "stable": false},
"intermediary": {"maven": "net.fabricmc:intermediary:1.20.1", "version": "1.20.1", "stable": true}},
{"loader": {"version": ""}}
]
+18
View File
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<metadata>
<groupId>net.minecraftforge</groupId>
<artifactId>forge</artifactId>
<versioning>
<latest>1.21.1-52.0.10</latest>
<release>1.21.1-52.0.10</release>
<versions>
<version>1.7.10-10.13.4.1614-1.7.10</version>
<version>1.20.1-47.3.0</version>
<version>1.20.1-47.4.0</version>
<version>1.20.1-47.4.1</version>
<version>1.21.1-52.0.10</version>
<version>1.21.1-52.0.2-beta</version>
<version>broken-</version>
</versions>
</versioning>
</metadata>
+2
View File
@@ -0,0 +1,2 @@
{"homepage": "https://files.minecraftforge.net/net/minecraftforge/forge/",
"promos": {"1.20.1-latest": "47.4.1", "1.20.1-recommended": "47.4.0", "1.21.1-latest": "52.0.10"}}
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<metadata>
<groupId>net.neoforged</groupId>
<artifactId>forge</artifactId>
<versioning>
<versions>
<version>1.20.1-47.1.106</version>
<version>1.20.1-47.1.79</version>
</versions>
</versioning>
</metadata>
+16
View File
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<metadata>
<groupId>net.neoforged</groupId>
<artifactId>neoforge</artifactId>
<versioning>
<versions>
<version>20.4.80-beta</version>
<version>20.4.237</version>
<version>21.0.10</version>
<version>21.1.200</version>
<version>21.1.248</version>
<version>26.1.0.5</version>
<version>26.1.1.3</version>
</versions>
</versioning>
</metadata>
+4
View File
@@ -0,0 +1,4 @@
[
{"loader": {"separator": ".", "build": 21, "maven": "org.quiltmc:quilt-loader:0.26.4-beta.1", "version": "0.26.4-beta.1"}},
{"loader": {"separator": ".", "build": 20, "maven": "org.quiltmc:quilt-loader:0.26.3", "version": "0.26.3"}}
]
+16
View File
@@ -0,0 +1,16 @@
{
"latest": {"release": "1.21.8", "snapshot": "25w33a"},
"versions": [
{"id": "1.20.1", "type": "release", "url": "https://piston-meta.mojang.com/v1/packages/aaaa/1.20.1.json",
"time": "2023-06-12T13:25:51+00:00", "releaseTime": "2023-06-12T13:25:51+00:00", "sha1": "aaaa", "complianceLevel": 1},
{"id": "25w33a", "type": "snapshot", "url": "https://piston-meta.mojang.com/v1/packages/cccc/25w33a.json",
"time": "2025-08-13T12:00:00+00:00", "releaseTime": "2025-08-13T12:00:00+00:00", "sha1": "cccc", "complianceLevel": 1},
{"id": "1.21.8", "type": "release", "url": "https://piston-meta.mojang.com/v1/packages/bbbb/1.21.8.json",
"time": "2025-07-17T12:00:00+00:00", "releaseTime": "2025-07-17T12:00:00+00:00", "sha1": "bbbb", "complianceLevel": 1},
{"id": "b1.7.3", "type": "old_beta", "url": "https://piston-meta.mojang.com/v1/packages/dddd/b1.7.3.json",
"time": "2011-07-08T00:00:00+00:00", "releaseTime": "2011-07-08T00:00:00+00:00", "sha1": "dddd", "complianceLevel": 0},
{"id": "insecure", "type": "release", "url": "http://piston-meta.mojang.com/insecure.json",
"releaseTime": "2024-01-01T00:00:00+00:00", "sha1": "eeee"},
{"type": "release", "url": "https://piston-meta.mojang.com/noid.json", "releaseTime": "2024-01-01T00:00:00+00:00"}
]
}
+240
View File
@@ -0,0 +1,240 @@
{
"formatVersion": 1,
"builds": [
{
"id": "season-4",
"name": "Сезон 4",
"revision": 1,
"minecraftVersion": "1.20.1",
"loader": "forge",
"loaderVersion": "47.4.0",
"modCount": 10,
"seasonStart": "2025-03-01",
"serverUrl": "play.example.com",
"javaMajor": 17,
"description": "d",
"archive": {
"url": "https://launcher-builds.galeonworx.com/packs/s4.zip",
"size": 100,
"sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
},
"seasonEnd": "2025-06-01"
},
{
"id": "season-5",
"name": "Сезон 5: Пустоши",
"revision": 3,
"minecraftVersion": "1.21.1",
"loader": "fabric",
"loaderVersion": "0.16.10",
"modCount": 120,
"seasonStart": "2025-07-01",
"serverUrl": "play.example.com",
"javaMajor": 21,
"description": " Пустоши ",
"archive": {
"url": "https://launcher-builds.galeonworx.com/packs/s5.zip",
"size": 123456,
"sha256": "ABCDEF0000000000000000000000000000000000000000000000000000000000"
}
},
{
"id": "vanilla-plus",
"name": "Vanilla+",
"revision": 1,
"minecraftVersion": "1.20.1",
"loader": "",
"loaderVersion": "",
"modCount": 10,
"seasonStart": "2025-05-01",
"serverUrl": "play.example.com",
"javaMajor": 17,
"description": "d",
"archive": {
"url": "https://launcher-builds.galeonworx.com/packs/v.zip",
"size": 100,
"sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
}
},
{
"id": "Bad-ID",
"name": "caps",
"revision": 1,
"minecraftVersion": "1.20.1",
"loader": "forge",
"loaderVersion": "47.4.0",
"modCount": 10,
"seasonStart": "2025-01-01",
"serverUrl": "play.example.com",
"javaMajor": 17,
"description": "d",
"archive": {
"url": "https://launcher-builds.galeonworx.com/packs/x.zip",
"size": 100,
"sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
}
},
{
"id": "no-name",
"name": " ",
"revision": 1,
"minecraftVersion": "1.20.1",
"loader": "forge",
"loaderVersion": "47.4.0",
"modCount": 10,
"seasonStart": "2025-01-01",
"serverUrl": "play.example.com",
"javaMajor": 17,
"description": "d",
"archive": {
"url": "https://launcher-builds.galeonworx.com/packs/x.zip",
"size": 100,
"sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
}
},
{
"id": "zero-rev",
"name": "X",
"revision": 0,
"minecraftVersion": "1.20.1",
"loader": "forge",
"loaderVersion": "47.4.0",
"modCount": 10,
"seasonStart": "2025-01-01",
"serverUrl": "play.example.com",
"javaMajor": 17,
"description": "d",
"archive": {
"url": "https://launcher-builds.galeonworx.com/packs/x.zip",
"size": 100,
"sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
}
},
{
"id": "bad-mc",
"name": "X",
"revision": 1,
"minecraftVersion": "../1.20",
"loader": "forge",
"loaderVersion": "47.4.0",
"modCount": 10,
"seasonStart": "2025-01-01",
"serverUrl": "play.example.com",
"javaMajor": 17,
"description": "d",
"archive": {
"url": "https://launcher-builds.galeonworx.com/packs/x.zip",
"size": 100,
"sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
}
},
{
"id": "bad-loader",
"name": "X",
"revision": 1,
"minecraftVersion": "1.20.1",
"loader": "liteloader",
"loaderVersion": "47.4.0",
"modCount": 10,
"seasonStart": "2025-01-01",
"serverUrl": "play.example.com",
"javaMajor": 17,
"description": "d",
"archive": {
"url": "https://launcher-builds.galeonworx.com/packs/x.zip",
"size": 100,
"sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
}
},
{
"id": "no-loader-version",
"name": "X",
"revision": 1,
"minecraftVersion": "1.20.1",
"loader": "forge",
"loaderVersion": "",
"modCount": 10,
"seasonStart": "2025-01-01",
"serverUrl": "play.example.com",
"javaMajor": 17,
"description": "d",
"archive": {
"url": "https://launcher-builds.galeonworx.com/packs/x.zip",
"size": 100,
"sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
}
},
{
"id": "bad-sha",
"name": "X",
"revision": 1,
"minecraftVersion": "1.20.1",
"loader": "forge",
"loaderVersion": "47.4.0",
"modCount": 10,
"seasonStart": "2025-01-01",
"serverUrl": "play.example.com",
"javaMajor": 17,
"description": "d",
"archive": {
"url": "https://launcher-builds.galeonworx.com/packs/x.zip",
"size": 100,
"sha256": "xyz"
}
},
{
"id": "neg-size",
"name": "X",
"revision": 1,
"minecraftVersion": "1.20.1",
"loader": "forge",
"loaderVersion": "47.4.0",
"modCount": 10,
"seasonStart": "2025-01-01",
"serverUrl": "play.example.com",
"javaMajor": 17,
"description": "d",
"archive": {
"url": "https://launcher-builds.galeonworx.com/packs/x.zip",
"size": -1,
"sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
}
},
{
"id": "foreign-host",
"name": "X",
"revision": 1,
"minecraftVersion": "1.20.1",
"loader": "forge",
"loaderVersion": "47.4.0",
"modCount": 10,
"seasonStart": "2025-01-01",
"serverUrl": "play.example.com",
"javaMajor": 17,
"description": "d",
"archive": {
"url": "https://evil.example/packs/x.zip",
"size": 100,
"sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
}
},
{
"id": "http-scheme",
"name": "X",
"revision": 1,
"minecraftVersion": "1.20.1",
"loader": "forge",
"loaderVersion": "47.4.0",
"modCount": 10,
"seasonStart": "2025-01-01",
"serverUrl": "play.example.com",
"javaMajor": 17,
"description": "d",
"archive": {
"url": "http://launcher-builds.galeonworx.com/packs/x.zip",
"size": 100,
"sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
}
}
]
}
+18
View File
@@ -0,0 +1,18 @@
-----BEGIN CERTIFICATE-----
MIIC6zCCAdOgAwIBAgIJALwPNtuB/leeMA0GCSqGSIb3DQEBCwUAMB0xGzAZBgNV
BAMMEmZha2UtaW50ZXJuZXQudGVzdDAgFw0yNjEwMTEyMDQ4NTZaGA8yMTI2MDkx
NzIwNDg1NlowHTEbMBkGA1UEAwwSZmFrZS1pbnRlcm5ldC50ZXN0MIIBIjANBgkq
hkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAvFAbXruU1WU9utLa/SSBIX8XUQs8rjum
KoK13RVPKlkSGln5umz0MZJas95QwHDf8s0qKR2wOyFZgDl5S4YD6sotHiqcF6zV
9FmP14So0dAL+B/RsxnLFc8seCDtW6GB1fS0G36EJBRFpt2RizGGx4zdj5thzlrf
0msXdzEY8gFriFhuejS4DbX7BlPjnuaXb2iFpTQnNw+MpIDcdfnUuUm56x29wZRb
trMnOqSP7YqVi06XM2w5NbDUlXXCSWR57nPlrleJlMX1YbDdERf7Nngl2ECBCOAY
J3pkKkQ2I8Ne059bC9ZRoF/daggGclp1JTi2PgDewefSkHPiTr7eBQIDAQABoyww
KjAoBgNVHREEITAfghJmYWtlLWludGVybmV0LnRlc3SCCWxvY2FsaG9zdDANBgkq
hkiG9w0BAQsFAAOCAQEAQQFCHNvJrn/fC8H6ntJktLyzseOQ8q6B8SSXGUd0l5/n
TQDufm/+49TpPrHu2+eoHMeeb4gijlMK8PME685x154E2erdjtilE/YBUemeLrrn
NLRIaqafArhd7qXE5pFzNc6QNAcrLF9Ao0SsI3xCxtK5duWMhQGFKQr6f7NqrSit
wNB9uYCf5pAZfL+bfRBtC6Xc6qnZy5LBUQcEkpu/HweE6DZKV1Z4XH4TGxWiO0y7
9YFYH9P/I0jsHDT/VK+6PF2JTdErqWfCxryDuUgheLw1/W8T+5CVVCvb4Hr0fsP3
0o/cCfPR9wdsqGalv6dl3cuAWCRqEq1lNVLQVbY1cg==
-----END CERTIFICATE-----
+28
View File
@@ -0,0 +1,28 @@
-----BEGIN PRIVATE KEY-----
MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQC8UBteu5TVZT26
0tr9JIEhfxdRCzyuO6YqgrXdFU8qWRIaWfm6bPQxklqz3lDAcN/yzSopHbA7IVmA
OXlLhgPqyi0eKpwXrNX0WY/XhKjR0Av4H9GzGcsVzyx4IO1boYHV9LQbfoQkFEWm
3ZGLMYbHjN2Pm2HOWt/Saxd3MRjyAWuIWG56NLgNtfsGU+Oe5pdvaIWlNCc3D4yk
gNx1+dS5SbnrHb3BlFu2syc6pI/tipWLTpczbDk1sNSVdcJJZHnuc+WuV4mUxfVh
sN0RF/s2eCXYQIEI4BgnemQqRDYjw17Tn1sL1lGgX91qCAZyWnUlOLY+AN7B59KQ
c+JOvt4FAgMBAAECggEABOS0AV8we+cYlo06IY/cRymNSo7XMdPc+ofRAAC4wOn1
nXevFJgWNF+QOdJSGN4WA7IvIUhjNvYj5EAaFctSuLwuNfV3fun/2Uymfzw0Qtd8
KCNziIroCxYKGskIQfRNcmjccUZ1WpaoRLDHQm20CeVwMHYiMYGNBWPBBVjiRA+X
+T+iQq1f4GX2ErPw1dlcCSW/HVzyRqRwxB12VfQpW71Jq9h7oeOtcrVAEZj+rPaa
io1u/Z4kmRxuyqgE9tcDUmdYhvijnZRhPSqWJhmQyVzzCpUX8Vtwy/Bow1EKUBaa
jKtwu75ap4oTvaSJsHbX3+69eXJjIvXAQTKC7HgUFQKBgQDendqYimm1G/GDCBCj
vXLYhHkP4MRI78jcggc4rJIv+rZGto7QZZRIEi5eVAdqlqoKL/xBIi7bDc6AH73p
/M+xTWDJ4p09AMOY0k8WwsThzNOsA+R3futbKaM4NSTqwMzqaZs+jcyl/sIJUr8S
tq7ndQtMSSf0O3mCg8nnvQuKLwKBgQDYjVmugVxKYchOZ3yXjPD7a/H54n4NiqnW
/BvFPwhxMtQtb3YzzgyAuBskK3PEFjI+hKoLDGjHV/uZS1sAm1qVm2mN/lyPSeaU
P2+1EOKsvWl9DoOkwTeb3QoGkXzZ35fORCLd7iphicFptFSgmqtaKT7KSQyKHaw2
pxSwnPlyCwKBgCraqGIFAyRiJLPcsZdTuPAFUGjGFAMWZcxNG6bFUb76vGwQaJ2l
cE4Pce2mr2bNah5b7mt7cRmqJr5zXdPVXwX6p0812J8NrpmUorT2+X+eNoE+npSC
UjxSX6FKWbj3NHtLqR75nRO3XxL8YtvvXuwfk/UzgAV8DYAabpu1wKrxAoGAMUep
ev7RQ9QQw1blkY9vX6fm/xi92tMNZ3Ej2LVJdIk+o8BTw+JGEhzTgF2rEJqEgJms
IKuIRKUPComNggvYh2dAlCGoaqtEn8P35dqb2veXw+/DoYCe6QdaOrhtEx/A6h2z
WZHTbzLsIuP4xjusdu4f7eTFx8CM6jsHiFGQqpUCgYBZlBn76C+3ctH4sgLfU/Zd
Kz8cP+K/QpvA2R6K7DBn57drVG6wRIhibv3YO4YwDntLyOwZ2r5VA5uTvLqU2gGO
AYDMTZ+g9csla0QjpoMDz3ThUgeYMb5nJ3maMx/kq3aNTGMKjncTLhiC7CNSWagC
Mv+y9lT/Hf6ZSiiti8EQGQ==
-----END PRIVATE KEY-----
@@ -0,0 +1,9 @@
{
"id": "fabric-loader-0.15.0-1.20.1", "inheritsFrom": "1.20.1", "type": "release",
"mainClass": "net.fabricmc.loader.impl.launch.knot.KnotClient",
"arguments": {"game": [], "jvm": ["-DFabricMcEmu= net.minecraft.client.main.Main "]},
"libraries": [
{"name": "net.fabricmc:fabric-loader:0.15.0", "url": "https://maven.fabricmc.net/"},
{"name": "com.mojang:brigadier:9.9.9", "url": "https://maven.fabricmc.net/"}
]
}
+16
View File
@@ -0,0 +1,16 @@
{
"id": "1.7.10", "mainClass": "net.minecraft.client.main.Main",
"minecraftArguments": "--username ${auth_player_name} --session ${auth_session} --gameDir ${game_directory}",
"assets": "1.7.10",
"libraries": [
{"name": "org.lwjgl.lwjgl:lwjgl-platform:2.9.1",
"natives": {"linux": "natives-linux", "windows": "natives-windows-${arch}", "osx": "natives-osx"},
"extract": {"exclude": ["META-INF/"]},
"downloads": {"classifiers": {
"natives-linux": {"path": "lw/natives-linux.jar", "url": "https://l/nl.jar", "sha1": "1", "size": 1},
"natives-windows-64": {"path": "lw/natives-windows-64.jar", "url": "https://l/nw.jar", "sha1": "2", "size": 2},
"natives-windows-32": {"path": "lw/natives-windows-32.jar", "url": "https://l/nw32.jar", "sha1": "3", "size": 3},
"natives-osx": {"path": "lw/natives-osx.jar", "url": "https://l/no.jar", "sha1": "4", "size": 4}}}},
{"name": "net.minecraft:launchwrapper:1.12", "url": "https://libraries.example/maven"}
]
}
+42
View File
@@ -0,0 +1,42 @@
{
"id": "1.20.1",
"type": "release",
"mainClass": "net.minecraft.client.main.Main",
"assets": "5",
"assetIndex": {"id": "5", "url": "https://piston-meta.mojang.com/5.json", "sha1": "aa", "size": 1},
"javaVersion": {"component": "java-runtime-gamma", "majorVersion": 17},
"logging": {"client": {"argument": "-Dlog4j.configurationFile=${path}",
"file": {"id": "client-1.12.xml", "url": "https://x/client-1.12.xml"}}},
"arguments": {
"game": ["--username", "${auth_player_name}", "--version", "${version_name}",
"--gameDir", "${game_directory}", "--assetsDir", "${assets_root}",
"--assetIndex", "${assets_index_name}", "--uuid", "${auth_uuid}",
"--accessToken", "${auth_access_token}", "--userType", "${user_type}",
"--versionType", "${version_type}",
{"rules": [{"action": "allow", "features": {"is_demo_user": true}}], "value": "--demo"},
{"rules": [{"action": "allow", "features": {"has_custom_resolution": true}}],
"value": ["--width", "${resolution_width}", "--height", "${resolution_height}"]},
{"rules": [{"action": "allow", "features": {"is_quick_play_multiplayer": true}}],
"value": ["--quickPlayMultiplayer", "${quickPlayMultiplayer}"]}],
"jvm": [{"rules": [{"action": "allow", "os": {"name": "%OS%"}}], "value": "-Dthis.os=yes"},
{"rules": [{"action": "allow", "os": {"name": "%OTHER%"}}], "value": "-Dother.os=yes"},
"-Djava.library.path=${natives_directory}", "-cp", "${classpath}"]
},
"libraries": [
{"name": "com.mojang:brigadier:1.1.8",
"downloads": {"artifact": {"path": "com/mojang/brigadier/1.1.8/brigadier-1.1.8.jar",
"url": "https://libraries.minecraft.net/com/mojang/brigadier/1.1.8/brigadier-1.1.8.jar",
"sha1": "5244ce82c3337bba4a196a3ce858bfaecc74404a", "size": 77392}}},
{"name": "org.lwjgl:lwjgl:3.3.1",
"downloads": {"artifact": {"path": "org/lwjgl/lwjgl/3.3.1/lwjgl-3.3.1.jar", "url": "https://l/lwjgl.jar", "sha1": "s1", "size": 1}}},
{"name": "org.lwjgl:lwjgl:3.3.1:%NATIVE%",
"downloads": {"artifact": {"path": "org/lwjgl/lwjgl/3.3.1/lwjgl-3.3.1-%NATIVE%.jar", "url": "https://l/native.jar", "sha1": "s2", "size": 2}},
"rules": [{"action": "allow", "os": {"name": "%OS%"}}]},
{"name": "org.lwjgl:lwjgl:3.3.1:natives-other",
"downloads": {"artifact": {"path": "org/lwjgl/lwjgl/3.3.1/lwjgl-3.3.1-natives-other.jar", "url": "https://l/o.jar", "sha1": "s3", "size": 3}},
"rules": [{"action": "allow", "os": {"name": "%OTHER%"}}]},
{"name": "only.other:lib:1.0",
"downloads": {"artifact": {"path": "only/other/lib/1.0/lib-1.0.jar", "url": "https://l/x.jar", "sha1": "s4", "size": 4}},
"rules": [{"action": "allow"}, {"action": "disallow", "os": {"name": "%OS%"}}]}
]
}
+15
View File
@@ -0,0 +1,15 @@
# Сетевые сервисы против FakeInternet (локальный прокси + TLS, без настоящей сети).
foreach(test IN ITEMS
tst_fakeinternet
tst_versionmanifestservice
tst_seasonalbuildservice
tst_modloaderversionservice
tst_javaruntimeservice
tst_authservice
tst_msaauthservice
tst_versioninstaller
tst_seasonalpackdownloader
tst_javainstaller
tst_modloaderinstaller)
bilauncher_add_test(${test} LABEL net SOURCES ${test}.cpp)
endforeach()
+298
View File
@@ -0,0 +1,298 @@
#include "testenv.h"
#include "fixtures.h"
#include "nettest.h"
#include "authservice.h"
#include "localization.h"
#include <QJsonDocument>
#include <QSignalSpy>
namespace {
const QString kAuth = QStringLiteral("https://authserver.ely.by/auth/authenticate");
const QString kValidate = QStringLiteral("https://authserver.ely.by/auth/validate");
const QString kRefresh = QStringLiteral("https://authserver.ely.by/auth/refresh");
const QString kInjectorManifest = QStringLiteral("https://authlib-injector.yushi.moe/artifact/latest.json");
const QString kInjectorJar = QStringLiteral("https://authlib-injector.yushi.moe/artifact/53/authlib-injector-1.2.5.jar");
QJsonObject body(const FakeRequest &request)
{
return QJsonDocument::fromJson(request.body).object();
}
QJsonObject profileResponse(const QString &token, const QString &clientToken)
{
return QJsonObject{{"accessToken", token},
{"clientToken", clientToken},
{"selectedProfile", QJsonObject{{"id", "ffffffff-ffff-ffff-ffff-ffffffffffff"},
{"name", "ElyPlayer"}}}};
}
// Сервер Ely.by в миниатюре: пароль «secret», у пользователя «2fa» — ещё код 123456.
FakeResponse elyAuthenticate(const FakeRequest &request)
{
const QJsonObject in = body(request);
const QString user = in.value("username").toString();
const QString password = in.value("password").toString();
if (user == QLatin1String("2fa")) {
if (password == QLatin1String("secret:123456"))
return FakeResponse::json(profileResponse("token-2fa", in.value("clientToken").toString()));
if (password == QLatin1String("secret"))
return FakeResponse::json(QJsonObject{{"error", "ForbiddenOperationException"},
{"errorMessage", "Account protected with two factor auth."}}, 401);
}
if (password == QLatin1String("secret"))
return FakeResponse::json(profileResponse("token-" + user, in.value("clientToken").toString()));
return FakeResponse::json(QJsonObject{{"error", "ForbiddenOperationException"},
{"errorMessage", "Invalid credentials. Invalid username or password."}}, 401);
}
struct Captured
{
int calls = 0;
AuthResult result;
auto callback()
{
return [this](const AuthResult &r) { ++calls; result = r; };
}
};
} // namespace
class TestAuthService : public QObject
{
Q_OBJECT
FakeInternet m_net;
QString m_dir;
static bool wait(Captured &c) { return QTest::qWaitFor([&] { return c.calls > 0; }, 15000); }
private slots:
void initTestCase() { BILAUNCHER_START_FAKE_INTERNET(m_net); }
void init()
{
m_net.reset();
m_net.route(kAuth, elyAuthenticate);
m_dir = TestEnv::freshDir(QStringLiteral("injector"));
}
void passwordLogin()
{
AuthService service;
QSignalSpy progress(&service, &AuthService::progress);
Captured c;
service.loginElyBy("steve", "secret", "client-1", QString(), c.callback());
QVERIFY(wait(c));
QVERIFY2(c.result.ok, qPrintable(c.result.error));
QCOMPARE(c.result.playerName, QStringLiteral("ElyPlayer"));
QCOMPARE(c.result.uuid, QStringLiteral("ffffffffffffffffffffffffffffffff")); // без дефисов
QCOMPARE(c.result.accessToken, QStringLiteral("token-steve"));
QCOMPARE(c.result.clientToken, QStringLiteral("client-1"));
QCOMPARE(c.result.userType, QStringLiteral("ELYBY"));
QCOMPARE(progress.count(), 1);
const QJsonObject sent = body(m_net.requests().constFirst());
QCOMPARE(sent.value("username").toString(), QStringLiteral("steve"));
QCOMPARE(sent.value("clientToken").toString(), QStringLiteral("client-1"));
QCOMPARE(sent.value("requestUser").toBool(), true);
QCOMPARE(m_net.requests().constFirst().header("content-type"), QByteArray("application/json"));
}
void emptyClientTokenIsGenerated()
{
AuthService service;
Captured c;
service.loginElyBy("steve", "secret", QString(), QString(), c.callback());
QVERIFY(wait(c));
QVERIFY(c.result.ok);
QVERIFY(!c.result.clientToken.isEmpty());
QCOMPARE(body(m_net.requests().constFirst()).value("clientToken").toString(), c.result.clientToken);
}
void wrongPasswordAndEmptyPassword()
{
AuthService service;
Captured wrong;
service.loginElyBy("steve", "nope", "c", QString(), wrong.callback());
QVERIFY(wait(wrong));
QVERIFY(!wrong.result.ok);
QVERIFY(!wrong.result.twoFactorRequired);
QCOMPARE(wrong.result.error, QStringLiteral("Invalid credentials. Invalid username or password."));
Captured empty;
service.loginElyBy("steve", QString(), "c", QString(), empty.callback());
QCOMPARE(empty.calls, 1); // сразу, без сети
QCOMPARE(empty.result.error, Loc::text("auth.ely.error.noPassword"));
QCOMPARE(m_net.requests().size(), 1);
}
void twoFactorFlow()
{
AuthService service;
Captured first;
service.loginElyBy("2fa", "secret", "c", QString(), first.callback());
QVERIFY(wait(first));
QVERIFY(!first.result.ok);
QVERIFY(first.result.twoFactorRequired);
QCOMPARE(first.result.error, Loc::text("auth.ely.error.twoFactor"));
Captured second;
service.loginElyByWithTotp("2fa", "secret", "123456", "c", second.callback());
QVERIFY(wait(second));
QVERIFY2(second.result.ok, qPrintable(second.result.error));
QCOMPARE(second.result.accessToken, QStringLiteral("token-2fa"));
// Код уходит через двоеточие после пароля — так требует Ely.by.
QCOMPARE(body(m_net.requests().constLast()).value("password").toString(), QStringLiteral("secret:123456"));
}
void validTokenSkipsPassword()
{
m_net.route(kValidate, FakeResponse::json("{}"));
AuthService service;
Captured c;
service.loginElyBy("steve", "secret", "c", "stored-token", c.callback());
QVERIFY(wait(c));
QVERIFY(c.result.ok);
QCOMPARE(c.result.accessToken, QStringLiteral("stored-token"));
QCOMPARE(m_net.hits(kAuth), 0);
QCOMPARE(body(m_net.requests().constFirst()).value("accessToken").toString(), QStringLiteral("stored-token"));
}
void expiredTokenIsRefreshed()
{
m_net.route(kValidate, FakeResponse::json(QJsonObject{{"error", "ForbiddenOperationException"}}, 401));
m_net.route(kRefresh, [](const FakeRequest &request) {
const QJsonObject in = body(request);
return FakeResponse::json(profileResponse("refreshed-" + in.value("accessToken").toString(),
in.value("clientToken").toString()));
});
AuthService service;
Captured c;
service.loginElyBy("steve", "secret", "c", "old", c.callback());
QVERIFY(wait(c));
QVERIFY(c.result.ok);
QCOMPARE(c.result.accessToken, QStringLiteral("refreshed-old"));
QCOMPARE(c.result.playerName, QStringLiteral("ElyPlayer"));
QCOMPARE(m_net.hits(kAuth), 0);
}
void failedRefreshFallsBackToPassword()
{
m_net.route(kValidate, FakeResponse::json("{}", 401));
m_net.route(kRefresh, FakeResponse::json("{}", 401));
AuthService service;
Captured c;
service.loginElyBy("steve", "secret", "c", "old", c.callback());
QVERIFY(wait(c));
QVERIFY(c.result.ok);
QCOMPARE(c.result.accessToken, QStringLiteral("token-steve"));
QCOMPARE(m_net.hits(kAuth), 1);
}
void transportErrorIsReported()
{
FakeResponse drop;
drop.drop = true;
m_net.route(kAuth, drop);
AuthService service;
Captured c;
service.loginElyBy("steve", "secret", "c", QString(), c.callback());
QVERIFY(wait(c));
QVERIFY(!c.result.ok);
QVERIFY(c.result.error.startsWith(Loc::text("auth.ely.error.unreachable").section(u'%', 0, 0)));
}
void responseWithoutProfileIsError()
{
m_net.route(kAuth, FakeResponse::json(QJsonObject{{"accessToken", "t"}}));
AuthService service;
Captured c;
service.loginElyBy("steve", "secret", "c", QString(), c.callback());
QVERIFY(wait(c));
QVERIFY(!c.result.ok);
QCOMPARE(c.result.error, Loc::text("auth.ely.error.noGameProfile"));
}
void serverErrorWithoutMessageShowsCode()
{
m_net.route(kAuth, FakeResponse::json("{}", 500));
AuthService service;
Captured c;
service.loginElyBy("steve", "secret", "c", QString(), c.callback());
QVERIFY(wait(c));
QCOMPARE(c.result.error, Loc::text("auth.error.serverCode").arg(500));
}
// --- authlib-injector -------------------------------------------------------
void injectorDownloadVerifyAndCache()
{
const QByteArray jar = "PK\x03\x04 fake authlib-injector";
m_net.route(kInjectorManifest, FakeResponse::json(QJsonObject{
{"build_number", 53}, {"version", "1.2.5"},
{"download_url", kInjectorJar},
{"checksums", QJsonObject{{"sha256", Fixtures::sha256(jar)}}}}));
m_net.route(kInjectorJar, FakeResponse::bytes(jar));
AuthService service;
QString path, error;
int calls = 0;
service.ensureAuthlibInjector(m_dir, [&](const QString &p, const QString &e) { ++calls; path = p; error = e; });
QTRY_COMPARE_WITH_TIMEOUT(calls, 1, 15000);
QVERIFY2(error.isEmpty(), qPrintable(error));
QCOMPARE(path, m_dir + "/authlib-injector.jar");
QCOMPARE(Fixtures::readFile(path), jar);
// Второй раз — сразу из файла.
calls = 0;
service.ensureAuthlibInjector(m_dir, [&](const QString &p, const QString &e) { ++calls; path = p; error = e; });
QCOMPARE(calls, 1);
QCOMPARE(m_net.hits(kInjectorJar), 1);
}
void injectorChecksumMismatch()
{
m_net.route(kInjectorManifest, FakeResponse::json(QJsonObject{
{"download_url", kInjectorJar},
{"checksums", QJsonObject{{"sha256", QString(64, u'0')}}}}));
m_net.route(kInjectorJar, FakeResponse::bytes("tampered"));
AuthService service;
QString path, error;
int calls = 0;
service.ensureAuthlibInjector(m_dir, [&](const QString &p, const QString &e) { ++calls; path = p; error = e; });
QTRY_COMPARE_WITH_TIMEOUT(calls, 1, 15000);
QCOMPARE(error, Loc::text("auth.ely.error.injectorChecksum"));
QVERIFY(path.isEmpty());
QVERIFY(!QFile::exists(m_dir + "/authlib-injector.jar"));
}
void injectorManifestProblems()
{
AuthService service;
QString error;
int calls = 0;
const auto cb = [&](const QString &, const QString &e) { ++calls; error = e; };
m_net.route(kInjectorManifest, FakeResponse::json(QJsonObject{{"version", "1.2.5"}}));
service.ensureAuthlibInjector(m_dir, cb);
QTRY_COMPARE_WITH_TIMEOUT(calls, 1, 15000);
QCOMPARE(error, Loc::text("auth.ely.error.injectorNoUrl"));
m_net.route(kInjectorManifest, FakeResponse::error(503));
service.ensureAuthlibInjector(m_dir, cb);
QTRY_COMPARE_WITH_TIMEOUT(calls, 2, 15000);
QVERIFY(error.startsWith(Loc::text("auth.ely.error.injectorInfoFailed").section(u'%', 0, 0)));
m_net.route(kInjectorManifest, FakeResponse::json(QJsonObject{{"download_url", kInjectorJar}}));
m_net.route(kInjectorJar, FakeResponse::error(404));
service.ensureAuthlibInjector(m_dir, cb);
QTRY_COMPARE_WITH_TIMEOUT(calls, 3, 15000);
QVERIFY(error.startsWith(Loc::text("auth.ely.error.injectorDownloadFailed").section(u'%', 0, 0)));
}
};
BILAUNCHER_TEST_MAIN(TestAuthService)
#include "tst_authservice.moc"
+135
View File
@@ -0,0 +1,135 @@
// Самопроверка FakeInternet: если она красная, остальные сетевые тесты
// бессмысленны — значит, перехват трафика на этой платформе не работает.
#include "testenv.h"
#include "fakeinternet.h"
#include <QNetworkAccessManager>
#include <QNetworkReply>
#include <QSignalSpy>
class TestFakeInternet : public QObject
{
Q_OBJECT
FakeInternet m_net;
QNetworkReply *get(QNetworkAccessManager &manager, const QString &url)
{
return manager.get(QNetworkRequest(QUrl(url)));
}
static bool waitFinished(QNetworkReply *reply)
{
if (reply->isFinished())
return true;
QSignalSpy spy(reply, &QNetworkReply::finished);
return spy.wait(15000);
}
private slots:
void initTestCase()
{
QString error;
if (!m_net.start(&error))
QSKIP(qPrintable(QStringLiteral("FakeInternet недоступен: ") + error));
}
void init() { m_net.reset(); }
void httpsGetIsIntercepted()
{
m_net.route(QStringLiteral("https://launchermeta.mojang.com/mc/game/version_manifest_v2.json"),
FakeResponse::json(QByteArrayLiteral("{\"ok\":true}")));
QNetworkAccessManager manager;
QScopedPointer<QNetworkReply> reply(
get(manager, QStringLiteral("https://launchermeta.mojang.com/mc/game/version_manifest_v2.json?x=1")));
QVERIFY(waitFinished(reply.data()));
QCOMPARE(reply->error(), QNetworkReply::NoError);
QCOMPARE(reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(), 200);
QCOMPARE(reply->readAll(), QByteArrayLiteral("{\"ok\":true}"));
QCOMPARE(m_net.hits(QStringLiteral("https://launchermeta.mojang.com/mc/game/version_manifest_v2.json")), 1);
QCOMPARE(m_net.requests().constFirst().query, QStringLiteral("x=1"));
}
void postBodyAndHeaders()
{
m_net.route(QStringLiteral("https://authserver.ely.by/auth/authenticate"),
[](const FakeRequest &request) {
return FakeResponse::json(request.body, request.method == "POST" ? 200 : 405);
});
QNetworkAccessManager manager;
QNetworkRequest request(QUrl(QStringLiteral("https://authserver.ely.by/auth/authenticate")));
request.setHeader(QNetworkRequest::ContentTypeHeader, QStringLiteral("application/json"));
QScopedPointer<QNetworkReply> reply(manager.post(request, QByteArrayLiteral("{\"a\":1}")));
QVERIFY(waitFinished(reply.data()));
QCOMPARE(reply->readAll(), QByteArrayLiteral("{\"a\":1}"));
QCOMPARE(m_net.requests().constFirst().header("content-type"), QByteArrayLiteral("application/json"));
}
void prefixRoutesAndKeepAlive()
{
m_net.route(QStringLiteral("https://resources.download.minecraft.net/*"),
[](const FakeRequest &request) { return FakeResponse::text(request.path.toUtf8()); });
QNetworkAccessManager manager;
for (int i = 0; i < 5; ++i) {
const QString path = QStringLiteral("/ab/ab%1").arg(i);
QScopedPointer<QNetworkReply> reply(
get(manager, QStringLiteral("https://resources.download.minecraft.net") + path));
QVERIFY(waitFinished(reply.data()));
QCOMPARE(reply->readAll(), path.toUtf8());
}
QCOMPARE(m_net.requests().size(), 5);
}
void unknownRouteIs404AndRecorded()
{
QNetworkAccessManager manager;
QTest::ignoreMessage(QtWarningMsg, QRegularExpression(QStringLiteral("FakeInternet: нет маршрута")));
QScopedPointer<QNetworkReply> reply(get(manager, QStringLiteral("https://example.invalid/x")));
QVERIFY(waitFinished(reply.data()));
QCOMPARE(reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(), 404);
QCOMPARE(m_net.unmatched(), QStringList{QStringLiteral("https://example.invalid/x")});
}
void errorStatusesAndDrop()
{
m_net.route(QStringLiteral("https://a.test/500"), FakeResponse::error(500));
m_net.route(QStringLiteral("https://a.test/drop"), [] {
FakeResponse response;
response.drop = true;
return response;
}());
m_net.route(QStringLiteral("https://a.test/short"), [] {
FakeResponse response = FakeResponse::bytes(QByteArray(1000, 'x'));
response.truncateTo = 10;
return response;
}());
QNetworkAccessManager manager;
QScopedPointer<QNetworkReply> r500(get(manager, QStringLiteral("https://a.test/500")));
QVERIFY(waitFinished(r500.data()));
QCOMPARE(r500->error(), QNetworkReply::InternalServerError);
QScopedPointer<QNetworkReply> drop(get(manager, QStringLiteral("https://a.test/drop")));
QVERIFY(waitFinished(drop.data()));
QVERIFY(drop->error() != QNetworkReply::NoError);
QScopedPointer<QNetworkReply> shortReply(get(manager, QStringLiteral("https://a.test/short")));
QVERIFY(waitFinished(shortReply.data()));
QVERIFY(shortReply->error() != QNetworkReply::NoError);
}
void plainHttpThroughProxy()
{
m_net.route(QStringLiteral("http://plain.test/hello"), FakeResponse::text("hi"));
QNetworkAccessManager manager;
QScopedPointer<QNetworkReply> reply(get(manager, QStringLiteral("http://plain.test/hello")));
QVERIFY(waitFinished(reply.data()));
QCOMPARE(reply->readAll(), QByteArrayLiteral("hi"));
QCOMPARE(m_net.requests().constFirst().scheme, QStringLiteral("http"));
}
};
BILAUNCHER_TEST_MAIN(TestFakeInternet)
#include "tst_fakeinternet.moc"
+292
View File
@@ -0,0 +1,292 @@
#include "testenv.h"
#include "fixtures.h"
#include "nettest.h"
#include "javainstaller.h"
#include "javalocator.h"
#include "javaruntime.h"
#include "launcherpaths.h"
#include "localization.h"
#include <QDir>
#include <QFileInfo>
#include <QJsonDocument>
#include <QSignalSpy>
namespace {
const QString kTemurin = QStringLiteral("https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21/");
const QString kMojangManifest = QStringLiteral("https://piston-meta.mojang.com/v1/packages/x/manifest.json");
const QString kMojangFiles = QStringLiteral("https://piston-data.mojang.com/v1/objects/");
JavaRuntimeEntry temurinEntry(const QString &archive, const QByteArray &data)
{
JavaRuntimeEntry entry;
entry.id = QStringLiteral("temurin-jdk-21.0.2_13-test-") + QString(archive).replace(u'.', u'-');
entry.kind = JavaRuntimeKind::Jdk;
entry.major = 21;
entry.version = QStringLiteral("21.0.2+13");
entry.archive = archive;
entry.url = QUrl(kTemurin + "OpenJDK21U-jdk." + archive);
entry.checksum = Fixtures::sha256(data);
entry.size = data.size();
return entry;
}
// Раскладка архива Temurin: один корневой каталог с bin/java внутри.
QMap<QString, QByteArray> jdkTree()
{
return {{QStringLiteral("jdk-21.0.2+13/bin/") + Fixtures::javaExecutableName(), "#!/bin/sh\necho java\n"},
{QStringLiteral("jdk-21.0.2+13/lib/modules"), QByteArray(2048, 'm')},
{QStringLiteral("jdk-21.0.2+13/release"), "JAVA_VERSION=\"21.0.2\""}};
}
} // namespace
class TestJavaInstaller : public QObject
{
Q_OBJECT
FakeInternet m_net;
QString m_work;
struct Run
{
explicit Run(JavaInstaller &installer)
: started(&installer, &JavaInstaller::started)
, finished(&installer, &JavaInstaller::finished)
, failed(&installer, &JavaInstaller::failed)
, canceled(&installer, &JavaInstaller::canceled)
{}
bool waitEnd()
{
return QTest::qWaitFor([&] { return finished.count() + failed.count() + canceled.count() > 0; }, 30000);
}
QString failure() const { return failed.isEmpty() ? QString() : failed.constFirst().at(1).toString(); }
QSignalSpy started, finished, failed, canceled;
};
void verifyRegistered(const JavaRuntimeEntry &entry, const Run &run)
{
QCOMPARE(run.finished.count(), 1);
QCOMPARE(run.finished.constFirst().at(0).toString(), entry.id);
const QString javaPath = run.finished.constFirst().at(1).toString();
QVERIFY(QFileInfo(javaPath).isFile());
QVERIFY(javaPath.startsWith(JavaRuntimeStore::dirFor(entry.id) + u'/'));
#if !defined(Q_OS_WIN)
QVERIFY(QFileInfo(javaPath).isExecutable());
#endif
const auto installed = JavaRuntimeStore::find(entry.id);
QVERIFY(installed.has_value());
QCOMPARE(installed->major, entry.major);
QCOMPARE(installed->version, entry.version);
QCOMPARE(installed->kind, javaKindKey(entry.kind));
// Лаунчер видит её при поиске Java.
QStringList canonical;
for (const QString &path : JavaLocator::findAll(m_work))
canonical << QFileInfo(path).canonicalFilePath();
QVERIFY(canonical.contains(QFileInfo(javaPath).canonicalFilePath()));
// Скачанный архив за собой не оставляем.
QVERIFY(QDir(LauncherPaths::javaDownloadDir()).entryList(QDir::Files).isEmpty());
}
private slots:
void initTestCase() { BILAUNCHER_START_FAKE_INTERNET(m_net); }
void init()
{
TestEnv::resetLauncherRoot();
m_net.reset();
m_work = TestEnv::freshDir(QStringLiteral("game"));
}
void installsZipArchive()
{
const QByteArray zip = Fixtures::zipBytes(jdkTree());
const JavaRuntimeEntry entry = temurinEntry("zip", zip);
m_net.route(entry.url.toString(), FakeResponse::bytes(zip));
JavaInstaller installer;
Run run(installer);
installer.install(entry);
QVERIFY(installer.isRunning());
QCOMPARE(run.started.count(), 1);
QVERIFY(run.waitEnd());
QVERIFY2(run.failed.isEmpty(), qPrintable(run.failure()));
verifyRegistered(entry, run);
QVERIFY(!installer.isRunning());
}
void installsTarGzArchive()
{
#if defined(Q_OS_WIN)
QSKIP("Temurin под Windows — только zip; tar.gz распаковывается системным tar из PATH");
#endif
const QString archive = m_work + "/jdk.tar.gz";
if (!Fixtures::makeTarGz(archive, jdkTree()))
QSKIP("системный tar недоступен");
const QByteArray data = Fixtures::readFile(archive);
const JavaRuntimeEntry entry = temurinEntry("tar.gz", data);
m_net.route(entry.url.toString(), FakeResponse::bytes(data));
JavaInstaller installer;
Run run(installer);
installer.install(entry);
QVERIFY(run.waitEnd());
QVERIFY2(run.failed.isEmpty(), qPrintable(run.failure()));
verifyRegistered(entry, run);
}
void archiveChecksumMismatch()
{
const QByteArray zip = Fixtures::zipBytes(jdkTree());
JavaRuntimeEntry entry = temurinEntry("zip", zip);
entry.checksum = QString(64, u'0');
m_net.route(entry.url.toString(), FakeResponse::bytes(zip));
JavaInstaller installer;
Run run(installer);
installer.install(entry);
QVERIFY(run.waitEnd());
QCOMPARE(run.failure(), Loc::text("java.error.archiveChecksum"));
QVERIFY(!QDir(JavaRuntimeStore::dirFor(entry.id)).exists());
}
void archiveWithoutJavaFails()
{
const QByteArray zip = Fixtures::zipBytes({{"jdk/README", "no binaries here"}});
const JavaRuntimeEntry entry = temurinEntry("zip", zip);
m_net.route(entry.url.toString(), FakeResponse::bytes(zip));
JavaInstaller installer;
Run run(installer);
installer.install(entry);
QVERIFY(run.waitEnd());
QCOMPARE(run.failure(), Loc::text("java.error.noExecutable"));
QVERIFY(!JavaRuntimeStore::find(entry.id).has_value());
}
void zipSlipIsRejected()
{
const QByteArray zip = Fixtures::zipBytes({{"jdk/../../../escaped", "x"}});
const JavaRuntimeEntry entry = temurinEntry("zip", zip);
m_net.route(entry.url.toString(), FakeResponse::bytes(zip));
JavaInstaller installer;
Run run(installer);
installer.install(entry);
QVERIFY(run.waitEnd());
QCOMPARE(run.failure(), Loc::text("java.error.archiveBadPath").arg("jdk/../../../escaped"));
QVERIFY(!QFile::exists(LauncherPaths::rootDir() + "/escaped"));
}
void unknownFormatAndInvalidEntry()
{
JavaInstaller installer;
Run run(installer);
installer.install(JavaRuntimeEntry{});
QCOMPARE(run.failure(), Loc::text("java.error.buildUnavailable"));
JavaRuntimeEntry entry = temurinEntry("7z", "x");
Run run2(installer);
installer.install(entry);
QCOMPARE(run2.failure(), Loc::text("java.error.unknownArchiveFormat").arg("7z"));
}
void mojangComponent()
{
const QByteArray java = "#!/bin/sh\necho mojang-java\n";
const QByteArray lib = QByteArray(1024, 'l');
const QString binary = QStringLiteral("bin/") + Fixtures::javaExecutableName();
QJsonObject files{
{"bin", QJsonObject{{"type", "directory"}}},
{binary, QJsonObject{{"type", "file"}, {"executable", true},
{"downloads", QJsonObject{{"raw", QJsonObject{
{"url", kMojangFiles + Fixtures::sha1(java) + "/java"},
{"sha1", Fixtures::sha1(java)}, {"size", java.size()}}}}}}},
{"lib/modules", QJsonObject{{"type", "file"},
{"downloads", QJsonObject{{"raw", QJsonObject{
{"url", kMojangFiles + Fixtures::sha1(lib) + "/modules"},
{"sha1", Fixtures::sha1(lib)}, {"size", lib.size()}}}}}}},
{"lib/modules-link", QJsonObject{{"type", "link"}, {"target", "modules"}}}};
m_net.route(kMojangManifest, FakeResponse::json(QJsonObject{{"files", files}}));
m_net.route(kMojangFiles + Fixtures::sha1(java) + "/java", FakeResponse::bytes(java));
int libAttempts = 0;
m_net.route(kMojangFiles + Fixtures::sha1(lib) + "/modules", [&](const FakeRequest &) {
return ++libAttempts == 1 ? FakeResponse::error(503) : FakeResponse::bytes(lib); // временный сбой
});
JavaRuntimeEntry entry;
entry.id = QStringLiteral("mojang-java-runtime-delta");
entry.kind = JavaRuntimeKind::Mojang;
entry.major = 21;
entry.version = QStringLiteral("21.0.7");
entry.component = QStringLiteral("java-runtime-delta");
entry.archive = QStringLiteral("mojang");
entry.url = QUrl(kMojangManifest);
JavaInstaller installer;
Run run(installer);
installer.install(entry);
QVERIFY(run.waitEnd());
QVERIFY2(run.failed.isEmpty(), qPrintable(run.failure()));
verifyRegistered(entry, run);
QCOMPARE(libAttempts, 2);
const QString dir = JavaRuntimeStore::dirFor(entry.id);
QCOMPARE(Fixtures::readFile(dir + "/lib/modules"), lib);
QVERIFY(QFileInfo::exists(dir + "/lib/modules-link"));
}
void mojangManifestEscapingIsRejected()
{
m_net.route(kMojangManifest, FakeResponse::json(QJsonObject{
{"files", QJsonObject{{"../../evil", QJsonObject{{"type", "directory"}}}}}}));
JavaRuntimeEntry entry;
entry.id = QStringLiteral("mojang-evil");
entry.kind = JavaRuntimeKind::Mojang;
entry.major = 17;
entry.version = QStringLiteral("17");
entry.archive = QStringLiteral("mojang");
entry.url = QUrl(kMojangManifest);
JavaInstaller installer;
Run run(installer);
installer.install(entry);
QVERIFY(run.waitEnd());
QCOMPARE(run.failure(), Loc::text("java.error.manifestBadPath").arg("../../evil"));
}
void cancelDuringDownload()
{
FakeResponse hang;
hang.hang = true;
const JavaRuntimeEntry entry = temurinEntry("zip", "whatever");
m_net.route(entry.url.toString(), hang);
JavaInstaller installer;
Run run(installer);
installer.install(entry);
QTRY_COMPARE_WITH_TIMEOUT(m_net.hits(entry.url.toString()), 1, 15000);
installer.cancel();
QVERIFY(run.waitEnd());
QVERIFY(!QDir(JavaRuntimeStore::dirFor(entry.id)).exists());
QVERIFY(!installer.isRunning());
QVERIFY(run.failed.isEmpty());
// abort() синхронно доводит ответ до handleArchiveFinished(), который уже
// шлёт canceled; затем cancel() видит m_reply == nullptr и шлёт его ещё раз.
QEXPECT_FAIL("", "canceled приходит дважды: из handleArchiveFinished() и из cancel()", Continue);
QCOMPARE(run.canceled.count(), 1);
}
void reinstallReplacesOld()
{
const QByteArray zip = Fixtures::zipBytes(jdkTree());
const JavaRuntimeEntry entry = temurinEntry("zip", zip);
Fixtures::write(JavaRuntimeStore::dirFor(entry.id) + "/stale/file", "old");
m_net.route(entry.url.toString(), FakeResponse::bytes(zip));
JavaInstaller installer;
Run run(installer);
installer.install(entry);
QVERIFY(run.waitEnd());
QVERIFY2(run.failed.isEmpty(), qPrintable(run.failure()));
QVERIFY(!QFile::exists(JavaRuntimeStore::dirFor(entry.id) + "/stale/file"));
}
};
BILAUNCHER_TEST_MAIN(TestJavaInstaller)
#include "tst_javainstaller.moc"
+319
View File
@@ -0,0 +1,319 @@
#include "testenv.h"
#include "fixtures.h"
#include "nettest.h"
#include "javaruntime.h"
#include "javaruntimeservice.h"
#include "launcherpaths.h"
#include "localization.h"
#include <QJsonArray>
#include <QSignalSpy>
#include <QSysInfo>
namespace {
const QString kReleases = QStringLiteral("https://api.adoptium.net/v3/info/available_releases");
const QString kFeature = QStringLiteral("https://api.adoptium.net/v3/assets/feature_releases/*");
const QString kMojang = QStringLiteral(
"https://piston-meta.mojang.com/v1/products/java-runtime/2ec0cc96c44e5a76b9c8b7c39df7210883d12871/all.json");
bool VersionLoaderOsIsWindows()
{
#if defined(Q_OS_WIN)
return true;
#else
return false;
#endif
}
QString hostAdoptiumArch()
{
const QString arch = QSysInfo::currentCpuArchitecture();
if (arch == QLatin1String("arm64"))
return QStringLiteral("aarch64");
if (arch == QLatin1String("x86_64"))
return QStringLiteral("x64");
return arch;
}
// Один релиз Temurin в формате Adoptium API.
QJsonObject release(int major, const QString &arch, const QString &openjdk, int security,
const QString &timestamp, const QString &os)
{
const QString ext = os == QLatin1String("windows") ? QStringLiteral("zip") : QStringLiteral("tar.gz");
QJsonArray binaries;
for (const QString type : {QStringLiteral("jdk"), QStringLiteral("jre"), QStringLiteral("testimage")}) {
const QString name = QStringLiteral("OpenJDK%1U-%2_%3_%4_hotspot.%5").arg(major).arg(type, arch, os, ext);
binaries << QJsonObject{
{"architecture", arch}, {"image_type", type}, {"os", os},
{"package", QJsonObject{{"name", name},
{"link", "https://github.com/adoptium/temurin/releases/download/x/" + name},
{"checksum", QString(64, u'c')},
{"size", 190000000}}}};
}
return QJsonObject{
{"release_name", QStringLiteral("jdk-%1").arg(openjdk)},
{"timestamp", timestamp},
{"binaries", binaries},
{"version_data", QJsonObject{{"major", major}, {"security", security},
{"openjdk_version", openjdk + "-LTS"}, {"semver", openjdk}}}};
}
QByteArray mojangAll()
{
const QJsonObject components{
{"java-runtime-delta", QJsonArray{QJsonObject{
{"manifest", QJsonObject{{"url", "https://piston-meta.mojang.com/v1/packages/d/manifest.json"},
{"sha1", "dddd"}, {"size", 1000}}},
{"version", QJsonObject{{"name", "21.0.7"}, {"released", "2025-04-15T00:00:00+00:00"}}}}}},
{"jre-legacy", QJsonArray{QJsonObject{
{"manifest", QJsonObject{{"url", "https://piston-meta.mojang.com/v1/packages/l/manifest.json"},
{"sha1", "llll"}, {"size", 1000}}},
{"version", QJsonObject{{"name", "8u51"}, {"released", "2015-07-15T00:00:00+00:00"}}}}}},
{"java-runtime-alpha", QJsonArray{}},
{"minecraft-java-exe", QJsonArray{QJsonObject{
{"manifest", QJsonObject{{"url", "https://piston-meta.mojang.com/v1/packages/e/manifest.json"}}},
{"version", QJsonObject{{"name", "7.0"}}}}}},
{"insecure", QJsonArray{QJsonObject{
{"manifest", QJsonObject{{"url", "http://piston-meta.mojang.com/x.json"}}},
{"version", QJsonObject{{"name", "17.0.1"}}}}}}};
QJsonObject root;
for (const char *platform : {"gamecore", "linux", "linux-i386", "mac-os", "mac-os-arm64",
"windows-arm64", "windows-x64", "windows-x86"})
root.insert(QLatin1String(platform), components);
return QJsonDocument(root).toJson(QJsonDocument::Compact);
}
} // namespace
class TestJavaRuntimeService : public QObject
{
Q_OBJECT
FakeInternet m_net;
// Отвечает релизами на запрошенную архитектуру/ОС; без «missingForArch» — для всех.
void routeAdoptium(const QString &missingForArch = QString())
{
m_net.route(kReleases, FakeResponse::json(QJsonObject{
{"available_lts_releases", QJsonArray{8, 17, 21}},
{"available_releases", QJsonArray{8, 17, 21, 24}}}));
m_net.route(kFeature, [missingForArch](const FakeRequest &request) {
const int major = request.path.section(u'/', -2, -2).toInt();
const QString arch = request.queryItems().queryItemValue("architecture");
const QString os = request.queryItems().queryItemValue("os");
if (arch == missingForArch)
return FakeResponse::json("[]");
QJsonArray list;
if (major == 8) {
list << release(8, arch, "1.8.0_452-b09", 452, "2025-04-20T00:00:00Z", os);
} else {
list << release(major, arch, QStringLiteral("%1.0.2+13").arg(major), 2, "2025-01-20T00:00:00Z", os)
<< release(major, arch, QStringLiteral("%1.0.1+12").arg(major), 1, "2024-10-20T00:00:00Z", os);
}
return FakeResponse::json(QJsonDocument(list).toJson());
});
}
static bool load(JavaRuntimeService &service, CallResult &r, bool force = false)
{
service.ensureLoaded(r.callback(), force);
return QTest::qWaitFor([&] { return r.calls > 0; }, 20000);
}
private slots:
void initTestCase() { BILAUNCHER_START_FAKE_INTERNET(m_net); }
void init()
{
TestEnv::resetLauncherRoot();
m_net.reset();
}
void mergesAdoptiumAndMojang()
{
routeAdoptium();
m_net.route(kMojang, FakeResponse::json(mojangAll()));
JavaRuntimeService service;
QSignalSpy changed(&service, &JavaRuntimeService::catalogChanged);
CallResult r;
QVERIFY(load(service, r));
QVERIFY2(r.ok, qPrintable(r.warning));
QVERIFY(r.warning.isEmpty());
QCOMPARE(changed.count(), 1);
QVERIFY(!service.isRefreshing());
// Запросы к Adoptium — с параметрами текущей машины.
const QList<FakeRequest> requests = m_net.requests();
int featureRequests = 0;
for (const FakeRequest &request : requests) {
if (!request.path.startsWith("/v3/assets/feature_releases/"))
continue;
++featureRequests;
QCOMPARE(request.queryItems().queryItemValue("architecture"), hostAdoptiumArch());
QCOMPARE(request.queryItems().queryItemValue("image_type"), QString());
QCOMPARE(request.queryItems().queryItemValue("vendor"), QStringLiteral("eclipse"));
}
QCOMPARE(featureRequests, 4);
const QList<JavaRuntimeEntry> jdks = service.entries(JavaRuntimeKind::Jdk);
const QList<JavaRuntimeEntry> jres = service.entries(JavaRuntimeKind::Jre);
QCOMPARE(jdks.size(), 7); // 8 — один релиз, 17/21/24 — по два
QCOMPARE(jres.size(), 7);
// Свежие major первыми, внутри — по дате.
QCOMPARE(jdks.first().major, 24);
QCOMPARE(jdks.first().version, QStringLiteral("24.0.2+13"));
QCOMPARE(jdks.last().major, 8);
QCOMPARE(jdks.last().version, QStringLiteral("8u452"));
const JavaRuntimeEntry jdk21 = jdks.at(2);
QCOMPARE(jdk21.major, 21);
QCOMPARE(jdk21.version, QStringLiteral("21.0.2+13")); // «-LTS» отрезан
QVERIFY(jdk21.lts);
QVERIFY(!jdks.first().lts); // 24 — не LTS
QCOMPARE(jdk21.architecture, hostAdoptiumArch());
QCOMPARE(jdk21.size, qint64(190000000));
QCOMPARE(jdk21.archive, VersionLoaderOsIsWindows() ? QStringLiteral("zip") : QStringLiteral("tar.gz"));
QCOMPARE(jdk21.id, JavaRuntimeStore::sanitizeId("temurin-jdk-21.0.2+13-" + hostAdoptiumArch()));
QVERIFY(service.find(jdk21.id).has_value());
// Mojang: java-runtime-delta (21) и jre-legacy (8); пустые, exe и http — выброшены.
const QList<JavaRuntimeEntry> mojang = service.entries(JavaRuntimeKind::Mojang);
QCOMPARE(mojang.size(), 2);
const auto delta = service.find("mojang-java-runtime-delta");
QVERIFY(delta.has_value());
QCOMPARE(delta->major, 21);
QCOMPARE(delta->component, QStringLiteral("java-runtime-delta"));
QCOMPARE(delta->archive, QStringLiteral("mojang"));
QCOMPARE(delta->checksum, QStringLiteral("dddd"));
QCOMPARE(service.find("mojang-jre-legacy")->major, 8);
QVERIFY(QFile::exists(LauncherPaths::javaCatalogFile()));
}
void bestForPrefersSmallestSuitableMajorThenJdk()
{
routeAdoptium();
m_net.route(kMojang, FakeResponse::json(mojangAll()));
JavaRuntimeService service;
CallResult r;
QVERIFY(load(service, r));
QVERIFY(!service.bestFor(0).has_value());
QVERIFY(!service.bestFor(99).has_value());
const auto for17 = service.bestFor(17);
QVERIFY(for17.has_value());
QCOMPARE(for17->major, 17);
QCOMPARE(for17->kind, JavaRuntimeKind::Jdk);
QCOMPARE(for17->version, QStringLiteral("17.0.2+13")); // самый свежий релиз
QCOMPARE(service.bestFor(18)->major, 21);
QCOMPARE(service.bestFor(8)->major, 8);
}
void partialFailureStillProducesCatalog()
{
routeAdoptium();
m_net.route(kMojang, FakeResponse::error(500));
JavaRuntimeService service;
CallResult r;
QVERIFY(load(service, r));
QVERIFY(r.ok);
QVERIFY(r.warning.startsWith(Loc::text("java.error.mojangRuntimesFailed").section(u'%', 0, 0)));
QVERIFY(service.entries(JavaRuntimeKind::Mojang).isEmpty());
QVERIFY(!service.entries(JavaRuntimeKind::Jdk).isEmpty());
}
void adoptiumDownMojangUp()
{
m_net.route(kReleases, FakeResponse::error(503));
m_net.route(kMojang, FakeResponse::json(mojangAll()));
JavaRuntimeService service;
CallResult r;
QVERIFY(load(service, r));
QVERIFY(r.ok);
QVERIFY(!r.warning.isEmpty());
QCOMPARE(service.entries(JavaRuntimeKind::Mojang).size(), 2);
QVERIFY(service.entries(JavaRuntimeKind::Jdk).isEmpty());
}
void everythingDownWithoutCacheFails()
{
m_net.route(kReleases, FakeResponse::error(503));
m_net.route(kMojang, FakeResponse::error(503));
JavaRuntimeService service;
CallResult r;
QVERIFY(load(service, r));
QVERIFY(!r.ok);
QVERIFY(r.warning.contains("; ")); // оба предупреждения склеены
QVERIFY(!service.hasData());
}
void everythingDownWithCacheUsesCache()
{
routeAdoptium();
m_net.route(kMojang, FakeResponse::json(mojangAll()));
{
JavaRuntimeService service;
CallResult r;
QVERIFY(load(service, r));
}
m_net.reset();
m_net.route(kReleases, FakeResponse::error(503));
m_net.route(kMojang, FakeResponse::error(503));
JavaRuntimeService service;
CallResult cached;
QVERIFY(load(service, cached));
QVERIFY(cached.ok);
QVERIFY(m_net.requests().isEmpty()); // кеш свежий — сети нет
QCOMPARE(service.entries(JavaRuntimeKind::Jdk).size(), 7);
CallResult forced;
QVERIFY(load(service, forced, true));
QVERIFY(forced.ok);
QVERIFY(!forced.warning.isEmpty());
QCOMPARE(service.entries(JavaRuntimeKind::Jdk).size(), 7);
}
void cacheForOtherPlatformIsIgnored()
{
QJsonObject entry;
entry["id"] = "x";
entry["kind"] = "jdk";
entry["major"] = 17;
entry["url"] = "https://x/y.zip";
QJsonObject cache;
cache["platform"] = "amiga-os";
cache["fetchedAt"] = QDateTime::currentDateTimeUtc().toString(Qt::ISODate);
cache["entries"] = QJsonArray{entry};
Fixtures::writeJson(LauncherPaths::javaCatalogFile(), cache);
m_net.route(kReleases, FakeResponse::error(503));
m_net.route(kMojang, FakeResponse::error(503));
JavaRuntimeService service;
CallResult r;
QVERIFY(load(service, r));
QVERIFY(!service.hasData());
QVERIFY(!r.ok);
}
void aarch64FallsBackToX64()
{
if (hostAdoptiumArch() != QLatin1String("aarch64"))
QSKIP("запасной x64 запрашивается только на ARM-машинах");
routeAdoptium(QStringLiteral("aarch64"));
m_net.route(kMojang, FakeResponse::json(mojangAll()));
JavaRuntimeService service;
CallResult r;
QVERIFY(load(service, r));
QVERIFY(r.ok);
const QList<JavaRuntimeEntry> jdks = service.entries(JavaRuntimeKind::Jdk);
QVERIFY(!jdks.isEmpty());
for (const JavaRuntimeEntry &entry : jdks)
QCOMPARE(entry.architecture, QStringLiteral("x64"));
}
};
BILAUNCHER_TEST_MAIN(TestJavaRuntimeService)
#include "tst_javaruntimeservice.moc"
+273
View File
@@ -0,0 +1,273 @@
#include "testenv.h"
#include "fakemojang.h"
#include "fixtures.h"
#include "nettest.h"
#include "launcherpaths.h"
#include "localization.h"
#include "minecraftversion.h"
#include "modloaderinstaller.h"
#include "modloaderversionservice.h"
#include "versioninstaller.h"
#include "versionmanifestservice.h"
#include "zlibreference.h"
#include <QDir>
#include <QJsonArray>
#include <QJsonDocument>
#include <QSignalSpy>
namespace {
const QString kFabricList = QStringLiteral("https://meta.fabricmc.net/v2/versions/loader/1.20.1");
const QString kFabricProfile = QStringLiteral("https://meta.fabricmc.net/v2/versions/loader/1.20.1/0.16.10/profile/json");
const QString kForgeMeta = QStringLiteral("https://maven.minecraftforge.net/net/minecraftforge/forge/maven-metadata.xml");
const QString kForgePromos = QStringLiteral("https://files.minecraftforge.net/maven/net/minecraftforge/forge/promotions_slim.json");
const QString kForgeInstaller = QStringLiteral(
"https://maven.minecraftforge.net/net/minecraftforge/forge/1.20.1-47.4.0/forge-1.20.1-47.4.0-installer.jar");
QByteArray fabricProfile(const QString &inherits = QStringLiteral("1.20.1"))
{
// Профиль Fabric: дочерняя версия с собственной библиотекой из Maven Fabric.
const QJsonObject json{
{"id", "fabric-loader-0.16.10-1.20.1"},
{"inheritsFrom", inherits},
{"type", "release"},
{"mainClass", "net.fabricmc.loader.impl.launch.knot.KnotClient"},
{"arguments", QJsonObject{{"game", QJsonArray{}}, {"jvm", QJsonArray{"-DFabricMcEmu= net.minecraft.client.main.Main "}}}},
{"libraries", QJsonArray{QJsonObject{{"name", "net.fabricmc:fabric-loader:0.16.10"},
{"url", "https://maven.fabricmc.net/"}}}}};
return QJsonDocument(json).toJson(QJsonDocument::Compact);
}
} // namespace
class TestModLoaderInstaller : public QObject
{
Q_OBJECT
FakeInternet m_net;
QString m_game;
struct Rig
{
VersionManifestService manifest;
ModLoaderVersionService meta;
VersionInstaller versions{&manifest};
ModLoaderInstaller installer{&meta, &versions};
QSignalSpy finished{&installer, &ModLoaderInstaller::finished};
QSignalSpy failed{&installer, &ModLoaderInstaller::failed};
QSignalSpy canceled{&installer, &ModLoaderInstaller::canceled};
QSignalSpy log{&installer, &ModLoaderInstaller::log};
bool waitEnd()
{
return QTest::qWaitFor([&] { return finished.count() + failed.count() + canceled.count() > 0; }, 30000);
}
QString failure() const { return failed.isEmpty() ? QString() : failed.constFirst().at(1).toString(); }
};
void routeForge()
{
m_net.route(kForgeMeta, FakeResponse::bytes(Fixtures::read("loaders/forge-maven-metadata.xml")));
m_net.route(kForgePromos, FakeResponse::json(Fixtures::read("loaders/forge-promotions_slim.json")));
m_net.route(kForgeInstaller, FakeResponse::bytes("PK fake forge installer"));
}
// Подставная java, которая ведёт себя как установщик Forge: кладёт профиль версии.
QString forgeLikeJava(const QStringList &extra = {})
{
const QString id = QStringLiteral("1.20.1-forge-47.4.0");
const QString json = QStringLiteral(
"{\"id\":\"%1\",\"inheritsFrom\":\"1.20.1\",\"mainClass\":\"cpw.mods.bootstraplauncher.BootstrapLauncher\"}").arg(id);
QStringList config{"args_file=" + m_game + "/installer-args.txt",
"dump_env=LD_PRELOAD",
"stdout=Extracting json",
"stdout=The client installed successfully"};
config << "create=" + m_game + "/versions/" + id + u'/' + id + ".json\t" + json;
config += extra;
return Fixtures::installFakeJava(m_game + "/jdk17", "17.0.8", config);
}
private slots:
void initTestCase() { BILAUNCHER_START_FAKE_INTERNET(m_net); }
void init()
{
TestEnv::resetLauncherRoot();
m_net.reset();
m_game = TestEnv::freshDir(QStringLiteral("game"));
FakeMojang mojang(m_net);
mojang.publish({.id = "1.20.1"});
m_net.route(kFabricList, FakeResponse::json(Fixtures::read("loaders/fabric-1.20.1.json")));
m_net.route("https://maven.fabricmc.net/net/fabricmc/fabric-loader/0.16.10/fabric-loader-0.16.10.jar",
FakeResponse::bytes("fabric-loader-jar"));
}
void fabricInstallsBaseThenProfile()
{
m_net.route(kFabricProfile, FakeResponse::json(fabricProfile()));
Rig rig;
QSignalSpy started(&rig.installer, &ModLoaderInstaller::started);
rig.installer.install(m_game, ModLoader::Fabric, "1.20.1", "0.16.10", QString());
QVERIFY(rig.installer.isRunning());
QCOMPARE(started.count(), 1);
QVERIFY(rig.waitEnd());
QVERIFY2(rig.failed.isEmpty(), qPrintable(rig.failure()));
const QList<QVariant> done = rig.finished.constFirst();
QCOMPARE(done.at(0).toString(), QStringLiteral("fabric"));
QCOMPARE(done.at(1).toString(), QStringLiteral("1.20.1"));
QCOMPARE(done.at(2).toString(), QStringLiteral("0.16.10"));
QCOMPARE(done.at(3).toString(), QStringLiteral("fabric-loader-0.16.10-1.20.1"));
QVERIFY(!rig.installer.isRunning());
// Ваниль докачана, профиль сохранён, библиотека загрузчика на месте.
QVERIFY(QFile::exists(m_game + "/versions/1.20.1/1.20.1.jar"));
QVERIFY(QFile::exists(m_game + "/versions/fabric-loader-0.16.10-1.20.1/fabric-loader-0.16.10-1.20.1.json"));
QCOMPARE(Fixtures::readFile(m_game + "/libraries/net/fabricmc/fabric-loader/0.16.10/fabric-loader-0.16.10.jar"),
QByteArray("fabric-loader-jar"));
const MinecraftVersion v = VersionLoader::load(m_game, "fabric-loader-0.16.10-1.20.1", {}, nullptr);
QVERIFY(v.isValid());
}
void fabricProfileForWrongGameIsRejected()
{
m_net.route(kFabricProfile, FakeResponse::json(fabricProfile("1.19.4")));
Rig rig;
rig.installer.install(m_game, ModLoader::Fabric, "1.20.1", "0.16.10", QString());
QVERIFY(rig.waitEnd());
QCOMPARE(rig.failure(), Loc::text("loader.error.descriptionWrongMinecraft")
.arg(loaderTitle(ModLoader::Fabric), "1.19.4", "1.20.1"));
QVERIFY(!QDir(m_game + "/versions/fabric-loader-0.16.10-1.20.1").exists());
}
void fabricProfileWithUnsafeIdIsRejected()
{
m_net.route(kFabricProfile, FakeResponse::json(QJsonObject{{"id", "../evil"}, {"inheritsFrom", "1.20.1"}}));
Rig rig;
rig.installer.install(m_game, ModLoader::Fabric, "1.20.1", "0.16.10", QString());
QVERIFY(rig.waitEnd());
QCOMPARE(rig.failure(), Loc::text("loader.error.descriptionBadId").arg(loaderTitle(ModLoader::Fabric)));
}
void validationErrors()
{
Rig rig;
rig.installer.install(m_game, ModLoader::Fabric, "1.20.1", " ", QString());
QCOMPARE(rig.failure(), Loc::text("loader.error.noVersionSelected").arg(loaderTitle(ModLoader::Fabric)));
QVERIFY(!rig.installer.isRunning());
}
void secondInstallWhileBusy()
{
FakeResponse slow = FakeResponse::json(fabricProfile());
slow.delayMs = 500;
m_net.route(kFabricProfile, slow);
Rig rig;
rig.installer.install(m_game, ModLoader::Fabric, "1.20.1", "0.16.10", QString());
rig.installer.install(m_game, ModLoader::Quilt, "1.20.1", "0.26.3", QString());
QCOMPARE(rig.failed.count(), 1);
QCOMPARE(rig.failure(), Loc::text("loader.error.installInProgress"));
QTRY_COMPARE_WITH_TIMEOUT(rig.finished.count(), 1, 30000);
}
void forgeRunsInstallerWithJava()
{
routeForge();
const QString java = forgeLikeJava();
Rig rig;
rig.installer.install(m_game, ModLoader::Forge, "1.20.1", "47.4.0", java);
QVERIFY(rig.waitEnd());
QVERIFY2(rig.failed.isEmpty(), qPrintable(rig.failure()));
QCOMPARE(rig.finished.constFirst().at(3).toString(), QStringLiteral("1.20.1-forge-47.4.0"));
// Установщик запущен как java -jar <installer> --installClient <папка игры>.
const QStringList args = QString::fromLocal8Bit(Fixtures::readFile(m_game + "/installer-args.txt"))
.split(u'\n', Qt::SkipEmptyParts);
QCOMPARE(args.value(0), QStringLiteral("-jar"));
QCOMPARE(QFileInfo(args.value(1)).fileName(), QStringLiteral("forge-47.4.0-installer.jar"));
QCOMPARE(args.value(2), QStringLiteral("--installClient"));
QCOMPARE(args.value(3), QDir::toNativeSeparators(m_game));
// Заглушка launcher_profiles.json, без которой установщик Forge отказывается работать.
QVERIFY(Fixtures::readJsonFile(m_game + "/launcher_profiles.json").contains("profiles"));
// Вывод установщика идёт в журнал.
QStringList lines;
for (const auto &call : rig.log)
lines << call.constFirst().toString();
QVERIFY(lines.contains("The client installed successfully"));
QVERIFY(QFile::exists(LauncherPaths::loaderDownloadDir() + "/forge-47.4.0-install.log"));
}
void forgeInstallerFailureCleansUp()
{
routeForge();
const QString java = forgeLikeJava({"exit=1", "stdout=Processor failed, invalid outputs"});
Rig rig;
rig.installer.install(m_game, ModLoader::Forge, "1.20.1", "47.4.0", java);
QVERIFY(rig.waitEnd());
const QString failure = rig.failure();
QVERIFY(failure.startsWith(Loc::text("loader.error.installerExitCode").arg(loaderTitle(ModLoader::Forge)).arg(1)));
QVERIFY(failure.contains("Processor failed")); // хвост лога в сообщении
// Версию, которую успел создать упавший установщик, убираем.
QVERIFY(!QDir(m_game + "/versions/1.20.1-forge-47.4.0").exists());
QVERIFY(QDir(m_game + "/versions/1.20.1").exists());
}
void forgeInstallerProducingNothing()
{
routeForge();
const QString java = Fixtures::installFakeJava(m_game + "/jdk17-empty", "17.0.8", {"stdout=did nothing"});
Rig rig;
rig.installer.install(m_game, ModLoader::Forge, "1.20.1", "47.4.0", java);
QVERIFY(rig.waitEnd());
QVERIFY(rig.failure().startsWith(Loc::text("loader.error.noVersionProduced").section(u'%', 0, 0)));
}
void forgeUnknownVersionIsIncompatible()
{
routeForge();
Rig rig;
rig.installer.install(m_game, ModLoader::Forge, "1.20.1", "1.0.0", QString());
QVERIFY(rig.waitEnd());
QCOMPARE(rig.failure(), Loc::text("loader.error.incompatible").arg(loaderTitle(ModLoader::Forge), "1.0.0", "1.20.1"));
}
void zlibNgEnvironmentOnLinux()
{
#if !defined(Q_OS_LINUX)
QSKIP("LD_PRELOAD с эталонным zlib — только Linux");
#else
routeForge();
const QString java = forgeLikeJava();
Rig rig;
rig.installer.install(m_game, ModLoader::Forge, "1.20.1", "47.4.0", java);
QVERIFY(rig.waitEnd());
const QStringList args = QString::fromLocal8Bit(Fixtures::readFile(m_game + "/installer-args.txt"))
.split(u'\n', Qt::SkipEmptyParts);
const QString preload = args.filter("env:LD_PRELOAD=").value(0).mid(int(sizeof("env:LD_PRELOAD=") - 1));
if (ZlibReference::systemIsZlibNg() && !ZlibReference::bundledPath().isEmpty())
QVERIFY(preload.startsWith(ZlibReference::bundledPath()));
else
QCOMPARE(preload, qEnvironmentVariable("LD_PRELOAD"));
#endif
}
void cancelDuringInstallerDownload()
{
routeForge();
FakeResponse hang;
hang.hang = true;
m_net.route(kForgeInstaller, hang);
Rig rig;
rig.installer.install(m_game, ModLoader::Forge, "1.20.1", "47.4.0", QString());
QTRY_COMPARE_WITH_TIMEOUT(m_net.hits(kForgeInstaller), 1, 30000);
rig.installer.cancel();
QVERIFY(rig.waitEnd());
QCOMPARE(rig.canceled.count(), 1);
QVERIFY(rig.failed.isEmpty());
QVERIFY(!rig.installer.isRunning());
}
};
BILAUNCHER_TEST_MAIN(TestModLoaderInstaller)
#include "tst_modloaderinstaller.moc"
+274
View File
@@ -0,0 +1,274 @@
#include "testenv.h"
#include "fixtures.h"
#include "nettest.h"
#include "launcherpaths.h"
#include "localization.h"
#include "modloaderversionservice.h"
#include <QSignalSpy>
namespace {
const QString kFabric = QStringLiteral("https://meta.fabricmc.net/v2/versions/loader/1.20.1");
const QString kQuilt = QStringLiteral("https://meta.quiltmc.org/v3/versions/loader/1.20.1");
const QString kForgeMeta = QStringLiteral("https://maven.minecraftforge.net/net/minecraftforge/forge/maven-metadata.xml");
const QString kForgePromos = QStringLiteral("https://files.minecraftforge.net/maven/net/minecraftforge/forge/promotions_slim.json");
const QString kNeoMeta = QStringLiteral("https://maven.neoforged.net/releases/net/neoforged/neoforge/maven-metadata.xml");
const QString kNeoLegacyMeta = QStringLiteral("https://maven.neoforged.net/releases/net/neoforged/forge/maven-metadata.xml");
FakeResponse xml(const QByteArray &body)
{
FakeResponse response = FakeResponse::bytes(body);
response.contentType = "application/xml";
return response;
}
QStringList loaderVersions(const QList<LoaderVersionEntry> &list)
{
QStringList out;
for (const LoaderVersionEntry &entry : list)
out << entry.loaderVersion;
return out;
}
} // namespace
class TestModLoaderVersionService : public QObject
{
Q_OBJECT
FakeInternet m_net;
void routeAll()
{
m_net.route(kFabric, FakeResponse::json(Fixtures::read("loaders/fabric-1.20.1.json")));
m_net.route(kQuilt, FakeResponse::json(Fixtures::read("loaders/quilt-1.20.1.json")));
m_net.route(kForgeMeta, xml(Fixtures::read("loaders/forge-maven-metadata.xml")));
m_net.route(kForgePromos, FakeResponse::json(Fixtures::read("loaders/forge-promotions_slim.json")));
m_net.route(kNeoMeta, xml(Fixtures::read("loaders/neoforge-maven-metadata.xml")));
m_net.route(kNeoLegacyMeta, xml(Fixtures::read("loaders/neoforge-legacy-maven-metadata.xml")));
}
static bool load(ModLoaderVersionService &service, ModLoader loader, const QString &game,
CallResult &r, bool force = false)
{
service.ensureLoaded(loader, game, r.callback(), force);
return QTest::qWaitFor([&] { return r.calls > 0; }, 15000);
}
private slots:
void initTestCase() { BILAUNCHER_START_FAKE_INTERNET(m_net); }
void init()
{
TestEnv::resetLauncherRoot();
m_net.reset();
routeAll();
}
void fabric()
{
ModLoaderVersionService service;
QSignalSpy changed(&service, &ModLoaderVersionService::versionsChanged);
CallResult r;
QVERIFY(load(service, ModLoader::Fabric, "1.20.1", r));
QVERIFY2(r.ok, qPrintable(r.warning));
const QList<LoaderVersionEntry> list = service.versions(ModLoader::Fabric, "1.20.1");
// Новые первыми: 0.16.10 > 0.16.9 (числовое сравнение, не строковое); пустые версии выброшены.
QCOMPARE(loaderVersions(list), QStringList({"0.17.0-beta.1", "0.16.10", "0.16.9"}));
QCOMPARE(list.at(1).versionId, QStringLiteral("fabric-loader-0.16.10-1.20.1"));
QCOMPARE(list.at(1).gameVersion, QStringLiteral("1.20.1"));
QVERIFY(list.at(1).stable);
QVERIFY(!list.at(0).stable);
QVERIFY(list.at(1).installerUrl.isEmpty());
QCOMPARE(changed.count(), 1);
QCOMPARE(changed.constFirst().at(0).toString(), QStringLiteral("fabric"));
QCOMPARE(changed.constFirst().at(1).toString(), QStringLiteral("1.20.1"));
QVERIFY(service.find(ModLoader::Fabric, "1.20.1", "0.16.9").has_value());
QVERIFY(!service.find(ModLoader::Fabric, "1.20.1", "9.9.9").has_value());
QVERIFY(service.versions(ModLoader::Fabric, "1.19.2").isEmpty());
QVERIFY(QFile::exists(LauncherPaths::loaderCacheFile("fabric")));
}
void quiltStabilityHeuristic()
{
ModLoaderVersionService service;
CallResult r;
QVERIFY(load(service, ModLoader::Quilt, "1.20.1", r));
QVERIFY(r.ok);
const QList<LoaderVersionEntry> list = service.versions(ModLoader::Quilt, "1.20.1");
QCOMPARE(loaderVersions(list), QStringList({"0.26.4-beta.1", "0.26.3"}));
QCOMPARE(list.at(0).versionId, QStringLiteral("quilt-loader-0.26.4-beta.1-1.20.1"));
QVERIFY(!list.at(0).stable); // поля stable нет — решает «beta» в номере
QVERIFY(list.at(1).stable);
}
void forgeMergesPromotions()
{
ModLoaderVersionService service;
CallResult r;
QVERIFY(load(service, ModLoader::Forge, "1.20.1", r));
QVERIFY2(r.ok, qPrintable(r.warning));
QCOMPARE(m_net.hits(kForgeMeta), 1);
QCOMPARE(m_net.hits(kForgePromos), 1);
const QList<LoaderVersionEntry> v1201 = service.versions(ModLoader::Forge, "1.20.1");
QCOMPARE(loaderVersions(v1201), QStringList({"47.4.1", "47.4.0", "47.3.0"}));
for (const LoaderVersionEntry &entry : v1201)
QCOMPARE(entry.recommended, entry.loaderVersion == QLatin1String("47.4.0"));
QCOMPARE(v1201.at(1).installerUrl,
QUrl("https://maven.minecraftforge.net/net/minecraftforge/forge/1.20.1-47.4.0/forge-1.20.1-47.4.0-installer.jar"));
QVERIFY(v1201.at(1).versionId.isEmpty()); // id Forge узнаём только после установки
// Весь каталог Forge — одним запросом, по всем версиям игры.
const QList<LoaderVersionEntry> v1211 = service.versions(ModLoader::Forge, "1.21.1");
QCOMPARE(loaderVersions(v1211), QStringList({"52.0.10", "52.0.2-beta"}));
QVERIFY(!v1211.at(1).stable);
QCOMPARE(loaderVersions(service.versions(ModLoader::Forge, "1.7.10")),
QStringList{"10.13.4.1614-1.7.10"});
// Другая версия игры у Forge не требует нового запроса.
CallResult again;
QVERIFY(load(service, ModLoader::Forge, "1.21.1", again));
QCOMPARE(m_net.hits(kForgeMeta), 1);
}
void forgeWithoutPromotionsStillWorks()
{
m_net.route(kForgePromos, FakeResponse::error(500));
ModLoaderVersionService service;
CallResult r;
QVERIFY(load(service, ModLoader::Forge, "1.20.1", r));
QVERIFY(r.ok);
for (const LoaderVersionEntry &entry : service.versions(ModLoader::Forge, "1.20.1"))
QVERIFY(!entry.recommended);
}
void neoForgeBothSchemes()
{
ModLoaderVersionService service;
CallResult r;
QVERIFY(load(service, ModLoader::NeoForge, "1.21.1", r));
QVERIFY2(r.ok, qPrintable(r.warning));
QCOMPARE(loaderVersions(service.versions(ModLoader::NeoForge, "1.21.1")),
QStringList({"21.1.248", "21.1.200"}));
QCOMPARE(loaderVersions(service.versions(ModLoader::NeoForge, "1.21")), QStringList{"21.0.10"});
QCOMPARE(loaderVersions(service.versions(ModLoader::NeoForge, "1.20.4")),
QStringList({"20.4.237", "20.4.80-beta"}));
// Новая нумерация (год.выпуск.патч.сборка).
QCOMPARE(loaderVersions(service.versions(ModLoader::NeoForge, "26.1")), QStringList{"26.1.0.5"});
QCOMPARE(loaderVersions(service.versions(ModLoader::NeoForge, "26.1.1")), QStringList{"26.1.1.3"});
QCOMPARE(service.find(ModLoader::NeoForge, "1.21.1", "21.1.248")->installerUrl,
QUrl("https://maven.neoforged.net/releases/net/neoforged/neoforge/21.1.248/neoforge-21.1.248-installer.jar"));
}
void neoForgeLegacyArtifactArrivesLast()
{
// Ранние сборки под 1.20.1 — из старого артефакта net.neoforged:forge.
FakeResponse legacySlow = xml(Fixtures::read("loaders/neoforge-legacy-maven-metadata.xml"));
legacySlow.delayMs = 400;
m_net.route(kNeoLegacyMeta, legacySlow);
ModLoaderVersionService service;
CallResult r;
QVERIFY(load(service, ModLoader::NeoForge, "1.20.1", r));
QVERIFY(r.ok);
const QList<LoaderVersionEntry> legacy = service.versions(ModLoader::NeoForge, "1.20.1");
QCOMPARE(loaderVersions(legacy), QStringList({"47.1.106", "47.1.79"}));
QCOMPARE(legacy.at(0).installerUrl,
QUrl("https://maven.neoforged.net/releases/net/neoforged/forge/1.20.1-47.1.106/forge-1.20.1-47.1.106-installer.jar"));
}
void neoForgeLegacyArtifactArrivesFirst()
{
// Тот же каталог, но маленький legacy-ответ приходит раньше основного.
FakeResponse primarySlow = xml(Fixtures::read("loaders/neoforge-maven-metadata.xml"));
primarySlow.delayMs = 400;
m_net.route(kNeoMeta, primarySlow);
ModLoaderVersionService service;
CallResult r;
QVERIFY(load(service, ModLoader::NeoForge, "1.20.1", r));
QVERIFY(r.ok);
QCOMPARE(service.versions(ModLoader::NeoForge, "1.21.1").size(), 2);
// В startRefresh() основной ответ делает state->merged = parsed и затирает
// уже слитые записи legacy — версии NeoForge для 1.20.1 пропадают.
QEXPECT_FAIL("", "гонка: основной maven-metadata NeoForge затирает уже пришедший legacy", Continue);
QCOMPARE(service.versions(ModLoader::NeoForge, "1.20.1").size(), 2);
}
void primaryFailureIsError()
{
m_net.route(kNeoMeta, FakeResponse::error(503));
ModLoaderVersionService service;
CallResult r;
QVERIFY(load(service, ModLoader::NeoForge, "1.21.1", r));
QVERIFY(!r.ok);
QVERIFY(r.warning.contains(loaderTitle(ModLoader::NeoForge)));
m_net.route(kForgeMeta, xml("<metadata><versioning><versions></versions></versioning></metadata>"));
CallResult empty;
QVERIFY(load(service, ModLoader::Forge, "1.20.1", empty));
QVERIFY(!empty.ok);
QCOMPARE(empty.warning, Loc::text("loader.error.versionsUnparsable").arg(loaderTitle(ModLoader::Forge)));
m_net.route(kFabric, FakeResponse::json("{\"not\": \"an array\"}"));
CallResult notArray;
QVERIFY(load(service, ModLoader::Fabric, "1.20.1", notArray));
QVERIFY(!notArray.ok);
QCOMPARE(notArray.warning, Loc::text("loader.error.versionsUnparsable").arg(loaderTitle(ModLoader::Fabric)));
}
void cachePerLoaderAndOfflineFallback()
{
{
ModLoaderVersionService service;
CallResult r;
QVERIFY(load(service, ModLoader::Fabric, "1.20.1", r));
}
QCOMPARE(m_net.hits(kFabric), 1);
ModLoaderVersionService service;
CallResult cached;
QVERIFY(load(service, ModLoader::Fabric, "1.20.1", cached));
QVERIFY(cached.ok);
QCOMPARE(m_net.hits(kFabric), 1); // свежий кеш
QCOMPARE(service.versions(ModLoader::Fabric, "1.20.1").size(), 3);
// Сеть легла, принудительное обновление: данные остаются, ok с предупреждением.
m_net.route(kFabric, FakeResponse::error(502));
CallResult offline;
QVERIFY(load(service, ModLoader::Fabric, "1.20.1", offline, true));
QVERIFY(offline.ok);
QVERIFY(!offline.warning.isEmpty());
QCOMPARE(service.versions(ModLoader::Fabric, "1.20.1").size(), 3);
}
void emptyGameVersionIsNoop()
{
ModLoaderVersionService service;
CallResult r;
QVERIFY(load(service, ModLoader::Fabric, " ", r));
QVERIFY(r.ok);
QVERIFY(m_net.requests().isEmpty());
}
void inFlightRequestsAreShared()
{
FakeResponse slow = FakeResponse::json(Fixtures::read("loaders/fabric-1.20.1.json"));
slow.delayMs = 300;
m_net.route(kFabric, slow);
ModLoaderVersionService service;
CallResult a, b;
service.ensureLoaded(ModLoader::Fabric, "1.20.1", a.callback());
QVERIFY(service.isRefreshing(ModLoader::Fabric, "1.20.1"));
QVERIFY(!service.isRefreshing(ModLoader::Fabric, "1.19.2"));
service.ensureLoaded(ModLoader::Fabric, "1.20.1", b.callback());
QTRY_VERIFY_WITH_TIMEOUT(a.calls == 1 && b.calls == 1, 15000);
QCOMPARE(m_net.hits(kFabric), 1);
QVERIFY(!service.isRefreshing(ModLoader::Fabric, "1.20.1"));
}
};
BILAUNCHER_TEST_MAIN(TestModLoaderVersionService)
#include "tst_modloaderversionservice.moc"
+265
View File
@@ -0,0 +1,265 @@
#include "testenv.h"
#include "nettest.h"
#include "authservice.h"
#include "localization.h"
#include "msaauthservice.h"
#include <QJsonArray>
#include <QJsonDocument>
#include <QSignalSpy>
namespace {
const QString kToken = QStringLiteral("https://login.live.com/oauth20_token.srf");
const QString kXbl = QStringLiteral("https://user.auth.xboxlive.com/user/authenticate");
const QString kXsts = QStringLiteral("https://xsts.auth.xboxlive.com/xsts/authorize");
const QString kMcLogin = QStringLiteral("https://api.minecraftservices.com/authentication/login_with_xbox");
const QString kEntitlements = QStringLiteral("https://api.minecraftservices.com/entitlements/mcstore");
const QString kProfile = QStringLiteral("https://api.minecraftservices.com/minecraft/profile");
QJsonObject jsonBody(const FakeRequest &request)
{
return QJsonDocument::fromJson(request.body).object();
}
struct Captured
{
int calls = 0;
AuthResult result;
auto callback()
{
return [this](const AuthResult &r) { ++calls; result = r; };
}
};
} // namespace
class TestMsaAuthService : public QObject
{
Q_OBJECT
FakeInternet m_net;
// Полностью рабочая цепочка Microsoft → Xbox → XSTS → Minecraft.
void routeHappyPath()
{
m_net.route(kToken, [](const FakeRequest &request) {
const QUrlQuery form = request.formItems();
const QString grant = form.queryItemValue("grant_type");
if (grant == QLatin1String("authorization_code") && form.queryItemValue("code") == QLatin1String("good-code"))
return FakeResponse::json(QJsonObject{{"access_token", "ms-access"}, {"refresh_token", "ms-refresh-2"}});
if (grant == QLatin1String("refresh_token") && form.queryItemValue("refresh_token") == QLatin1String("ms-refresh-1"))
return FakeResponse::json(QJsonObject{{"access_token", "ms-access"}, {"refresh_token", "ms-refresh-3"}});
return FakeResponse::json(QJsonObject{{"error", "invalid_grant"},
{"error_description", "The provided value for the 'code' parameter is not valid."}}, 400);
});
m_net.route(kXbl, [](const FakeRequest &request) {
const QJsonObject props = jsonBody(request).value("Properties").toObject();
if (props.value("RpsTicket").toString() != QLatin1String("ms-access"))
return FakeResponse::error(400);
return FakeResponse::json(QJsonObject{{"Token", "xbl-token"}});
});
m_net.route(kXsts, [](const FakeRequest &request) {
const QJsonObject props = jsonBody(request).value("Properties").toObject();
if (props.value("UserTokens").toArray().first().toString() != QLatin1String("xbl-token"))
return FakeResponse::error(400);
const QJsonObject claims{{"xui", QJsonArray{QJsonObject{{"uhs", "user-hash"}, {"xid", "2535400000000000"}}}}};
return FakeResponse::json(QJsonObject{{"Token", "xsts-token"}, {"DisplayClaims", claims}});
});
m_net.route(kMcLogin, [](const FakeRequest &request) {
if (jsonBody(request).value("identityToken").toString() != QLatin1String("XBL3.0 x=user-hash;xsts-token"))
return FakeResponse::error(401);
return FakeResponse::json(QJsonObject{{"access_token", "mc-token"}, {"expires_in", 3600}});
});
m_net.route(kEntitlements, [](const FakeRequest &request) {
if (request.header("authorization") != "Bearer mc-token")
return FakeResponse::error(401);
return FakeResponse::json(QJsonObject{{"items", QJsonArray{QJsonObject{{"name", "game_minecraft"}}}}});
});
m_net.route(kProfile, [](const FakeRequest &request) {
if (request.header("authorization") != "Bearer mc-token")
return FakeResponse::error(401);
return FakeResponse::json(QJsonObject{{"id", "069a79f4-44e9-4726-a5be-fca90e38aaf5"}, {"name", "Notch"}});
});
}
static bool wait(Captured &c) { return QTest::qWaitFor([&] { return c.calls > 0; }, 15000); }
private slots:
void initTestCase() { BILAUNCHER_START_FAKE_INTERNET(m_net); }
void init()
{
m_net.reset();
routeHappyPath();
}
void fullChainWithCode()
{
MsaAuthService service;
QSignalSpy progress(&service, &MsaAuthService::progress);
Captured c;
const QDateTime before = QDateTime::currentDateTimeUtc();
service.loginWithCode("good-code", c.callback());
QVERIFY(wait(c));
QVERIFY2(c.result.ok, qPrintable(c.result.error));
QCOMPARE(c.result.playerName, QStringLiteral("Notch"));
QCOMPARE(c.result.uuid, QStringLiteral("069a79f444e94726a5befca90e38aaf5"));
QCOMPARE(c.result.accessToken, QStringLiteral("mc-token"));
QCOMPARE(c.result.refreshToken, QStringLiteral("ms-refresh-2"));
QCOMPARE(c.result.xuid, QStringLiteral("2535400000000000"));
QCOMPARE(c.result.userType, QStringLiteral("msa"));
QCOMPARE(c.result.clientToken, MsaAuthService::clientId());
QVERIFY(!c.result.licenseMissing);
// expiresAt = момент ответа login_with_xbox + expires_in; на медленном раннере
// вся цепочка может идти дольше секунды, поэтому границы — до и после логина.
const QDateTime after = QDateTime::currentDateTimeUtc();
QVERIFY(c.result.expiresAt >= before.addSecs(3600));
QVERIFY(c.result.expiresAt <= after.addSecs(3600));
QVERIFY(progress.count() >= 5);
// Порядок шагов и формат запроса токена.
QCOMPARE(m_net.requestedUrls(), QStringList({kToken, kXbl, kXsts, kMcLogin, kEntitlements, kProfile}));
const FakeRequest tokenRequest = m_net.requests().constFirst();
QCOMPARE(tokenRequest.header("content-type"), QByteArray("application/x-www-form-urlencoded"));
QCOMPARE(tokenRequest.formItems().queryItemValue("client_id"), MsaAuthService::clientId());
QCOMPARE(jsonBody(m_net.requests().at(1)).value("RelyingParty").toString(), QStringLiteral("http://auth.xboxlive.com"));
QCOMPARE(jsonBody(m_net.requests().at(2)).value("RelyingParty").toString(), QStringLiteral("rp://api.minecraftservices.com/"));
QCOMPARE(jsonBody(m_net.requests().at(2)).value("Properties").toObject().value("SandboxId").toString(),
QStringLiteral("RETAIL"));
}
void refreshTokenLogin()
{
MsaAuthService service;
Captured c;
service.loginWithRefreshToken("ms-refresh-1", c.callback());
QVERIFY(wait(c));
QVERIFY2(c.result.ok, qPrintable(c.result.error));
QCOMPARE(c.result.refreshToken, QStringLiteral("ms-refresh-3")); // новый refresh-токен сохраняется
QCOMPARE(m_net.requests().constFirst().formItems().queryItemValue("grant_type"), QStringLiteral("refresh_token"));
}
void emptyInputsFailImmediately()
{
MsaAuthService service;
Captured noCode;
service.loginWithCode(QString(), noCode.callback());
QCOMPARE(noCode.calls, 1);
QCOMPARE(noCode.result.error, Loc::text("auth.msa.error.noAuthCode"));
Captured noRefresh;
service.loginWithRefreshToken(QString(), noRefresh.callback());
QCOMPARE(noRefresh.calls, 1);
QCOMPARE(noRefresh.result.error, Loc::text("auth.msa.error.profileNotLinked"));
QVERIFY(m_net.requests().isEmpty());
}
void badCodeShowsMicrosoftDescription()
{
MsaAuthService service;
Captured c;
service.loginWithCode("bad-code", c.callback());
QVERIFY(wait(c));
QVERIFY(!c.result.ok);
QCOMPARE(c.result.error, QStringLiteral("The provided value for the 'code' parameter is not valid."));
QCOMPARE(m_net.requests().size(), 1);
}
void xstsErrors_data()
{
QTest::addColumn<double>("xerr");
QTest::addColumn<QString>("message");
QTest::addColumn<QString>("expected");
QTest::newRow("no xbox profile") << 2148916233.0 << "" << Loc::text("auth.msa.error.noXboxProfile");
QTest::newRow("country") << 2148916235.0 << "" << Loc::text("auth.msa.error.countryBlocked");
QTest::newRow("age 36") << 2148916236.0 << "" << Loc::text("auth.msa.error.ageVerification");
QTest::newRow("age 37") << 2148916237.0 << "" << Loc::text("auth.msa.error.ageVerification");
QTest::newRow("child") << 2148916238.0 << "" << Loc::text("auth.msa.error.childAccount");
QTest::newRow("unknown with message") << 1.0 << "Something odd" << "Something odd";
QTest::newRow("unknown without message") << 7.0 << "" << Loc::text("auth.msa.error.xboxRejected").arg(7);
}
void xstsErrors()
{
QFETCH(double, xerr);
QFETCH(QString, message);
QFETCH(QString, expected);
m_net.route(kXsts, FakeResponse::json(QJsonObject{{"XErr", xerr}, {"Message", message}}, 401));
MsaAuthService service;
Captured c;
service.loginWithCode("good-code", c.callback());
QVERIFY(wait(c));
QVERIFY(!c.result.ok);
QCOMPARE(c.result.error, expected);
}
void stepFailures_data()
{
QTest::addColumn<QString>("url");
QTest::addColumn<int>("status");
QTest::addColumn<QByteArray>("body");
QTest::addColumn<QString>("expected");
QTest::newRow("token without access_token") << kToken << 200 << QByteArray("{}") << Loc::text("auth.msa.error.noAccessToken");
QTest::newRow("xbl rejected") << kXbl << 400 << QByteArray("{}") << Loc::text("auth.msa.error.xboxTokenRejected").arg(400);
QTest::newRow("xbl no token") << kXbl << 200 << QByteArray("{}") << Loc::text("auth.msa.error.xboxNoToken");
QTest::newRow("xsts 500") << kXsts << 500 << QByteArray("{}") << Loc::text("auth.msa.error.xstsRejected").arg(500);
QTest::newRow("xsts no user") << kXsts << 200 << QByteArray("{\"Token\":\"t\"}") << Loc::text("auth.msa.error.xstsNoUser");
QTest::newRow("services rejected") << kMcLogin << 429 << QByteArray("{}") << Loc::text("auth.msa.error.servicesRejected").arg(429);
QTest::newRow("services no token") << kMcLogin << 200 << QByteArray("{}") << Loc::text("auth.msa.error.servicesNoToken");
QTest::newRow("profile 500") << kProfile << 500 << QByteArray("{}") << Loc::text("auth.msa.error.profileFetchFailed").arg(500);
QTest::newRow("profile empty") << kProfile << 200 << QByteArray("{}") << Loc::text("auth.msa.error.servicesNoProfile");
}
void stepFailures()
{
QFETCH(QString, url);
QFETCH(int, status);
QFETCH(QByteArray, body);
QFETCH(QString, expected);
m_net.route(url, FakeResponse::json(body, status));
MsaAuthService service;
Captured c;
service.loginWithCode("good-code", c.callback());
QVERIFY(wait(c));
QVERIFY(!c.result.ok);
QCOMPARE(c.result.error, expected);
}
void licenseChecks()
{
// Профиля нет, покупки тоже нет — нет лицензии.
m_net.route(kEntitlements, FakeResponse::json(QJsonObject{{"items", QJsonArray{}}}));
m_net.route(kProfile, FakeResponse::json("{}", 404));
MsaAuthService service;
Captured noLicense;
service.loginWithCode("good-code", noLicense.callback());
QVERIFY(wait(noLicense));
QVERIFY(!noLicense.result.ok);
QVERIFY(noLicense.result.licenseMissing);
QCOMPARE(noLicense.result.error, Loc::text("auth.msa.error.noLicence"));
// Покупка есть, но профиль (ник) ещё не создан.
m_net.route(kEntitlements, FakeResponse::json(QJsonObject{{"items", QJsonArray{QJsonObject{{"name", "x"}}}}}));
Captured noProfile;
service.loginWithCode("good-code", noProfile.callback());
QVERIFY(wait(noProfile));
QVERIFY(noProfile.result.licenseMissing);
QCOMPARE(noProfile.result.error, Loc::text("auth.msa.error.noProfileYet"));
}
void transportErrorAtMicrosoft()
{
FakeResponse drop;
drop.drop = true;
m_net.route(kToken, drop);
MsaAuthService service;
Captured c;
service.loginWithCode("good-code", c.callback());
QVERIFY(wait(c));
QVERIFY(c.result.error.startsWith(Loc::text("auth.msa.error.microsoftUnreachable").section(u'%', 0, 0)));
}
};
BILAUNCHER_TEST_MAIN(TestMsaAuthService)
#include "tst_msaauthservice.moc"
+197
View File
@@ -0,0 +1,197 @@
#include "testenv.h"
#include "fixtures.h"
#include "nettest.h"
#include "launcherpaths.h"
#include "localization.h"
#include "seasonalbuildservice.h"
#include <QJsonArray>
#include <QSignalSpy>
namespace {
const QUrl kBase(QStringLiteral("https://launcher-builds.galeonworx.com"));
const QString kIndex = QStringLiteral("https://launcher-builds.galeonworx.com/index.json");
}
class TestSeasonalBuildService : public QObject
{
Q_OBJECT
FakeInternet m_net;
private slots:
void initTestCase() { BILAUNCHER_START_FAKE_INTERNET(m_net); }
void init()
{
TestEnv::resetLauncherRoot();
m_net.reset();
}
void withoutBaseUrlFails()
{
SeasonalBuildService service;
CallResult r;
service.ensureLoaded(r.callback());
QTRY_COMPARE_WITH_TIMEOUT(r.calls, 1, 5000);
QVERIFY(!r.ok);
QCOMPARE(r.warning, Loc::text("seasonal.error.noServerUrl"));
QCOMPARE(service.lastError(), Loc::text("seasonal.error.noServerUrl"));
QVERIFY(m_net.requests().isEmpty());
}
void parsesValidatesAndSorts()
{
m_net.route(kIndex, FakeResponse::json(Fixtures::read("seasonal/index.json")));
SeasonalBuildService service;
service.setBaseUrl(kBase);
QCOMPARE(service.baseUrl(), kBase);
CallResult r;
service.ensureLoaded(r.callback());
QTRY_COMPARE_WITH_TIMEOUT(r.calls, 1, 15000);
QVERIFY2(r.ok, qPrintable(r.warning));
QStringList ids;
for (const SeasonalBuildEntry &entry : service.builds())
ids << entry.id;
// Открытые сезоны первыми (новые выше), закрытые — после; всё невалидное отброшено.
QCOMPARE(ids, QStringList({"season-5", "vanilla-plus", "season-4"}));
const auto s5 = service.find("season-5");
QVERIFY(s5.has_value());
QVERIFY(s5->isValid());
QCOMPARE(s5->name, QStringLiteral("Сезон 5: Пустоши"));
QCOMPARE(s5->revision, 3);
QCOMPARE(s5->minecraftVersion, QStringLiteral("1.21.1"));
QCOMPARE(s5->loader, QStringLiteral("fabric"));
QCOMPARE(s5->loaderVersion, QStringLiteral("0.16.10"));
QCOMPARE(s5->javaMajor, 21);
QCOMPARE(s5->modCount, 120);
QCOMPARE(s5->description, QStringLiteral("Пустоши")); // обрезаны пробелы
QCOMPARE(s5->seasonStart, QDate(2025, 7, 1));
QVERIFY(!s5->seasonEnd.isValid());
QCOMPARE(s5->archiveSize, qint64(123456));
QCOMPARE(s5->archiveSha256, QString("abcdef" + QString(58, u'0'))); // к нижнему регистру
QCOMPARE(s5->archiveUrl, QUrl("https://launcher-builds.galeonworx.com/packs/s5.zip"));
QCOMPARE(service.find("season-4")->seasonEnd, QDate(2025, 6, 1));
QVERIFY(service.find("vanilla-plus")->loader.isEmpty());
QVERIFY(QFile::exists(LauncherPaths::seasonalCatalogFile()));
}
void baseUrlPathIsKept()
{
m_net.route("https://launcher-builds.galeonworx.com/beta/index.json",
FakeResponse::json(QJsonObject{{"formatVersion", 1}, {"builds", QJsonArray{}}}));
SeasonalBuildService service;
service.setBaseUrl(QUrl("https://launcher-builds.galeonworx.com/beta"));
CallResult r;
service.ensureLoaded(r.callback());
QTRY_COMPARE_WITH_TIMEOUT(r.calls, 1, 15000);
QCOMPARE(m_net.hits("https://launcher-builds.galeonworx.com/beta/index.json"), 1);
QVERIFY(!r.ok);
QCOMPARE(r.warning, Loc::text("seasonal.error.catalogEmpty"));
}
void formatVersionChecks_data()
{
QTest::addColumn<int>("format");
QTest::addColumn<QString>("error");
QTest::newRow("too new") << 2 << Loc::text("seasonal.error.formatTooNew").arg(2);
QTest::newRow("unknown") << 0 << Loc::text("seasonal.error.formatUnknown").arg(0);
}
void formatVersionChecks()
{
QFETCH(int, format);
QFETCH(QString, error);
QJsonObject catalog = Fixtures::readJson("seasonal/index.json");
catalog["formatVersion"] = format;
m_net.route(kIndex, FakeResponse::json(catalog));
SeasonalBuildService service;
service.setBaseUrl(kBase);
CallResult r;
service.ensureLoaded(r.callback());
QTRY_COMPARE_WITH_TIMEOUT(r.calls, 1, 15000);
QVERIFY(!r.ok);
QCOMPARE(r.warning, error);
QCOMPARE(service.lastError(), error);
}
void cacheIsBoundToBaseUrl()
{
m_net.route(kIndex, FakeResponse::json(Fixtures::read("seasonal/index.json")));
{
SeasonalBuildService service;
service.setBaseUrl(kBase);
CallResult r;
service.ensureLoaded(r.callback());
QTRY_COMPARE_WITH_TIMEOUT(r.calls, 1, 15000);
}
// Тот же адрес — свежий кеш, без сети.
{
SeasonalBuildService service;
service.setBaseUrl(kBase);
CallResult r;
service.ensureLoaded(r.callback());
QVERIFY(service.hasData());
QTRY_COMPARE_WITH_TIMEOUT(r.calls, 1, 5000);
QCOMPARE(m_net.hits(kIndex), 1);
}
// Другой адрес — чужой кеш не подхватывается.
SeasonalBuildService other;
other.setBaseUrl(QUrl("https://mirror.example"));
m_net.route("https://mirror.example/index.json", FakeResponse::error(503));
CallResult r;
other.ensureLoaded(r.callback());
QVERIFY(!other.hasData());
QTRY_COMPARE_WITH_TIMEOUT(r.calls, 1, 15000);
QVERIFY(!r.ok);
QVERIFY(r.warning.startsWith(Loc::text("seasonal.error.catalogFetchFailed").section(u'%', 0, 0)));
}
void setBaseUrlResetsData()
{
m_net.route(kIndex, FakeResponse::json(Fixtures::read("seasonal/index.json")));
SeasonalBuildService service;
service.setBaseUrl(kBase);
CallResult r;
service.ensureLoaded(r.callback());
QTRY_COMPARE_WITH_TIMEOUT(r.calls, 1, 15000);
QVERIFY(service.hasData());
QSignalSpy changed(&service, &SeasonalBuildService::buildsChanged);
service.setBaseUrl(kBase); // тот же адрес — ничего не происходит
QCOMPARE(changed.count(), 0);
service.setBaseUrl(QUrl("https://mirror.example"));
QCOMPARE(changed.count(), 1);
QVERIFY(!service.hasData());
QVERIFY(!service.find("season-5").has_value());
}
void offlineWithCacheKeepsBuilds()
{
m_net.route(kIndex, FakeResponse::json(Fixtures::read("seasonal/index.json")));
{
SeasonalBuildService service;
service.setBaseUrl(kBase);
CallResult r;
service.ensureLoaded(r.callback());
QTRY_COMPARE_WITH_TIMEOUT(r.calls, 1, 15000);
}
m_net.route(kIndex, FakeResponse::error(502));
SeasonalBuildService service;
service.setBaseUrl(kBase);
CallResult r;
service.ensureLoaded(r.callback(), /*forceRefresh=*/true);
QTRY_COMPARE_WITH_TIMEOUT(r.calls, 1, 15000);
QVERIFY(r.ok);
QVERIFY(!r.warning.isEmpty());
QCOMPARE(service.builds().size(), 3);
QCOMPARE(service.lastError(), r.warning);
}
};
BILAUNCHER_TEST_MAIN(TestSeasonalBuildService)
#include "tst_seasonalbuildservice.moc"
+139
View File
@@ -0,0 +1,139 @@
#include "testenv.h"
#include "fixtures.h"
#include "nettest.h"
#include "localization.h"
#include "seasonalpackdownloader.h"
#include <QSignalSpy>
namespace {
const QString kPack = QStringLiteral("https://launcher-builds.galeonworx.com/packs/season-5-r3.zip");
}
class TestSeasonalPackDownloader : public QObject
{
Q_OBJECT
FakeInternet m_net;
QString m_dir;
struct Run
{
explicit Run(SeasonalPackDownloader &d)
: started(&d, &SeasonalPackDownloader::started)
, finished(&d, &SeasonalPackDownloader::finished)
, failed(&d, &SeasonalPackDownloader::failed)
, canceled(&d, &SeasonalPackDownloader::canceled)
{}
bool waitEnd()
{
return QTest::qWaitFor([&] { return finished.count() + failed.count() + canceled.count() > 0; }, 20000);
}
QSignalSpy started, finished, failed, canceled;
};
private slots:
void initTestCase() { BILAUNCHER_START_FAKE_INTERNET(m_net); }
void init()
{
m_net.reset();
m_dir = TestEnv::freshDir(QStringLiteral("packs"));
}
void downloadsAndVerifies()
{
const QByteArray data(300 * 1024, 'p');
m_net.route(kPack, FakeResponse::bytes(data));
SeasonalPackDownloader downloader;
Run run(downloader);
const QString target = m_dir + "/sub/season-5.zip";
downloader.download(QUrl(kPack), target, Fixtures::sha256(data).toUpper(), data.size(), "Season 5");
QVERIFY(downloader.isRunning());
QCOMPARE(downloader.label(), QStringLiteral("Season 5"));
QCOMPARE(downloader.currentFile(), QStringLiteral("season-5.zip"));
QCOMPARE(downloader.bytesTotal(), qint64(data.size()));
QCOMPARE(run.started.count(), 1);
QVERIFY(run.waitEnd());
QCOMPARE(run.finished.count(), 1);
QCOMPARE(run.finished.constFirst().constFirst().toString(), target);
QCOMPARE(Fixtures::readFile(target), data);
QVERIFY(!downloader.isRunning());
QCOMPARE(downloader.fraction(), -1.0);
}
void checksumMismatchLeavesNoFile()
{
m_net.route(kPack, FakeResponse::bytes("tampered"));
SeasonalPackDownloader downloader;
Run run(downloader);
const QString target = m_dir + "/season.zip";
downloader.download(QUrl(kPack), target, QString(64, u'a'), 8, "S");
QVERIFY(run.waitEnd());
QCOMPARE(run.failed.count(), 1);
QCOMPARE(run.failed.constFirst().at(0).toString(), QStringLiteral("S"));
QCOMPARE(run.failed.constFirst().at(1).toString(), Loc::text("seasonal.error.checksumMismatch"));
QVERIFY(!QFile::exists(target));
}
void httpErrorAndTruncation()
{
SeasonalPackDownloader downloader;
{
m_net.route(kPack, FakeResponse::error(404));
Run run(downloader);
downloader.download(QUrl(kPack), m_dir + "/a.zip", QString(64, u'a'), 0, "A");
QVERIFY(run.waitEnd());
QVERIFY(run.failed.constFirst().at(1).toString().startsWith(
Loc::text("seasonal.error.downloadFailed").section(u'%', 0, 0)));
}
{
FakeResponse cut = FakeResponse::bytes(QByteArray(4096, 'x'));
cut.truncateTo = 100;
m_net.route(kPack, cut);
Run run(downloader);
downloader.download(QUrl(kPack), m_dir + "/b.zip", Fixtures::sha256(QByteArray(4096, 'x')), 4096, "B");
QVERIFY(run.waitEnd());
QCOMPARE(run.failed.count(), 1);
QVERIFY(!QFile::exists(m_dir + "/b.zip"));
}
}
void cancelInFlight()
{
FakeResponse hang;
hang.hang = true;
m_net.route(kPack, hang);
SeasonalPackDownloader downloader;
Run run(downloader);
downloader.download(QUrl(kPack), m_dir + "/c.zip", QString(64, u'a'), 10, "C");
QTRY_COMPARE_WITH_TIMEOUT(m_net.hits(kPack), 1, 15000);
downloader.cancel();
QVERIFY(run.waitEnd());
QCOMPARE(run.canceled.count(), 1);
QCOMPARE(run.canceled.constFirst().constFirst().toString(), QStringLiteral("C"));
QVERIFY(!QFile::exists(m_dir + "/c.zip"));
QVERIFY(!downloader.isRunning());
downloader.cancel(); // повторная отмена без загрузки — ничего не делает
QCOMPARE(run.canceled.count(), 1);
}
void secondDownloadWhileBusyIsRejected()
{
FakeResponse slow = FakeResponse::bytes("x");
slow.delayMs = 500;
m_net.route(kPack, slow);
SeasonalPackDownloader downloader;
Run run(downloader);
downloader.download(QUrl(kPack), m_dir + "/d.zip", Fixtures::sha256("x"), 1, "D");
downloader.download(QUrl(kPack), m_dir + "/e.zip", Fixtures::sha256("x"), 1, "E");
QCOMPARE(run.failed.count(), 1);
QCOMPARE(run.failed.constFirst().at(0).toString(), QStringLiteral("E"));
QCOMPARE(run.failed.constFirst().at(1).toString(), Loc::text("seasonal.error.downloadInProgress"));
QTRY_COMPARE_WITH_TIMEOUT(run.finished.count(), 1, 15000);
}
};
BILAUNCHER_TEST_MAIN(TestSeasonalPackDownloader)
#include "tst_seasonalpackdownloader.moc"
+383
View File
@@ -0,0 +1,383 @@
#include "testenv.h"
#include "fakemojang.h"
#include "fixtures.h"
#include "nettest.h"
#include "gamelauncher.h"
#include "localization.h"
#include "minecraftversion.h"
#include "versioninstaller.h"
#include "versionmanifestservice.h"
#include <QDir>
#include <QSignalSpy>
class TestVersionInstaller : public QObject
{
Q_OBJECT
FakeInternet m_net;
QString m_game;
struct Run
{
explicit Run(VersionInstaller &installer)
: started(&installer, &VersionInstaller::started)
, finished(&installer, &VersionInstaller::finished)
, failed(&installer, &VersionInstaller::failed)
, canceled(&installer, &VersionInstaller::canceled)
, progress(&installer, &VersionInstaller::progressChanged)
{}
bool waitEnd(int expectedEvents = 1)
{
return QTest::qWaitFor([&] { return finished.count() + failed.count() + canceled.count() >= expectedEvents; },
30000);
}
QString failure() const { return failed.isEmpty() ? QString() : failed.constLast().at(1).toString(); }
QSignalSpy started, finished, failed, canceled, progress;
};
void verifyInstalled(const FakeMojang &mojang, const QString &id)
{
const auto expected = mojang.expectedFiles(id);
QVERIFY(!expected.isEmpty());
for (auto it = expected.cbegin(); it != expected.cend(); ++it) {
const QString path = m_game + u'/' + it.key();
QVERIFY2(QFile::exists(path), qPrintable("нет файла " + it.key()));
QCOMPARE(Fixtures::readFile(path), it.value());
}
}
private slots:
void initTestCase() { BILAUNCHER_START_FAKE_INTERNET(m_net); }
void init()
{
TestEnv::resetLauncherRoot();
m_net.reset();
m_game = TestEnv::freshDir(QStringLiteral("game"));
}
void installsModernVersionCompletely()
{
FakeMojang mojang(m_net);
mojang.publish({.id = "1.20.1"});
VersionManifestService manifest;
VersionInstaller installer(&manifest);
Run run(installer);
installer.install(m_game, "1.20.1");
QVERIFY(installer.isRunning());
QCOMPARE(installer.versionId(), QStringLiteral("1.20.1"));
QVERIFY(run.waitEnd());
QVERIFY2(run.failed.isEmpty(), qPrintable(run.failure()));
QCOMPARE(run.started.count(), 1);
QCOMPARE(run.finished.constFirst().constFirst().toString(), QStringLiteral("1.20.1"));
QVERIFY(!installer.isRunning());
QVERIFY(run.progress.count() > 0);
QVERIFY(m_net.unmatched().isEmpty());
verifyInstalled(mojang, "1.20.1");
// Результат пригоден для запуска: GameLauncher не видит недостающих файлов.
QString error;
const MinecraftVersion version = VersionLoader::load(m_game, "1.20.1", {}, &error);
QVERIFY2(version.isValid(), qPrintable(error));
LaunchOptions options;
options.gameDir = m_game;
QVERIFY(GameLauncher::missingFiles(options, version).isEmpty());
}
void secondInstallSkipsExistingFiles()
{
FakeMojang mojang(m_net);
mojang.publish({.id = "1.20.1"});
VersionManifestService manifest;
VersionInstaller installer(&manifest);
{
Run run(installer);
installer.install(m_game, "1.20.1");
QVERIFY(run.waitEnd());
QVERIFY(run.failed.isEmpty());
}
const int firstRequests = m_net.requests().size();
Run run(installer);
installer.install(m_game, "1.20.1");
QVERIFY(run.waitEnd());
QVERIFY(run.failed.isEmpty());
// Повторно скачивается только json версии; файлы нужного размера пропускаются.
const int extra = m_net.requests().size() - firstRequests;
QVERIFY2(extra <= 1, qPrintable(QString::number(extra)));
}
void legacyAssetsAreCopiedToVirtual()
{
FakeMojang mojang(m_net);
mojang.publish({.id = "1.5.2", .legacyAssets = true, .javaMajor = 8});
VersionManifestService manifest;
VersionInstaller installer(&manifest);
Run run(installer);
installer.install(m_game, "1.5.2");
QVERIFY(run.waitEnd());
QVERIFY2(run.failed.isEmpty(), qPrintable(run.failure()));
verifyInstalled(mojang, "1.5.2"); // включая assets/virtual/legacy/*
}
void inheritedVersionPullsParentFirst()
{
FakeMojang mojang(m_net);
mojang.publish({.id = "1.20.1"});
mojang.publish({.id = "fabric-loader-0.16.10-1.20.1", .inheritsFrom = "1.20.1"});
VersionManifestService manifest;
VersionInstaller installer(&manifest);
Run run(installer);
installer.install(m_game, "fabric-loader-0.16.10-1.20.1");
QVERIFY(run.waitEnd(2));
QVERIFY2(run.failed.isEmpty(), qPrintable(run.failure()));
// started приходит на каждую версию, которую очередь берёт «с нуля».
QCOMPARE(run.started.constFirst().constFirst().toString(), QStringLiteral("fabric-loader-0.16.10-1.20.1"));
QStringList finishedIds;
for (const auto &call : run.finished)
finishedIds << call.constFirst().toString();
QCOMPARE(finishedIds, QStringList({"1.20.1", "fabric-loader-0.16.10-1.20.1"}));
verifyInstalled(mojang, "1.20.1");
verifyInstalled(mojang, "fabric-loader-0.16.10-1.20.1");
}
void localJsonUsedWhenNotInManifest()
{
// Версия не из манифеста (например, профиль модлоадера), но json уже лежит локально.
FakeMojang mojang(m_net);
mojang.publish({.id = "custom-1", .listed = false});
Fixtures::write(m_game + "/versions/custom-1/custom-1.json", mojang.versionJson("custom-1"));
VersionManifestService manifest;
VersionInstaller installer(&manifest);
Run run(installer);
installer.install(m_game, "custom-1");
QVERIFY(run.waitEnd());
QVERIFY2(run.failed.isEmpty(), qPrintable(run.failure()));
verifyInstalled(mojang, "custom-1");
QCOMPARE(m_net.hits(mojang.versionJsonUrl("custom-1")), 0);
}
void unknownVersionFails()
{
FakeMojang mojang(m_net);
mojang.publish({.id = "1.20.1"});
VersionManifestService manifest;
VersionInstaller installer(&manifest);
Run run(installer);
installer.install(m_game, "9.9.9");
QVERIFY(run.waitEnd());
QCOMPARE(run.failure(), Loc::text("version.error.notInManifest").arg("9.9.9"));
}
void invalidNamesRejected_data()
{
QTest::addColumn<QString>("id");
QTest::newRow("traversal") << "../evil";
QTest::newRow("slash") << "a/b";
QTest::newRow("dot") << ".";
QTest::newRow("colon") << "c:x";
}
void invalidNamesRejected()
{
QFETCH(QString, id);
VersionManifestService manifest;
VersionInstaller installer(&manifest);
Run run(installer);
installer.install(m_game, id);
QCOMPARE(run.failed.count(), 1);
QCOMPARE(run.failure(), Loc::text("version.error.invalidName").arg(id));
QVERIFY(m_net.requests().isEmpty());
installer.install(m_game, " "); // пустой id молча игнорируется
QCOMPARE(run.failed.count(), 1);
}
void versionJsonChecksumMismatch()
{
FakeMojang mojang(m_net);
mojang.publish({.id = "1.20.1"});
m_net.route(mojang.versionJsonUrl("1.20.1"), FakeResponse::json("{\"tampered\":true}"));
VersionManifestService manifest;
VersionInstaller installer(&manifest);
Run run(installer);
installer.install(m_game, "1.20.1");
QVERIFY(run.waitEnd());
QCOMPARE(run.failure(), Loc::text("version.error.descriptionChecksum").arg("1.20.1"));
QVERIFY(!QFile::exists(m_game + "/versions/1.20.1/1.20.1.json"));
}
void transientErrorIsRetried()
{
// Сбой не на последнем файле: пока ждём повтора, остальные ещё качаются.
FakeMojang mojang(m_net);
mojang.publish({.id = "1.20.1"});
const QString lib = QStringLiteral("libraries/com/mojang/brigadier/1.1.8/brigadier-1.1.8.jar");
const QString jar = QStringLiteral("versions/1.20.1/1.20.1.jar");
FakeResponse slowJar = FakeResponse::bytes(mojang.expectedFiles("1.20.1").value(jar));
slowJar.delayMs = 1500;
mojang.overrideFile(jar, slowJar);
int attempts = 0;
m_net.route(mojang.fileUrl(lib), [&attempts](const FakeRequest &) {
return ++attempts == 1 ? FakeResponse::error(503) : FakeResponse::bytes("brigadier");
});
VersionManifestService manifest;
VersionInstaller installer(&manifest);
Run run(installer);
installer.install(m_game, "1.20.1");
QVERIFY(run.waitEnd());
QVERIFY2(run.failed.isEmpty(), qPrintable(run.failure()));
QCOMPARE(attempts, 2);
verifyInstalled(mojang, "1.20.1");
}
void retryOfLastFileIsNotLost()
{
// Сбой на последнем файле: остальные уже скачаны, повтор стоит в таймере.
FakeMojang mojang(m_net);
mojang.publish({.id = "1.20.1"});
const QString lib = QStringLiteral("libraries/com/mojang/brigadier/1.1.8/brigadier-1.1.8.jar");
int attempts = 0;
m_net.route(mojang.fileUrl(lib), [&attempts](const FakeRequest &) {
if (++attempts > 1)
return FakeResponse::bytes("brigadier");
FakeResponse late = FakeResponse::error(503);
late.delayMs = 1000; // все прочие файлы успевают докачаться
return late;
});
VersionManifestService manifest;
VersionInstaller installer(&manifest);
Run run(installer);
installer.install(m_game, "1.20.1");
QVERIFY(run.waitEnd());
// retryOrFail() откладывает повтор на 400 мс, а handleReplyFinished() тут же
// зовёт pump(): очередь и активные загрузки пусты — установка «завершается»
// и падает на проверке файлов, повтор так и не происходит.
QEXPECT_FAIL("", "повтор последнего файла теряется: pump() завершает установку раньше таймера", Abort);
QVERIFY2(run.failed.isEmpty(), qPrintable(run.failure()));
QCOMPARE(attempts, 2);
}
void checksumMismatchRetriesThenFails()
{
FakeMojang mojang(m_net);
mojang.publish({.id = "1.20.1"});
const QString lib = QStringLiteral("libraries/com/mojang/brigadier/1.1.8/brigadier-1.1.8.jar");
const QString jar = QStringLiteral("versions/1.20.1/1.20.1.jar");
// client.jar держим долго, чтобы все повторы библиотеки шли при живой установке.
FakeResponse slowJar = FakeResponse::bytes(mojang.expectedFiles("1.20.1").value(jar));
slowJar.delayMs = 4000;
mojang.overrideFile(jar, slowJar);
mojang.overrideFile(lib, FakeResponse::bytes("corrupted"));
VersionManifestService manifest;
VersionInstaller installer(&manifest);
Run run(installer);
installer.install(m_game, "1.20.1");
QVERIFY(run.waitEnd());
QVERIFY2(run.failure().startsWith(Loc::text("common.error.downloadFailed")
.arg("com.mojang:brigadier:1.1.8", Loc::text("common.error.checksumMismatch"))),
qPrintable(run.failure()));
QCOMPARE(m_net.hits(mojang.fileUrl(lib)), 3); // kMaxAttempts
QVERIFY(!QFile::exists(m_game + u'/' + lib)); // битый файл не сохранён
}
void permanentErrorFailsImmediately()
{
FakeMojang mojang(m_net);
mojang.publish({.id = "1.20.1"});
const QString lib = QStringLiteral("libraries/com/mojang/brigadier/1.1.8/brigadier-1.1.8.jar");
mojang.overrideFile(lib, FakeResponse::error(404));
VersionManifestService manifest;
VersionInstaller installer(&manifest);
Run run(installer);
installer.install(m_game, "1.20.1");
QVERIFY(run.waitEnd());
QVERIFY(!run.failed.isEmpty());
QCOMPARE(m_net.hits(mojang.fileUrl(lib)), 1);
}
void cancelStopsDownloads()
{
FakeMojang mojang(m_net);
mojang.publish({.id = "1.20.1"});
FakeResponse hang;
hang.hang = true;
mojang.overrideFile("versions/1.20.1/1.20.1.jar", hang);
VersionManifestService manifest;
VersionInstaller installer(&manifest);
Run run(installer);
installer.install(m_game, "1.20.1");
QTRY_VERIFY_WITH_TIMEOUT(m_net.hits(mojang.fileUrl("versions/1.20.1/1.20.1.jar")) == 1, 15000);
installer.cancel();
QVERIFY(run.waitEnd());
QCOMPARE(run.canceled.count(), 1);
QCOMPARE(run.canceled.constFirst().constFirst().toString(), QStringLiteral("1.20.1"));
QVERIFY(run.finished.isEmpty());
QVERIFY(!installer.isRunning());
QVERIFY(!QFile::exists(m_game + "/versions/1.20.1/1.20.1.jar"));
}
void queueRunsSequentiallyAndDeduplicates()
{
FakeMojang mojang(m_net);
mojang.publish({.id = "1.20.1"});
mojang.publish({.id = "1.19.4", .releaseTime = "2023-03-14T12:00:00+00:00"});
VersionManifestService manifest;
VersionInstaller installer(&manifest);
Run run(installer);
installer.install(m_game, "1.20.1");
installer.install(m_game, "1.19.4");
installer.install(m_game, "1.19.4"); // дубль в очереди
installer.install(m_game, "1.20.1"); // уже ставится
QVERIFY(installer.isQueued("1.19.4"));
QVERIFY(!installer.isQueued("1.20.1"));
QVERIFY(run.waitEnd(2));
QVERIFY2(run.failed.isEmpty(), qPrintable(run.failure()));
QCOMPARE(run.finished.count(), 2);
QCOMPARE(run.started.count(), 2);
verifyInstalled(mojang, "1.19.4");
}
void offlineWithLocalJsonStillInstalls()
{
FakeMojang mojang(m_net);
mojang.publish({.id = "1.20.1"});
Fixtures::write(m_game + "/versions/1.20.1/1.20.1.json", mojang.versionJson("1.20.1"));
m_net.route(FakeMojang::manifestUrl(), FakeResponse::error(503));
VersionManifestService manifest;
VersionInstaller installer(&manifest);
Run run(installer);
installer.install(m_game, "1.20.1");
QVERIFY(run.waitEnd());
QVERIFY2(run.failed.isEmpty(), qPrintable(run.failure()));
verifyInstalled(mojang, "1.20.1");
}
void fractionReportsProgress()
{
FakeMojang mojang(m_net);
mojang.publish({.id = "1.20.1"});
VersionManifestService manifest;
VersionInstaller installer(&manifest);
QCOMPARE(installer.fraction(), -1.0);
Run run(installer);
double maxFraction = -1;
connect(&installer, &VersionInstaller::progressChanged, this, [&] {
maxFraction = std::max(maxFraction, installer.fraction());
QVERIFY(installer.fraction() <= 1.0);
});
installer.install(m_game, "1.20.1");
QVERIFY(run.waitEnd());
QVERIFY(run.failed.isEmpty());
QVERIFY(maxFraction > 0.0);
}
};
BILAUNCHER_TEST_MAIN(TestVersionInstaller)
#include "tst_versioninstaller.moc"
+181
View File
@@ -0,0 +1,181 @@
#include "testenv.h"
#include "fixtures.h"
#include "nettest.h"
#include "launcherpaths.h"
#include "localization.h"
#include "versionmanifestservice.h"
#include <QJsonArray>
#include <QSignalSpy>
namespace {
const QString kManifest = QStringLiteral("https://launchermeta.mojang.com/mc/game/version_manifest_v2.json");
}
class TestVersionManifestService : public QObject
{
Q_OBJECT
FakeInternet m_net;
private slots:
void initTestCase() { BILAUNCHER_START_FAKE_INTERNET(m_net); }
void init()
{
TestEnv::resetLauncherRoot();
m_net.reset();
}
void loadsSortsAndFilters()
{
m_net.route(kManifest, FakeResponse::json(Fixtures::read("mojang/version_manifest_v2.json")));
VersionManifestService service;
QSignalSpy changed(&service, &VersionManifestService::versionsChanged);
QSignalSpy refreshing(&service, &VersionManifestService::refreshingChanged);
CallResult result;
service.ensureLoaded(result.callback());
QVERIFY(service.isRefreshing());
QTRY_COMPARE_WITH_TIMEOUT(result.calls, 1, 15000);
QVERIFY(result.ok);
QVERIFY(result.warning.isEmpty());
QVERIFY(!service.isRefreshing());
QCOMPARE(changed.count(), 1);
QCOMPARE(refreshing.count(), 2);
// Новые первыми; без id и с http-ссылкой — выброшены.
QStringList ids;
for (const RemoteVersionEntry &entry : service.versions())
ids << entry.id;
QCOMPARE(ids, QStringList({"25w33a", "1.21.8", "1.20.1", "b1.7.3"}));
const auto entry = service.find("1.20.1");
QVERIFY(entry.has_value());
QCOMPARE(entry->type, QStringLiteral("release"));
QCOMPARE(entry->sha1, QStringLiteral("aaaa"));
QCOMPARE(entry->url, QUrl("https://piston-meta.mojang.com/v1/packages/aaaa/1.20.1.json"));
QCOMPARE(entry->releaseTime, QDateTime(QDate(2023, 6, 12), QTime(13, 25, 51), QTimeZone::UTC));
QVERIFY(!service.find("insecure").has_value());
QVERIFY(service.fetchedAt().isValid());
// Кеш на диске записан.
const QJsonObject cache = Fixtures::readJsonFile(LauncherPaths::versionManifestFile());
QCOMPARE(cache.value("versions").toArray().size(), 4);
QVERIFY(!cache.value("fetchedAt").toString().isEmpty());
}
void freshCacheAvoidsNetwork()
{
m_net.route(kManifest, FakeResponse::json(Fixtures::read("mojang/version_manifest_v2.json")));
{
VersionManifestService first;
CallResult r;
first.ensureLoaded(r.callback());
QTRY_COMPARE_WITH_TIMEOUT(r.calls, 1, 15000);
}
QCOMPARE(m_net.hits(kManifest), 1);
// Новый экземпляр (как после перезапуска лаунчера) берёт свежий кеш.
VersionManifestService second;
CallResult r;
second.ensureLoaded(r.callback());
QVERIFY(second.hasData()); // данные из кеша уже доступны синхронно
QCOMPARE(r.calls, 0); // колбэк — только через цикл событий
QTRY_COMPARE_WITH_TIMEOUT(r.calls, 1, 5000);
QVERIFY(r.ok);
QCOMPARE(m_net.hits(kManifest), 1);
// forceRefresh идёт в сеть даже при свежем кеше.
CallResult forced;
second.ensureLoaded(forced.callback(), true);
QTRY_COMPARE_WITH_TIMEOUT(forced.calls, 1, 15000);
QCOMPARE(m_net.hits(kManifest), 2);
}
void staleCacheIsRefreshed()
{
QJsonObject cache = Fixtures::readJson("mojang/version_manifest_v2.json");
cache["fetchedAt"] = QDateTime::currentDateTimeUtc().addDays(-2).toString(Qt::ISODate);
Fixtures::writeJson(LauncherPaths::versionManifestFile(), cache);
m_net.route(kManifest, FakeResponse::json(Fixtures::read("mojang/version_manifest_v2.json")));
VersionManifestService service;
CallResult r;
service.ensureLoaded(r.callback());
QVERIFY(service.hasData());
QTRY_COMPARE_WITH_TIMEOUT(r.calls, 1, 15000);
QCOMPARE(m_net.hits(kManifest), 1);
}
void networkErrorWithoutCacheFails()
{
m_net.route(kManifest, FakeResponse::error(503));
VersionManifestService service;
CallResult r;
service.ensureLoaded(r.callback());
QTRY_COMPARE_WITH_TIMEOUT(r.calls, 1, 15000);
QVERIFY(!r.ok);
QVERIFY(r.warning.startsWith(Loc::text("version.error.manifestFetchFailed").section(u'%', 0, 0)));
QVERIFY(!service.hasData());
}
void networkErrorWithCacheSucceedsWithWarning()
{
QJsonObject cache = Fixtures::readJson("mojang/version_manifest_v2.json");
cache["fetchedAt"] = QDateTime::currentDateTimeUtc().addDays(-2).toString(Qt::ISODate);
Fixtures::writeJson(LauncherPaths::versionManifestFile(), cache);
m_net.route(kManifest, FakeResponse::error(500));
VersionManifestService service;
CallResult r;
service.ensureLoaded(r.callback());
QTRY_COMPARE_WITH_TIMEOUT(r.calls, 1, 15000);
QVERIFY(r.ok); // офлайн, но список есть
QVERIFY(!r.warning.isEmpty());
QCOMPARE(service.versions().size(), 4);
}
void garbageResponseIsRejected()
{
m_net.route(kManifest, FakeResponse::json("{\"versions\": []}"));
VersionManifestService service;
CallResult r;
service.ensureLoaded(r.callback());
QTRY_COMPARE_WITH_TIMEOUT(r.calls, 1, 15000);
QVERIFY(!r.ok);
QCOMPARE(r.warning, Loc::text("version.error.manifestUnparsable"));
QVERIFY(!QFile::exists(LauncherPaths::versionManifestFile()));
}
void concurrentCallersShareOneRequest()
{
FakeResponse slow = FakeResponse::json(Fixtures::read("mojang/version_manifest_v2.json"));
slow.delayMs = 300;
m_net.route(kManifest, slow);
VersionManifestService service;
CallResult a, b, c;
service.ensureLoaded(a.callback());
service.ensureLoaded(b.callback());
service.ensureLoaded(c.callback());
QTRY_VERIFY_WITH_TIMEOUT(a.calls == 1 && b.calls == 1 && c.calls == 1, 15000);
QVERIFY(a.ok && b.ok && c.ok);
QCOMPARE(m_net.hits(kManifest), 1);
}
void corruptCacheIsIgnored()
{
Fixtures::write(LauncherPaths::versionManifestFile(), "\x00\x01garbage");
m_net.route(kManifest, FakeResponse::json(Fixtures::read("mojang/version_manifest_v2.json")));
VersionManifestService service;
CallResult r;
service.ensureLoaded(r.callback());
QVERIFY(!service.hasData());
QTRY_COMPARE_WITH_TIMEOUT(r.calls, 1, 15000);
QVERIFY(r.ok);
QVERIFY(service.hasData());
}
};
BILAUNCHER_TEST_MAIN(TestVersionManifestService)
#include "tst_versionmanifestservice.moc"
+8
View File
@@ -0,0 +1,8 @@
# QML: Qt Quick Test для компонентов и диалогов + дымовой тест главного окна.
bilauncher_add_test(tst_qml LABEL qml GUI TIMEOUT 180 SOURCES tst_qml.cpp)
target_compile_definitions(tst_qml PRIVATE QUICK_TEST_SOURCE_DIR="${CMAKE_CURRENT_SOURCE_DIR}")
# Сами тесты лежат рядом, в tst_*.qml; Qt Creator покажет их в проекте.
target_sources(tst_qml PRIVATE tst_components.qml tst_dialogs.qml)
set_source_files_properties(tst_components.qml tst_dialogs.qml PROPERTIES HEADER_FILE_ONLY TRUE)
bilauncher_add_test(tst_qml_smoke LABEL qml GUI TIMEOUT 120 SOURCES tst_qml_smoke.cpp)
+155
View File
@@ -0,0 +1,155 @@
import QtQuick
import QtTest
import BiLauncher
// Маленькие переиспользуемые компоненты: DarkCombo, LabelledField,
// ProgressPanel, LoaderRow.
TestCase {
id: testCase
name: "Components"
width: 600
height: 400
visible: true
when: windowShown
Component { id: comboComponent; DarkCombo { width: 200; height: 32 } }
Component { id: fieldComponent; LabelledField { width: 220 } }
Component { id: panelComponent; ProgressPanel {} }
Component { id: loaderRowComponent; LoaderRow { width: 300 } }
// Подставной backend для LoaderRow: те же методы и сигнал, что у LauncherBackend.
Component {
id: fakeBackendComponent
QtObject {
property var versions: ({})
property bool loading: false
property var refreshCalls: []
signal loaderVersionsChanged(string loaderKey, string gameVersion)
function loaderVersions(key, game) { return versions[key + "/" + game] || [] }
function refreshLoaderVersions(key, game) { refreshCalls.push(key + "/" + game) }
function loaderVersionsLoading(key, game) { return loading }
}
}
// --- DarkCombo -------------------------------------------------------------
function test_comboSelectsAndDisplays() {
const combo = createTemporaryObject(comboComponent, testCase, { model: ["Alpha", "Beta", "Gamma"] })
verify(combo)
compare(combo.count, 3)
compare(combo.displayText, "Alpha")
const activated = signalSpy.createObject(testCase, { target: combo, signalName: "activated" })
mouseClick(combo)
tryVerify(() => combo.popup.opened)
combo.currentIndex = 2
compare(combo.displayText, "Gamma")
combo.popup.close()
tryVerify(() => !combo.popup.visible)
// Клавиатура: стрелки меняют выбор и шлют activated.
combo.forceActiveFocus()
keyClick(Qt.Key_Up)
compare(combo.currentIndex, 1)
compare(activated.count, 1)
verify(combo.indicator.source.toString().length > 0)
}
// --- LabelledField -------------------------------------------------------
function test_labelledFieldAliasesAndSignal() {
const field = createTemporaryObject(fieldComponent, testCase, { label: "Ник", placeholder: "Steve" })
verify(field)
compare(field.children[0].text, "Ник")
const input = field.children[1]
compare(input.placeholderText, "Steve")
const finished = signalSpy.createObject(testCase, { target: field, signalName: "editingFinished" })
input.forceActiveFocus()
keyClick(Qt.Key_A)
keyClick(Qt.Key_B)
compare(field.text, "ab")
keyClick(Qt.Key_Return)
compare(finished.count, 1)
field.text = "preset"
compare(input.text, "preset")
}
// --- ProgressPanel -------------------------------------------------------
function test_progressPanelPercentAndCancel() {
const panel = createTemporaryObject(panelComponent, testCase,
{ title: "1.20.1", status: "Libraries", fraction: -1 })
verify(panel)
const texts = panel.children.filter(c => c.text !== undefined)
const percent = texts.find(t => t.text === "…")
verify(percent, "без известного итога показывается многоточие")
panel.fraction = 0.426
compare(percent.text, "43%")
panel.fraction = 1
compare(percent.text, "100%")
// Подпись: detail важнее status.
const detailText = texts.find(t => t.text === "Libraries")
verify(detailText)
panel.detail = "brigadier-1.1.8.jar"
compare(detailText.text, "brigadier-1.1.8.jar")
const cancel = signalSpy.createObject(testCase, { target: panel, signalName: "cancelRequested" })
mouseClick(panel, panel.width - 14, panel.height - 12)
compare(cancel.count, 1)
panel.cancellable = false
mouseClick(panel, panel.width - 14, panel.height - 12)
compare(cancel.count, 1, "крестик скрыт — отмены нет")
}
// --- LoaderRow -----------------------------------------------------------
function test_loaderRowFollowsBackend() {
const backend = createTemporaryObject(fakeBackendComponent, testCase)
backend.versions = {
"fabric/1.20.1": [ { version: "0.16.10", label: "0.16.10", recommended: false, stable: true },
{ version: "0.16.9", label: "0.16.9", recommended: false, stable: true } ]
}
const row = createTemporaryObject(loaderRowComponent, testCase,
{ backend: backend, loaderKey: "fabric", title: "Fabric Loader" })
verify(row)
const changed = signalSpy.createObject(testCase, { target: row, signalName: "changed" })
// Без версии игры галочка недоступна, список пуст.
compare(row.entries.length, 0)
row.gameVersion = "1.20.1"
compare(row.entries.length, 0, "пока галочка снята — версии не запрашиваются")
// Сборка уже с Fabric: выбранная версия сохраняется, запрос обновления уходит.
row.applyBuild("fabric", "0.16.9")
verify(row.checked)
compare(row.selectedVersion, "0.16.9")
compare(row.entries.length, 2)
verify(backend.refreshCalls.indexOf("fabric/1.20.1") >= 0)
compare(changed.count, 0, "применение сохранённой сборки не считается правкой")
// Каталог обновился и выбранной версии в нём больше нет — берётся первая.
backend.versions = { "fabric/1.20.1": [ { version: "0.17.0", label: "0.17.0", recommended: true, stable: true } ] }
backend.loaderVersionsChanged("fabric", "1.20.1")
compare(row.selectedVersion, "0.17.0")
compare(changed.count, 1)
// Чужой лоадер в сборке — галочка снимается.
row.applyBuild("forge", "47.4.0")
verify(!row.checked)
compare(row.selectedVersion, "")
compare(row.entries.length, 0)
// Смена версии игры сбрасывает выбор.
row.applyBuild("fabric", "0.17.0")
row.gameVersion = "1.19.4"
compare(row.selectedVersion, "")
}
Component { id: signalSpy; SignalSpy {} }
}
+119
View File
@@ -0,0 +1,119 @@
import QtQuick
import QtTest
import BiLauncher
// Диалоги с настоящим LauncherBackend. Данные для них подготавливает tst_qml.cpp:
// кеш манифеста версий (1.20.1, 25w33a, b1.7.3), установленная 1.20.1,
// кеш каталога сезонных сборок с одной сборкой. Сеть закрыта FakeInternet.
TestCase {
id: testCase
name: "Dialogs"
width: 1280
height: 720
visible: true
when: windowShown
LauncherBackend { id: launcherBackend }
Component { id: versionPickerComponent; VersionPickerDialog { backend: launcherBackend } }
Component { id: javaPickerComponent; JavaPickerDialog { backend: launcherBackend } }
Component { id: buildsComponent; BuildsDialog { backend: launcherBackend } }
Component { id: seasonalComponent; SeasonalBuildsDialog { backend: launcherBackend } }
Component { id: signalSpy; SignalSpy {} }
function openAndWait(dialog) {
tryVerify(() => dialog.opened, 3000, "диалог не открылся")
}
function test_versionPickerFiltersCatalog() {
const picker = createTemporaryObject(versionPickerComponent, testCase)
verify(picker)
tryVerify(() => launcherBackend.versionCatalog.length >= 3)
picker.openFor("1.20.1")
openAndWait(picker)
compare(picker.category, "release")
compare(picker.visibleEntries.length, 1)
compare(picker.visibleEntries[0].id, "1.20.1")
verify(picker.visibleEntries[0].installed)
compare(picker.indexOfSelected(), 0)
picker.category = "snapshot"
compare(picker.visibleEntries.length, 1)
compare(picker.visibleEntries[0].id, "25w33a")
compare(picker.categoryOf("b1.7.3"), "old_beta")
compare(picker.categoryOf("no-such"), "release")
picker.category = "release"
picker.filterText = "1.19"
compare(picker.visibleEntries.length, 0)
picker.filterText = ""
picker.installedOnly = true
compare(picker.visibleEntries.length, 1)
const chosen = signalSpy.createObject(testCase, { target: picker, signalName: "versionChosen" })
picker.selectedId = "1.20.1"
picker.acceptSelection()
compare(chosen.count, 1)
compare(chosen.signalArguments[0][0], "1.20.1")
tryVerify(() => !picker.visible)
}
function test_javaPickerOpensWithRequirement() {
const picker = createTemporaryObject(javaPickerComponent, testCase)
verify(picker)
picker.openFor("", 17)
openAndWait(picker)
compare(picker.requiredMajor, 17)
compare(picker.category, "java")
compare(picker.selectedEntry, null)
const chosen = signalSpy.createObject(testCase, { target: picker, signalName: "runtimeChosen" })
picker.acceptSelection() // ничего не выбрано — ничего не происходит
compare(chosen.count, 0)
picker.close()
}
function test_buildsDialogEditsBackend() {
const dialog = createTemporaryObject(buildsComponent, testCase)
verify(dialog)
const before = launcherBackend.customBuildNames.length
const name = dialog.newBuildName()
verify(name.length > 0)
verify(launcherBackend.customBuildNames.indexOf(name) === -1)
launcherBackend.addCustomBuild(name, "", "1.20.1")
tryVerify(() => !launcherBackend.busy, 20000)
compare(launcherBackend.customBuildNames.length, before + 1)
dialog.open()
openAndWait(dialog)
dialog.selectBuild(before)
compare(dialog.editIndex, before)
dialog.commit({ serverUrl: "play.example.com" })
compare(launcherBackend.customBuildAt(before).serverUrl, "play.example.com")
verify(dialog.newBuildName() !== name)
dialog.close()
}
function test_seasonalDialogShowsCatalog() {
const dialog = createTemporaryObject(seasonalComponent, testCase)
verify(dialog)
dialog.openCatalog()
openAndWait(dialog)
tryVerify(() => launcherBackend.seasonalCatalog.length === 1)
const entry = launcherBackend.seasonalCatalog[0]
compare(entry.id, "season-qml")
compare(dialog.cellText(entry, "loaderLabel"), entry.loaderTitle + " 0.16.10")
compare(dialog.formatMb(3 * 1024 * 1024), "3.0")
compare(dialog.formatMb(0), "")
dialog.close()
}
function test_languageSwitchUpdatesTexts() {
const en = Loc.t.app.tooltip.openMods
launcherBackend.updateSettings({ language: "ru" })
verify(Loc.t.app.tooltip.openMods !== en)
launcherBackend.updateSettings({ language: "en" })
compare(Loc.t.app.tooltip.openMods, en)
}
}
+88
View File
@@ -0,0 +1,88 @@
// Qt Quick Test для QML-компонентов лаунчера.
//
// Модуль BiLauncher у приложения вшит в исполняемый файл и плагина не имеет,
// поэтому qmltestrunner его не импортирует. Здесь тот же модуль собран
// статической библиотекой (bilauncher_testlib) и подключён через
// Q_IMPORT_QML_PLUGIN — тесты пишут обычный «import BiLauncher».
#include "testenv.h"
#include "fakeinternet.h"
#include "fixtures.h"
#include "launcherpaths.h"
#include <QDateTime>
#include <QDir>
#include <QJsonArray>
#include <QQmlEngine>
#include <QQuickStyle>
#include <QtQml/qqmlextensionplugin.h>
#include <QtQuickTest/quicktest.h>
Q_IMPORT_QML_PLUGIN(BiLauncherPlugin)
class Setup : public QObject
{
Q_OBJECT
public slots:
void applicationAvailable()
{
TestEnv::finishSetup();
QQuickStyle::setStyle(QStringLiteral("Basic"));
seedData();
// Диалоги создают настоящий LauncherBackend — наружу он не должен ходить.
QString error;
if (m_net.start(&error))
m_net.setFallback(FakeResponse::error(503));
}
void qmlEngineAvailable(QQmlEngine *engine)
{
engine->addImportPath(QStringLiteral("qrc:/qt/qml"));
}
private:
// Данные, на которых работают диалоги (см. tst_dialogs.qml).
static void seedData()
{
const QString game = TestEnv::freshDir(QStringLiteral("game"));
const QString now = QDateTime::currentDateTimeUtc().toString(Qt::ISODate);
Fixtures::writeJson(LauncherPaths::settingsFile(), QJsonObject{{"gameDir", game}, {"language", "en"}});
const auto version = [](const QString &id, const QString &type, const QString &time) {
return QJsonObject{{"id", id}, {"type", type}, {"releaseTime", time}, {"sha1", "00"},
{"url", "https://piston-meta.mojang.com/v1/packages/x/" + id + ".json"}};
};
Fixtures::writeJson(LauncherPaths::versionManifestFile(), QJsonObject{
{"fetchedAt", now},
{"versions", QJsonArray{version("25w33a", "snapshot", "2025-08-13T12:00:00Z"),
version("1.20.1", "release", "2023-06-12T13:25:51Z"),
version("b1.7.3", "old_beta", "2011-07-08T00:00:00Z")}}});
Fixtures::write(game + "/versions/1.20.1/1.20.1.json",
"{\"id\":\"1.20.1\",\"mainClass\":\"net.minecraft.client.main.Main\"}");
const QJsonObject archive{{"url", "https://launcher-builds.galeonworx.com/packs/q.zip"},
{"size", 3 * 1024 * 1024}, {"sha256", QString(64, u'a')}};
const QJsonObject build{{"id", "season-qml"}, {"name", "QML Season"}, {"revision", 1},
{"minecraftVersion", "1.20.1"}, {"loader", "fabric"},
{"loaderVersion", "0.16.10"}, {"seasonStart", "2026-01-01"},
{"archive", archive}};
Fixtures::writeJson(LauncherPaths::seasonalCatalogFile(), QJsonObject{
{"formatVersion", 1}, {"fetchedAt", now},
{"baseUrl", "https://launcher-builds.galeonworx.com"}, {"builds", QJsonArray{build}}});
}
FakeInternet m_net;
};
int main(int argc, char **argv)
{
TestEnv::prepareSandbox();
QTEST_SET_MAIN_SOURCE_PATH
Setup setup;
return quick_test_main_with_setup(argc, argv, "bilauncher_qml", QUICK_TEST_SOURCE_DIR, &setup);
}
#include "tst_qml.moc"
+109
View File
@@ -0,0 +1,109 @@
// Дымовой тест главного окна: Main.qml поднимается так же, как в main.cpp,
// без единого предупреждения QML, и ни одного запроса в настоящую сеть.
// Ловит то, что не видно компилятору: опечатки в именах свойств и ключах Loc.t,
// сломанные биндинги, несуществующие картинки в ресурсах.
#include "testenv.h"
#include "fakeinternet.h"
#include "fixtures.h"
#include "launcherpaths.h"
#include "localization.h"
#include <QGuiApplication>
#include <QQmlApplicationEngine>
#include <QQuickStyle>
#include <QQuickWindow>
#include <QtQml/qqmlextensionplugin.h>
Q_IMPORT_QML_PLUGIN(BiLauncherPlugin)
namespace {
QStringList g_problems;
QtMessageHandler g_previous = nullptr;
void collect(QtMsgType type, const QMessageLogContext &context, const QString &message)
{
// Предупреждения QML приходят с файлом qrc:/… или с категорией qt.qml.*.
const QString file = QString::fromUtf8(context.file ? context.file : "");
const QString category = QString::fromUtf8(context.category ? context.category : "");
const bool fromQml = file.startsWith(QLatin1String("qrc:")) || file.endsWith(QLatin1String(".qml"))
|| category.startsWith(QLatin1String("qt.qml"))
|| message.contains(QLatin1String(".qml:"));
if (type != QtDebugMsg && type != QtInfoMsg && fromQml)
g_problems << message;
if (g_previous)
g_previous(type, context, message);
}
} // namespace
class TestQmlSmoke : public QObject
{
Q_OBJECT
FakeInternet m_net;
private slots:
void initTestCase()
{
QString error;
if (m_net.start(&error))
m_net.setFallback(FakeResponse::error(503));
const QString game = TestEnv::freshDir(QStringLiteral("game"));
Fixtures::writeJson(LauncherPaths::settingsFile(), QJsonObject{{"gameDir", game}, {"language", "en"}});
QQuickStyle::setStyle(QStringLiteral("Basic"));
g_previous = qInstallMessageHandler(collect);
}
void cleanupTestCase() { qInstallMessageHandler(g_previous); }
void init() { g_problems.clear(); }
void mainWindowLoadsCleanly()
{
// Один движок на весь тест, как в приложении: Localization держит указатель
// на JS-движок, создавший синглтон, и второй QQmlApplicationEngine после
// уничтожения первого оставил бы его висячим.
QQmlApplicationEngine engine;
QStringList warnings;
connect(&engine, &QQmlEngine::warnings, this, [&](const QList<QQmlError> &list) {
for (const QQmlError &e : list)
warnings << e.toString();
});
engine.loadFromModule("BiLauncher", "Main");
QCOMPARE(engine.rootObjects().size(), 1);
auto *window = qobject_cast<QQuickWindow *>(engine.rootObjects().constFirst());
QVERIFY(window);
QVERIFY(QTest::qWaitForWindowExposed(window));
// Даём отработать отложенным таймерам, загрузке каталогов и анимациям.
QTest::qWait(2500);
QVERIFY2(warnings.isEmpty(), qPrintable(warnings.join(u'\n')));
QVERIFY2(g_problems.isEmpty(), qPrintable(g_problems.join(u'\n')));
// Смена языка на лету перестраивает Loc.t — биндинги не должны ломаться.
const QString titleEn = window->title();
Localization::instance().setLanguage(QStringLiteral("ru"));
QTest::qWait(500);
Localization::instance().setLanguage(QStringLiteral("en"));
QTest::qWait(300);
QCOMPARE(window->title(), titleEn);
QVERIFY2(warnings.isEmpty(), qPrintable(warnings.join(u'\n')));
QVERIFY2(g_problems.isEmpty(), qPrintable(g_problems.join(u'\n')));
// Наружу — только через FakeInternet, и только на известные адреса лаунчера.
static const QStringList known{"mojang.com", "galeonworx.com", "adoptium.net", "fabricmc.net",
"quiltmc.org", "minecraftforge.net", "neoforged.net"};
for (const QString &url : m_net.requestedUrls()) {
const QString host = QUrl(url).host();
const bool ok = std::any_of(known.cbegin(), known.cend(),
[&](const QString &domain) { return host.endsWith(domain); });
QVERIFY2(ok, qPrintable("неожиданный запрос: " + url));
}
}
};
BILAUNCHER_GUI_TEST_MAIN(TestQmlSmoke)
#include "tst_qml_smoke.moc"
+4
View File
@@ -0,0 +1,4 @@
53
# Число предупреждений qmllint по модулю BiLauncher, которое считается нормой.
# tests/scripts/static_checks.py падает, если их стало больше. Исправили часть —
# опустите число, чтобы улучшение закрепилось.
+69
View File
@@ -0,0 +1,69 @@
# Дымовая проверка установленного дерева (результат cmake --install) на Windows.
#
# powershell -File tests\scripts\smoke_installed.ps1 -Prefix C:\r\bl-smoke-stage
#
# Проверяет, что windeployqt положил всё нужное (DLL Qt, плагины платформ и QML),
# лаунчер запускается без окна и через 10 секунд ещё жив, а в логе нет ошибок
# загрузки модулей.
#
# QDir::homePath() на Windows берёт профиль у системы и USERPROFILE не читает,
# поэтому лаунчер запускается с BILAUNCHER_HOME на временную папку (см.
# launcherpaths.h) — настоящая %APPDATA%\BiLauncher раннера не трогается.
param(
[Parameter(Mandatory = $true)][string]$Prefix,
[int]$RunSeconds = 10
)
$ErrorActionPreference = "Stop"
function Fail($message) { Write-Host "SMOKE FAIL: $message"; exit 1 }
$bin = Join-Path $Prefix "bin\BiLauncher.exe"
if (-not (Test-Path $bin)) { Fail "нет $bin" }
foreach ($required in @("bin\Qt6Core.dll", "bin\Qt6Quick.dll", "bin\Qt6Network.dll",
"bin\platforms\qwindows.dll", "bin\qml\QtQuick\Controls")) {
if (-not (Test-Path (Join-Path $Prefix $required))) { Fail "нет $required" }
}
if (-not (Get-ChildItem (Join-Path $Prefix "bin\tls") -Filter "*.dll" -ErrorAction SilentlyContinue)) {
Fail "нет TLS-плагина (bin\tls) — лаунчер не сможет ходить по https"
}
$realProfile = Join-Path $env:APPDATA "BiLauncher"
$realExisted = Test-Path $realProfile
$sandbox = Join-Path $env:TEMP ("bilauncher-smoke-" + [guid]::NewGuid().ToString("N"))
$log = Join-Path $sandbox "log"
New-Item -ItemType Directory -Force $log | Out-Null
$stdout = Join-Path $log "stdout.log"
$stderr = Join-Path $log "stderr.log"
$profileRoot = Join-Path $sandbox "AppData\Roaming\BiLauncher"
$env:BILAUNCHER_HOME = $sandbox
$env:QT_QPA_PLATFORM = "offscreen"
$env:QT_FORCE_STDERR_LOGGING = "1"
$env:QTWEBENGINE_CHROMIUM_FLAGS = "--disable-gpu"
try {
$process = Start-Process -FilePath $bin -PassThru -NoNewWindow `
-RedirectStandardOutput $stdout -RedirectStandardError $stderr
Start-Sleep -Seconds $RunSeconds
$alive = -not $process.HasExited
if ($alive) { Stop-Process -Id $process.Id -Force; $process.WaitForExit(5000) | Out-Null }
$text = ""
if (Test-Path $stderr) { $text += Get-Content $stderr -Raw }
if (Test-Path $stdout) { $text += Get-Content $stdout -Raw }
Write-Host "----- лог запуска -----"
Write-Host $text
if (-not $alive) { Fail "лаунчер завершился раньше чем через $RunSeconds с (код $($process.ExitCode))" }
if ($text -match "is not installed|Cannot load library|could not find the Qt platform plugin|QQmlApplicationEngine failed") {
Fail "в логе ошибки загрузки модулей"
}
if (-not (Test-Path (Join-Path $profileRoot "settings.json"))) { Fail "лаунчер не создал $profileRoot\settings.json" }
if (-not $realExisted -and (Test-Path $realProfile)) { Fail "лаунчер создал настоящую $realProfile мимо BILAUNCHER_HOME" }
Write-Host "SMOKE OK: $bin"
}
finally {
Remove-Item -Recurse -Force $sandbox -ErrorAction SilentlyContinue
}
+105
View File
@@ -0,0 +1,105 @@
#!/usr/bin/env bash
# Дымовая проверка установленного дерева (результат cmake --install) на Linux и macOS.
#
# tests/scripts/smoke_installed.sh <prefix>
#
# Linux: <prefix>/bin/BiLauncher (+ lib/BiLauncher/libz.so.1, плагины Qt рядом)
# macOS: <prefix>/BiLauncher.app
#
# Проверяет, что деплой Qt положил всё нужное: бинарник не ссылается на
# библиотеки вне дерева (ldd/otool), запускается без окна и через 10 секунд
# ещё жив, а в логе нет «module is not installed», «Cannot load library» и т. п.
# Запуск идёт с подменённым HOME — настоящие данные лаунчера на машине не трогаются.
set -euo pipefail
PREFIX="$(cd "${1:?нужен путь к установленному дереву}" && pwd)"
RUN_SECONDS="${RUN_SECONDS:-10}"
fail() { echo "SMOKE FAIL: $*" >&2; exit 1; }
case "$(uname -s)" in
Darwin)
APP="$PREFIX/BiLauncher.app"
BIN="$APP/Contents/MacOS/BiLauncher"
[ -x "$BIN" ] || fail "нет $BIN"
[ -f "$APP/Contents/Resources/icon.icns" ] || fail "нет иконки в бандле"
/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$APP/Contents/Info.plist" >/dev/null \
|| fail "в Info.plist нет версии"
# Ссылки на Qt должны вести внутрь бандла, а не в папку Qt сборочной машины.
if otool -L "$BIN" | tail -n +2 | grep -E '/Qt/|/Users/|/home/' ; then
fail "бинарник ссылается на библиотеки вне бандла"
fi
[ -d "$APP/Contents/Frameworks/QtQuick.framework" ] || fail "нет QtQuick.framework в бандле"
[ -d "$APP/Contents/PlugIns/platforms" ] || fail "нет плагинов платформ"
# Деплой идёт с -hardened-runtime: без подписи dyld откажется грузить фреймворки
# («different Team IDs»). Подписываем ad-hoc ровно как release.yml перед упаковкой.
codesign --force --deep --sign - "$APP" >/dev/null 2>&1 || fail "ad-hoc подпись не удалась"
codesign --verify --deep --strict "$APP" || fail "подпись бандла не проходит проверку"
;;
Linux)
BIN="$PREFIX/bin/BiLauncher"
[ -x "$BIN" ] || fail "нет $BIN"
[ -f "$PREFIX/lib/BiLauncher/libz.so.1" ] || fail "нет эталонного zlib (lib/BiLauncher/libz.so.1)"
[ -f "$PREFIX/share/applications/bilauncher.desktop" ] || fail "нет .desktop-файла"
[ -f "$PREFIX/share/icons/hicolor/256x256/apps/bilauncher.png" ] || fail "нет иконки hicolor"
[ -f "$PREFIX/share/doc/BiLauncher/LICENSE" ] || fail "нет LICENSE в share/doc"
# Все разделяемые библиотеки — бинарника, Qt и плагинов — должны находиться.
missing=0
while IFS= read -r -d '' file; do
if ldd "$file" 2>/dev/null | grep -q 'not found'; then
echo "не хватает библиотек для $file:" >&2
ldd "$file" | grep 'not found' >&2
missing=1
fi
done < <(find "$PREFIX" \( -name '*.so*' -o -path '*/bin/BiLauncher' \) -type f -print0)
[ "$missing" = 0 ] || fail "ldd: есть ненайденные зависимости"
;;
*) fail "неизвестная система $(uname -s)" ;;
esac
# Платформа Qt. Проверяем то, что реально задеплоено: macdeployqt кладёт только
# cocoa — на macOS окно на 10 секунд действительно откроется (раннеру нужна
# GUI-сессия). На Linux берём offscreen, если деплой его положил, иначе xcb
# под xvfb-run (пакет xvfb, см. ci/setup-runner-linux.sh).
LAUNCH=()
PLATFORM=""
if [ "$(uname -s)" = Linux ]; then
if find "$PREFIX" -name 'libqoffscreen.so' | grep -q .; then
PLATFORM=offscreen
elif command -v xvfb-run >/dev/null; then
LAUNCH=(xvfb-run -a)
elif [ -z "${DISPLAY:-}" ]; then
fail "нет ни плагина offscreen в деплое, ни xvfb-run, ни DISPLAY"
fi
fi
SANDBOX="$(mktemp -d)"
LOG="$SANDBOX/stderr.log"
trap 'rm -rf "$SANDBOX"' EXIT
env HOME="$SANDBOX" \
${PLATFORM:+QT_QPA_PLATFORM=$PLATFORM} \
QT_FORCE_STDERR_LOGGING=1 \
QTWEBENGINE_DISABLE_SANDBOX=1 \
QTWEBENGINE_CHROMIUM_FLAGS="--no-sandbox --disable-gpu" \
${LAUNCH[@]+"${LAUNCH[@]}"} "$BIN" >"$LOG" 2>&1 &
PID=$!
sleep "$RUN_SECONDS"
if ! kill -0 "$PID" 2>/dev/null; then
wait "$PID" || true
echo "----- лог -----" >&2; cat "$LOG" >&2
fail "лаунчер завершился раньше чем через ${RUN_SECONDS} с"
fi
kill "$PID" 2>/dev/null || true
wait "$PID" 2>/dev/null || true
echo "----- лог запуска -----"; cat "$LOG"
if grep -E 'is not installed|Cannot load library|could not find the Qt platform plugin|QQmlApplicationEngine failed|module ".*" plugin ".*" not found|Каталог переводов' "$LOG"; then
fail "в логе ошибки загрузки модулей"
fi
# Лаунчер создал свою папку в подменённом HOME, а не где-то ещё.
if [ "$(uname -s)" = Darwin ]; then ROOT="$SANDBOX/Library/Application Support/BiLauncher"; else ROOT="$SANDBOX/BiLauncher"; fi
[ -f "$ROOT/settings.json" ] || fail "лаунчер не создал $ROOT/settings.json"
echo "SMOKE OK: $BIN"
+106
View File
@@ -0,0 +1,106 @@
#!/usr/bin/env python3
"""Статические проверки без запуска лаунчера. Ненулевой код возврата при ошибке.
Запуск: python3 tests/scripts/static_checks.py <папка сборки с BILAUNCHER_BUILD_TESTS=ON>
1. tools/check_translations.py — каталог переводов цел и совпадает с кодом.
2. qmllint по модулю BiLauncher (цель BiLauncher_qmllint, её создаёт qt_add_qml_module).
В коде уже есть предупреждения, а код ради тестов не правится, поэтому
проверка работает «храповиком»: падает, только если предупреждений стало
больше, чем записано в tests/qmllint-baseline.txt. Стало меньше — просит
опустить планку, чтобы достигнутое не откатилось.
3. CHANGELOG.md содержит раздел для версии из project(BiLauncher VERSION …):
без него release.yml создаст релиз с пустым описанием (только предупреждение).
4. Скрипты .ps1 с не-ASCII символами сохранены в UTF-8 с BOM: без BOM Windows
PowerShell 5.1 читает их в ANSI-кодировке и падает на разборе кириллицы.
"""
import glob, os, re, subprocess, sys
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
BASELINE = os.path.join(ROOT, "tests", "qmllint-baseline.txt")
def step(title):
print(f"\n=== {title}", flush=True)
def check_translations():
step("Каталог переводов")
return subprocess.call([sys.executable, os.path.join(ROOT, "tools", "check_translations.py")]) == 0
def check_qmllint(build_dir):
step("qmllint (BiLauncher_qmllint)")
cmake = os.environ.get("CMAKE", "cmake")
proc = subprocess.run([cmake, "--build", build_dir, "--target", "BiLauncher_qmllint"],
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
encoding="utf-8", errors="replace")
output = proc.stdout
with open(os.path.join(build_dir, "qmllint.log"), "w", encoding="utf-8") as log:
log.write(output)
warnings = [line for line in output.splitlines() if line.startswith("Warning:")]
errors = [line for line in output.splitlines() if line.startswith("Error:")]
for line in warnings + errors:
print(line)
baseline = int(open(BASELINE, encoding="utf-8").read().split()[0])
print(f"\nпредупреждений: {len(warnings)}, ошибок: {len(errors)}, планка: {baseline}"
f" (полный вывод — {os.path.join(build_dir, 'qmllint.log')})")
if proc.returncode != 0 and not warnings and not errors:
print("qmllint не запустился — см. лог")
return False
if errors:
print("ОШИБКА: qmllint нашёл ошибки")
return False
if len(warnings) > baseline:
print(f"ОШИБКА: предупреждений стало больше ({len(warnings)} > {baseline}). "
"Исправьте новые или, если они осознанные, поднимите tests/qmllint-baseline.txt.")
return False
if len(warnings) < baseline:
print(f"Хорошо: предупреждений меньше планки — опустите tests/qmllint-baseline.txt до {len(warnings)}.")
return True
def check_changelog():
step("CHANGELOG.md и версия проекта")
cmake_text = open(os.path.join(ROOT, "CMakeLists.txt"), encoding="utf-8").read()
match = re.search(r"^project\(BiLauncher VERSION ([0-9]+\.[0-9]+\.[0-9]+)", cmake_text, re.M)
if not match:
print("ОШИБКА: не найден project(BiLauncher VERSION x.y.z)")
return False
version = match.group(1)
changelog = open(os.path.join(ROOT, "CHANGELOG.md"), encoding="utf-8").read()
if re.search(r"^## \[" + re.escape(version) + r"\]", changelog, re.M):
print(f"версия {version}: раздел в CHANGELOG.md есть")
else:
print(f"ПРЕДУПРЕЖДЕНИЕ: в CHANGELOG.md нет раздела ## [{version}] — описание релиза будет пустым")
return True
def check_ps1_bom():
step("Кодировка .ps1")
ok = True
for path in glob.glob(os.path.join(ROOT, "**", "*.ps1"), recursive=True):
if os.path.relpath(path, ROOT).startswith("build"): # папки сборки
continue
data = open(path, "rb").read()
if not data.startswith(b"\xef\xbb\xbf") and any(b > 0x7F for b in data):
print(f"ОШИБКА: {os.path.relpath(path, ROOT)} содержит не-ASCII, но сохранён без UTF-8 BOM")
ok = False
if ok:
print("все .ps1 читаются Windows PowerShell 5.1")
return ok
def main():
if len(sys.argv) != 2:
print(__doc__)
return 2
build_dir = os.path.abspath(sys.argv[1])
results = [check_translations(), check_qmllint(build_dir), check_changelog(), check_ps1_bom()]
print("\nИТОГ:", "OK" if all(results) else "ЕСТЬ ОШИБКИ")
return 0 if all(results) else 1
if __name__ == "__main__":
sys.exit(main())
+16
View File
@@ -0,0 +1,16 @@
# Модульные тесты: без сети, только файлы в песочнице и подставная java.
foreach(test IN ITEMS
tst_launcherpaths
tst_javaruntime
tst_modloader
tst_localization
tst_minecraftversion
tst_authoffline
tst_msaredirect
tst_gamelauncher
tst_javalocator
tst_zlibreference
tst_buildarchiveworker
tst_buildswitcher)
bilauncher_add_test(${test} LABEL unit SOURCES ${test}.cpp)
endforeach()
+67
View File
@@ -0,0 +1,67 @@
#include "testenv.h"
#include "authservice.h"
#include "localization.h"
#include <QCryptographicHash>
#include <QRegularExpression>
#include <QUuid>
class TestAuthOffline : public QObject
{
Q_OBJECT
private slots:
void emptyNicknameFails()
{
const AuthResult result = AuthService::offline(QString());
QVERIFY(!result.ok);
QCOMPARE(result.error, Loc::text("auth.offline.error.noNickname"));
}
void uuidMatchesVanillaOfflineScheme()
{
// Так же UUID офлайн-игрока считает сервер в offline-mode:
// UUID v3 от MD5("OfflinePlayer:<ник>").
const AuthResult result = AuthService::offline(QStringLiteral("Notch"));
QVERIFY(result.ok);
QCOMPARE(result.playerName, QStringLiteral("Notch"));
QCOMPARE(result.uuid, QStringLiteral("b50ad385829d3141a2167e7d7539ba7f"));
QCOMPARE(result.accessToken, QStringLiteral("0"));
QCOMPARE(result.clientToken, QStringLiteral("0"));
QCOMPARE(result.userType, QStringLiteral("legacy"));
}
void uuidIsStableAndWellFormed_data()
{
QTest::addColumn<QString>("nick");
QTest::newRow("ascii") << "Steve";
QTest::newRow("digits") << "player_123";
QTest::newRow("cyrillic") << QStringLiteral("Игрок");
}
void uuidIsStableAndWellFormed()
{
QFETCH(QString, nick);
const AuthResult a = AuthService::offline(nick);
const AuthResult b = AuthService::offline(nick);
QCOMPARE(a.uuid, b.uuid);
static const QRegularExpression hex(QStringLiteral("^[0-9a-f]{32}$"));
QVERIFY(hex.match(a.uuid).hasMatch());
QCOMPARE(a.uuid.at(12), QChar(u'3')); // версия 3
QVERIFY(QStringLiteral("89ab").contains(a.uuid.at(16))); // вариант RFC 4122
QVERIFY(AuthService::offline(nick + u'x').uuid != a.uuid);
}
void clientTokenIsRandomUuid()
{
const QString a = AuthService::generateClientToken();
const QString b = AuthService::generateClientToken();
QVERIFY(a != b);
QVERIFY(!QUuid::fromString(a).isNull());
QVERIFY(!a.startsWith(u'{'));
}
};
BILAUNCHER_TEST_MAIN(TestAuthOffline)
#include "tst_authoffline.moc"
+236
View File
@@ -0,0 +1,236 @@
#include "testenv.h"
#include "fixtures.h"
#include "buildarchiveworker.h"
#include "localization.h"
#include <QDir>
#include <QSignalSpy>
class TestBuildArchiveWorker : public QObject
{
Q_OBJECT
QString m_game;
QString m_work;
void populateGame()
{
Fixtures::write(m_game + "/options.txt", "fov:90");
Fixtures::write(m_game + "/mods/a.jar", "mod-a");
Fixtures::write(m_game + "/mods/sub/b.jar", "mod-b");
Fixtures::write(m_game + "/config/x/y.toml", "y=1");
Fixtures::write(m_game + "/saves/World/level.dat", "level"); // общее — исключается
Fixtures::write(m_game + "/versions/1.20.1/1.20.1.json", "{}"); // общее — исключается
}
// Последний вызов finished(): (ok, error)
static QPair<bool, QString> result(const QSignalSpy &spy)
{
if (spy.isEmpty())
return {false, QStringLiteral("finished не пришёл")};
return {spy.constLast().at(0).toBool(), spy.constLast().at(1).toString()};
}
private slots:
void init()
{
m_game = TestEnv::freshDir(QStringLiteral("game"));
m_work = TestEnv::freshDir(QStringLiteral("archives"));
}
void archiveClearRestoreRoundTrip()
{
populateGame();
const QStringList shared{"saves", "versions"};
const QString zip = m_work + "/build.zip";
BuildArchiveWorker worker;
QSignalSpy finished(&worker, &BuildArchiveWorker::finished);
QSignalSpy progress(&worker, &BuildArchiveWorker::progress);
worker.archive(m_game, zip, shared);
QCOMPARE(result(finished), qMakePair(true, QString()));
QCOMPARE(Fixtures::zipEntries(zip),
QStringList({"config/x/y.toml", "mods/a.jar", "mods/sub/b.jar", "options.txt"}));
// progress: (0,total) в начале и (total,total) в конце.
QCOMPARE(progress.constFirst().at(0).toInt(), 0);
QCOMPARE(progress.constLast().at(0).toInt(), 4);
QCOMPARE(progress.constLast().at(1).toInt(), 4);
worker.clear(m_game, shared);
QCOMPARE(result(finished), qMakePair(true, QString()));
QCOMPARE(Fixtures::listTree(m_game),
QStringList({"saves/World/level.dat", "versions/1.20.1/1.20.1.json"}));
worker.restore(zip, m_game);
QCOMPARE(result(finished), qMakePair(true, QString()));
QCOMPARE(Fixtures::listTree(m_game),
QStringList({"config/x/y.toml", "mods/a.jar", "mods/sub/b.jar",
"options.txt", "saves/World/level.dat", "versions/1.20.1/1.20.1.json"}));
QCOMPARE(Fixtures::readFile(m_game + "/mods/sub/b.jar"), QByteArray("mod-b"));
}
void dotFilesSurviveRoundTrip()
{
// В .minecraft живут .fabric/, .mixin.out/, на macOS ещё .DS_Store.
Fixtures::write(m_game + "/.fabric/remappedJars/x.jar", "cache");
Fixtures::write(m_game + "/mods/a.jar", "a");
const QString zip = m_work + "/dots.zip";
BuildArchiveWorker worker;
QSignalSpy finished(&worker, &BuildArchiveWorker::finished);
worker.archive(m_game, zip, {});
QVERIFY(result(finished).first);
worker.clear(m_game, {});
worker.restore(zip, m_game);
QVERIFY(result(finished).first);
QVERIFY(QFile::exists(m_game + "/mods/a.jar"));
// QZipReader при чтении срезает ведущие «.» и «/» в именах записей, поэтому
// после переключения сборок .fabric/ возвращается как fabric/.
QEXPECT_FAIL("", "QZipReader срезает ведущую точку: .fabric/ восстанавливается как fabric/", Continue);
QVERIFY(QFile::exists(m_game + "/.fabric/remappedJars/x.jar"));
}
void archiveMissingGameDir()
{
BuildArchiveWorker worker;
QSignalSpy finished(&worker, &BuildArchiveWorker::finished);
const QString missing = m_game + "/nope";
worker.archive(missing, m_work + "/x.zip", {});
QCOMPARE(result(finished),
qMakePair(false, Loc::text("switch.error.gameDirMissing").arg(QDir::toNativeSeparators(missing))));
}
void archiveCancelRemovesTempZip()
{
populateGame();
BuildArchiveWorker worker;
QSignalSpy finished(&worker, &BuildArchiveWorker::finished);
worker.requestCancel();
worker.archive(m_game, m_work + "/cancel.zip", {});
QCOMPARE(result(finished), qMakePair(false, Loc::text("switch.status.archivingCancelled")));
QVERIFY(!QFile::exists(m_work + "/cancel.zip"));
worker.clearCancel();
worker.archive(m_game, m_work + "/ok.zip", {});
QVERIFY(result(finished).first);
}
void clearMissingDirIsOk()
{
BuildArchiveWorker worker;
QSignalSpy finished(&worker, &BuildArchiveWorker::finished);
worker.clear(m_game + "/nope", {});
QVERIFY(result(finished).first);
}
void restoreRejectsUnreadableAndUnsafeArchives()
{
BuildArchiveWorker worker;
QSignalSpy finished(&worker, &BuildArchiveWorker::finished);
const QString missing = m_work + "/missing.zip";
worker.restore(missing, m_game);
QCOMPARE(result(finished),
qMakePair(false, Loc::text("switch.error.archiveOpenFailed").arg(QDir::toNativeSeparators(missing))));
// Zip-slip: запись с «..» не должна вылезти из папки игры.
const QString evil = m_work + "/evil.zip";
// Ведущие «../» QZipReader срезает сам, а «..» в середине пути — нет.
QVERIFY(Fixtures::makeZip(evil, {{"ok.txt", "ok"}, {"mods/../../escaped.txt", "pwned"}}));
QVERIFY(Fixtures::zipEntries(evil).contains("mods/../../escaped.txt"));
worker.restore(evil, m_game);
QCOMPARE(result(finished),
qMakePair(false, Loc::text("switch.error.archiveBadPath").arg("mods/../../escaped.txt")));
QVERIFY(!QFile::exists(QFileInfo(m_game).absolutePath() + "/escaped.txt"));
}
void restoreCancel()
{
const QString zip = m_work + "/r.zip";
QVERIFY(Fixtures::makeZip(zip, {{"a.txt", "a"}}));
BuildArchiveWorker worker;
QSignalSpy finished(&worker, &BuildArchiveWorker::finished);
worker.requestCancel();
worker.restore(zip, m_game);
QCOMPARE(result(finished), qMakePair(false, Loc::text("switch.status.extractCancelled")));
}
void applyPackRemovesThenWrites()
{
populateGame();
Fixtures::write(m_game + "/mods/old/legacy.jar", "old");
Fixtures::write(m_game + "/mods/keep.jar", "keep");
const QString pack = m_work + "/pack.zip";
QVERIFY(Fixtures::makeZip(pack, {{"mods/new.jar", "new"},
{"config/x/y.toml", "y=2"},
{"options.txt", "fov:70"}}));
BuildArchiveWorker worker;
QSignalSpy finished(&worker, &BuildArchiveWorker::finished);
QSignalSpy entries(&worker, &BuildArchiveWorker::packEntries);
worker.applyPack(pack, m_game,
{"mods/old/legacy.jar", "mods/missing.jar",
"../outside.txt", "versions/1.20.1/1.20.1.json"},
{"versions", "saves"});
QCOMPARE(result(finished), qMakePair(true, QString()));
QVERIFY(!QFile::exists(m_game + "/mods/old/legacy.jar"));
QVERIFY(!QDir(m_game + "/mods/old").exists()); // опустевшая папка убрана
QVERIFY(QFile::exists(m_game + "/mods/keep.jar"));
// Удаление в запретной зоне молча пропускается.
QVERIFY(QFile::exists(m_game + "/versions/1.20.1/1.20.1.json"));
QCOMPARE(Fixtures::readFile(m_game + "/mods/new.jar"), QByteArray("new"));
QCOMPARE(Fixtures::readFile(m_game + "/config/x/y.toml"), QByteArray("y=2"));
QCOMPARE(Fixtures::readFile(m_game + "/options.txt"), QByteArray("fov:70"));
QCOMPARE(entries.count(), 1);
QStringList applied = entries.constFirst().constFirst().toStringList();
applied.sort();
QCOMPARE(applied, QStringList({"config/x/y.toml", "mods/new.jar", "options.txt"}));
}
void applyPackRefusesLauncherFolders()
{
populateGame();
const QString pack = m_work + "/bad.zip";
QVERIFY(Fixtures::makeZip(pack, {{"mods/new.jar", "new"}, {"versions/hack/hack.json", "{}"}}));
BuildArchiveWorker worker;
QSignalSpy finished(&worker, &BuildArchiveWorker::finished);
worker.applyPack(pack, m_game, {"mods/a.jar"}, {"versions"});
QCOMPARE(result(finished),
qMakePair(false, Loc::text("switch.error.packTouchesLauncher").arg("versions/hack/hack.json")));
// Проверка до любых изменений: ничего не удалено и не записано.
QVERIFY(QFile::exists(m_game + "/mods/a.jar"));
QVERIFY(!QFile::exists(m_game + "/mods/new.jar"));
}
void applyPackRefusesUnsafePaths()
{
const QString pack = m_work + "/slip.zip";
QVERIFY(Fixtures::makeZip(pack, {{"mods/../../../etc/x", "x"}}));
BuildArchiveWorker worker;
QSignalSpy finished(&worker, &BuildArchiveWorker::finished);
worker.applyPack(pack, m_game, {}, {});
QCOMPARE(result(finished), qMakePair(false, Loc::text("switch.error.packBadPath").arg("mods/../../../etc/x")));
}
void applyPackCancel()
{
const QString pack = m_work + "/c.zip";
QVERIFY(Fixtures::makeZip(pack, {{"a.txt", "a"}}));
BuildArchiveWorker worker;
QSignalSpy finished(&worker, &BuildArchiveWorker::finished);
worker.requestCancel();
worker.applyPack(pack, m_game, {}, {});
QCOMPARE(result(finished), qMakePair(false, Loc::text("switch.status.installCancelled")));
QVERIFY(!QFile::exists(m_game + "/a.txt"));
}
};
BILAUNCHER_TEST_MAIN(TestBuildArchiveWorker)
#include "tst_buildarchiveworker.moc"
+302
View File
@@ -0,0 +1,302 @@
#include "testenv.h"
#include "fixtures.h"
#include "buildswitcher.h"
#include "launcherpaths.h"
#include "localization.h"
#include <QDir>
#include <QJsonArray>
#include <QSignalSpy>
class TestBuildSwitcher : public QObject
{
Q_OBJECT
QString m_game;
static QJsonObject index()
{
return Fixtures::readJsonFile(LauncherPaths::buildStorageDir() + "/index.json");
}
static QStringList zipsOf(int buildId)
{
return QDir(LauncherPaths::buildDir(buildId)).entryList({"*.zip"}, QDir::Files | QDir::Hidden);
}
// Ждёт finished или failed; возвращает true при finished(expectedId).
static bool waitDone(QSignalSpy &finished, QSignalSpy &failed, int expectedId)
{
if (!QTest::qWaitFor([&] { return finished.count() + failed.count() > 0; }, 20000)) {
qWarning("ни finished, ни failed за 20 с");
return false;
}
if (!failed.isEmpty()) {
qWarning("failed: %s", qPrintable(failed.constFirst().at(1).toString()));
return false;
}
return finished.constFirst().constFirst().toInt() == expectedId;
}
void writeShared()
{
Fixtures::write(m_game + "/versions/1.20.1/1.20.1.json", "{}");
Fixtures::write(m_game + "/libraries/x/y.jar", "lib");
Fixtures::write(m_game + "/assets/indexes/5.json", "{}");
Fixtures::write(m_game + "/launcher_profiles.json", "{}");
}
private slots:
void init()
{
TestEnv::resetLauncherRoot();
m_game = TestEnv::freshDir(QStringLiteral("game"));
}
void sharedTopLevelIsStable()
{
QStringList shared = BuildSwitcher::sharedTopLevel();
shared.sort();
QCOMPARE(shared, QStringList({"assets", "launcher_profiles.json", "libraries", "runtime", "versions"}));
}
void switchBackAndForthKeepsEachBuildContent()
{
writeShared();
Fixtures::write(m_game + "/mods/alpha.jar", "alpha");
Fixtures::write(m_game + "/saves/A/level.dat", "A");
Fixtures::write(m_game + "/options.txt", "alpha-options");
BuildSwitcher switcher;
QSignalSpy finished(&switcher, &BuildSwitcher::finished);
QSignalSpy failed(&switcher, &BuildSwitcher::failed);
// 1 → 2: у сборки 2 архива ещё нет, папка игры очищается до общих файлов.
switcher.switchTo(1, QStringLiteral("Alpha build"), 2, QStringLiteral("Beta"), m_game);
QVERIFY(switcher.isRunning());
QVERIFY(waitDone(finished, failed, 2));
QVERIFY(!switcher.isRunning());
QCOMPARE(Fixtures::listTree(m_game),
QStringList({"assets/indexes/5.json", "launcher_profiles.json",
"libraries/x/y.jar", "versions/1.20.1/1.20.1.json"}));
QVERIFY(switcher.hasArchive(1));
QVERIFY(!switcher.hasArchive(2));
const QStringList zips = zipsOf(1);
QCOMPARE(zips.size(), 1);
QVERIFY(zips.constFirst().endsWith("_Alpha_build.zip")); // имя из названия сборки
QJsonObject idx = index();
QCOMPARE(idx.value("state").toString(), QStringLiteral("idle"));
QCOMPARE(idx.value("active").toInt(), 2);
QCOMPARE(idx.value("builds").toObject().value("1").toObject().value("name").toString(),
QStringLiteral("Alpha build"));
// Наполняем сборку 2 и возвращаемся в 1.
Fixtures::write(m_game + "/mods/beta.jar", "beta");
finished.clear();
switcher.switchTo(2, QStringLiteral("Beta"), 1, QStringLiteral("Alpha build"), m_game);
QVERIFY(waitDone(finished, failed, 1));
QCOMPARE(Fixtures::readFile(m_game + "/mods/alpha.jar"), QByteArray("alpha"));
QCOMPARE(Fixtures::readFile(m_game + "/saves/A/level.dat"), QByteArray("A"));
QCOMPARE(Fixtures::readFile(m_game + "/options.txt"), QByteArray("alpha-options"));
QVERIFY(!QFile::exists(m_game + "/mods/beta.jar"));
QVERIFY(QFile::exists(m_game + "/versions/1.20.1/1.20.1.json")); // общее не тронуто
QVERIFY(switcher.hasArchive(2));
// И снова в 2 — его моды на месте.
finished.clear();
switcher.switchTo(1, QStringLiteral("Alpha build"), 2, QStringLiteral("Beta"), m_game);
QVERIFY(waitDone(finished, failed, 2));
QCOMPARE(Fixtures::readFile(m_game + "/mods/beta.jar"), QByteArray("beta"));
QVERIFY(!QFile::exists(m_game + "/mods/alpha.jar"));
// У сборки хранится один актуальный архив, временных не остаётся.
QCOMPARE(zipsOf(1).size(), 1);
QVERIFY(zipsOf(1).filter(".tmp-").isEmpty());
}
void switchWithoutSourceBuildOnlyClears()
{
writeShared();
Fixtures::write(m_game + "/mods/stray.jar", "x");
BuildSwitcher switcher;
QSignalSpy finished(&switcher, &BuildSwitcher::finished);
QSignalSpy failed(&switcher, &BuildSwitcher::failed);
switcher.switchTo(-1, QString(), 5, QStringLiteral("Five"), m_game);
QVERIFY(waitDone(finished, failed, 5));
QVERIFY(!QFile::exists(m_game + "/mods/stray.jar"));
QVERIFY(QFile::exists(m_game + "/libraries/x/y.jar"));
QVERIFY(QDir(LauncherPaths::buildStorageDir()).entryList({"*.zip"}).isEmpty());
}
void secondSwitchWhileBusyIsRejected()
{
writeShared();
for (int i = 0; i < 50; ++i)
Fixtures::write(m_game + QStringLiteral("/mods/m%1.jar").arg(i), QByteArray(4096, 'x'));
BuildSwitcher switcher;
QSignalSpy finished(&switcher, &BuildSwitcher::finished);
QSignalSpy failed(&switcher, &BuildSwitcher::failed);
switcher.switchTo(1, "A", 2, "B", m_game);
switcher.switchTo(1, "A", 3, "C", m_game);
QCOMPARE(failed.count(), 1);
QCOMPARE(failed.constFirst().at(0).toInt(), 3);
QCOMPARE(failed.constFirst().at(1).toString(), Loc::text("common.error.switchInProgress"));
QCOMPARE(failed.constFirst().at(2).toBool(), true);
QTRY_COMPARE_WITH_TIMEOUT(finished.count(), 1, 20000);
}
void interruptedSwitchIsReportedAndResumed()
{
writeShared();
Fixtures::write(m_game + "/mods/half-cleared.jar", "x");
// Архив сборки 7 записан, переключение 3 → 7 оборвалось посреди очистки.
QVERIFY(Fixtures::makeZip(LauncherPaths::buildDir(7) + "/2026-01-01_Seven.zip",
{{"mods/seven.jar", "seven"}, {"options.txt", "seven-options"}}));
Fixtures::writeJson(LauncherPaths::buildStorageDir() + "/index.json",
QJsonObject{{"state", "switching"}, {"from", 3}, {"to", 7},
{"builds", QJsonObject{{"7", QJsonObject{{"name", "Seven"},
{"archive", "2026-01-01_Seven.zip"}}}}}});
// Мусор от прерванной архивации подметается конструктором.
Fixtures::write(LauncherPaths::buildDir(3) + "/.tmp-deadbeef.zip", "partial");
BuildSwitcher switcher;
QVERIFY(!QFile::exists(LauncherPaths::buildDir(3) + "/.tmp-deadbeef.zip"));
const QString warning = switcher.interruptedSwitchWarning();
QCOMPARE(warning, Loc::text("switch.status.switchInterrupted")
.arg(Loc::text("switch.status.targetSuffix").arg("Seven")));
QSignalSpy finished(&switcher, &BuildSwitcher::finished);
QSignalSpy failed(&switcher, &BuildSwitcher::failed);
switcher.resumeInterrupted(m_game);
QVERIFY(waitDone(finished, failed, 7));
QVERIFY(!QFile::exists(m_game + "/mods/half-cleared.jar"));
QCOMPARE(Fixtures::readFile(m_game + "/mods/seven.jar"), QByteArray("seven"));
QCOMPARE(index().value("state").toString(), QStringLiteral("idle"));
QVERIFY(switcher.interruptedSwitchWarning().isEmpty());
}
void applyPackAndRecordNote()
{
writeShared();
Fixtures::write(m_game + "/mods/old.jar", "old");
const QString pack = LauncherPaths::buildDir(9) + "/season-pack.zip";
QVERIFY(Fixtures::makeZip(pack, {{"mods/new.jar", "new"}, {"config/c.toml", "c"}}));
BuildSwitcher switcher;
QSignalSpy finished(&switcher, &BuildSwitcher::finished);
QSignalSpy failed(&switcher, &BuildSwitcher::failed);
const QJsonObject note{{"seasonalId", "season-5"}, {"revision", 3}};
switcher.applyPack(9, QStringLiteral("Season 5"), pack, {"mods/old.jar"}, note, m_game);
QVERIFY(waitDone(finished, failed, 9));
QVERIFY(!QFile::exists(m_game + "/mods/old.jar"));
QCOMPARE(Fixtures::readFile(m_game + "/mods/new.jar"), QByteArray("new"));
QVERIFY(!QFile::exists(pack)); // пакет удаляется после установки
QStringList entries = switcher.lastPackEntries();
entries.sort();
QCOMPARE(entries, QStringList({"config/c.toml", "mods/new.jar"}));
QCOMPARE(switcher.lastPackNote(), note);
const QJsonObject idx = index();
QCOMPARE(idx.value("state").toString(), QStringLiteral("idle"));
QCOMPARE(idx.value("active").toInt(), 9);
QVERIFY(!idx.contains("pack"));
}
void applyPackErrors()
{
BuildSwitcher switcher;
QSignalSpy failed(&switcher, &BuildSwitcher::failed);
const QString missing = LauncherPaths::buildDir(4) + "/nope.zip";
switcher.applyPack(4, "Four", missing, {}, {}, m_game);
QCOMPARE(failed.count(), 1);
QCOMPARE(failed.constFirst().at(1).toString(),
Loc::text("switch.error.archiveMissing").arg(QDir::toNativeSeparators(missing)));
// Пакет лезет в общие папки лаунчера — отказ, папка игры цела.
const QString bad = LauncherPaths::buildDir(4) + "/bad.zip";
QVERIFY(Fixtures::makeZip(bad, {{"versions/evil/evil.json", "{}"}}));
failed.clear();
switcher.applyPack(4, "Four", bad, {}, {}, m_game);
QTRY_COMPARE_WITH_TIMEOUT(failed.count(), 1, 20000);
QCOMPARE(failed.constFirst().at(1).toString(),
Loc::text("switch.error.packTouchesLauncher").arg("versions/evil/evil.json"));
QCOMPARE(failed.constFirst().at(2).toBool(), true);
}
void interruptedApplyIsResumedOrCleaned()
{
writeShared();
const QString pack = LauncherPaths::buildDir(11) + "/pack.zip";
QVERIFY(Fixtures::makeZip(pack, {{"mods/resumed.jar", "r"}}));
const QJsonObject note{{"k", "v"}};
Fixtures::writeJson(LauncherPaths::buildStorageDir() + "/index.json",
QJsonObject{{"state", "applying"}, {"to", 11}, {"pack", "pack.zip"},
{"packName", "Eleven"}, {"packRemove", QJsonArray{"mods/gone.jar"}},
{"packNote", note}});
Fixtures::write(m_game + "/mods/gone.jar", "g");
{
BuildSwitcher switcher;
QCOMPARE(switcher.interruptedSwitchWarning(),
Loc::text("switch.status.applyInterrupted")
.arg(Loc::text("switch.status.nameSuffix").arg("Eleven")));
QSignalSpy finished(&switcher, &BuildSwitcher::finished);
QSignalSpy failed(&switcher, &BuildSwitcher::failed);
switcher.resumeInterrupted(m_game);
QVERIFY(waitDone(finished, failed, 11));
QVERIFY(QFile::exists(m_game + "/mods/resumed.jar"));
QVERIFY(!QFile::exists(m_game + "/mods/gone.jar"));
QCOMPARE(switcher.lastPackNote(), note);
}
// Пакет пропал — индекс просто приводится в порядок, без сигналов.
Fixtures::writeJson(LauncherPaths::buildStorageDir() + "/index.json",
QJsonObject{{"state", "applying"}, {"to", 12}, {"pack", "lost.zip"}});
BuildSwitcher switcher;
QSignalSpy finished(&switcher, &BuildSwitcher::finished);
QSignalSpy failed(&switcher, &BuildSwitcher::failed);
switcher.resumeInterrupted(m_game);
QCOMPARE(index().value("state").toString(), QStringLiteral("idle"));
QVERIFY(!index().contains("pack"));
QCOMPARE(finished.count() + failed.count(), 0);
}
void forgetBuild()
{
QVERIFY(Fixtures::makeZip(LauncherPaths::buildDir(5) + "/a.zip", {{"x", "y"}}));
Fixtures::writeJson(LauncherPaths::buildStorageDir() + "/index.json",
QJsonObject{{"state", "idle"}, {"active", 5},
{"builds", QJsonObject{{"5", QJsonObject{{"name", "Five"}}},
{"6", QJsonObject{{"name", "Six"}}}}}});
BuildSwitcher switcher;
QVERIFY(switcher.hasArchive(5));
QVERIFY(switcher.forgetBuild(5));
QVERIFY(!QDir(LauncherPaths::buildDir(5)).exists());
QVERIFY(!switcher.hasArchive(5));
const QJsonObject idx = index();
QVERIFY(!idx.contains("active"));
QVERIFY(!idx.value("builds").toObject().contains("5"));
QVERIFY(idx.value("builds").toObject().contains("6"));
QVERIFY(switcher.forgetBuild(-1));
}
void fractionAndStage()
{
BuildSwitcher switcher;
QCOMPARE(switcher.fraction(), -1.0);
QVERIFY(switcher.stage().isEmpty());
writeShared();
Fixtures::write(m_game + "/mods/a.jar", "a");
QSignalSpy finished(&switcher, &BuildSwitcher::finished);
QSignalSpy progress(&switcher, &BuildSwitcher::progressChanged);
switcher.switchTo(1, "A", 2, "B", m_game);
QCOMPARE(switcher.stage(), Loc::text("switch.progress.saving").arg("A"));
QTRY_COMPARE_WITH_TIMEOUT(finished.count(), 1, 20000);
QVERIFY(progress.count() > 2);
QCOMPARE(switcher.fraction(), -1.0);
}
};
BILAUNCHER_TEST_MAIN(TestBuildSwitcher)
#include "tst_buildswitcher.moc"
+454
View File
@@ -0,0 +1,454 @@
#include "testenv.h"
#include "fixtures.h"
#include "gamelauncher.h"
#include "javalocator.h"
#include "localization.h"
#include "minecraftversion.h"
#include <QDir>
#include <QSignalSpy>
namespace {
#if defined(Q_OS_WIN)
const QChar kSep = u';';
const char *kNativeName = "lwjgl.dll";
#elif defined(Q_OS_MACOS)
const QChar kSep = u':';
const char *kNativeName = "liblwjgl.dylib";
#else
const QChar kSep = u':';
const char *kNativeName = "liblwjgl.so";
#endif
QString nat(const QString &path)
{
return QDir::toNativeSeparators(path);
}
MinecraftLibrary lib(const QString &name, const QString &path, bool native = false)
{
MinecraftLibrary library;
library.name = name;
library.path = path;
library.native = native;
return library;
}
} // namespace
class TestGameLauncher : public QObject
{
Q_OBJECT
QString m_game;
MinecraftVersion modernVersion() const
{
MinecraftVersion v;
v.id = QStringLiteral("1.20.1");
v.mainClass = QStringLiteral("net.minecraft.client.main.Main");
v.type = QStringLiteral("release");
v.assetIndexId = QStringLiteral("5");
v.assetsKind = QStringLiteral("5");
v.clientJarPath = m_game + QStringLiteral("/versions/1.20.1/1.20.1.jar");
v.javaMajor = 17;
v.jvmArgs = QStringList{"-Djava.library.path=${natives_directory}",
"-Dminecraft.launcher.brand=${launcher_name}",
"-Dminecraft.launcher.version=${launcher_version}",
"-Dunknown=${not_a_variable}",
"-cp", "${classpath}"};
v.gameArgs = QStringList{"--username", "${auth_player_name}", "--version", "${version_name}",
"--gameDir", "${game_directory}", "--assetsDir", "${assets_root}",
"--assetIndex", "${assets_index_name}", "--uuid", "${auth_uuid}",
"--accessToken", "${auth_access_token}", "--clientId", "${clientid}",
"--xuid", "${auth_xuid}", "--userType", "${user_type}",
"--versionType", "${version_type}", "--mystery", "${unknown_thing}"};
v.libraries << lib("com.mojang:brigadier:1.1.8", "com/mojang/brigadier-1.1.8.jar")
<< lib("org.lwjgl:lwjgl:3.3.1", "org/lwjgl/lwjgl-3.3.1.jar")
<< lib("org.lwjgl:lwjgl:3.3.1:natives", "org/lwjgl/lwjgl-3.3.1-natives.jar", true);
return v;
}
LaunchOptions options() const
{
LaunchOptions o;
o.gameDir = m_game;
o.versionId = QStringLiteral("1.20.1");
o.playerName = QStringLiteral("Steve");
o.uuid = QStringLiteral("0123456789abcdef0123456789abcdef");
o.accessToken = QStringLiteral("secret-token");
o.userType = QStringLiteral("msa");
o.minMemoryMb = 1024;
o.maxMemoryMb = 6144;
return o;
}
static QString valueAfter(const QStringList &args, const QString &flag)
{
const qsizetype i = args.indexOf(flag);
return (i >= 0 && i + 1 < args.size()) ? args.at(i + 1) : QString();
}
void createGameFiles(const MinecraftVersion &v)
{
Fixtures::write(v.clientJarPath, "client");
Fixtures::write(m_game + "/libraries/com/mojang/brigadier-1.1.8.jar", "lib");
Fixtures::write(m_game + "/libraries/org/lwjgl/lwjgl-3.3.1.jar", "lib");
Fixtures::makeZip(m_game + "/libraries/org/lwjgl/lwjgl-3.3.1-natives.jar",
{{QStringLiteral("META-INF/MANIFEST.MF"), "x"},
{QStringLiteral("linux/x64/org/lwjgl/") + kNativeName, "native-bytes"},
{QStringLiteral("skip/") + kNativeName + ".sha1", "hash"},
// Служебные файлы LWJGL рядом с .so: не должны приниматься
// за версионированную библиотеку (libfoo.so.1) ни на одной ОС.
{QStringLiteral("skip/liblwjgl.so.sha1"), "hash"},
{QStringLiteral("skip/liblwjgl.so.git"), "git"}});
Fixtures::write(m_game + "/assets/indexes/5.json", "{}");
QDir().mkpath(m_game + "/assets/objects");
}
private slots:
void init()
{
TestEnv::resetLauncherRoot();
m_game = TestEnv::freshDir(QStringLiteral("game"));
}
// --- buildArguments ---------------------------------------------------------
void substitutesVariables()
{
const MinecraftVersion v = modernVersion();
const QString natives = m_game + "/versions/1.20.1/natives";
const QStringList args = GameLauncher::buildArguments(options(), v, natives);
QCOMPARE(args.at(0), QStringLiteral("-Xms1024M"));
QCOMPARE(args.at(1), QStringLiteral("-Xmx6144M"));
QVERIFY(args.contains("-Djava.library.path=" + nat(natives)));
QVERIFY(args.contains("-Dminecraft.launcher.brand=BiLauncher"));
QVERIFY(args.contains(QStringLiteral("-Dminecraft.launcher.version=") + QStringLiteral(LAUNCHER_VERSION)));
// Неизвестный плейсхолдер — аргумент выбрасывается целиком.
QVERIFY(!args.join(' ').contains("not_a_variable"));
QVERIFY(!args.contains("${unknown_thing}"));
QVERIFY(args.contains("--mystery")); // сам флаг остаётся, как у официального лаунчера
// classpath: обычные библиотеки, без нативов, клиентский jar последним.
const QStringList cp = valueAfter(args, "-cp").split(kSep);
QCOMPARE(cp, QStringList({nat(m_game + "/libraries/com/mojang/brigadier-1.1.8.jar"),
nat(m_game + "/libraries/org/lwjgl/lwjgl-3.3.1.jar"),
nat(v.clientJarPath)}));
// mainClass — после jvm-аргументов, перед игровыми.
const qsizetype main = args.indexOf(v.mainClass);
QVERIFY(main > args.indexOf("-cp"));
QVERIFY(main < args.indexOf("--username"));
QCOMPARE(valueAfter(args, "--username"), QStringLiteral("Steve"));
QCOMPARE(valueAfter(args, "--version"), QStringLiteral("1.20.1"));
QCOMPARE(valueAfter(args, "--gameDir"), nat(m_game));
QCOMPARE(valueAfter(args, "--assetsDir"), nat(m_game + "/assets"));
QCOMPARE(valueAfter(args, "--assetIndex"), QStringLiteral("5"));
QCOMPARE(valueAfter(args, "--uuid"), QStringLiteral("0123456789abcdef0123456789abcdef"));
QCOMPARE(valueAfter(args, "--accessToken"), QStringLiteral("secret-token"));
QCOMPARE(valueAfter(args, "--clientId"), QStringLiteral("0")); // пустой → «0»
QCOMPARE(valueAfter(args, "--xuid"), QStringLiteral("0"));
QCOMPARE(valueAfter(args, "--userType"), QStringLiteral("msa"));
QCOMPARE(valueAfter(args, "--versionType"), QStringLiteral("release"));
QVERIFY(!args.contains("--width"));
QVERIFY(!args.contains("--fullscreen"));
QVERIFY(!args.contains("--server"));
}
void legacyAssetsAndSession()
{
MinecraftVersion v = modernVersion();
v.assetsKind = QStringLiteral("legacy");
v.gameArgs = QStringList{"--session", "${auth_session}", "--assetsDir", "${game_assets}"};
const QStringList args = GameLauncher::buildArguments(options(), v, m_game + "/n");
QCOMPARE(valueAfter(args, "--session"),
QStringLiteral("token:secret-token:0123456789abcdef0123456789abcdef"));
QCOMPARE(valueAfter(args, "--assetsDir"), nat(m_game + "/assets/virtual/legacy"));
v.assetsKind = QStringLiteral("pre-1.6");
QCOMPARE(valueAfter(GameLauncher::buildArguments(options(), v, m_game + "/n"), "--assetsDir"),
nat(m_game + "/assets/virtual/legacy"));
v.assetsKind = QStringLiteral("5");
QCOMPARE(valueAfter(GameLauncher::buildArguments(options(), v, m_game + "/n"), "--assetsDir"),
nat(m_game + "/assets"));
}
void memoryAndExtraJvmArgs()
{
LaunchOptions o = options();
o.minMemoryMb = 0;
o.maxMemoryMb = 0;
o.extraJvmArgs = QStringList{"-XX:+UseG1GC", "-Dfoo=bar"};
const QStringList args = GameLauncher::buildArguments(o, modernVersion(), m_game + "/n");
QVERIFY(!args.join(' ').contains("-Xms"));
QVERIFY(!args.join(' ').contains("-Xmx"));
QCOMPARE(args.at(0), QStringLiteral("-XX:+UseG1GC"));
QCOMPARE(args.at(1), QStringLiteral("-Dfoo=bar"));
}
void authlibInjectorGoesFirst()
{
LaunchOptions o = options();
o.authlibInjectorPath = m_game + "/authlib-injector.jar";
const QStringList args = GameLauncher::buildArguments(o, modernVersion(), m_game + "/n");
QCOMPARE(args.at(0), "-javaagent:" + nat(o.authlibInjectorPath) + "=ely.by");
QCOMPARE(args.at(1), QStringLiteral("-Dauthlibinjector.side=client"));
QCOMPARE(args.at(2), QStringLiteral("-Xms1024M"));
}
void windowSizeAndFullscreen()
{
LaunchOptions o = options();
o.windowWidth = 1280;
o.windowHeight = 720;
o.fullscreen = true;
MinecraftVersion v = modernVersion();
QStringList args = GameLauncher::buildArguments(o, v, m_game + "/n");
QCOMPARE(valueAfter(args, "--width"), QStringLiteral("1280"));
QCOMPARE(valueAfter(args, "--height"), QStringLiteral("720"));
QCOMPARE(args.count("--fullscreen"), 1);
// У версии свои аргументы разрешения — лаунчер свои не дублирует.
v.hasCustomResolutionArgs = true;
v.gameArgs << "--width" << "${resolution_width}" << "--height" << "${resolution_height}";
args = GameLauncher::buildArguments(o, v, m_game + "/n");
QCOMPARE(args.count("--width"), 1);
QCOMPARE(valueAfter(args, "--width"), QStringLiteral("1280"));
// Только ширина — не передаём ничего.
o.windowHeight = 0;
o.fullscreen = false;
args = GameLauncher::buildArguments(o, modernVersion(), m_game + "/n");
QVERIFY(!args.contains("--width"));
QVERIFY(!args.contains("--fullscreen"));
}
void serverAutoJoin_data()
{
QTest::addColumn<bool>("quickPlay");
QTest::addColumn<QString>("address");
QTest::addColumn<QStringList>("expectedTail");
QTest::newRow("quickplay") << true << "play.example.com:25570"
<< QStringList{"--quickPlayMultiplayer", "play.example.com:25570"};
QTest::newRow("legacy with port") << false << "play.example.com:25570"
<< QStringList{"--server", "play.example.com", "--port", "25570"};
QTest::newRow("legacy default port") << false << "play.example.com"
<< QStringList{"--server", "play.example.com", "--port", "25565"};
}
void serverAutoJoin()
{
QFETCH(bool, quickPlay);
QFETCH(QString, address);
QFETCH(QStringList, expectedTail);
LaunchOptions o = options();
o.serverAddress = address;
MinecraftVersion v = modernVersion();
v.supportsQuickPlay = quickPlay;
const QStringList args = GameLauncher::buildArguments(o, v, m_game + "/n");
QCOMPARE(args.mid(args.size() - expectedTail.size()), expectedTail);
}
void forgeIgnoreListGetsClientJar()
{
MinecraftVersion v = modernVersion();
v.jvmArgs.prepend("-DignoreList=bootstraplauncher,securejarhandler,${version_name}.jar");
QStringList args = GameLauncher::buildArguments(options(), v, m_game + "/n");
// ${version_name} = 1.20.1 совпадает с именем jar — дописывать нечего.
QCOMPARE(args.filter("-DignoreList=").constFirst(),
QStringLiteral("-DignoreList=bootstraplauncher,securejarhandler,1.20.1.jar"));
// Профиль Forge со своим id, а jar родительский — имя jar дописывается.
v.id = QStringLiteral("1.20.1-forge-47.4.0");
args = GameLauncher::buildArguments(options(), v, m_game + "/n");
QCOMPARE(args.filter("-DignoreList=").constFirst(),
QStringLiteral("-DignoreList=bootstraplauncher,securejarhandler,1.20.1-forge-47.4.0.jar,1.20.1.jar"));
}
void loggingArgumentOnlyWhenConfigExists()
{
MinecraftVersion v = modernVersion();
v.loggingArgument = QStringLiteral("-Dlog4j.configurationFile=${path}");
v.loggingConfigPath = m_game + "/assets/log_configs/client-1.12.xml";
QVERIFY(GameLauncher::buildArguments(options(), v, m_game + "/n").filter("log4j").isEmpty());
Fixtures::write(v.loggingConfigPath, "<xml/>");
const QStringList args = GameLauncher::buildArguments(options(), v, m_game + "/n");
QCOMPARE(args.filter("log4j"), QStringList{"-Dlog4j.configurationFile=" + nat(v.loggingConfigPath)});
QVERIFY(args.indexOf(args.filter("log4j").constFirst()) < args.indexOf(v.mainClass));
}
// --- missingFiles / extractNatives -------------------------------------------
void missingFiles()
{
const MinecraftVersion v = modernVersion();
LaunchOptions o = options();
o.gameDir = m_game + "/does-not-exist";
QCOMPARE(GameLauncher::missingFiles(o, v), QStringList{nat(o.gameDir)});
o = options();
QStringList missing = GameLauncher::missingFiles(o, v);
QVERIFY(missing.contains(nat(v.clientJarPath)));
QVERIFY(missing.contains(nat(m_game + "/libraries/com/mojang/brigadier-1.1.8.jar")));
QVERIFY(missing.contains(nat(m_game + "/assets/indexes/5.json")));
QCOMPARE(GameLauncher::missingFiles(o, v, 2).size(), 2);
createGameFiles(v);
QVERIFY(GameLauncher::missingFiles(o, v).isEmpty());
QDir(m_game + "/assets/objects").removeRecursively();
QCOMPARE(GameLauncher::missingFiles(o, v), QStringList{nat(m_game + "/assets/objects")});
}
void extractNatives()
{
MinecraftVersion v = modernVersion();
createGameFiles(v);
const QString natives = m_game + "/versions/1.20.1/natives";
QString error;
QVERIFY2(GameLauncher::extractNatives(options(), v, natives, &error), qPrintable(error));
// Только сама библиотека: без META-INF и без .sha1, в плоскую папку.
QCOMPARE(Fixtures::listTree(natives), QStringList{QString::fromLatin1(kNativeName)});
QCOMPARE(Fixtures::readFile(natives + u'/' + kNativeName), QByteArray("native-bytes"));
// Повторный запуск поверх готовой папки — успех.
QVERIFY(GameLauncher::extractNatives(options(), v, natives, &error));
// extractExclude по префиксу пути внутри архива.
QDir(natives).removeRecursively();
v.libraries[2].extractExclude = QStringList{"linux/"};
QVERIFY(GameLauncher::extractNatives(options(), v, natives, &error));
QVERIFY(Fixtures::listTree(natives).isEmpty());
// Архива нет вовсе.
const QString jar = m_game + "/libraries/org/lwjgl/lwjgl-3.3.1-natives.jar";
QFile::remove(jar);
error.clear();
QVERIFY(!GameLauncher::extractNatives(options(), v, natives, &error));
QCOMPARE(error, Loc::text("launch.error.nativeReadFailed").arg(nat(jar)));
// Битый архив: QZipReader::isReadable() проверяет только, что файл открылся,
// поэтому испорченный jar «распаковывается» в ничто без ошибки, и игра
// падает уже на загрузке LWJGL. Фиксируем текущее поведение.
Fixtures::write(jar, "not a zip");
error.clear();
QEXPECT_FAIL("", "битый jar с нативами не распознаётся как ошибка (QZipReader::isReadable)", Continue);
QVERIFY(!GameLauncher::extractNatives(options(), v, natives, &error));
}
// --- launch (с подставной java) ----------------------------------------------
void launchRunsJavaAndReportsExit()
{
MinecraftVersion v = modernVersion();
createGameFiles(v);
const QString argsFile = m_game + "/java-args.txt";
const QString java = Fixtures::installFakeJava(m_game + "/jdk", "17.0.8",
{"args_file=" + argsFile, "stdout=[main] Setting user: Steve",
"stdout=Game crashed? no", "exit=0"});
LaunchOptions o = options();
o.javaPath = java;
GameLauncher launcher;
QSignalSpy started(&launcher, &GameLauncher::gameStarted);
QSignalSpy finished(&launcher, &GameLauncher::gameFinished);
QSignalSpy output(&launcher, &GameLauncher::output);
QSignalSpy progress(&launcher, &GameLauncher::progress);
QString error;
QVERIFY2(launcher.launch(o, v, &error), qPrintable(error));
QCOMPARE(started.count(), 1);
// Токен скрыт в показанной строке запуска.
const QString shown = started.constFirst().constFirst().toString();
QVERIFY(!shown.contains("secret-token"));
QVERIFY(shown.contains("--accessToken"));
QTRY_COMPARE_WITH_TIMEOUT(finished.count(), 1, 15000);
QCOMPARE(finished.constFirst().at(0).toInt(), 0);
QCOMPARE(finished.constFirst().at(1).toBool(), false);
QVERIFY(!launcher.isRunning());
QVERIFY(progress.count() >= 3);
QStringList lines;
for (const QList<QVariant> &call : output)
lines << call.constFirst().toString();
QVERIFY(lines.contains("[main] Setting user: Steve"));
// Java получила те же аргументы, что строит buildArguments, и в рабочей папке игры.
const QStringList received = QString::fromLocal8Bit(Fixtures::readFile(argsFile)).split(u'\n', Qt::SkipEmptyParts);
QCOMPARE(received, GameLauncher::buildArguments(o, v, m_game + "/versions/1.20.1/natives"));
QVERIFY(QFile::exists(m_game + "/versions/1.20.1/natives/" + kNativeName));
}
void launchReportsCrash()
{
MinecraftVersion v = modernVersion();
createGameFiles(v);
LaunchOptions o = options();
o.javaPath = Fixtures::installFakeJava(m_game + "/jdk-crash", "17.0.8", {"crash=1"});
GameLauncher launcher;
QSignalSpy finished(&launcher, &GameLauncher::gameFinished);
QString error;
QVERIFY2(launcher.launch(o, v, &error), qPrintable(error));
QTRY_COMPARE_WITH_TIMEOUT(finished.count(), 1, 15000);
QCOMPARE(finished.constFirst().at(1).toBool(), true);
}
void launchRefusesWhileRunningAndTerminate()
{
MinecraftVersion v = modernVersion();
createGameFiles(v);
LaunchOptions o = options();
o.javaPath = Fixtures::installFakeJava(m_game + "/jdk-slow", "17.0.8", {"sleep_ms=30000"});
GameLauncher launcher;
QSignalSpy finished(&launcher, &GameLauncher::gameFinished);
QString error;
QVERIFY(launcher.launch(o, v, &error));
QVERIFY(launcher.isRunning());
QVERIFY(!launcher.launch(o, v, &error));
QCOMPARE(error, Loc::text("common.error.gameRunning"));
launcher.terminate();
QTRY_COMPARE_WITH_TIMEOUT(finished.count(), 1, 15000);
QVERIFY(!launcher.isRunning());
}
void launchFailsOnMissingFilesAndOldJava()
{
MinecraftVersion v = modernVersion();
LaunchOptions o = options();
GameLauncher launcher;
QSignalSpy started(&launcher, &GameLauncher::gameStarted);
QString error;
QVERIFY(!launcher.launch(o, v, &error));
QVERIFY(error.startsWith(Loc::text("launch.error.filesMissing").section(u'%', 0, 0)));
createGameFiles(v);
// На машине может стоять своя Java 17+: тогда лаунчер законно выберет её.
for (const QString &candidate : JavaLocator::findAll(m_game)) {
if (JavaLocator::majorVersion(candidate) >= 17)
QSKIP("в системе есть Java 17+, проверка «слишком старой Java» невозможна");
}
o.javaPath = Fixtures::installFakeJava(m_game + "/jdk8", "1.8.0_392");
error.clear();
QVERIFY(!launcher.launch(o, v, &error));
QCOMPARE(error, Loc::text("java.error.tooOld").arg(17).arg(8).arg(nat(o.javaPath)));
QCOMPARE(started.count(), 0);
}
};
BILAUNCHER_TEST_MAIN(TestGameLauncher)
#include "tst_gamelauncher.moc"
Loaded 100 of 108 files, more files were not shown because too many files have changed in this diff. Show more