1 Commits
Author SHA1 Message Date
galeon cdcd3ea7c1 Merge branch 'master' into beta-2 2026-10-06 11:32:23 +00:00
141 changed files with 4729 additions and 12898 deletions

No files matched your search

-311
View File
@@ -1,311 +0,0 @@
name: release
# master -> релиз v<версия>, если такого ещё нет
# rc -> пре-релиз v<версия>-rcN, N — следующий свободный номер
# тег vX.Y.Z -> релиз, тег vX.Y.Z-rcN -> пре-релиз
# ручной запуск -> только сборки, файлы остаются артефактами прогона
on:
push:
#branches: [master, rc]
tags: ["v*"]
workflow_dispatch:
concurrency:
group: release-${{ github.ref }}
env:
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
jobs:
create-release:
runs-on: linux
outputs:
version: ${{ steps.meta.outputs.version }}
tag: ${{ steps.meta.outputs.tag }}
skip: ${{ steps.meta.outputs.skip }}
release_id: ${{ steps.release.outputs.id }}
steps:
- uses: actions/checkout@v4
- id: meta
name: Версия из CMakeLists.txt, тег и тип релиза
env:
TOKEN: ${{ secrets.RELEASE_TOKEN }}
run: |
version=$(sed -nE 's/^project\(BiLauncher VERSION ([0-9]+\.[0-9]+\.[0-9]+).*/\1/p' CMakeLists.txt)
[ -n "$version" ] || { echo "Не нашёл project(BiLauncher VERSION ...)"; exit 1; }
# Теги релизов, включая черновики (токену с правом записи API отдаёт и их)
release_tags=$(curl -fsS -H "Authorization: token $TOKEN" "$API/releases?limit=50" \
| python3 -c 'import json,sys; [print(r["tag_name"]) for r in json.load(sys.stdin)]')
has_release() { printf '%s\n' "$release_tags" | grep -xF "$1" > /dev/null; }
# У черновика тега в git ещё нет, поэтому смотрим и в git, и в релизы
known() {
git ls-remote --tags origin | sed -E 's#.*refs/tags/##; s/\^\{\}$//'
printf '%s\n' "$release_tags"
}
tag=""; skip=false
case "${GITHUB_REF_TYPE}:${GITHUB_REF_NAME}" in
tag:*)
tag="${GITHUB_REF_NAME}"
base="${tag#v}"; base="${base%%-*}"
if [ "$base" != "$version" ]; then
echo "Тег $tag не совпадает с версией $version в CMakeLists.txt"; exit 1
fi
# Тег мог создать сам релиз из прогона по ветке — тогда второй раз не собираем
if has_release "$tag"; then
echo "Релиз $tag уже есть"; skip=true
fi
;;
branch:master)
tag="v$version"
if known | grep -xF "$tag" > /dev/null; then
echo "Релиз $tag уже есть, поднимите версию в CMakeLists.txt"; skip=true
fi
;;
branch:rc)
n=$(known | sed -nE "s/^v${version//./\\.}-rc([0-9]+)\$/\1/p" | sort -n | tail -1)
tag="v$version-rc$(( ${n:-0} + 1 ))"
;;
esac
[ -n "$tag" ] && version="${tag#v}"
echo "version=$version" >> "$GITHUB_OUTPUT"
echo "tag=$tag" >> "$GITHUB_OUTPUT"
echo "skip=$skip" >> "$GITHUB_OUTPUT"
echo "Версия: $version, тег: ${tag:-нет}, пропуск: $skip"
- id: release
name: Черновик релиза
if: steps.meta.outputs.tag != '' && steps.meta.outputs.skip != 'true'
env:
TOKEN: ${{ secrets.RELEASE_TOKEN }}
TAG: ${{ steps.meta.outputs.tag }}
run: |
full="${TAG#v}"; base="${full%%-*}"
section() {
awk -v v="$1" '
/^## \[/ { if (on) exit; if (index($0, "[" v "]")) { on=1; next } }
on' CHANGELOG.md
}
# Сначала раздел ровно под тег ([1.0.1-rc1]), иначе общий под версию ([1.0.1])
notes=$(section "$full")
[ -n "$notes" ] || notes=$(section "$base")
[ -n "$notes" ] || echo "::warning::В CHANGELOG.md нет раздела [$full] или [$base]"
prerelease=false; case "$TAG" in *-rc*) prerelease=true;; esac
# target_commitish: у новой ветки тег появится на этом коммите при публикации
body=$(python3 -c 'import json,sys; print(json.dumps({"tag_name":sys.argv[1],"target_commitish":sys.argv[4],"name":"BiLauncher "+sys.argv[1],"body":sys.argv[2],"draft":True,"prerelease":sys.argv[3]=="true"}))' \
"$TAG" "$notes" "$prerelease" "$GITHUB_SHA")
id=$(curl -fsS -X POST "$API/releases" \
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
-d "$body" | python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])')
echo "id=$id" >> "$GITHUB_OUTPUT"
echo "Создан черновик $([ "$prerelease" = true ] && echo пре-)релиза $TAG (id $id)"
build-linux:
needs: create-release
if: needs.create-release.outputs.skip != 'true'
runs-on: linux
env:
VERSION: ${{ needs.create-release.outputs.version }}
# Только для релизов/пре-релизов; при ручном запуске канал определит CMake по ветке
BUILD_VERSION: ${{ needs.create-release.outputs.tag != '' && needs.create-release.outputs.version || '' }}
RELEASE_ID: ${{ needs.create-release.outputs.release_id }}
outputs:
sums: ${{ steps.pack.outputs.sums }}
steps:
- uses: actions/checkout@v4
- name: Сборка
run: |
export PATH="$QT_TOOLS/CMake/bin:$QT_TOOLS/Ninja:$PATH"
rm -rf build AppDir dist
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release \
-DCMAKE_PREFIX_PATH="$QT_ROOT" -DLAUNCHER_BUILD_VERSION="$BUILD_VERSION" \
-DCMAKE_INSTALL_PREFIX=/usr -DCMAKE_INSTALL_LIBDIR=lib
cmake --build build -j 8
cmake --install build --prefix "$PWD/AppDir/usr"
- id: pack
name: AppImage и tar.gz
run: |
mkdir -p dist
cp AppDir/usr/share/applications/bilauncher.desktop AppDir/
cp AppDir/usr/share/icons/hicolor/256x256/apps/bilauncher.png AppDir/
ln -sf usr/bin/BiLauncher AppDir/AppRun
ARCH=x86_64 appimagetool --no-appstream AppDir "dist/BiLauncher-$VERSION-x86_64.AppImage"
cp -a AppDir/usr "BiLauncher-$VERSION"
tar -czf "dist/BiLauncher-$VERSION-linux-x86_64.tar.gz" "BiLauncher-$VERSION"
rm -rf "BiLauncher-$VERSION"
ls -lh dist
echo "sums=$(cd dist && sha256sum * | base64 -w0)" >> "$GITHUB_OUTPUT"
- name: Загрузка в релиз
if: env.RELEASE_ID != ''
env:
TOKEN: ${{ secrets.RELEASE_TOKEN }}
run: |
for f in dist/*; do
curl -fsS -X POST -H "Authorization: token $TOKEN" \
-F "attachment=@$f" "$API/releases/$RELEASE_ID/assets?name=$(basename "$f")" > /dev/null
echo "Загружен $(basename "$f")"
done
- name: Артефакты прогона
if: env.RELEASE_ID == ''
uses: actions/upload-artifact@v4
with:
name: linux
path: dist/
build-windows:
needs: create-release
if: needs.create-release.outputs.skip != 'true'
runs-on: windows
env:
VERSION: ${{ needs.create-release.outputs.version }}
# Только для релизов/пре-релизов; при ручном запуске канал определит CMake по ветке
BUILD_VERSION: ${{ needs.create-release.outputs.tag != '' && needs.create-release.outputs.version || '' }}
RELEASE_ID: ${{ needs.create-release.outputs.release_id }}
outputs:
sums: ${{ steps.build.outputs.sums }}
steps:
- uses: actions/checkout@v4
- id: build
name: Сборка, установщик и portable-архив
shell: powershell
run: |
$ErrorActionPreference = "Stop"
$build = "C:\r\bl-build"; $stage = "C:\r\bl-stage"; $dist = "$PWD\dist"
Remove-Item -Recurse -Force $build, $stage, $dist -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" "-DLAUNCHER_BUILD_VERSION=$env:BUILD_VERSION"
if ($LASTEXITCODE) { throw "cmake configure" }
cmake --build $build
if ($LASTEXITCODE) { throw "cmake build" }
cmake --install $build --prefix $stage
if ($LASTEXITCODE) { throw "cmake install" }
New-Item -ItemType Directory -Force $dist | Out-Null
& "$env:ISCC_DIR\ISCC.exe" /Q "/DAppVersion=$env:VERSION" "/DSourceDir=$stage" "/DOutputDir=$dist" packaging\windows\BiLauncher.iss
if ($LASTEXITCODE) { throw "iscc" }
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
& "$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
Get-ChildItem $dist | Format-Table Name, Length
$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"))
[IO.File]::AppendAllText($env:GITHUB_OUTPUT, "sums=$b64`n")
- name: Загрузка в релиз
if: env.RELEASE_ID != ''
shell: powershell
env:
TOKEN: ${{ secrets.RELEASE_TOKEN }}
run: |
foreach ($f in Get-ChildItem dist) {
curl.exe -fsS -X POST -H "Authorization: token $env:TOKEN" `
-F "attachment=@$($f.FullName)" "$env:API/releases/$env:RELEASE_ID/assets?name=$($f.Name)" | Out-Null
if ($LASTEXITCODE) { throw "Не удалось загрузить $($f.Name)" }
"Загружен $($f.Name)"
}
- name: Артефакты прогона
if: env.RELEASE_ID == ''
uses: actions/upload-artifact@v4
with:
name: windows
path: dist/
build-macos:
needs: create-release
if: needs.create-release.outputs.skip != 'true'
runs-on: macos
env:
VERSION: ${{ needs.create-release.outputs.version }}
# Только для релизов/пре-релизов; при ручном запуске канал определит CMake по ветке
BUILD_VERSION: ${{ needs.create-release.outputs.tag != '' && needs.create-release.outputs.version || '' }}
RELEASE_ID: ${{ needs.create-release.outputs.release_id }}
outputs:
sums: ${{ steps.pack.outputs.sums }}
steps:
- uses: actions/checkout@v4
- name: Сборка (universal, arm64 + x86_64)
run: |
export PATH="$QT_TOOLS/CMake/CMake.app/Contents/bin:$QT_TOOLS/Ninja:$PATH"
rm -rf build stage dist dmgroot
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release \
-DCMAKE_PREFIX_PATH="$QT_ROOT" -DLAUNCHER_BUILD_VERSION="$BUILD_VERSION" \
-DCMAKE_OSX_ARCHITECTURES="arm64;x86_64"
cmake --build build
cmake --install build --prefix "$PWD/stage"
- id: pack
name: Ad-hoc подпись и DMG
run: |
codesign --force --deep --sign - stage/BiLauncher.app
codesign --verify --deep --strict stage/BiLauncher.app
mkdir -p dmgroot dist
cp -R stage/BiLauncher.app dmgroot/
ln -s /Applications dmgroot/Applications
hdiutil create -volname "BiLauncher" -srcfolder dmgroot -ov -format UDZO \
"dist/BiLauncher-$VERSION-macOS-universal.dmg"
rm -rf dmgroot
ls -lh dist
echo "sums=$(cd dist && shasum -a 256 * | base64 | tr -d '\n')" >> "$GITHUB_OUTPUT"
- name: Загрузка в релиз
if: env.RELEASE_ID != ''
env:
TOKEN: ${{ secrets.RELEASE_TOKEN }}
run: |
for f in dist/*; do
curl -fsS -X POST -H "Authorization: token $TOKEN" \
-F "attachment=@$f" "$API/releases/$RELEASE_ID/assets?name=$(basename "$f")" > /dev/null
echo "Загружен $(basename "$f")"
done
- name: Артефакты прогона
if: env.RELEASE_ID == ''
uses: actions/upload-artifact@v4
with:
name: macos
path: dist/
publish:
needs: [create-release, build-linux, build-windows, build-macos]
if: needs.create-release.outputs.release_id != ''
runs-on: linux
env:
RELEASE_ID: ${{ needs.create-release.outputs.release_id }}
TOKEN: ${{ secrets.RELEASE_TOKEN }}
steps:
- name: SHA256SUMS.txt и публикация
run: |
{
echo "${{ needs.build-windows.outputs.sums }}" | base64 -d
echo "${{ needs.build-linux.outputs.sums }}" | base64 -d
echo "${{ needs.build-macos.outputs.sums }}" | base64 -d
} | tr -d '\r' | sed '/^$/d' > SHA256SUMS.txt
cat SHA256SUMS.txt
curl -fsS -X POST -H "Authorization: token $TOKEN" \
-F "attachment=@SHA256SUMS.txt" "$API/releases/$RELEASE_ID/assets?name=SHA256SUMS.txt" > /dev/null
curl -fsS -X PATCH -H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
-d '{"draft": false}' "$API/releases/$RELEASE_ID" > /dev/null
echo "Релиз опубликован"
-183
View File
@@ -1,183 +0,0 @@
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
-3
View File
@@ -61,9 +61,6 @@ replay_pid*
build/
.qtcreator/
.DS_Store
/doc/adr/
CMakeLists.txt.user*
stage/
dist/
build-tests/
build-*/
+3 -5
View File
@@ -15,9 +15,8 @@ Dialog {
padding: 0
width: 880
height: 600
closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
//closePolicy: buildsDialog.backend.switching ? Popup.NoAutoClose
// : Popup.CloseOnEscape | Popup.CloseOnPressOutside
closePolicy: buildsDialog.backend.switching ? Popup.NoAutoClose
: Popup.CloseOnEscape | Popup.CloseOnPressOutside
background: Rectangle {
color: "#1e1e1e"
@@ -315,8 +314,7 @@ Dialog {
anchors.verticalCenter: parent.verticalCenter
visible: buildRow.index === buildsDialog.backend.activeBuildIndex
text: Loc.t.build.label.active
color: (buildsDialog.editIndex === buildRow.index || buildRow.hovered)
? "#ffffff" : "#91B315"
color: "#91B315"
font.pixelSize: 10
}
Image {
+49 -19
View File
@@ -1,21 +1,51 @@
# Changelog
# First Level Heading
## [1.0.0] — 2026-10-07
### Добавлено
- Настройки запуска Minecraft
- Автоматическая загрузка версий java из предложенного списка
- Система хранения сборок, автоматического скачивания версий игры из меню и модлоадеры к каждой версии
- Сохранение сборок в zip формате и отображение в списке
- Система сезонных сборок: полная автоматизация в один клик
- Авторизация через ely.by и\или лицензию, с возможностью работы скинов
### Исправлено
- UI фиксы
Paragraph.
## [1.0.1-rc1] — 2026-10-23
### Добавлено
- Отображене текущей версии сборки лаунчера на главном окне
### Исправлено
- Исправлен баг когда невозможно выйти из модального окна сезонных сборок и\или отменить текущую загрузку
- Исправлен баг отображения статуса активной сборки на зелёном фоне
- Исправлен баг, что во время игры можно было потенциально сменить сборку
- Исправлен баг первого запуска после установки лаунчера, если у пользователя была не пустая .minecraft
## Second Level Heading
Paragraph.
- bullet
+ other bullet
* another bullet
* child bullet
1. ordered
2. next ordered
### Third Level Heading
Some *italic* and **bold** text and `inline code`.
An empty line starts a new paragraph.
Use two spaces at the end
to force a line break.
A horizontal ruler follows:
---
Add links inline like [this link to the Qt homepage](https://www.qt.io),
or with a reference like [this other link to the Qt homepage][1].
Add code blocks with
four spaces at the front.
> A blockquote
> starts with >
>
> and has the same paragraph rules as normal text.
First Level Heading in Alternate Style
======================================
Paragraph.
Second Level Heading in Alternate Style
---------------------------------------
Paragraph.
[1]: https://www.qt.io
+25 -38
View File
@@ -1,27 +1,6 @@
cmake_minimum_required(VERSION 3.20)
project(BiLauncher VERSION 1.0.1 LANGUAGES CXX C)
# Канал сборки (dev / rc / релиз) не хранится в репозитории, а вычисляется
# при сборке — см. cmake/LauncherVersion.cmake. CI передаёт полную версию:
# -DLAUNCHER_BUILD_VERSION=1.0.1-rc2.
set(LAUNCHER_BUILD_VERSION "" CACHE STRING "Полная версия от CI (X.Y.Z[-rcN]); пусто — по тегу и ветке git")
set(LAUNCHER_VERSION_NUMERIC "${PROJECT_VERSION}")
set(LAUNCHER_SOURCE_DIR "${PROJECT_SOURCE_DIR}")
set(LAUNCHER_GENERATED_DIR "${PROJECT_BINARY_DIR}/generated")
set(LAUNCHER_VERSION_HEADER "${LAUNCHER_GENERATED_DIR}/launcherversion.h")
include(cmake/LauncherVersion.cmake)
message(STATUS "Версия лаунчера: ${LAUNCHER_VERSION} (канал ${LAUNCHER_CHANNEL})")
add_custom_target(launcher_version
COMMAND "${CMAKE_COMMAND}"
"-DLAUNCHER_VERSION_NUMERIC=${LAUNCHER_VERSION_NUMERIC}"
"-DLAUNCHER_SOURCE_DIR=${LAUNCHER_SOURCE_DIR}"
"-DLAUNCHER_VERSION_HEADER=${LAUNCHER_VERSION_HEADER}"
"-DLAUNCHER_BUILD_VERSION=${LAUNCHER_BUILD_VERSION}"
-P "${PROJECT_SOURCE_DIR}/cmake/LauncherVersion.cmake"
BYPRODUCTS "${LAUNCHER_VERSION_HEADER}"
COMMENT "Версия лаунчера из git"
VERBATIM)
project(BiLauncher VERSION 1.0.0 LANGUAGES CXX C)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(QT_NO_PRIVATE_MODULE_WARNING ON)
@@ -40,6 +19,15 @@ endif()
qt_standard_project_setup(REQUIRES 6.8)
# Иконку самого файла приложения (то, что видно до запуска) каждая система берёт
# по-своему: Windows — из ресурса, вкомпилированного в .exe, macOS — из .icns
# внутри бандла. На Linux такого механизма нет, там иконку даёт .desktop-файл,
# который ставится ниже вместе с темой hicolor.
#
# Растры и .icns/.ico собираются из одного исходника скриптом icons/generate_icons.sh:
# у macOS арт по сетке Apple занимает 824 из 1024 точек холста, у Windows и Linux
# идёт без полей. Правки иконки вносятся в icons/master/icon_master.png с
# последующим перезапуском скрипта, а не в отдельные файлы размеров.
set(LAUNCHER_ICON_DIR "${CMAKE_CURRENT_SOURCE_DIR}/icons")
if(WIN32)
@@ -59,6 +47,10 @@ qt_add_executable(BiLauncher
${LAUNCHER_APP_ICON}
)
# Иконка окна выставляется в main.cpp через setWindowIcon() и обязана быть
# доступна на всех платформах, поэтому лежит в отдельном ресурсе, а не в
# ресурсах QML-модуля: путь :/icons/... не зависит от URI модуля.
# Размеры 24, 48 и 64 нужны панели задач Windows и значкам рабочих столов Linux.
qt_add_resources(BiLauncher "appicon"
PREFIX "/icons"
BASE "icons"
@@ -78,14 +70,8 @@ qt_add_resources(BiLauncher "appicon"
icons/icon_512x512@2x.png
)
# Путь импорта для QML code model Qt Creator / qmlls: модуль лежит в
# ${CMAKE_CURRENT_BINARY_DIR}/qml, а не в корне каталога сборки.
set(QML_IMPORT_PATH "${CMAKE_CURRENT_BINARY_DIR}/qml"
CACHE STRING "Пути импорта QML для Qt Creator" FORCE)
qt_add_qml_module(BiLauncher
URI BiLauncher
OUTPUT_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}/qml/BiLauncher"
SOURCES
localization.h
localization.cpp
@@ -124,8 +110,6 @@ qt_add_qml_module(BiLauncher
buildarchiveworker.cpp
buildswitcher.h
buildswitcher.cpp
gamedirimport.h
gamedirimport.cpp
seasonalbuildservice.h
seasonalbuildservice.cpp
seasonalpackdownloader.h
@@ -143,9 +127,12 @@ qt_add_qml_module(BiLauncher
${LAUNCHER_MSA_QML_FILES}
RESOURCES images/Folder/Folder_Active.svg images/Folder/Folder_Idle.svg images/Folder/Folder_Pressed.svg images/Options/Options_active.svg images/Options/Options_Idle.svg images/Options/Options_Pressed.svg images/Play_Button/Play_Active.svg images/Play_Button/Play_Idle.svg images/Play_Button/Play_pressed.svg images/Profile_Box/Asset_23.jpg images/Profile_Box/Asset_23.svg images/Profile_Box/Asset_24.jpg images/Profile_Box/Asset_24.svg images/Profile_Box/Kishka_Profile_Active.svg images/Profile_Box/Kishka_Profile_Idle.svg images/Profile_Box/Kishka_Profile_open.svg images/Scroll/Scroll_Lever.svg images/Scroll/Scroll_palka.svg images/Version_box/Kishka_Profile_Open.svg images/Version_box/Kishka_Ver_Active.svg images/Version_box/Kishka_Ver_idle.svg images/Trash.svg images/Pencil.svg images/GovuztTW8AAHqBf.jpeg images/photo_2025-12-16_15-04-17.jpg images/photo_2025-12-21_02-30-09.jpg i18n/translations.json
RESOURCES images/launcherBackground.png
RESOURCES images/launcherBackground2static.jpg
RESOURCES images/launcherBackground2.mp4 images/launcherBackground2static.jpg
RESOURCES CHANGELOG.md
RESOURCES LICENSE
RESOURCES icons/icon.rc
RESOURCES icons/icon.rc.in
RESOURCES icons/icon.rc
RESOURCES README.md
)
@@ -156,6 +143,8 @@ set_target_properties(BiLauncher PROPERTIES
MACOSX_BUNDLE_GUI_IDENTIFIER com.galeondev.BiLauncher
MACOSX_BUNDLE_BUNDLE_VERSION ${PROJECT_VERSION}
MACOSX_BUNDLE_SHORT_VERSION_STRING ${PROJECT_VERSION}
# Попадает в CFBundleIconFile сгенерированного Info.plist — только имя
# файла, без пути: сам .icns кладётся в Contents/Resources выше.
MACOSX_BUNDLE_ICON_FILE icon.icns
MACOSX_BUNDLE TRUE
WIN32_EXECUTABLE TRUE
@@ -165,8 +154,9 @@ target_link_libraries(BiLauncher
PRIVATE Qt6::Quick Qt6::QuickControls2 Qt6::Svg Qt6::Core Qt6::CorePrivate Qt6::Gui Qt6::Network
)
target_include_directories(BiLauncher PRIVATE "${LAUNCHER_GENERATED_DIR}")
add_dependencies(BiLauncher launcher_version)
target_compile_definitions(BiLauncher PRIVATE
LAUNCHER_VERSION="${PROJECT_VERSION}"
)
if(Qt6WebEngineQuick_FOUND)
target_link_libraries(BiLauncher PRIVATE Qt6::WebEngineQuick)
@@ -215,6 +205,8 @@ if(UNIX AND NOT APPLE)
)
install(TARGETS launcher_zlib_reference LIBRARY DESTINATION ${LAUNCHER_ZLIB_DIR})
# Рабочие столы ищут иконку не по имени файла, а по имени из ключа Icon=
# .desktop-файла, перебирая размеры в теме hicolor, — отсюда переименование.
foreach(icon_size IN ITEMS 16 24 32 48 64 128 256 512)
install(FILES "${LAUNCHER_ICON_DIR}/icon_${icon_size}x${icon_size}.png"
DESTINATION "${CMAKE_INSTALL_DATAROOTDIR}/icons/hicolor/${icon_size}x${icon_size}/apps"
@@ -250,8 +242,3 @@ qt_generate_deploy_qml_app_script(
DEPLOY_USER_QML_MODULES_ON_UNSUPPORTED_PLATFORM
DEPLOY_TOOL_OPTIONS ${deploy_opts})
install(SCRIPT ${deploy_script})
option(BILAUNCHER_BUILD_TESTS "Собрать автотесты (ctest)" OFF)
if(BILAUNCHER_BUILD_TESTS)
enable_testing()
add_subdirectory(tests)
endif()
+3
View File
@@ -1,9 +1,12 @@
import QtQuick
import QtQuick.Controls 2.15
// Комбобокс в общем тёмном стиле окна.
ComboBox {
id: darkCombo
// Та же стрелка, что у комбобоксов профиля и версии на главном окне,
// вместо двойного шеврона Basic-стиля.
indicator: Image {
width: 10; height: 10
x: darkCombo.width - width - 12
+20 -1
View File
@@ -4,16 +4,22 @@ import QtQuick
import QtQuick.Controls 2.15
import QtQuick.Layouts 2.15
// Выбор сборки Java отдельным окном: слева типы, справа сами версии с поиском.
// Устроено так же, как выбор версии Minecraft, но выбранное здесь ещё и
// качается — в отличие от версий игры, где загрузку начинает сама сборка.
Dialog {
id: picker
required property var backend
// Сборка, с которой окно открылось: по «Отмене» выбор к ней и возвращается.
property string selectedId: ""
property string category: "java"
property string filterText: ""
property bool installedOnly: false
// Java, ниже которой выбранной версии игры не запуститься. 0 — версия игры
// не выбрана, предупреждать не о чем.
property int requiredMajor: 0
signal runtimeChosen(string runtimeId)
@@ -37,6 +43,8 @@ Dialog {
{ key: "jre", title: Loc.t.common.java.kind.jre, hint: Loc.t.java.filter.temurinJre }
]
// Каталог приходит из C++ уже отсортированным (новые сверху, скачанные
// первыми), поэтому здесь только отбор — порядок не трогаем.
readonly property var visibleEntries: {
const query = picker.filterText.trim().toLowerCase()
const source = picker.backend.javaCatalog
@@ -99,6 +107,7 @@ Dialog {
runtimeList.positionViewAtIndex(index, ListView.Center)
}
// «Выбрать» и скачивает: сборка, которой нет на диске, запуску не поможет.
function acceptSelection() {
const entry = picker.selectedEntry
if (!entry)
@@ -129,6 +138,7 @@ Dialog {
contentItem: RowLayout {
spacing: 0
// ── Типы сборок ─────────────────────────────────────────────────
Rectangle {
Layout.preferredWidth: 170
Layout.fillHeight: true
@@ -185,6 +195,8 @@ Dialog {
Item { width: 1; height: 8 }
// Требование выбранной сборки — чтобы не пришлось держать в
// голове, какая Java нужна какой версии игры.
Text {
width: parent.width - 4
visible: picker.requiredMajor > 0
@@ -203,6 +215,7 @@ Dialog {
color: "#333333"
}
// ── Версии ──────────────────────────────────────────────────────
Item {
Layout.fillWidth: true
Layout.fillHeight: true
@@ -304,7 +317,8 @@ Dialog {
required property int index
readonly property bool chosen: picker.selectedId === runtimeRow.modelData.id
// Сборка ниже требования игры выбирается, но помечается: она
// может пригодиться для другой сборки.
readonly property bool tooOld: picker.requiredMajor > 0
&& runtimeRow.modelData.major < picker.requiredMajor
@@ -339,6 +353,8 @@ Dialog {
}
Text {
// Колонка растянута якорями, поэтому её ширина
// задана, а не выведена из детей — цикла тут нет.
width: parent.width
elide: Text.ElideRight
color: runtimeRow.chosen ? "#1e1e1e"
@@ -372,6 +388,8 @@ Dialog {
anchors.verticalCenter: parent.verticalCenter
}
// Сборки весят по двести мегабайт — удалять их нужно
// прямо здесь, иначе папка лаунчера растёт молча.
Image {
visible: runtimeRow.modelData.installed
source: "images/Trash.svg"
@@ -412,6 +430,7 @@ Dialog {
}
}
// Пустая категория, ничего не нашлось или каталог ещё не пришёл.
Text {
anchors.centerIn: runtimeList
width: runtimeList.width - 20
-201
View File
@@ -1,201 +0,0 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [2026] [GaleonDev dertiellis@gmail.com]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+2
View File
@@ -1,6 +1,7 @@
import QtQuick
import QtQuick.Controls 2.15
// Подпись + поле ввода одной колонкой — используется в настройках.
Column {
id: labelled
@@ -9,6 +10,7 @@ Column {
property string label: ""
property string placeholder: ""
// Правка закончена: поле потеряло фокус или пользователь нажал Enter.
signal editingFinished()
spacing: 3
+28
View File
@@ -1,6 +1,13 @@
import QtQuick
import QtQuick.Controls 2.15
// Один модлоадер в карточке сборки: чекбокс и, когда он отмечен, список версий
// именно под выбранную версию Minecraft.
//
// Совместимость проверять не нужно: backend.loaderVersions() отдаёт список уже
// под конкретную версию игры, поэтому несовместимой строки в нём не бывает.
// Пустой список — это «лоадер не поддерживает эту версию игры», и он тоже
// показывается словами, а не пустым выпадающим списком.
Column {
id: loaderRow
@@ -8,13 +15,21 @@ Column {
required property string loaderKey
required property string title
// Версия Minecraft, выбранная в карточке. Её смена сбрасывает выбор.
property string gameVersion: ""
property alias checked: box.checked
property string selectedVersion: ""
property var entries: []
// Пользователь щёлкнул чекбокс — карточка снимет отметки с остальных
// лоадеров: одновременно в .minecraft может жить только один.
signal userChecked()
// Отметка или версия изменились — карточке пора сохранить сборку.
signal changed()
// Ставится, пока карточка заполняет строку данными сборки: в это время
// обработчики не должны считать изменения пользовательскими.
property bool applying: false
spacing: 4
@@ -27,9 +42,16 @@ Column {
return -1
}
// Единственное место, где меняются entries и selectedVersion. Через него
// проходят все три пути — кэш, ответ сети и заполнение из сборки, — потому
// что выбранная версия обязана существовать в списке под текущую версию
// игры. Раньше проверял только путь из сети, и версия лоадера от прошлой
// версии игры доживала до сохранения сборки.
function applyEntries(list) {
loaderRow.entries = list
// Список отсортирован новыми вперёд, поэтому максимально доступная
// версия для этой версии игры — всегда первая строка.
if (loaderRow.versionIndex(loaderRow.selectedVersion) < 0) {
const fallback = list.length > 0 ? list[0].version : ""
const changedValue = loaderRow.selectedVersion !== fallback
@@ -46,20 +68,26 @@ Column {
loaderRow.applyEntries([])
return
}
// Сначала из кэша — список появляется мгновенно, — потом обновление сети.
loaderRow.applyEntries(loaderRow.backend.loaderVersions(loaderRow.loaderKey,
loaderRow.gameVersion))
loaderRow.backend.refreshLoaderVersions(loaderRow.loaderKey, loaderRow.gameVersion)
}
// Заполнение из сохранённой сборки: без сигналов changed().
function applyBuild(loader, loaderVersion) {
loaderRow.applying = true
box.checked = loader === loaderRow.loaderKey
// Версию из сборки берём как пожелание: если под выбранную версию игры
// её нет, applyEntries() заменит её на максимально доступную.
loaderRow.selectedVersion = box.checked ? loaderVersion : ""
loaderRow.reload()
loaderRow.applying = false
}
onGameVersionChanged: {
// Сборка лоадера привязана к версии игры: под новой версией прежний
// выбор недействителен, и список перезапрашивается заново.
loaderRow.selectedVersion = ""
loaderRow.reload()
}
+2 -41
View File
@@ -39,9 +39,6 @@ Window {
onMicrosoftLoginFailed: (message) => window.showToast(message, "#cc3333", 8000)
onMicrosoftReloginRequired: (profileIndex) => backend.startMicrosoftLogin(profileIndex)
onGameOutput: (line) => console.log(line)
onGameDirImported: (buildName) => {
window.showToast(Loc.t.build.status.imported.arg(buildName), "#4b7a1f", 10000)
}
onSeasonalInstallFinished: (seasonalId, buildName) => {
window.showToast(Loc.t.seasonal.status.readyToLaunch.arg(buildName),
"#4b7a1f", 8000)
@@ -92,38 +89,6 @@ Window {
id: image
anchors.fill: parent
source: "images/launcherBackground.png"
Text {
id: launcherVersion
x: 8
y: 8
color: "#ffffff"
text: Loc.t.app.label.version.arg(Qt.application.version)
font.pixelSize: 12
}
// Канал сборки из суффикса версии (cmake/LauncherVersion.cmake): у релиза бейджа нет
Rectangle {
id: channelBadge
readonly property var channel: /-(dev|rc)/.exec(Qt.application.version)
visible: channel !== null
anchors.left: launcherVersion.right
anchors.leftMargin: 6
anchors.verticalCenter: launcherVersion.verticalCenter
width: channelLabel.implicitWidth + 8
height: channelLabel.implicitHeight + 2
radius: 3
color: channel && channel[1] === "rc" ? "#c08a1e" : "#b03a3a"
Text {
id: channelLabel
anchors.centerIn: parent
text: channelBadge.channel ? channelBadge.channel[1].toUpperCase() : ""
color: "#ffffff"
font.pixelSize: 10
font.bold: true
}
}
}
// ── Status toast ───────────────────────────────────────────────────────
@@ -227,10 +192,6 @@ Window {
rotation: 0
editable: false
hoverEnabled: true
// Пока идёт загрузка или установка, профиль и сборку не меняем:
// единственное доступное действие — отмена на панели загрузки.
enabled: !backend.busy && !backend.gameRunning
opacity: enabled ? 1.0 : 0.45
model: backend.profileNames
@@ -416,6 +377,8 @@ Window {
}
// ── Активная сборка ────────────────────────────────────────────────────
// Кнопка занимает место бывшего выпадающего списка версий: и сама сборка,
// и её содержимое правятся теперь в модальном окне.
Button {
id: buildButton
x: 624
@@ -428,7 +391,6 @@ Window {
anchors.bottomMargin: -218
hoverEnabled: true
enabled: !backend.gameRunning && !backend.switching
opacity: enabled ? 1.0 : 0.45
contentItem: Text {
text: backend.activeBuildName !== "" ? backend.activeBuildName
@@ -502,7 +464,6 @@ Window {
anchors.verticalCenter: folderButton.verticalCenter
anchors.leftMargin: 8
hoverEnabled: true
enabled: !backend.busy && !backend.gameRunning
background: Image {
anchors.fill: parent
+12
View File
@@ -4,12 +4,19 @@ import QtQuick
import QtQuick.Controls 2.15
import QtWebEngine
// Вход в аккаунт Microsoft — то же самое, что делает официальный лаунчер:
// страница входа показывается внутри приложения, а лаунчер ждёт, когда браузер
// уйдёт на redirect_uri с кодом авторизации в адресе.
Dialog {
id: msLogin
required property var backend
// Окно ничего не знает про тосты главного окна: о неудаче сообщает сигналом.
signal failed(string message)
// Код уже отдан backend — второй раз по тому же адресу не реагируем:
// WebEngineView успевает сообщить об urlChanged несколько раз.
property bool codeTaken: false
anchors.centerIn: parent
@@ -25,6 +32,8 @@ Dialog {
open()
}
// Разбор адреса живёт в C++: правила совпадения должны совпадать с теми,
// по которым сервис строит сам redirect_uri.
function handleUrl(url) {
if (codeTaken)
return
@@ -67,6 +76,9 @@ Dialog {
}
contentItem: Item {
// Профиль без storageName — значит без диска: куки живут только пока
// работает лаунчер и в общий браузер не попадают. За выбор аккаунта в
// пределах сессии отвечает prompt=select_account в адресе входа.
WebEngineProfilePrototype {
id: msProfile
}
+4
View File
@@ -1,12 +1,16 @@
import QtQuick
import QtQuick.Controls 2.15
// Плашка хода долгой операции в левом нижнем углу: не задевает кнопку запуска,
// сообщение по центру и кнопки папки с настройками.
Rectangle {
id: panel
property string title: ""
property string status: ""
// -1 — итог ещё неизвестен, показываем «…» вместо процентов.
property double fraction: -1
// Необязательная вторая строка: мегабайты у загрузки, путь у архивации.
property string detail: ""
property bool cancellable: true
+1 -32
View File
@@ -1,34 +1,3 @@
# BiLauncher
Самописный простой лаунчер для Minecraft без рекламы и лишнего мусора.
<img src="https://projects-live.galeonworx.com/galeon/BiLauncher/raw/branch/master/Screenshots/menu.png" alt="drawing" width="65%"/>
# Основной функционал
<h3>Возможность гибкой настройки параметров запуска игры с динамическим выбором версии Java под каждую версию Minecraft</h3>
<p float="left">
<img src="https://projects-live.galeonworx.com/galeon/BiLauncher/raw/branch/master/Screenshots/settings.png" alt="drawing" width="45%"/>
<img src="https://projects-live.galeonworx.com/galeon/BiLauncher/raw/branch/master/Screenshots/javaSelect.png" alt="drawing" width="52%"/>
</p>
<h3>Сохранение профилей пользователей и авторизация тремя доступными способами</h3>
<p float="left">
<img src="https://projects-live.galeonworx.com/galeon/BiLauncher/raw/branch/master/Screenshots/profiles.png" alt="drawing" width="45%"/>
</p>
<h3>Сборки: централизованное управление вашей сборкой с возможностью выбора любой версии игры, добавления модлоадеров и сохранение каждой сборки без необходимости очищать .minecraft</h3>
<p float="left">
<img src="https://projects-live.galeonworx.com/galeon/BiLauncher/raw/branch/master/Screenshots/versions.png" alt="drawing" width="65%"/>
<img src="https://projects-live.galeonworx.com/galeon/BiLauncher/raw/branch/master/Screenshots/versionsLoaders.png" alt="drawing" width="65%"/>
<img src="https://projects-live.galeonworx.com/galeon/BiLauncher/raw/branch/master/Screenshots/versionsSelect.png" alt="drawing" width="65%"/>
</p>
<h3>Сезонные сборки: централизованное управление сборками со стороны команды разработчика, полная автоматизация скачивания, установки и разрешения зависимостей для конечных пользователей</h3>
<p float="left">
<img src="https://projects-live.galeonworx.com/galeon/BiLauncher/raw/branch/master/Screenshots/seasonal.png" alt="drawing" width="75%"/>
</p>
Самописный простой лаунчер для Minecraft без рекламы и ненужного мусора.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 307 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 225 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 245 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 244 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 219 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 225 KiB

+29 -3
View File
@@ -3,11 +3,23 @@ pragma ComponentBehavior: Bound
import QtQuick
import QtQuick.Controls 2.15
// Готовые сезонные сборки с сервера лаунчера: таблица со всем, что нужно
// решить, ставить её или нет, и одна кнопка, которая делает всё остальное —
// заводит сборку, ставит версию игры, модлоадер, Java и раскладывает файлы.
//
// Таблица собрана из Row фиксированных колонок, а не из TableView: в проекте
// нет ни одной модели QAbstractItemModel, а строки приходят готовыми
// QVariantMap — заводить ради семи колонок отдельную модель незачем.
Dialog {
id: seasons
required property var backend
// Выбранная строка. Хранится по id, а не по индексу: список обновляется
// под руками, и индекс после обновления указывал бы на другую сборку.
property string selectedId: ""
// Ширины колонок в одном месте: их повторяют и шапка, и делегат.
readonly property var columns: [
{ key: "name", title: Loc.t.common.header.name, width: 250, align: Text.AlignLeft },
{ key: "minecraftVersion", title: Loc.t.common.header.version, width: 90, align: Text.AlignLeft },
@@ -22,9 +34,11 @@ Dialog {
padding: 0
width: 960
height: 560
// Закрывается и во время установки: отменить её можно только на панели
// загрузки главного окна, а модальное окно эту панель перекрывает.
closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
// Пока идёт установка, окно закрывается только кнопкой: случайный щелчок
// мимо не должен спрятать единственную видимую отмену.
closePolicy: seasons.backend.seasonalInstalling
? Popup.NoAutoClose
: (Popup.CloseOnEscape | Popup.CloseOnPressOutside)
background: Rectangle {
color: "#1e1e1e"
@@ -33,6 +47,8 @@ Dialog {
border.width: 1
}
// Строки приходят из C++ уже отсортированными и сведёнными с локальными
// записями — здесь только показ.
readonly property var entries: seasons.backend.seasonalCatalog
readonly property var selectedEntry: {
@@ -63,6 +79,7 @@ Dialog {
return String(entry[key])
}
// Свежий кэш отвечает без сети, поэтому дёргаем при каждом открытии.
onAboutToShow: seasons.backend.refreshSeasonalCatalog(false)
header: Item {
@@ -84,6 +101,7 @@ Dialog {
contentItem: Item {
// ── Шапка таблицы ───────────────────────────────────────────────
Row {
id: tableHeader
anchors.top: parent.top
@@ -121,6 +139,9 @@ Dialog {
color: "#333333"
}
// ── Сообщение вместо таблицы ────────────────────────────────────
// Пустой список и ошибка выглядят одинаково пустыми, поэтому причина
// показывается прямо на месте строк.
Text {
anchors.centerIn: parent
width: parent.width - 80
@@ -136,6 +157,7 @@ Dialog {
: seasons.backend.seasonalCatalogError)
}
// ── Строки ──────────────────────────────────────────────────────
ListView {
id: buildList
anchors.top: headerLine.bottom
@@ -221,6 +243,8 @@ Dialog {
color: "#333333"
}
// Описание и размер выбранной сборки: они длинные и в таблицу не
// помещаются, а решение принимается по ним.
Column {
anchors.left: parent.left
anchors.leftMargin: 18
@@ -290,6 +314,8 @@ Dialog {
id: installButton
width: 150
height: 36
// Установка занимает и панель загрузки, и .minecraft целиком:
// пока лаунчер занят чем угодно, вторую начинать нельзя.
enabled: seasons.selectedEntry !== null && !seasons.backend.busy
contentItem: Text {
text: seasons.selectedEntry && seasons.selectedEntry.updateAvailable
+22
View File
@@ -4,16 +4,25 @@ import QtQuick
import QtQuick.Controls 2.15
import QtQuick.Layouts 2.15
// Выбор версии Minecraft отдельным окном: слева категории, справа сами версии
// с поиском сверху.
//
// Раньше это был выпадающий список на всю тысячу версий: найти в нём бету 1.7
// можно было только поиском по точному номеру. Категории делят список на
// обозримые части, а поиск работает внутри выбранной.
Dialog {
id: picker
required property var backend
// Версия, с которой окно открылось: по «Отмене» выбор к ней и возвращается.
property string selectedId: ""
property string category: "release"
property string filterText: ""
property bool installedOnly: false
// Выбранная версия подтверждена кнопкой или двойным щелчком. Имя не
// accepted(): такой сигнал у Dialog уже есть, и переопределить его нельзя.
signal versionChosen(string versionId)
modal: true
@@ -37,6 +46,8 @@ Dialog {
{ key: "other", title: Loc.t.version.filter.other }
]
// Каталог приходит из C++ уже отсортированным (новые сверху), поэтому здесь
// только отбор — порядок не трогаем.
readonly property var visibleEntries: {
const query = picker.filterText.trim().toLowerCase()
const source = picker.backend.versionCatalog
@@ -58,6 +69,7 @@ Dialog {
picker.selectedId = versionId
picker.filterText = ""
searchField.text = ""
// Открываемся на категории текущей версии, а не всегда на релизах.
picker.category = picker.categoryOf(versionId)
picker.backend.refreshVersionCatalog()
picker.open()
@@ -105,6 +117,9 @@ Dialog {
return false
}
// Спрашиваем до удаления: версию придётся качать заново, а профили
// модлоадеров поверх неё — переустанавливать. Id запоминаем здесь:
// к моменту ответа строка под курсором может быть уже другой.
function askRemove(versionId) {
const info = picker.backend.versionRemovalInfo(versionId)
if (!info || info.installed !== true)
@@ -118,6 +133,8 @@ Dialog {
function performRemove(versionId) {
picker.backend.removeVersion(versionId)
// Профиль модлоадера был в каталоге только потому, что установлен:
// после удаления строка исчезает совсем, и выбор надо снять.
if (picker.selectedId === versionId && !picker.catalogHas(versionId))
picker.selectedId = ""
picker.revealSelected()
@@ -288,6 +305,7 @@ Dialog {
contentItem: RowLayout {
spacing: 0
// ── Категории ───────────────────────────────────────────────────
Rectangle {
Layout.preferredWidth: 150
Layout.fillHeight: true
@@ -340,6 +358,7 @@ Dialog {
color: "#333333"
}
// ── Версии ──────────────────────────────────────────────────────
Item {
Layout.fillWidth: true
Layout.fillHeight: true
@@ -474,6 +493,8 @@ Dialog {
anchors.verticalCenter: parent.verticalCenter
}
// Версия весит от десятков мегабайт, а галочка рядом —
// единственное место, где видно, что именно скачано.
Image {
visible: versionRow.modelData.installed
source: "images/Trash.svg"
@@ -510,6 +531,7 @@ Dialog {
}
}
// Пустая категория, ничего не нашлось или манифест ещё не пришёл.
Text {
anchors.centerIn: versionList
width: versionList.width - 20
-107
View File
@@ -1,107 +0,0 @@
#!/usr/bin/env bash
# Подготовка Linux-раннера Gitea Actions для сборки BiLauncher (Ubuntu 22.04).
#
# Qt (~/Qt/6.11.1/gcc_64 с WebEngine), CMake и Ninja (~/Qt/Tools) ставятся заранее
# через Qt Online Installer — скрипт их не трогает, а докачивает только то, чего Qt
# не даёт: компилятор, системные библиотеки для сборки и деплоя, Node.js (для
# actions/checkout), appimagetool и сам gitea-runner. Повторный запуск безопасен.
#
# Запуск от пользователя, которому принадлежит Qt (нужен sudo без пароля):
# ./setup-runner-linux.sh [токен регистрации]
set -euo pipefail
TOKEN="${1:-}"
INSTANCE="${INSTANCE:-https://projects-live.galeonworx.com}"
RUNNER_NAME="${RUNNER_NAME:-linux-vm}"
RUNNER_VERSION="${RUNNER_VERSION:-4.1.0}"
QT_ROOT="${QT_ROOT:-$HOME/Qt/6.11.1/gcc_64}"
QT_TOOLS="${QT_TOOLS:-$HOME/Qt/Tools}"
RDIR="$HOME/act_runner"
[ -d "$QT_ROOT/lib/cmake/Qt6WebEngineQuick" ] || { echo "Нет Qt с WebEngine в $QT_ROOT" >&2; exit 1; }
# --- Компилятор и библиотеки ---------------------------------------------------
# libgl1-mesa-dev — заголовки OpenGL для Qt6::Gui; xcb-* и прочие — то, что
# Linux-деплой Qt и плагин xcb ищут при упаковке и запуске; libfuse2 — для
# запуска самого 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 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 \
libnss3 libxcomposite1 libxdamage1 libxrandr2 libxtst6 libxkbfile1 libxshmfence1 libasound2
# --- Node.js LTS ---------------------------------------------------------------
if ! command -v node >/dev/null; then
NODE_VER=$(curl -fsSL https://nodejs.org/dist/index.json \
| python3 -c 'import json,sys; print(next(r["version"] for r in json.load(sys.stdin) if r["lts"]))')
curl -fsSL "https://nodejs.org/dist/$NODE_VER/node-$NODE_VER-linux-x64.tar.xz" -o /tmp/node.tar.xz
sudo rm -rf /opt/node && sudo mkdir -p /opt/node
sudo tar -xJf /tmp/node.tar.xz -C /opt/node --strip-components=1
sudo ln -sf /opt/node/bin/node /opt/node/bin/npm /opt/node/bin/npx /usr/local/bin/
fi
# --- appimagetool ----------------------------------------------------------------
if ! command -v appimagetool >/dev/null; then
sudo curl -fsSL -o /usr/local/bin/appimagetool \
https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage
sudo chmod +x /usr/local/bin/appimagetool
fi
# --- gitea-runner ------------------------------------------------------------------
mkdir -p "$RDIR/work"
if [ ! -x "$RDIR/gitea-runner" ]; then
curl -fsSL -o "$RDIR/gitea-runner" \
"https://gitea.com/gitea/runner/releases/download/v$RUNNER_VERSION/gitea-runner-$RUNNER_VERSION-linux-amd64"
chmod +x "$RDIR/gitea-runner"
fi
cat > "$RDIR/config.yaml" <<EOF
log:
level: info
runner:
file: $RDIR/.runner
capacity: 1
timeout: 3h
fetch_timeout: 5s
fetch_interval: 2s
envs:
QT_ROOT: $QT_ROOT
QT_TOOLS: $QT_TOOLS
labels:
- "linux:host"
cache:
enabled: false
host:
workdir_parent: $RDIR/work
EOF
if [ -n "$TOKEN" ] && [ ! -f "$RDIR/.runner" ]; then
(cd "$RDIR" && ./gitea-runner register --no-interactive --instance "$INSTANCE" \
--token "$TOKEN" --name "$RUNNER_NAME" --labels "linux:host" --config config.yaml)
fi
# --- systemd: раннер работает от владельца Qt, а не от root ------------------------
sudo tee /etc/systemd/system/gitea-runner.service >/dev/null <<EOF
[Unit]
Description=Gitea Actions runner (BiLauncher)
After=network-online.target
Wants=network-online.target
[Service]
User=$USER
WorkingDirectory=$RDIR
Environment=PATH=/usr/local/bin:/usr/bin:/bin
ExecStart=$RDIR/gitea-runner daemon --config $RDIR/config.yaml
Restart=always
RestartSec=10
[Install]
WantedBy=multi-user.target
EOF
sudo systemctl daemon-reload
sudo systemctl enable --now gitea-runner
echo "Готово: $(systemctl is-active gitea-runner)"
-143
View File
@@ -1,143 +0,0 @@
# Подготовка Windows-раннера Gitea Actions для сборки BiLauncher.
#
# Qt (C:\Qt\6.11.1\msvc2022_64 с WebEngine), CMake и Ninja (C:\Qt\Tools) ставятся
# заранее через Qt Online Installer — скрипт их не трогает, а докачивает только то,
# чего Qt не даёт: компилятор MSVC, git, Node.js (для actions/checkout), Inno Setup
# и сам gitea-runner. Повторный запуск безопасен: уже установленное пропускается.
#
# Запуск (PowerShell от администратора):
# powershell -ExecutionPolicy Bypass -File setup-runner-windows.ps1 [-Token <токен регистрации>]
param(
[string]$Token = "",
[string]$Instance = "https://projects-live.galeonworx.com",
[string]$RunnerName = "win-vm",
[string]$RunnerVersion = "4.1.0",
[string]$QtRoot = "C:\Qt\6.11.1\msvc2022_64",
[string]$QtTools = "C:\Qt\Tools"
)
$ErrorActionPreference = "Stop"
$ProgressPreference = "SilentlyContinue" # иначе Invoke-WebRequest падает без консоли (по SSH)
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
$dl = "C:\ci-downloads"
New-Item -ItemType Directory -Force -Path $dl | Out-Null
function Fetch($url, $name) {
$out = Join-Path $dl $name
if (-not (Test-Path $out)) {
Write-Host "Скачиваю $url"
Invoke-WebRequest -UseBasicParsing -Uri $url -OutFile $out
}
return $out
}
function Run($exe, $argList) {
$p = Start-Process -FilePath $exe -ArgumentList $argList -Wait -PassThru -NoNewWindow
# 3010 — «успешно, нужна перезагрузка» у установщиков MSI и VS
if ($p.ExitCode -ne 0 -and $p.ExitCode -ne 3010) { throw "$exe завершился с кодом $($p.ExitCode)" }
}
# --- Visual Studio 2022 Build Tools (MSVC x64 + Windows SDK) -----------------
$vswhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe"
$haveMsvc = (Test-Path $vswhere) -and
(& $vswhere -products * -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath)
if (-not $haveMsvc) {
$vs = Fetch "https://aka.ms/vs/17/release/vs_BuildTools.exe" "vs_BuildTools.exe"
Write-Host "Ставлю VS 2022 Build Tools (20–40 минут)…"
Run $vs @("--quiet", "--wait", "--norestart", "--nocache",
"--add", "Microsoft.VisualStudio.Workload.VCTools", "--includeRecommended")
} else { Write-Host "MSVC уже установлен" }
# --- Git for Windows -----------------------------------------------------------
if (-not (Test-Path "C:\Program Files\Git\cmd\git.exe")) {
$rel = Invoke-RestMethod "https://api.github.com/repos/git-for-windows/git/releases/latest"
$asset = $rel.assets | Where-Object { $_.name -match '^Git-.*-64-bit\.exe$' } | Select-Object -First 1
$git = Fetch $asset.browser_download_url $asset.name
Run $git @("/VERYSILENT", "/NORESTART", "/NOCANCEL", "/SP-", "/COMPONENTS=gitlfs")
} else { Write-Host "Git уже установлен" }
# --- Node.js LTS (нужен JS-экшенам вроде actions/checkout) ---------------------
if (-not (Test-Path "C:\Program Files\nodejs\node.exe")) {
# Скобки вокруг Invoke-RestMethod обязательны: PowerShell 5.1 иначе отдаёт
# JSON-массив в конвейер одним объектом, и фильтр видит все версии сразу.
$lts = ((Invoke-RestMethod "https://nodejs.org/dist/index.json") | Where-Object { $_.lts } | Select-Object -First 1).version
$msi = Fetch "https://nodejs.org/dist/$lts/node-$lts-x64.msi" "node-$lts-x64.msi"
Run "msiexec.exe" @("/i", "`"$msi`"", "/qn", "/norestart")
} else { Write-Host "Node.js уже установлен" }
# --- Inno Setup ----------------------------------------------------------------
$iscc = Get-ChildItem "C:\Program Files*\Inno Setup*\ISCC.exe" -ErrorAction SilentlyContinue | Select-Object -First 1
if (-not $iscc) {
# jrsoftware.org/download.php без браузера отдаёт HTML — берём файл из релизов на GitHub
$rel = Invoke-RestMethod "https://api.github.com/repos/jrsoftware/issrc/releases/latest"
$asset = $rel.assets | Where-Object { $_.name -match '^innosetup-[\d.]+(-x64)?\.exe$' } |
Sort-Object { $_.name -notmatch '-x64' } | Select-Object -First 1
$is = Fetch $asset.browser_download_url $asset.name
Run $is @("/VERYSILENT", "/SUPPRESSMSGBOXES", "/NORESTART", "/ALLUSERS")
$iscc = Get-ChildItem "C:\Program Files*\Inno Setup*\ISCC.exe" | Select-Object -First 1
} else { Write-Host "Inno Setup уже установлен" }
# --- Длинные пути: дерево WebEngine легко вылезает за 260 символов --------------
Set-ItemProperty HKLM:\SYSTEM\CurrentControlSet\Control\FileSystem -Name LongPathsEnabled -Value 1
& "C:\Program Files\Git\cmd\git.exe" config --system core.longpaths true
# --- gitea-runner ----------------------------------------------------------------
$rdir = "C:\act_runner"
New-Item -ItemType Directory -Force -Path $rdir, "C:\r" | Out-Null
$exe = Join-Path $rdir "gitea-runner.exe"
if (-not (Test-Path $exe)) {
$src = Fetch "https://gitea.com/gitea/runner/releases/download/v$RunnerVersion/gitea-runner-$RunnerVersion-windows-amd64.exe" "gitea-runner-$RunnerVersion.exe"
Copy-Item $src $exe
}
# Конфиг: host-режим, рабочая папка с коротким путём, пути к Qt через env.
$isccDir = Split-Path $iscc.FullName
$cfg = @"
log:
level: info
runner:
file: $($rdir -replace '\\','/')/.runner
capacity: 1
timeout: 3h
fetch_timeout: 5s
fetch_interval: 2s
envs:
QT_ROOT: '$QtRoot'
QT_TOOLS: '$QtTools'
ISCC_DIR: '$isccDir'
labels:
- "windows:host"
cache:
enabled: false
host:
workdir_parent: C:/r
"@
Set-Content -Path (Join-Path $rdir "config.yaml") -Value $cfg -Encoding UTF8
if ($Token -and -not (Test-Path (Join-Path $rdir ".runner"))) {
Push-Location $rdir
& $exe register --no-interactive --instance $Instance --token $Token `
--name $RunnerName --labels "windows:host" --config config.yaml
if ($LASTEXITCODE -ne 0) { throw "Регистрация раннера не удалась" }
Pop-Location
}
# Обёртка запуска: PATH дополняется git и node — задача планировщика может
# стартовать раньше, чем сессия получит обновлённый системный PATH.
$cmd = @"
@echo off
set PATH=C:\Program Files\Git\cmd;C:\Program Files\Git\usr\bin;C:\Program Files\nodejs;%PATH%
cd /d $rdir
gitea-runner.exe daemon --config config.yaml >> runner.log 2>&1
"@
Set-Content -Path (Join-Path $rdir "run-runner.cmd") -Value $cmd -Encoding ASCII
# Автозапуск при входе galeon: так задаче не нужен пароль учётной записи.
# Чтобы раннер поднимался и без входа, пересоздайте задачу с /sc onstart /rp *.
schtasks /create /f /tn "Gitea Runner" /sc onlogon /ru $env:USERNAME /it /rl highest `
/tr "powershell.exe -WindowStyle Hidden -NoProfile -Command $rdir\run-runner.cmd" | Out-Null
if (-not (Get-Process gitea-runner -ErrorAction SilentlyContinue)) { schtasks /run /tn "Gitea Runner" | Out-Null }
Write-Host "Готово. MSVC, git, node, Inno Setup и gitea-runner на месте."
-101
View File
@@ -1,101 +0,0 @@
# Полная версия лаунчера и канал сборки (dev / rc / release).
#
# В репозитории хранится только число X.Y.Z из project(), одинаковое во всех
# ветках, — поэтому merge dev → rc → master не трогает версию. Канал
# вычисляется при сборке (по убыванию приоритета):
# 1. LAUNCHER_BUILD_VERSION — полная версия от CI: 1.0.1, 1.0.1-rc2;
# 2. тег на HEAD: vX.Y.Z — релиз, vX.Y.Z-rcN — rc;
# 3. ветка (из переменных CI или git): master — релиз, rc/beta — rc;
# 4. всё остальное, в т.ч. сборка без git, — dev.
#
# Работает в двух режимах:
# include() при configure — проверяет входные данные и пишет заголовок;
# cmake -P на каждой сборке — чтобы после git checkout без reconfigure
# канал и хэш коммита не устаревали.
# Входные переменные: LAUNCHER_VERSION_NUMERIC, LAUNCHER_SOURCE_DIR,
# LAUNCHER_VERSION_HEADER, LAUNCHER_BUILD_VERSION (необязательна).
# Результат: LAUNCHER_VERSION, LAUNCHER_CHANNEL и файл LAUNCHER_VERSION_HEADER.
set(_numeric "${LAUNCHER_VERSION_NUMERIC}")
find_package(Git QUIET)
function(_launcher_git out)
set(${out} "" PARENT_SCOPE)
if(NOT GIT_EXECUTABLE)
return()
endif()
execute_process(COMMAND "${GIT_EXECUTABLE}" ${ARGN}
WORKING_DIRECTORY "${LAUNCHER_SOURCE_DIR}"
RESULT_VARIABLE _rc OUTPUT_VARIABLE _out ERROR_QUIET
OUTPUT_STRIP_TRAILING_WHITESPACE)
if(_rc EQUAL 0)
set(${out} "${_out}" PARENT_SCOPE)
endif()
endfunction()
set(_full "")
if(LAUNCHER_BUILD_VERSION)
set(_full "${LAUNCHER_BUILD_VERSION}")
string(REGEX REPLACE "^v" "" _full "${_full}")
string(REGEX REPLACE "-.*$" "" _base "${_full}")
if(NOT _base STREQUAL _numeric)
message(FATAL_ERROR "LAUNCHER_BUILD_VERSION=${LAUNCHER_BUILD_VERSION} не совпадает "
"с project(BiLauncher VERSION ${_numeric})")
endif()
else()
# Тег vX.Y.Z или vX.Y.Z-rcN ровно на HEAD, относящийся к текущей версии
_launcher_git(_tags tag --points-at HEAD)
string(REPLACE "\n" ";" _tags "${_tags}")
string(REPLACE "." "\\." _numeric_re "${_numeric}")
foreach(_tag IN LISTS _tags)
if(_tag MATCHES "^v(${_numeric_re}(-rc[0-9]+)?)$")
# Релизный тег важнее rc-тега на том же коммите
if(NOT _full OR NOT CMAKE_MATCH_2)
set(_full "${CMAKE_MATCH_1}")
endif()
endif()
endforeach()
endif()
if(_full)
if(_full MATCHES "-rc[0-9]*$")
set(LAUNCHER_CHANNEL "rc")
elseif(_full STREQUAL _numeric)
set(LAUNCHER_CHANNEL "release")
else()
set(LAUNCHER_CHANNEL "dev")
endif()
set(LAUNCHER_VERSION "${_full}")
else()
# В CI на detached HEAD имени ветки в git нет — берём его из окружения
set(_branch "$ENV{GITHUB_HEAD_REF}")
if(NOT _branch AND "$ENV{GITHUB_REF_TYPE}" STREQUAL "branch")
set(_branch "$ENV{GITHUB_REF_NAME}")
endif()
if(NOT _branch)
_launcher_git(_branch rev-parse --abbrev-ref HEAD)
endif()
if(_branch STREQUAL "master")
set(LAUNCHER_CHANNEL "release")
set(LAUNCHER_VERSION "${_numeric}")
elseif(_branch STREQUAL "rc" OR _branch STREQUAL "beta")
set(LAUNCHER_CHANNEL "rc")
set(LAUNCHER_VERSION "${_numeric}-rc")
else()
set(LAUNCHER_CHANNEL "dev")
set(LAUNCHER_VERSION "${_numeric}-dev")
_launcher_git(_hash rev-parse --short=7 HEAD)
if(_hash)
# Неотслеживаемые файлы (каталоги сборки и т.п.) не считаем
execute_process(COMMAND "${GIT_EXECUTABLE}" diff --quiet HEAD --
WORKING_DIRECTORY "${LAUNCHER_SOURCE_DIR}" RESULT_VARIABLE _dirty ERROR_QUIET)
if(NOT _dirty EQUAL 0)
set(_hash "${_hash}.dirty")
endif()
string(APPEND LAUNCHER_VERSION "+${_hash}")
endif()
endif()
endif()
configure_file("${CMAKE_CURRENT_LIST_DIR}/launcherversion.h.in" "${LAUNCHER_VERSION_HEADER}" @ONLY)
-7
View File
@@ -1,7 +0,0 @@
#pragma once
// Сгенерировано cmake/LauncherVersion.cmake — не редактировать.
#define LAUNCHER_VERSION "@LAUNCHER_VERSION@" // 1.0.1, 1.0.1-rc2, 1.0.1-dev+a1b2c3d
#define LAUNCHER_VERSION_NUMERIC "@LAUNCHER_VERSION_NUMERIC@"
#define LAUNCHER_CHANNEL "@LAUNCHER_CHANNEL@" // dev, rc, release
BIN
View File
Binary file not shown.
-220
View File
@@ -1,220 +0,0 @@
# Сборка, упаковка и релизы
## Что нужно
- 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)` в `CMakeLists.txt`. Оно
одинаковое во всех ветках, поэтому merge `dev` → `rc` → `master` версию не перетирает и не даёт
конфликтов. `PROJECT_VERSION` идёт в ресурс версии `.exe`, в `Info.plist` и в установщик: все три
принимают только числовую версию.
Канал (dev / rc / релиз) в коде не записан — его на каждой сборке вычисляет
`cmake/LauncherVersion.cmake` и пишет в сгенерированный `<build>/generated/launcherversion.h`
(`LAUNCHER_VERSION`, `LAUNCHER_CHANNEL`). Порядок определения:
1. `-DLAUNCHER_BUILD_VERSION=1.0.1-rc2` — полная версия от CI; база обязана совпадать с
`project()`, иначе configure падает;
2. тег на HEAD: `vx.y.z` — релиз, `vx.y.z-rcN` — rc;
3. ветка (из `GITHUB_HEAD_REF`/`GITHUB_REF_NAME` или git): `master` — релиз, `rc` и `beta` — rc;
4. любая другая ветка или сборка без git — dev.
| Сборка | `LAUNCHER_VERSION` | В окне |
|---------------------------|----------------------|-----------------|
| релиз (master, `vx.y.z`) | `1.0.1` | `Версия: 1.0.1` |
| rc из CI (`vx.y.z-rcN`) | `1.0.1-rc2` | + бейдж RC |
| rc локально | `1.0.1-rc` | + бейдж RC |
| dev | `1.0.1-dev+a1b2c3d` | + бейдж DEV |
У dev-сборки после `+` — короткий хэш коммита, `.dirty` — есть незакоммиченные правки в
отслеживаемых файлах. Генерация идёт на каждой сборке, так что после `git checkout` другой
ветки reconfigure не нужен.
Полная строка уходит в `QCoreApplication::applicationVersion`, в аргументы запуска игры и в
подпись «Версия: …» в левом верхнем углу окна; бейдж канала QML берёт из её суффикса. Почему так,
см. [ADR-0002](adr/0002-launcher-version-display.md).
**Когда поднимать версию.** Номер поднимается в `dev` в начале цикла (сразу после релиза) и
дальше приходит в `rc` и `master` через merge. Хотфикс прямо в `rc` или `master` — правка и bump
patch там же, затем обратный merge `master` → `rc` → `dev`.
## Что собирается
**`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] — дата`. Его текст станет описанием релиза.
Для пре-релиза можно завести отдельный раздел `## [x.y.z-rcN]`: он берётся в первую очередь,
а если его нет — используется `## [x.y.z]`.
3. Запустить релиз одним из способов:
- **Пуш в `master`** — полноценный релиз `vx.y.z`. Если релиз или тег с этой версией уже есть,
прогон ничего не собирает: сначала поднимите версию.
- **Пуш в `rc`** — пре-релиз `vx.y.z-rcN`, где `N` — следующий свободный номер для этой версии
(`rc1`, `rc2`, ...).
- **Тег `vx.y.z`** — полноценный релиз, **тег `vx.y.z-rcN`** — пре-релиз. Пре-релизом
становится только тег с суффиксом `-rc`, любой другой суффикс даёт обычный релиз.
Если тег не совпадает с версией в `CMakeLists.txt` (без учёта суффикса), прогон сразу падает.
Для веток тег создаёт сам Gitea при публикации релиза, на коммите прогона. Этот тег снова
запускает `release.yml`, но прогон видит, что релиз уже есть, и ничего не собирает.
### Что происходит
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).
---
При создании этого документа использовался ИИ.
+141 -58
View File
@@ -1,87 +1,170 @@
# AuthService
`authservice.h`, `authservice.cpp`
## Обзор класса
Вход через Ely.by, офлайн-профиль и загрузка `authlib-injector`. Вход через Microsoft живёт
отдельно, в [MsaAuthService](MsaAuthService.md), но отдаёт результат в той же структуре
`AuthResult`. Поэтому запуск игры дальше идёт одинаково, как бы игрок ни вошёл.
`Minecraft_launcher` поддерживает три способа входа: офлайн-профиль без пароля, учётную запись
Ely.by и учётную запись Microsoft. `AuthService` закрывает первые два: это Yggdrasil-клиент Ely.by
плюс офлайн-режим. За третий отвечает [MsaAuthService](MsaAuthService.md).
Экземпляр создаёт и держит [LauncherBackend](LauncherBackend.md). Сервис ничего не пишет на диск,
кроме скачанного jar: токены между запусками сохраняет бэкенд в `profiles.json`.
Результат любого способа — структура `AuthResult`, объявленная в этом же заголовке. Она содержит
ровно то, что подставляется в аргументы запуска вида `${auth_*}`, поэтому дальше запуск игры идёт
по общему пути независимо от того, как пользователь вошёл.
## AuthResult
Класс также умеет скачивать `authlib-injector` — библиотеку, которая перенаправляет обращения игры
к серверу авторизации на Ely.by.
Каждое поле соответствует подстановке в аргументах запуска.
## Место в проекте и зависимости
| Поле | Подстановка / смысл |
|------|---------------------|
| `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 |
Экземпляр создаётся и принадлежит [LauncherBackend](LauncherBackend.md); других владельцев нет.
Заголовок подключает [MsaAuthService](MsaAuthService.md) — ради общей структуры `AuthResult` — и
[GameLauncher](GameLauncher.md) через поля `LaunchOptions`, которые заполняются из результата
авторизации.
## Офлайн
Требования сборки: `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
## Публичные структуры
Сервер — `https://authserver.ely.by`, протокол Yggdrasil, JSON по HTTPS.
### AuthResult
`loginElyBy(login, password, clientToken, accessToken, callback)` старается не спрашивать пароль:
Результат авторизации — то, что подставляется в `${auth_*}` аргументы запуска.
1. Если сохранённого `accessToken` нет, сразу `/auth/authenticate` с паролем.
2. Если есть, `/auth/validate`. Ответ 200 — токен рабочий, вход закончен.
3. Иначе `/auth/refresh`. Если продлить удалось, приходят новый токен, ник и UUID.
4. Если и это не вышло, `/auth/authenticate` с паролем.
| Поле | Тип | Описание |
|------|-----|----------|
| `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` |
Если сервер недоступен (статус 0 и сетевая ошибка), цепочка обрывается с ошибкой, без попытки войти
по паролю.
## Псевдонимы типов
Второй фактор Ely.by не сообщает отдельным кодом. Сервис ищет в тексте ошибки подстроку
`two factor` и выставляет `twoFactorRequired`. Тогда бэкенд спрашивает код у игрока и вызывает
`loginElyByWithTotp(login, password, totp, clientToken, callback)`, который отправляет пароль в виде
`пароль:код`, как требует Ely.by.
`AuthService::Callback` — `std::function<void(const AuthResult &)>`. Все сетевые методы
асинхронные: колбэк вызывается ровно один раз и всегда в потоке GUI.
`clientToken` генерируется один раз на профиль (`generateClientToken()`, случайный UUID) и хранится
вместе с ним: Yggdrasil привязывает выданный `accessToken` к этому значению.
## Публичные методы
По ходу работы испускается `progress(message)`. Бэкенд пересылает его в `launchProgress`.
#### explicit AuthService(QObject \*parent = nullptr)
## authlib-injector
Создаёт сервис и его `QNetworkAccessManager`. Конструктор помечен `explicit`.
Без него игра пойдёт проверять сессию на серверы Mojang, и токен Ely.by окажется недействительным.
Jar подключается к JVM через `-javaagent` (см. [GameLauncher](GameLauncher.md)).
#### static AuthResult offline(const QString &nickname)
`ensureAuthlibInjector(targetDir, callback)`:
Готовит результат для офлайн-профиля без единого сетевого запроса. UUID выводится из ника так же,
как это делает сам Minecraft в офлайне: `UUID.nameUUIDFromBytes(("OfflinePlayer:" + name)
.getBytes(UTF_8))`. Благодаря этому один и тот же ник всегда даёт один и тот же UUID, и прогресс
на сервере не теряется.
- если `<targetDir>/authlib-injector.jar` уже есть, сразу отдаёт путь. Версия не проверяется, и
обновить файл можно, только удалив его;
- иначе читает `https://authlib-injector.yushi.moe/artifact/latest.json`, скачивает jar по
`download_url` и, если в манифесте есть `checksums.sha256`, сверяет хеш.
#### static QString generateClientToken()
Колбэк получает либо путь, либо текст ошибки. `targetDir` — это `<root>/runtime`.
Случайный `clientToken` лаунчера. Генерируется один раз на профиль и хранится вместе с ним:
Yggdrasil связывает выданный `accessToken` именно с этим значением.
## Как вызываются колбэки
#### void loginElyBy(const QString &login, const QString &password, const QString &clientToken, const QString &accessToken, Callback callback)
Все сетевые методы асинхронные, колбэк вызывается один раз в GUI-потоке. Если сервис удалить во
время запроса, менеджер сети уйдёт вместе с ним, и колбэк не вызовется вообще. Бэкенд к тому же
проверяет в каждом колбэке, не отменили ли запуск, пока шёл запрос.
Полный цикл входа в Ely.by: сначала проверка имеющегося токена, затем его продление, и только при
неудаче — авторизация по паролю. Пароль можно оставить пустым, если уже есть рабочий
`accessToken`, — тогда пользователю не придётся вводить его заново.
Повторных попыток при сетевых ошибках нет.
По ходу работы испускает `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);
});
```
---
+144 -33
View File
@@ -1,54 +1,165 @@
# BuildArchiveWorker
`buildarchiveworker.h`, `buildarchiveworker.cpp`
## Обзор класса
Файловые операции над `.minecraft`: упаковать, очистить, распаковать, раскатать пак. В
`.minecraft` могут лежать гигабайты модов и миров, поэтому это единственная часть проекта,
которая работает не в GUI-потоке.
Смена сборки в лаунчере — это перекладывание содержимого `.minecraft`: текущее упаковывается в
архив, папка вычищается, на её место распаковывается архив другой сборки. Речь о гигабайтах модов,
конфигов и миров, и гонять их в потоке GUI нельзя — окно замерзало бы на всё время смены.
Объект создаёт [BuildSwitcher](BuildSwitcher.md) без родителя, переносит в свой `QThread` и вызывает
слоты через `QMetaObject::invokeMethod(..., Qt::QueuedConnection)`. Работник не знает ни про
сборки, ни про журнал. Он получает пути и отвечает сигналами.
`BuildArchiveWorker` — исполнитель этих операций в отдельном потоке. Он умеет четыре вещи:
упаковать, вычистить, распаковать и докатить пак сезонной сборки поверх уже разложенного
содержимого.
ZIP читается и пишется через `QZipReader` и `QZipWriter` из `Qt6::CorePrivate`.
Класс намеренно ничего не знает ни о сборках, ни о путях лаунчера: он принимает готовые пути и
списки. Порядок шагов и восстановление после сбоя — дело [BuildSwitcher](BuildSwitcher.md).
## Слоты
## Место в проекте и зависимости
Каждый слот заканчивается одним `finished(ok, error)`. По ходу работы испускается
`progress(done, total, currentPath)` на каждый файл.
Создаётся и целиком управляется [BuildSwitcher](BuildSwitcher.md), который же и переносит его в
собственный `QThread`. Больше к классу никто не обращается.
### archive(gameDir, tempZipPath, excludeTop)
Требования сборки: `Qt6::Core` и `Qt6::CorePrivate` — последний нужен ради `QZipReader` и
`QZipWriter`, которыми читаются и пишутся архивы сборок.
Упаковывает всё содержимое `gameDir`, кроме папок и файлов верхнего уровня из `excludeTop`.
Символьные ссылки пропускаются. Сжатие `AutoCompress`: Qt сам не сжимает то, что уже сжато.
При ошибке или отмене недописанный архив удаляется.
## Иерархия и роль
### clear(gameDir, keepTop)
Наследует `QObject`: мета-объектная система, слоты, сигналы и возможность жить в отдельном потоке
через `moveToThread()`. Виртуальных методов базового класса не переопределяет.
Удаляет всё на верхнем уровне `gameDir`, кроме `keepTop`. Символьная ссылка удаляется как файл,
то, на что она указывает, не трогается. Если `gameDir` нет, это успех. Отменить нельзя.
Объект живёт в своём `QThread` и вызывается только через очередь сигналов — прямых вызовов его
слотов из потока GUI быть не должно.
### restore(zipPath, gameDir)
## Публичные методы
Распаковывает архив в `gameDir`. Пути с `..`, абсолютные, с `\` или `:` останавливают распаковку.
#### explicit BuildArchiveWorker(QObject \*parent = nullptr)
### applyPack(zipPath, gameDir, removeRelative, forbiddenTop)
Создаёт исполнителя. Конструктор помечен `explicit`.
1. Сначала проверяет весь архив. Небезопасный путь или файл внутри общих папок (`forbiddenTop`:
`versions`, `libraries` и т. д.) — отказ до любых изменений. Пак не может подменить jar игры
или библиотеки.
2. Удаляет файлы из `removeRelative`. Опасные пути и пути в общих папках молча пропускаются. После
этого удаляются папки, которые стали пустыми.
3. Распаковывает пак, перезаписывая существующие файлы.
4. Испускает `packEntries(список файлов пака)`, потом `finished(true)`.
#### void requestCancel()
## Отмена
Просит прервать текущую операцию. Вызывается из потока GUI прямо во время работы — это
единственный метод класса, предназначенный для вызова снаружи рабочего потока.
`requestCancel()` и `clearCancel()` выставляют атомарный флаг, его можно трогать из любого потока.
Флаг проверяется перед каждым файлом в `archive`, `restore` и `applyPack`.
Отмена не прерывает операцию мгновенно: признак проверяется между файлами. Хранится он в
`QAtomicInt`, поэтому запись из одного потока и чтение из другого безопасны без блокировок.
Файлы распаковываются через `QFile`, а не `QSaveFile`. Если операцию прервать, последний файл
может остаться недописанным. Поэтому `BuildSwitcher` и не даёт отменить шаги после архивации.
#### 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));
```
---
+177 -72
View File
@@ -1,105 +1,210 @@
# BuildSwitcher
`buildswitcher.h`, `buildswitcher.cpp`
## Обзор класса
Смена активной сборки и раскатка пака сезонной сборки. Файлы двигает
[BuildArchiveWorker](BuildArchiveWorker.md) в отдельном потоке, а этот класс решает, в каком порядке
это делать, и ведёт журнал, по которому можно продолжить после падения.
`BuildSwitcher` меняет активную сборку: содержимое `.minecraft` уезжает в архив своей сборки,
папка чистится, на её место разворачивается архив выбранной. Он же докатывает пак сезонной
сборки поверх уже разложенного содержимого.
Экземпляр держит [LauncherBackend](LauncherBackend.md).
Порядок шагов подчинён одному правилу: пока новый архив не записан целиком и не переименован на
место, из `.minecraft` не удаляется ничего. Отметка о начатом переключении пишется в
`builds/index.json` до первого разрушающего действия, поэтому обрыв питания или принудительное
завершение процесса всегда обнаружим на следующем запуске.
## Как устроены сборки на диске
Сами файловые операции выполняет [BuildArchiveWorker](BuildArchiveWorker.md) в отдельном потоке;
`BuildSwitcher` — это порядок шагов, учёт состояния и восстановление после сбоя.
В `.minecraft` в каждый момент разложена одна сборка, активная. Содержимое остальных лежит в
архивах `<root>/builds/<id>/<дата>_<имя>.zip`, по одному на сборку.
## Место в проекте и зависимости
Общие для всех сборок папки и файлы никогда не архивируются и не удаляются (`sharedTopLevel()`):
Экземпляр создаётся и принадлежит [LauncherBackend](LauncherBackend.md). Класс создаёт и целиком
владеет своим `QThread` и объектом [BuildArchiveWorker](BuildArchiveWorker.md).
- `assets`
- `libraries`
- `versions`
- `launcher_profiles.json`
- `runtime`
Пути к папкам сборок и файлу состояния даёт [launcherpaths.h](launcherpaths.md)
(`buildStorageDir()`, `buildDir()`).
Всё остальное (моды, конфиги, миры, скриншоты, `options.txt`) принадлежит сборке.
Требования сборки: `Qt6::Core` (`QThread`, `QJsonObject`) и `Qt6::CorePrivate` — опосредованно,
через исполнителя.
В папке сборки могут лежать и другие zip, но файлы с точкой в начале имени не считаются архивом
сборки. Это временные архивы (`.tmp-*.zip`) и скачанные паки сезонных сборок (`.pack-r*.zip`).
## Иерархия и роль
## switchTo(fromId, fromName, toId, toName, gameDir)
Наследует `QObject`: мета-объектная система, три сигнала и владение по родителю. Объявлен
виртуальный деструктор — класс обязан корректно остановить свой поток.
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)`.
## Публичные методы
При ошибке приходит `failed(toId, message, gameDirIntact)`. `gameDirIntact` равен `true`, если
ошибка случилась до очистки. Бэкенд по нему выбирает текст: «ничего не изменилось» или «папка
игры разобрана».
#### explicit BuildSwitcher(QObject \*parent = nullptr)
`cancel()` работает только на шаге архивации. После него отступать уже некуда.
Создаёт переключатель, рабочий поток и исполнителя, переносит второй в первый и запускает поток.
Конструктор помечен `explicit`.
## applyPack(buildId, buildName, packZip, removeRelative, note, gameDir)
#### bool isRunning() const
Раскатка пака поверх активной сборки. Сначала в `index.json` пишется `state: "applying"` со всем,
что нужно для повтора: имя файла пака, имя сборки, список на удаление и `note`. Потом работник
удаляет файлы прошлой ревизии и распаковывает пак.
Идёт ли сейчас переключение или раскатка пака.
После успеха пак удаляется, `index.json` возвращается в `idle`, а `lastPackEntries()` и
`lastPackNote()` содержат список разложенных файлов и `note`. Бэкенд забирает их в обработчике
`finished` и пишет `season.json`.
#### QString stage() const
`gameDirIntact` при ошибке раскатки равен `true`, только если работник не успел обработать ни одного
файла.
Текущий этап словами — заголовок панели прогресса.
## После падения
#### QString status() const
При старте бэкенд вызывает `interruptedSwitchWarning()`. Если в `index.json` осталось
`switching` или `applying`, метод возвращает текст для пользователя, а `resumeInterrupted(gameDir)`
продолжает операцию:
Строка состояния: что обрабатывается сейчас.
- `switching` — архив прежней сборки уже записан целиком, поэтому начинаем с очистки и
распаковываем сборку `to`. Начинать с архивации нельзя: заархивировалась бы полуразобранная
`.minecraft`;
- `applying` — раскатка повторяется с тем же паком и тем же списком на удаление. Если файла пака
уже нет, журнал просто сбрасывается в `idle`.
#### double fraction() const
Если программа упала во время самой архивации, в журнале ещё ничего нет, и продолжать нечего:
`.minecraft` цела, а временный архив удалится при следующем старте (`sweepTempArchives()` в
конструкторе).
Доля выполнения от `0` до `1` либо `-1`, пока итог неизвестен.
## Прочее
#### static QStringList sharedTopLevel()
- `forgetBuild(id)` — удаляет запись о сборке из `index.json` и папку `builds/<id>` целиком.
- `hasArchive(id)` — есть ли у сборки архив.
- `isRunning()`, `stage()`, `status()` (текущий файл), `fraction()` — для панели прогресса.
Сигнал `progressChanged()` не ограничен по частоте и приходит на каждый обработанный файл.
Общие для всех сборок каталоги верхнего уровня. Они не архивируются и не чистятся: принадлежат
лаунчеру, а не какой-либо сборке. Список передаётся исполнителю как `excludeTop` при упаковке и
как `keepTop` при очистке.
### index.json
#### void switchTo(int fromBuildId, const QString &fromName, int toBuildId, const QString &toName, const QString &gameDir)
`<root>/builds/index.json`:
Переключает активную сборку. Проходит четыре этапа: упаковка текущего содержимого во временный
файл, фиксация архива переименованием на место, очистка папки игры и распаковка архива целевой
сборки.
```json
{
"state": "idle",
"active": 3,
"builds": {
"2": { "archive": "2026-10-09_Vanilla.zip", "name": "Vanilla", "savedAt": "..." }
}
Имена сборок нужны для текста в панели прогресса, идентификаторы — для путей к архивам.
#### 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);
}
```
Во время работы к нему добавляются `from`, `to`, а для раскатки ещё `pack`, `packName`,
`packRemove`, `packNote`. Пишется через `QSaveFile`.
Деструктор просит работника остановиться и ждёт поток до 5 секунд.
---
При создании этого документа использовался ИИ.
+170 -77
View File
@@ -1,106 +1,199 @@
# GameLauncher
`gamelauncher.h`, `gamelauncher.cpp`
## Обзор класса
Собирает командную строку, распаковывает нативные библиотеки и запускает JVM с игрой. К этому
моменту версия уже разобрана ([minecraftversion.h](minecraftversion.md)), файлы скачаны, а игрок
вошёл в аккаунт. Всё остальное приходит в структуре `LaunchOptions`.
`GameLauncher` — то, ради чего существует весь остальной лаунчер: он готовит и запускает JVM с
Minecraft. К моменту его вызова уже известно всё — версия разобрана, файлы скачаны, пользователь
авторизован, — и класс превращает это в командную строку и дочерний процесс.
Экземпляр один, его держит [LauncherBackend](LauncherBackend.md). Одновременно может работать
только одна игра.
Кроме самого запуска класс умеет проверять комплектность `.minecraft` и распаковывать нативные
библиотеки, без которых игра не стартует.
## 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` |
## Место в проекте и зависимости
## launch(options, version, &error)
Подключает [minecraftversion.h](minecraftversion.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 секунд. Если процесс не стартовал, отказ.
Экземпляр создаётся и принадлежит [LauncherBackend](LauncherBackend.md), который заполняет
`LaunchOptions` из настроек, выбранной сборки и результата авторизации.
Если всё прошло, метод возвращает `true` и испускает `gameStarted(commandLine)`. В этой строке
значение после `--accessToken` заменено на `<скрыт>`, чтобы токен не попал в журнал. Других
мест, где мог бы оказаться токен (например, в `${auth_session}`), замена не касается.
Требования сборки: `Qt6::Core` (`QProcess`, `QStringList`) и `Qt6::CorePrivate` — последний нужен
ради `QZipReader`, которым распаковываются нативные библиотеки LWJGL. Зависимость от приватного
модуля привязывает проект к конкретной версии Qt; в `CMakeLists.txt` это осознанный выбор, и
предупреждение о нём отключено.
Перед шагами 2–4 испускается `progress(message)`.
## Иерархия и роль
Вывод игры читается кусками и испускается построчно в `output(line)`. Когда процесс завершится,
придёт `gameFinished(exitCode, crashed)`, где `crashed` значит `QProcess::CrashExit`.
Наследует `QObject`: мета-объектная система, четыре сигнала и владение по родителю. Виртуальных
методов базового класса не переопределяет.
`terminate()` вызывает `kill()`, а не `terminate()`. Игра не успеет ничего сохранить, это то же
самое, что снять процесс в диспетчере задач.
## Публичные структуры
## missingFiles(options, version, limit = 12)
### LaunchOptions
Проверяет, что на месте папка игры, клиентский 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)
## Публичные методы
Для каждой библиотеки с `native == true` открывает jar через `QZipReader` (отсюда зависимость от
`Qt6::CorePrivate`) и вынимает файлы `.dll`, `.so`, `.so.N`, `.dylib`, `.jnilib`. Пропускаются
`META-INF/` и пути из `extract.exclude` описания версии. Все файлы кладутся в одну плоскую папку.
Если файл такого же размера уже лежит, его не переписывают.
#### explicit GameLauncher(QObject \*parent = nullptr)
## buildArguments(options, version, nativesDir)
Создаёт объект. Процесс игры при этом не запускается. Конструктор помечен `explicit`.
Порядок аргументов:
#### 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).
Идёт ли сейчас игра. От этого зависит доступность кнопки запуска в интерфейсе.
Подстановки: `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`.
#### static QStringList missingFiles(const LaunchOptions &options, const MinecraftVersion &version, int limit = 12)
Аргумент, в котором после подстановки остался `${`, выбрасывается целиком. Так отсекаются
переменные, о которых лаунчер не знает (например, связанные с демо-режимом).
Проверяет `.minecraft` на комплектность и возвращает описания недостающих файлов. Пустой список
означает, что всё на месте и сборку можно запускать.
У версий со старыми ресурсами (`legacy`, `pre-1.6`) `game_assets` указывает на
`assets/virtual/legacy`.
Параметр `limit` ограничивает длину списка: перечислять все отсутствующие файлы у неустановленной
версии бессмысленно, важен сам факт и пара примеров.
Classpath — это все не-нативные библиотеки и в конце клиентский jar.
Метод статический, ничего не меняет и вызывается интерфейсом для строки состояния сборки.
### Forge 1.17+ и `-DignoreList`
#### static QStringList buildArguments(const LaunchOptions &options, const MinecraftVersion &version, const QString &nativesDir)
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, если его там ещё нет.
Собирает аргументы ровно в том порядке, в котором их ждёт 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);
```
---
+180 -40
View File
@@ -1,60 +1,200 @@
# JavaInstaller
`javainstaller.h`, `javainstaller.cpp`
## Обзор класса
Ставит одну сборку Java из каталога [JavaRuntimeService](JavaRuntimeService.md) в
`<root>/java/<id>/`. Сборки Temurin приходят архивом, сборки Mojang — деревом отдельных файлов,
поэтому путей установки два.
`JavaInstaller` ставит сборку Java в `<root>/java/<id>`. Как и
[ModLoaderInstaller](ModLoaderInstaller.md), он прячет за одним фасадом два разных пути.
Экземпляр держит [LauncherBackend](LauncherBackend.md). Геттеры прогресса такие же, как у других
установщиков: `label`, `stage`, `currentFile`, `bytesDone`, `bytesTotal`, `fraction`, плюс
`runtimeId`. Сигналы: `started(label)`, `progressChanged()` (не чаще раза в 100 мс),
`finished(runtimeId, javaPath)`, `failed(label, message)`, `canceled(label)`.
**Temurin** отдаёт один архив: лаунчер качает его, сверяет sha256 и распаковывает. **Mojang**
отдаёт манифест с деревом файлов: лаунчер качает файлы по отдельности, как это делает официальный
лаунчер.
## install(entry)
Набор геттеров прогресса повторяет [VersionInstaller](VersionInstaller.md): панель загрузки в
интерфейсе читает их одинаково, независимо от того, кто сейчас работает.
Если установка уже идёт, вызов молча игнорируется. Иначе:
## Место в проекте и зависимости
1. проверяется папка лаунчера;
2. **папка `<root>/java/<id>` удаляется целиком**, если она была, и создаётся заново. Повторная
установка — это всегда установка с нуля;
3. дальше одна из двух веток.
Подключает [javaruntime.h](javaruntime.md): на вход установщик принимает запись каталога
`JavaRuntimeEntry`, а результат записывает через `JavaRuntimeStore`.
### Temurin (`zip`, `tar.gz`)
Экземпляр создаётся и принадлежит [LauncherBackend](LauncherBackend.md); записи каталога приходят
от [JavaRuntimeService](JavaRuntimeService.md).
1. Архив качается в `<root>/cache/java/<имя файла>` через `QSaveFile`, sha256 считается на ходу и
сверяется с каталогом.
2. `zip` распаковывается через `QZipReader` по 24 записи за такт таймера, чтобы окно не
подвисало. Права из архива переносятся. Записи, которые ведут за пределы папки сборки,
останавливают установку.
3. `tar.gz` распаковывается системным `tar -xzf`. Он есть на Linux, macOS и Windows 10 и новее.
`QZipReader` tar не умеет, а держать ради этого ещё одну библиотеку незачем.
4. Архив удаляется.
Требования сборки: `Qt6::Core` (`QCryptographicHash`, `QSaveFile`, `QProcess`, `QTimer`),
`Qt6::CorePrivate` (`QZipReader` для распаковки zip) и `Qt6::Network`.
Прогресс при загрузке считается в байтах, при распаковке zip — в записях. Для tar прогресса нет.
## Иерархия и роль
### Mojang
Наследует `QObject`: мета-объектная система, пять сигналов и владение по родителю. Объявлен
виртуальный деструктор — класс владеет незавершёнными загрузками, открытым архивом и процессом
распаковки.
1. Качается `manifest.json` компонента. Его sha1 из каталога сейчас **не проверяется**.
2. Из `files` строится дерево: папки создаются сразу, ссылки откладываются, файлы ставятся в
очередь.
3. До 4 файлов качаются параллельно, sha1 каждого сверяется. При временной ошибке (таймаут,
5xx и т. п.) и при несовпадении хеша файл уходит в конец очереди без паузы. После третьей
неудачи установка падает. Файлам с `executable: true` ставится бит исполнения.
4. Ссылки создаются через `QFile::link`. Если не вышло (на Windows без прав разработчика), файл
копируется.
## Публичные структуры
### Конец
### JavaFileTask
`JavaRuntimeStore::locateBinary()` находит `bin/java`. На Unix ему ставится бит исполнения.
`runtime.json` записывается последним: пока его нет, `JavaRuntimeStore` сборку не видит. Поэтому
недокачанная сборка в списке не появится, даже если лаунчер упал на середине.
Один файл рантайма Mojang.
При ошибке и при отмене папка сборки и скачанный архив удаляются.
| Поле | Тип | По умолчанию | Описание |
|------|-----|--------------|----------|
| `url` | `QUrl` | — | Откуда качать |
| `path` | `QString` | — | Абсолютный путь назначения |
| `sha1` | `QString` | — | Контрольная сумма файла |
| `size` | `qint64` | `0` | Размер в байтах |
| `executable` | `bool` | `false` | Файлу нужно выставить право на исполнение — иначе `bin/java` не запустится |
| `attempts` | `int` | `0` | Сколько попыток уже сделано |
## cancel()
### JavaActiveDownload
Обрывает загрузки и убивает `tar`. `canceled` приходит, когда вернутся все активные запросы.
Файл в процессе скачивания: задача, сетевой ответ, открытый `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);
```
---
+133 -45
View File
@@ -1,67 +1,155 @@
# JavaRuntimeService
`javaruntimeservice.h`, `javaruntimeservice.cpp`
## Обзор класса
Каталог сборок Java, которые лаунчер может скачать. Устроен как
[VersionManifestService](VersionManifestService.md), но кэш хранится сутки и лежит в
`<root>/cache/java_runtimes.json`.
`JavaRuntimeService` — каталог сборок Java, которые лаунчер умеет скачать: качает, кэширует в
папке лаунчера и отдаёт из кэша, пока тот не устарел. Устроен так же, как
[VersionManifestService](VersionManifestService.md).
Источников два:
Источников два, и они дополняют друг друга. **Mojang** (категория «Java») — ровно тот рантайм,
которым запускает игру официальный лаунчер: версий немного, зато они заведомо совместимы.
**Eclipse Temurin** (категории JDK и JRE) — свежие сборки всех мажорных версий, включая те, до
которых Mojang ещё не дошёл.
- **Mojang** (в интерфейсе категория «Java») — те же рантаймы, с которыми игру запускает
официальный лаунчер. Их немного, зато с игрой они точно совместимы.
- **Eclipse Temurin** (JDK и JRE) — свежие сборки всех мажорных версий, в том числе тех, которых у
Mojang ещё нет.
Отдаются только сборки под текущие операционную систему и архитектуру: выбрать заведомо
неработающую нечем. Исключение — macOS на Apple Silicon, где под Java 8 и 16 сборок `aarch64` нет
вовсе и приходится брать `x64`, который работает через Rosetta.
В каталог попадают только сборки под текущую ОС и архитектуру.
## Место в проекте и зависимости
Экземпляр держит [LauncherBackend](LauncherBackend.md), ставит сборки
[JavaInstaller](JavaInstaller.md).
Подключает [javaruntime.h](javaruntime.md): перечисление `JavaRuntimeKind` и структура
`JavaRuntimeEntry` приходят оттуда.
## Как обновляется каталог
Экземпляр создаётся и принадлежит [LauncherBackend](LauncherBackend.md). Записями каталога
пользуется [JavaInstaller](JavaInstaller.md).
Запросы идут параллельно. Каталог заменяется целиком, когда ответят все.
Путь к файлу кэша даёт `LauncherPaths::javaCatalogFile()` из [launcherpaths.h](launcherpaths.md).
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.
Требования сборки: `Qt6::Core` (`QDateTime`, `QHash`, `QSet`) и `Qt6::Network`
(`QNetworkAccessManager`).
**Нет сборки под aarch64.** У Temurin под ARM нет Java 8 и 16. Если для aarch64 ничего не нашлось,
запрос повторяется для `x64`. На Mac с Apple Silicon такая сборка работает через Rosetta. Сейчас
повтор делается на любой ОС с aarch64, хотя комментарий в заголовке говорит только про macOS.
## Иерархия и роль
Id строк: `temurin-<jdk|jre>-<версия>-<арх>` и `mojang-<компонент>`, пропущенные через
`JavaRuntimeStore::sanitizeId()`.
Наследует `QObject`: мета-объектная система, два сигнала и владение по родителю. Виртуальных
методов базового класса не переопределяет.
Версия Temurin 8 показывается как `8u<security>`, остальные — как `openjdk_version` без `-LTS`.
Мажорная версия у Mojang берётся из имени (`8u…` → 8, иначе первое число).
## Псевдонимы типов
У сборок Mojang `size` равен 0: в каталоге указан только размер манифеста, а не рантайма.
`JavaRuntimeService::Callback` — `std::function<void(bool ok, const QString &warning)>`. Как и в
остальных каталогах лаунчера, `ok == true` с непустым `warning` означает данные из устаревшего
кэша.
Если какие-то запросы упали, а другие ответили, каталог всё равно обновляется, а предупреждения
склеиваются через `; `. Если не пришло ничего, но старые данные есть, колбэк получает `ok = true` и
предупреждение.
## Публичные методы
Кэш помечается платформой (`mojangPlatform()`). Кэш с другой платформы не читается. Это возможно,
например, если папку лаунчера перенесли с другой машины.
#### explicit JavaRuntimeService(QObject \*parent = nullptr)
## Методы
Создаёт сервис и его `QNetworkAccessManager`. Кэш читается лениво. Конструктор помечен `explicit`.
- `ensureLoaded(callback, forceRefresh)` — как у манифеста версий.
- `entries(kind)` — строки одного вида, отсортированы по мажорной версии, потом по дате выхода,
новые сверху.
- `find(id)`.
- `bestFor(major)` — что скачать, если игре нужна Java `major`, а подходящей нет. Выбирается
наименьшая мажорная версия не ниже `major`. При равенстве порядок такой: Temurin JDK, потом
Mojang, потом Temurin JRE. JDK идёт первым, потому что установщикам Forge и NeoForge иногда нужны
инструменты из полного комплекта. Если и здесь равенство, берётся более свежая.
- `isRefreshing()`, `hasData()`.
#### void ensureLoaded(Callback callback, bool forceRefresh = false)
Сигналы: `catalogChanged()`, `refreshingChanged()`.
Единственная точка входа. Свежий кэш означает отложенный вызов колбэка без сети; иначе запускается
обновление. Параметр `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);
});
```
---
+431 -271
View File
@@ -1,371 +1,531 @@
# LauncherBackend
`launcherbackend.h`, `launcherbackend.cpp`
## Обзор класса
Через этот класс QML работает со всем остальным кодом. Интерфейс читает его свойства, вызывает
методы и слушает сигналы, а к сервисам напрямую не обращается.
`LauncherBackend` — единственный класс проекта, видимый из QML, и центр всего приложения. Интерфейс
лаунчера ничего не знает ни о сети, ни о файлах, ни о процессах: он читает свойства этого класса,
вызывает его методы и слушает его сигналы.
Сам бэкенд почти ничего не скачивает и не запускает. Он хранит профили, сборки и настройки,
создаёт двенадцать сервисов и следит, чтобы они не мешали друг другу: нельзя ставить Java во время
смены сборки, удалять версию, пока она качается, или запускать игру посреди установки модлоадера.
Ещё он сводит каталоги из сети с тем, что лежит на диске, чтобы окнам не пришлось этого делать.
Сам бэкенд почти ничего не делает руками. Он владеет двенадцатью сервисами — авторизация, запуск
игры, каталоги версий, модлоадеров, Java и сезонных сборок, три установщика, загрузчик паков и
переключатель сборок — и отвечает за то, чтобы они работали в правильном порядке. Кроме того, он
хранит состояние лаунчера: профили игрока, пользовательские сборки и настройки запуска, которые
читает и пишет в файлы папки лаунчера.
Экземпляр один, его объявляет [Main.qml](../qml/Main.md). Во вложенные окна он передаётся через
`required property var backend`. Тип зарегистрирован через `QML_ELEMENT` в модуле `BiLauncher`;
синглтоном он не сделан.
Ещё одна его задача — приводить данные к виду, удобному QML. Каталоги отдаются в интерфейс уже
сведёнными с локальным состоянием: строка версии знает, скачана ли она, строка сезонной сборки —
установлена ли и не устарела ли. Окно показывает статус, не считая ничего само.
## Сервисы
## Место в проекте и зависимости
Все создаются в конструкторе с бэкендом в роли родителя.
Единственный экземпляр создаётся декларативно в [Main.qml](../qml/Main.md); в `main.cpp` он не
упоминается.
| Поле | Класс | Зачем |
|------|-------|-------|
| `m_auth` | [AuthService](AuthService.md) | Ely.by, офлайн, загрузка authlib-injector |
Владеет двенадцатью сервисами, каждому из которых посвящена своя страница:
| Поле | Класс | Роль |
|------|-------|------|
| `m_auth` | [AuthService](AuthService.md) | вход через Ely.by и офлайн |
| `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) | загрузка архива сезонной сборки |
`VersionInstaller` получает указатель на сервис манифеста, `ModLoaderInstaller` — на сервис версий
модлоадеров и на установщик версий. Владельцем этих объектов остаётся бэкенд.
Пути ко всем файлам состояния берутся из [launcherpaths.h](launcherpaths.md), описания версий — из
[minecraftversion.h](minecraftversion.md), словарь модлоадеров — из [modloader.h](modloader.md),
поиск системной Java — из [javalocator.md](javalocator.md).
## Что происходит при старте
Требования сборки: `Qt6::Core`, `Qt6::Gui`, `Qt6::Network`, `Qt6::CorePrivate` (опосредованно) и
`Qt6::Quick` — класс зарегистрирован в QML-модуле `Minecraft_launcher`, объявленном в
`CMakeLists.txt`.
Конструктор делает следующее, порядок важен:
## Иерархия и роль
1. `LauncherPaths::ensureRootExists()` создаёт папку лаунчера и проверяет, что в неё можно писать.
2. `loadSettings()` заполняет настройки значениями по умолчанию и накладывает сверху
`settings.json`.
3. `migrateLegacyBuildsFile()` переносит старый `versions.json` в `customBuilds.json`. Это должно
случиться до следующего шага, иначе поверх старых данных появится пустой файл.
4. `loadData()` читает профили и сборки и сортирует их по `id`.
5. Если это первый запуск, `importGameDir()` заводит сборку под то, что уже лежит в папке игры
(см. ниже).
6. `ensureDataFiles()` создаёт недостающие файлы.
7. Если `BuildSwitcher` нашёл журнал оборванного переключения, бэкенд сразу продолжает его через
`resumeInterrupted()`. Пользователю ничего не предлагается. Если начать переключение заново,
оно сначала заархивирует полуразобранную `.minecraft` поверх целого архива прежней сборки.
8. Подписка на сигналы сервисов.
Наследует `QObject`: мета-объектная система, свойства, сигналы и владение по родителю. Объявлен
виртуальный деструктор. Виртуальных методов базового класса не переопределяет.
Все проблемы, найденные по пути (битый JSON, папка без права записи, пропущенные записи сборок),
копятся в `m_storageIssues` и уходят одним `launchError` через `QTimer::singleShot(0)`: к этому
моменту QML уже подписан на сигнал.
## Свойства Q_PROPERTY
### Сборка из папки игры
### Профили и сборки
Первым запуском считается старт, когда нет ни `settings.json`, ни `customBuilds.json`, ни
старого `versions.json`. Проверка делается до `loadData()`: битый файл она отводит в `.bak`, а
`ensureDataFiles()` потом создаёт пустой. Существующий пустой `customBuilds.json` — это решение
пользователя «сборок нет», и импорта тогда нет.
| Свойство | Тип | READ | WRITE | NOTIFY | Описание |
|----------|-----|------|-------|--------|----------|
| `profileNames` | `QStringList` | `profileNames` | — | `profilesChanged` | Имена профилей игрока в порядке добавления. Только для чтения; модель выпадающего списка профилей |
| `customBuildNames` | `QStringList` | `customBuildNames` | — | `customBuildsChanged` | Имена пользовательских сборок. Только для чтения; модель списка в окне сборок |
| `activeBuildIndex` | `int` | `activeBuildIndex` | `setActiveBuildIndex` | `activeBuildChanged` | Сборка, которую запускает кнопка игры. Хранится по идентификатору сборки, а не по индексу: удаление соседней записи не должно переназначать активную. Запись в свойство запускает смену сборки |
| `activeBuildName` | `QString` | `activeBuildName` | — | `activeBuildChanged` | Имя активной сборки для подписи на кнопке. Только для чтения |
| `installedVersions` | `QStringList` | `installedVersions` | — | `installedVersionsChanged` | Версии, реально установленные в папке игры. Только для чтения |
Если [GameDirImport::detect()](gamedirimport.md) что-то нашёл, запись сборки добавляется сразу,
минуя `addCustomBuild()` (тот начал бы установку), становится активной и сохраняется вместе с
`activeBuildId`. `BuildSwitcher` не вызывается: сборка описывает то, что уже разложено в папке
игры, а активная сборка без архива — штатное состояние. Сама папка игры только читается.
### Занятость и смена сборки
Имя: `<loaderTitle> <версия>` или `Minecraft <версия>`, а без версии — `build.title.fromGameDir`.
Через `QTimer::singleShot(0)` приходит `gameDirImported(buildName)`, раньше сообщения об ошибках
хранилища, чтобы оно их не перекрыло. Main.qml показывает по нему зелёную плашку.
| Свойство | Тип | 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 показать нечем, и интерфейс не должен предлагать этот путь |
## Файлы на диске
### Каталог версий
Все пути берутся из [launcherpaths.h](launcherpaths.md).
| Свойство | Тип | READ | WRITE | NOTIFY | Описание |
|----------|-----|------|-------|--------|----------|
| `versionCatalog` | `QVariantList` | `versionCatalog` | — | `versionCatalogChanged` | Объединённый список для выбора версии: установленные, разделитель, затем весь каталог Mojang. Строка содержит поля `id`, `label`, `category`, `installed` и `search`. Только для чтения |
| `catalogLoading` | `bool` | `catalogLoading` | — | `catalogLoadingChanged` | Идёт загрузка манифеста версий. Только для чтения |
- `settings.json` — объект с настройками;
- `profiles.json` — массив профилей;
- `customBuilds.json` — массив сборок.
### Каталог Java
Если файла нет, это первый запуск. Если он не читается как JSON или в нём не тот тип (объект вместо
массива и наоборот), файл переименовывается в `<имя>.bak`, а на его месте создаётся пустой.
Данные из `.bak` можно потом достать руками.
| Свойство | Тип | READ | WRITE | NOTIFY | Описание |
|----------|-----|------|-------|--------|----------|
| `javaCatalog` | `QVariantList` | `javaCatalog` | — | `javaCatalogChanged` | Сборки Java для окна выбора: скачиваемые из сети плюс те, что уже лежат в папке лаунчера. Строка содержит поля `id`, `label`, `kind`, `major`, `installed`, `downloadable`, `lts`, `sizeMb`, `detail`, `coverage` и `search`. Только для чтения |
| `javaCatalogLoading` | `bool` | `javaCatalogLoading` | — | `javaCatalogLoadingChanged` | Идёт загрузка каталога Java. Только для чтения |
Перед каждой записью снова вызывается `ensureRootExists()`: папку могли удалить, пока лаунчер
работал, и без этой проверки сохранения пропадали бы без ошибки. Пишется всё через обычный `QFile`,
кроме `builds/<id>/season.json`, для которого используется `QSaveFile`.
### Сезонные сборки
Пароли Ely.by и токены лежат в `profiles.json` открытым текстом.
| Свойство | Тип | 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`.
### Настройки
| Ключ | По умолчанию | Что это |
|------|--------------|---------|
| `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` | сервер сезонных сборок |
#### QVariantMap settings() const
`seasonalBaseUrl` в интерфейсе нет. Он нужен, чтобы проверять установку против локального
HTTP-сервера. Хвостовые слэши отрезаются.
Настройки запуска одной картой: `gameDir`, `javaPath`, `javaRuntime`, `minMemoryMb`,
`maxMemoryMb`, `jvmArgs`, `windowWidth`, `windowHeight`, `fullscreen`, `language` и вычисленный
`resolvedGameDir`.
Имя и версия лаунчера для `${launcher_name}` и `${launcher_version}` в настройках не хранятся: они
берутся из `QCoreApplication`, то есть из `project(... VERSION)` в CMake. Ключи `launcherName` и
`launcherVersion`, оставшиеся в старых `settings.json`, при загрузке выбрасываются и пропадают из
файла при следующем сохранении.
#### void updateSettings(const QVariantMap &values)
`settings()` возвращает всю карту и добавляет к ней вычисленный `resolvedGameDir`.
`updateSettings()` вливает присланные ключи, сохраняет файл и испускает `settingsChanged`. Если
сменился язык, после записи вызывается `Localization::setLanguage()`: так выбор сохранится, даже если
лаунчер упадёт сразу после переключения. Если сменилась папка игры, перестраивается каталог версий.
Записывает настройки и сохраняет их на диск. Испускает `settingsChanged`.
## Профили
Смену `language` после записи пробрасывает в [Localization](Localization.md) — порядок
«сохранили → переключили» гарантирует, что выбранный язык переживёт падение сразу после
переключения. Сам `Localization` в `settings.json` не пишет: файл ведёт только бэкенд.
Профиль хранит имя, логин, пароль, тип входа (`offline`, `elyby`, `microsoft`; всё прочее читается
как `offline`) и полученные при входе данные: токены, UUID, ник с сервера, `xuid`, срок жизни токена.
#### QStringList detectedJava() const
- `addProfile(name, login, password, authType)` — добавить.
- `updateProfile(index, ...)` — перезаписать. Если у профиля Ely.by поменялись логин или пароль,
сохранённый токен сбрасывается. Если поменялся тип входа, сбрасывается всё, что было получено
при входе. Переименование профиля Microsoft сессию не трогает.
- `profileAt(index)` — `name`, `login`, `password`, `authType`, `resolvedName` и
`hasMicrosoftSession` (есть ли refresh-токен).
- `removeProfile(index)` — удалить.
Пути ко всем java, найденным в системе. Показывается справочной строкой в диалоге настроек.
Все испускают `profilesChanged`. Свойство `profileNames` отдаёт имена в порядке `id`.
### Сборки Java
## Сборки
#### void refreshJavaCatalog(bool force = false)
Сборка — это имя, адрес сервера для автоподключения, версия Minecraft и, если выбран, модлоадер с
версией. После установки модлоадера в `resolvedVersionId` записывается id профиля в `versions/`,
который на самом деле запускается. У сезонных сборок есть ещё `seasonalId` и `seasonalRevision`.
Обновляет каталог сборок Java. Вызывается при открытии окна выбора: свежий кэш отвечает без сети.
Какую папку из `versions/` запускать, решает `launchVersionIdOf()`: сначала `resolvedVersionId`,
потом `minecraftVersion`, а если и её нет, то имя сборки. Последний вариант остался от старых
записей, у которых версия была записана в имени.
#### void installJavaRuntime(const QString &runtimeId)
- `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)` — список того, чего не хватает для запуска; пустой список значит, что
всё на месте.
Скачивает и распаковывает сборку Java. По завершении испускается `javaRuntimeInstalled`.
### Активная сборка
#### void removeJavaRuntime(const QString &runtimeId)
`activeBuildIndex` хранится в настройках как `activeBuildId`, а не как индекс. Если этот `id`
пропал, активной считается первая сборка.
Удаляет скачанную сборку Java из папки лаунчера.
Запись в свойство запускает `BuildSwitcher::switchTo()`: содержимое `.minecraft` уезжает в архив
прежней сборки, на его место распаковывается архив новой. Смена отклоняется, если уже идёт другая
смена, запущена игра или работает любой установщик. `activeBuildId` меняется только в обработчике
`BuildSwitcher::finished`, поэтому после неудачи активной остаётся прежняя сборка.
#### QVariantMap javaRuntimeInfo(const QString &runtimeId) const
Пока идёт смена, выставлено `switching`, а ход виден в `switchProgress` (от 0 до 1, или −1, пока
объём неизвестен), `switchStage` и `switchStatus`.
Описание установленной сборки: подпись, версия, путь к java и признак установки. Пустая карта
означает, что сборки с таким идентификатором в папке лаунчера нет.
## Версии игры
Метод не является привязкой и сам не пересчитывается, когда сборка докачается, — диалог настроек
обновляет его по сигналу `javaRuntimeInstalled`.
- `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)` — удалить папку версии. Библиотеки и ресурсы общие, их не трогает. Пока
лаунчер занят или запущена игра, отказывает. Сборки, ссылающиеся на версию, не меняются:
ссылка на неустановленную версию — нормальное состояние.
#### int requiredJavaMajor(int buildIndex) const
Список строк каталога строится лениво и кэшируется: QML читает свойство много раз за кадр, пока
открыт список. То же сделано для каталогов Java и сезонных сборок.
Минимальная мажорная версия Java для версии игры выбранной сборки; `0` — версия не выбрана.
Передаётся в окно выбора Java, чтобы пометить слишком старые сборки.
## Модлоадеры
### Сезонные сборки
- `loaderVersions(loaderKey, gameVersion)` — список `{version, label, recommended, stable}` для этой
версии игры, новые сверху. Несовместимых строк в нём не бывает. Пустой список означает, что
модлоадер эту версию не поддерживает.
- `refreshLoaderVersions(loaderKey, gameVersion, force)` — обновить список. По окончании всегда
приходит `loaderVersionsChanged(loaderKey, gameVersion)`, даже если ответил кэш. Обработчик должен
сверить пару со своей: сигнал может относиться к другому модлоадеру или к прошлой версии игры.
- `loaderVersionsLoading(loaderKey, gameVersion)` — помогает отличить «ещё грузится» от «не
поддерживается».
#### void refreshSeasonalCatalog(bool force = false)
Ключи: `forge`, `fabric`, `neoforge`, `quilt`.
Обновляет каталог сезонных сборок.
Установщику модлоадера передаётся та же Java, что пойдёт в игру: сначала скачанная лаунчером
сборка, потом `javaPath`.
#### void installSeasonalBuild(const QString &seasonalId)
## Java
Ставит или обновляет сборку целиком одной цепочкой: запись сборки, версия игры, модлоадер, 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 — /путь`, для справки в
настройках.
Порядок шагов жёсткий: сначала сборка делается активной, затем ставится Java — она нужна
установщику Forge, — затем модлоадер, затем качается пак и только в конце его файлы раскатываются
поверх `.minecraft`. Раскатывать файлы имеет смысл только когда всё остальное на месте.
## Запуск игры
Строка каталога копируется на момент старта: обновление списка посреди установки не должно менять
то, что ставится. По завершении испускается `seasonalInstallFinished`.
`launchGame(profileIndex, buildIndex)` сначала проверяет всё, что можно проверить синхронно:
#### void cancelSeasonalInstall()
- не работает ни один установщик, не идёт другой запуск и игра не запущена;
- профиль и сборка выбраны, папка игры существует;
- версия сборки есть в `versions/`;
- описание версии читается (`VersionLoader::load`), и все нужные файлы на месте
(`GameLauncher::missingFiles`).
Отменяет установку сезонной сборки.
После этого выставляется `busy`, а сборка запоминается по `id`, а не по индексу: пока идёт вход,
её могут удалить в другом окне.
### Папки
Дальше зависит от типа профиля:
#### void openMinecraftFolder()
- **офлайн** — ник из логина или имени профиля, UUID считается из ника;
- **Ely.by** — сначала докачивается `authlib-injector` в `<root>/runtime`, потом вход. Если Ely.by
просит второй фактор, `busy` снимается и приходит `twoFactorRequired(profileName)`. Окно должно
вызвать `submitTwoFactorCode(code)` или `cancelPendingLaunch()`, иначе запуск так и останется
в ожидании;
- **Microsoft** — если refresh-токена нет, запуск прерывается с `microsoftReloginRequired(index)`.
Если сохранённый токен Minecraft живёт ещё дольше двух минут, сеть не трогается. Иначе сессия
продлевается по refresh-токену. Если продлить не вышло, тоже `microsoftReloginRequired`. Если у
аккаунта нет лицензии, просто ошибка.
Открывает папку модов Minecraft в файловом менеджере системы.
Полученные при входе ник, UUID и токены сохраняются в профиль. Java выбирается так: скачанная
лаунчером сборка из `javaRuntime`, иначе `javaPath`, иначе её ищет `GameLauncher`. Если
`javaRuntime` задан, но такой сборки на диске нет, об этом будет сообщение в `launchProgress`.
#### void openGameFolder()
Ход виден по сигналам `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()` забывает начатый вход. Если окно закрыли, пока шёл обмен кода, результат
будет выброшен.
Эти сигналы объявлены как `NOTIFY` соответствующих свойств; обработчик перечитывает свойство.
`profilesChanged`, `customBuildsChanged`, `activeBuildChanged`, `switchChanged`,
`installedVersionsChanged`, `settingsChanged`, `gameRunningChanged`, `busyChanged`,
`versionCatalogChanged`, `catalogLoadingChanged`, `javaCatalogChanged`,
`javaCatalogLoadingChanged`, `seasonalCatalogChanged`, `seasonalCatalogLoadingChanged`,
`seasonalInstallingChanged` и `downloadChanged`.
`microsoftAvailable` — константа: собран ли лаунчер с Qt WebEngine.
#### loaderVersionsChanged(const QString &loaderKey, const QString &gameVersion)
## Сезонные сборки
Список версий модлоадера изменился. Параметры сужают событие до конкретной пары, поэтому
обработчик обязан сверить их со своим текущим состоянием: обновление может относиться к другой
строке лоадера или к прошлой версии игры. Именно так поступает [LoaderRow](../qml/LoaderRow.md).
- `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`).
#### seasonalInstallFinished(const QString &seasonalId, const QString &buildName)
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)`.
#### javaRuntimeInstalled(const QString &runtimeId)
При ошибке на любом шаге скачанный архив удаляется, и приходит `launchError`. На шагах Switching и
Applying отменить нельзя: `.minecraft` уже разобрана, операцию нужно довести до конца.
Сборка Java установлена. Диалог настроек по этому сигналу обновляет подпись выбранной сборки, не
переоткрываясь.
Строка каталога копируется в `m_season.entry` при старте. Если каталог обновится во время установки,
ставиться будет всё равно то, что выбрали.
### Запуск игры
## Панель загрузки
#### launchProgress(const QString &message)
Источников загрузки четыре: установщик версий, установщик модлоадера, установщик Java и загрузчик
пака. Наружу они видны через одну группу свойств: `downloading`, `downloadProgress`,
`downloadVersion` (что качается), `downloadStatus` (`этап — файл`), `downloadBytesDone`,
`downloadBytesTotal`. Все обновляются одним сигналом `downloadChanged`.
Описание текущего шага запуска. Обработчик показывает сообщение без таймаута: шаг может занять
заметное время, и сообщение должно держаться до следующего.
Чьи числа показывать, решается так. Пак качается, только когда остальные уже закончили. Java не
качается одновременно с версией или модлоадером. Во время установки модлоадера доля берётся у
`VersionInstaller`, если он сейчас качает файлы версии, потому что его оценка точнее.
#### launched(const QString &profileName, const QString &buildName, const QString &serverUrl)
`cancelDownload()` останавливает первое, что работает, в порядке: модлоадер (он сам остановит
вложенную установку версии), версия, Java, пак.
Игра запущена. Обработчик сообщает пользователю, какой профиль и какая сборка стартовали.
`busy` равно `true`, пока идёт вход или запуск, работает любой установщик, идёт смена сборки или
качается пак. На это время в интерфейсе гаснет кнопка запуска.
#### launchError(const QString &message)
## Папки
Запуск не удался либо произошла ошибка, о которой нужно сказать пользователю. Через этот же сигнал
сообщается о проблемах записи файлов лаунчера.
- `openMinecraftFolder()` открывает `<gameDir>/mods` (создаёт, если её нет).
- `openGameFolder()` открывает саму папку игры.
#### twoFactorRequired(const QString &profileName)
## Потоки
Ely.by отклонил пароль с пометкой two factor. Обработчик открывает диалог ввода кода и передаёт
введённое значение в `submitTwoFactorCode()`; отказ должен вызвать `cancelPendingLaunch()`, иначе
запуск останется висеть в ожидании.
Весь класс работает в GUI-потоке. В отдельном потоке выполняются только файловые операции
`BuildSwitcher`, и этот поток скрыт внутри него.
#### 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++:
```qml
import QtQuick
import BiLauncher
import Minecraft_launcher
Window {
id: window
visible: true
LauncherBackend {
id: backend
onLaunchError: (message) => console.warn(message)
onTwoFactorRequired: (profileName) => twoFactorDialog.open()
onMicrosoftLoginUrlReady: (url) => window.openMicrosoftLogin(url)
}
Button {
+94 -103
View File
@@ -1,15 +1,17 @@
# 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 перевычисляются, перезапуск не требуется.
Синглтон `Loc` (`QML_NAMED_ELEMENT(Loc)` + `QML_SINGLETON`) доступен в любом файле модуля без
импорта.
## Доступ из QML
Синглтон зарегистрирован декларативно (`QML_NAMED_ELEMENT(Loc)` + `QML_SINGLETON`) и доступен
в любом файле модуля без импорта.
```qml
Text { text: Loc.t.settings.title }
@@ -17,22 +19,31 @@ Text { text: Loc.t.java.progress.downloading.arg(label) }
DarkCombo { model: Loc.t.profile.authTypes }
```
`Loc.t` — дерево JS-объектов, собранное из плоских ключей каталога разбиением по точке.
Точечный путь `Loc.t.a.b.c` — это обращение к вложенным объектам дерева, которое строится из
плоских ключей каталога разбиением по точке.
Почему `t` сделано свойством, а не методом `Loc.t("ключ")`. Вызов `Q_INVOKABLE` не регистрирует
зависимость привязки, и текст не обновился бы при смене языка. Чтение `Q_PROPERTY` с `NOTIFY`
регистрирует, поэтому `languageChanged` перевычисляет все привязки, где читали `Loc.t`. Дальнейшие
`.settings.title` — обычные обращения к полям JS-объекта. Следить за ними не нужно, потому что при
смене языка дерево заменяется целиком.
**Почему свойство, а не метод.** Вызов `Q_INVOKABLE` не регистрирует зависимость привязки, и
`text: Loc.t("ключ")` никогда бы не обновился при смене языка. Чтение `Q_PROPERTY` с сигналом
`NOTIFY` зависимость регистрирует: `languageChanged` перевычисляет все привязки, которые читали
`Loc.t`. Сегменты после `t` — обычные обращения к членам JS-объекта, отслеживать их не нужно,
потому что при смене языка дерево заменяется целиком.
Тип свойства — `QJSValue`. С `QVariantMap` движок собирал бы новый JS-объект при каждом чтении, а
привязок в проекте около полутора сотен. `QJSValue` строится один раз на каждую смену языка.
Тип свойства — `QJSValue`, а не `QVariantMap`: `QVariantMap` пересобирался бы в новый JS-объект
при каждом чтении, а привязок в проекте полторы сотни. `QJSValue` строится один раз на смену
языка.
Кроме `t` есть `language` (что выбрано: `system`, `ru`, `en`), `resolvedLanguage` (во что это
превратилось, всегда `ru` или `en`) и `availableLanguages()`. Последний сейчас не используется:
список языков в настройках [Main.qml](../qml/Main.md) задан руками (`stLanguage`).
Из тела JS-функции `Loc.t` читается так же — это просто чтение свойства:
## Из C++
```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` нет.
```cpp
#include "localization.h"
@@ -42,34 +53,29 @@ emit launchProgress(Loc::text("java.progress.downloading").arg(label));
const QStringList kinds = Loc::list("profile.authTypes");
```
Это свободные функции, потому что их вызывают и из пространств имён без `QObject`
([launcherpaths.h](launcherpaths.md), [javalocator.h](javalocator.md),
[zlibreference.h](zlibreference.md)).
Если ключа нет, возвращается сам ключ, а в отладочной сборке ещё пишется `qWarning`. В интерфейсе
такую строку легко заметить.
Ключа нет — возвращается сам ключ, а в отладочной сборке ещё и `qWarning`: строка вида
`launch.error.noProfile` в интерфейсе сразу бросается в глаза.
### Потоки
`Loc::text()` и `Loc::list()` можно вызывать из любого потока. Каталог заполняется один раз в
`load()`, до того как [BuildSwitcher](BuildSwitcher.md) запустит свой поток, и дальше только
читается. На ходу меняется лишь индекс текущего языка, а он `QAtomicInt`. В худшем случае строка,
собранная в момент переключения, окажется на прежнем языке.
`Loc::text()` и `Loc::list()` можно звать из любого потока. Каталог заполняется ровно один раз
в `load()`, который отрабатывает в `main()` до того, как [BuildSwitcher](BuildSwitcher.md)
создаст свой поток; дальше он только читается, а копирование `QString` из хэша безопасно само по
себе. Единственное, что меняется на ходу, — индекс текущего языка, и он `QAtomicInt`. В худшем
случае сообщение, которое собиралось в момент переключения, уедет на прежнем языке.
`setLanguage()` вызывается только из GUI-потока, это проверяет `Q_ASSERT`.
`setLanguage()` и рассылка `languageChanged` — только поток GUI; это проверяется `Q_ASSERT`.
## Каталог
Файл попадает в ресурсы через `RESOURCES` в `qt_add_qml_module` и читается из
`:/qt/qml/BiLauncher/i18n/translations.json`.
`i18n/translations.json` попадает в ресурсы через список `RESOURCES` в `qt_add_qml_module`,
поэтому читается по пути `:/qt/qml/Minecraft_launcher/i18n/translations.json`.
```json
{
"_meta": {
"languages": ["ru", "en"],
"displayNames": { "ru": "Русский", "en": "English" },
"_readme": ["..."],
"_glossary": { "сборка": "build", "...": "..." }
"displayNames": { "ru": "Русский", "en": "English" }
},
"strings": {
"settings.title": { "ru": "Настройки запуска", "en": "Launch settings" },
@@ -82,92 +88,77 @@ 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`.
`<домен>.<вид>.<имя>`, сегменты в lowerCamelCase.
Литерал, который нужен в двух и более файлах, живёт в `common.*`.
- **Домен** — область интерфейса, а не файл: `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`. Тогда при правке перевода ключ не придётся переименовывать.
Подстановки `%1`/`%2` сохраняются дословно: их одинаково понимают `QString::arg()` и
QML-овский `String.arg()`. **Набор `%N` в `ru` и `en` обязан совпадать** — порядок слов может
отличаться, состав нет. Множественного числа формат не поддерживает; в коде сейчас нет ни одной
строки, которой оно требуется.
Строка, которая нужна в двух и более местах, кладётся в `common.*`.
## Как добавить строку
`%1`, `%2` пишутся как есть: их одинаково понимают `QString::arg()` и `String.arg()` в QML. Набор
`%N` в `ru` и `en` должен совпадать, порядок может быть разным. Множественного числа формат не
поддерживает, и пока ни одна строка его не требует.
1. Добавить запись в `strings` файла `i18n/translations.json` — сразу с `ru` и `en`.
2. Сослаться на неё: `Loc.t.домен.вид.имя` в QML или `Loc::text("домен.вид.имя")` в C++.
3. Прогнать `python3 tools/check_translations.py`.
## Добавить строку
## Как добавить язык
1. Запись в `strings` сразу с `ru` и `en`.
2. `Loc.t.домен.вид.имя` в QML или `Loc::text("домен.вид.имя")` в C++.
3. `python3 tools/check_translations.py`.
1. Дописать код в `_meta.languages` и название в `_meta.displayNames`.
2. Добавить колонку с этим кодом в каждую запись `strings`.
3. Добавить код в `stLanguage.codes` и пункт в модель комбобокса в [Main.qml](../qml/Main.md),
а также ключ `settings.language.<код>` с эндонимом (название языка не переводится — оно
одинаково во всех колонках).
4. При необходимости поправить `systemLanguage()` в `localization.cpp`: сейчас он выбирает
русский для русской системной локали и английский во всех остальных случаях.
## Добавить язык
## Выбор языка и его хранение
1. Код в `_meta.languages`, название в `_meta.displayNames`.
2. Значение для этого кода в каждой записи `strings`.
3. Ключ `settings.language.<код>` с названием языка на нём самом, одинаковым во всех колонках.
4. Пункт в модели `stLanguage` и код в `stLanguage.codes` в [Main.qml](../qml/Main.md).
5. Если нужно, поправить `systemLanguage()`: сейчас при русской системной локали выбирается
русский, при любой другой — английский.
Ключ настройки — `language`, значения `"system"`, `"ru"`, `"en"`, по умолчанию `"system"`.
Хранится в `settings.json` рядом с остальными настройками; в интерфейсе — первым пунктом
диалога «Настройки запуска», применяется по кнопке «Сохранить».
## Где хранится выбранный язык
Ключ `language` в `settings.json`, по умолчанию `system`. Меняется в окне настроек и применяется
по кнопке «Сохранить».
Круг замкнут в одну сторону, циклической зависимости нет:
```
main.cpp ──► Localization::load() ──► читает settings.json (один раз)
LauncherBackend::updateSettings() ──► Localization::setLanguage()
main.cpp ──► Localization::load() ──► LauncherPaths::settingsFile() (только чтение, один раз)
LauncherBackend::updateSettings() ──► Localization::setLanguage() (в одну сторону)
```
`settings.json` записывает только [LauncherBackend](LauncherBackend.md). Localization его только
читает, и только при старте: язык нужен раньше первой привязки QML, а бэкенд создаётся вместе с
движком. Обратной зависимости от бэкенда у Localization нет.
`Localization` ничего не знает про [LauncherBackend](LauncherBackend.md) — писать `settings.json`
по-прежнему может только он. Читать настройки самому приходится потому, что язык нужен раньше,
чем QML вычислит первую привязку, а бэкенд появляется только вместе с движком.
Неизвестный язык в файле (если его правили руками) заменяется на `system` с предупреждением.
Неизвестное значение (файл правили руками) откатывается на `"system"` с предупреждением.
Отсутствующий или испорченный каталог — ошибка на старте: `main()` пишет причину и возвращает
`-1`. Файл вкомпилирован в бинарник, так что это может быть только ошибка сборки, а лаунчер,
у которого все подписи выглядят как точечные ключи, хуже, чем лаунчер, который сказал, почему
не запустился.
Если каталог не найден или не разбирается, это ошибка сборки, потому что файл вкомпилирован в
бинарник. `main()` пишет причину в `qCritical` и возвращает `-1`.
### Что не переводится при смене языка
Строки, которые уже собраны и лежат в полях C++, остаются на старом языке:
**Известное ограничение.** Уже сложенные в поля C++ строки не перепереводятся:
`LauncherBackend::m_storageIssues` собирается при старте, а `stage()`/`status()` у
[BuildSwitcher](BuildSwitcher.md) обновятся только на следующем шаге. Всё это короткоживущие
диагностические сообщения.
[BuildSwitcher](BuildSwitcher.md) заменяются на следующем тике прогресса. Все они
диагностические и короткоживущие.
Отдельно: `GameLauncher` прячет токен в строке запуска за `<скрыт>`, и эта строка не берётся из
каталога.
## Проверка
## Проверка каталога
`tools/check_translations.py` — только чтение, ненулевой код возврата при любой ошибке:
`tools/check_translations.py` ничего не меняет и возвращает ненулевой код при любой ошибке.
Проверяет:
- у всех языков одинаковый набор ключей и типы, пустых значений нет, длины списков равны;
- набор `%N` совпадает по языкам;
- каждый `Loc::text("…")` и `Loc::list("…")` в C++ есть в каталоге с нужным типом;
- каждый `Loc.t.a.b.c` в QML ведёт к существующему ключу (последний сегмент может быть методом
JS, например `.arg`);
- нигде не осталось `tr(`, `qsTr(`, `QCoreApplication::translate`.
Ключи, на которые никто не ссылается, выводятся предупреждением.
Скрипт смотрит только файлы в корне репозитория. Если исходники разложат по подпапкам, его нужно
будет поправить.
---
При создании этого документа использовался ИИ.
- каждый `Loc::text("…")` и `Loc::list("…")` из C++ есть в каталоге и совпадает по типу значения;
- каждый путь `Loc.t.a.b.c` из QML разворачивается в существующий ключ;
- наборы ключей у всех языков совпадают, пустых значений нет, типы одинаковы;
- наборы `%N` совпадают по языкам, длины списков равны;
- не осталось ни одного `tr(`, `qsTr(` или `QCoreApplication::translate`;
- ключи, на которые никто не ссылается, — предупреждением.
+167 -54
View File
@@ -1,77 +1,190 @@
# ModLoaderInstaller
`modloaderinstaller.h`, `modloaderinstaller.cpp`
## Обзор класса
Ставит модлоадер в `.minecraft`. Fabric и Quilt ставятся иначе, чем Forge и NeoForge, но снаружи у
них одинаковый интерфейс.
`ModLoaderInstaller` ставит модлоадер в `.minecraft`. Под одним фасадом он прячет два совершенно
разных пути установки.
- **Fabric и Quilt** отдают готовое описание версии. Его достаточно положить в
`versions/<id>/<id>.json`, а остальное докачает [VersionInstaller](VersionInstaller.md).
- **Forge и NeoForge** при установке патчат клиентский jar, поэтому запускается их официальный
`installer.jar` без окна. После него проверяется, какой профиль появился в `versions/`.
**Fabric и Quilt** отдают готовое описание версии: лаунчер кладёт его в
`versions/<id>/<id>.json` и передаёт дальше [VersionInstaller](VersionInstaller.md), который по
полю `inheritsFrom` сам поставит ванильную версию и библиотеки лоадера.
Экземпляр держит [LauncherBackend](LauncherBackend.md). Геттеры прогресса устроены так же, как у
`VersionInstaller` (`label`, `stage`, `currentFile`, `bytesDone`, `bytesTotal`, `fraction`), и
панель загрузки читает их одинаково.
**Forge и NeoForge** так не умеют: их установка — это патч клиентского jar. Поэтому лаунчер
запускает официальный `installer.jar` найденной Java в headless-режиме и смотрит, какой профиль
появился в `versions`. Процессу установщика при этом подсовывается эталонный zlib — см.
[ZlibReference](zlibreference.md).
## install(gameDir, loader, gameVersion, loaderVersion, javaPreference)
Оба пути начинаются одинаково: пока ванильная версия не скачана целиком, ставить лоадер некуда.
Если установка уже идёт или не указана версия, сразу приходит `failed`. Иначе:
Набор геттеров прогресса намеренно повторяет `VersionInstaller`: панель загрузки в интерфейсе
читает их одинаково, независимо от того, кто сейчас работает.
1. Список версий модлоадера через [ModLoaderVersionService](ModLoaderVersionService.md).
2. **Базовая версия игры.** Если её нет или не хватает файлов, она ставится через
`VersionInstaller`, и ход идёт дальше только после её `finished`. Без целой ванильной версии
модлоадер ставить некуда.
3. Дальше один из двух путей.
## Место в проекте и зависимости
### Fabric и Quilt
Подключает [modloader.h](modloader.md). В конструктор принимает
[ModLoaderVersionService](ModLoaderVersionService.md) — за адресом установщика — и
[VersionInstaller](VersionInstaller.md) — за базовой версией и за докачиванием того, что
`installer.jar` не положил.
Описание скачивается из `meta.fabricmc.net/v2/versions/loader/<игра>/<лоадер>/profile/json`
(у Quilt `meta.quiltmc.org/v3/...`). Проверяется, что в нём есть безопасный `id` и что
`inheritsFrom` совпадает с выбранной версией игры. Файл сохраняется, и `VersionInstaller` получает
этот `id`.
Экземпляр создаётся и принадлежит [LauncherBackend](LauncherBackend.md).
### Forge и NeoForge
Требования сборки: `Qt6::Core` (`QProcess`, `QFile`) и `Qt6::Network` (`QNetworkAccessManager`,
`QNetworkReply`).
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 минут процесс убивается.
## Иерархия и роль
После успешного завершения новый профиль ищется как разница списков до и после. Угадывать имя по
строке нельзя: у Forge в разные годы оно составлялось по-разному. Если новых папок нет (установщик
переписал существующую), ищется профиль, который наследует базовую версию и содержит в имени версию
модлоадера. Если новых папок несколько, берётся та, где есть версия модлоадера.
Наследует `QObject`: мета-объектная система, шесть сигналов и владение по родителю. Объявлен
виртуальный деструктор — класс владеет процессом установщика и открытым файлом журнала.
Найденный профиль передаётся `VersionInstaller`, который докачивает библиотеки, не положенные
установщиком. Когда он отчитается, приходит `finished(loaderKey, gameVersion, loaderVersion,
producedVersionId)`. Бэкенд записывает `producedVersionId` в `resolvedVersionId` сборки.
## Публичные методы
### Если установщик упал
#### ModLoaderInstaller(ModLoaderVersionService \*meta, VersionInstaller \*versionInstaller, QObject \*parent = nullptr)
Все папки, которые он успел создать в `versions/`, удаляются. В текст ошибки идут код выхода,
хвост журнала и путь к полному журналу.
Создаёт установщик поверх двух сервисов. Ни один из них не переходит во владение установщика —
оба обязаны пережить его.
Если подменить zlib не удалось, а в журнале есть `Processor failed` или `invalid outputs`, к
сообщению добавляется прямая подсказка про zlib-ng. Почти наверняка дело в нём.
#### bool isRunning() const
## cancel()
Идёт ли установка прямо сейчас.
Обрывает загрузку, убивает процесс установщика и удаляет то, что он создал. Если ход уже передан
`VersionInstaller`, отменяется и он, а `canceled` придёт после его отчёта.
#### QString label() const
Деструктор убивает процесс установщика, если тот ещё работает.
Подпись текущей установки для интерфейса — название лоадера с версиями.
#### 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);
```
---
+129 -48
View File
@@ -1,71 +1,152 @@
# ModLoaderVersionService
`modloaderversionservice.h`, `modloaderversionservice.cpp`
## Обзор класса
Списки версий Forge, Fabric, NeoForge и Quilt. Устроен так же, как
[VersionManifestService](VersionManifestService.md): качает, хранит копию в
`<root>/cache/loader_<ключ>.json` и отвечает из неё, пока она моложе 6 часов.
Каждый модлоадер публикует свой список версий, и у каждого он устроен по-своему. `ModLoaderVersionService`
приводит все четыре к одному виду: качает списки, кэширует в папке лаунчера и отдаёт из кэша, пока
тот не устарел. Устроен так же, как [VersionManifestService](VersionManifestService.md).
Совместимость с версией игры отдельно не проверяется: данные с самого начала разложены по версиям
игры. Под версию, для которой сборок нет, лежит пустой список, и выбрать несовместимый модлоадер
нечем.
Главная особенность класса — в том, чего в нём нет: отдельной проверки совместимости с версией
игры. Совместимость заложена в структуру данных. Fabric и Quilt отдают список сразу под нужную
версию игры, а `maven-metadata` Forge и NeoForge раскладывается по версиям игры при разборе.
Версии игры, под которую сборок нет, соответствует пустой список — выбрать несовместимый лоадер
физически нечем.
Экземпляр держит [LauncherBackend](LauncherBackend.md). [ModLoaderInstaller](ModLoaderInstaller.md)
берёт отсюда ссылку на `installer.jar` через `find()`.
## Место в проекте и зависимости
## Откуда берутся данные
Подключает [modloader.h](modloader.md): перечисление `ModLoader` и структура `LoaderVersionEntry`
приходят оттуда.
**Fabric и Quilt** спрашиваются отдельно под каждую версию игры:
`meta.fabricmc.net/v2/versions/loader/<игра>` и `meta.quiltmc.org/v3/versions/loader/<игра>`.
Признак стабильности берётся из ответа, а если его нет, угадывается по имени.
Экземпляр создаётся и принадлежит [LauncherBackend](LauncherBackend.md). Ссылку на него получает
[ModLoaderInstaller](ModLoaderInstaller.md) — из записи списка он берёт адрес `installer.jar`.
**Forge** — один `maven-metadata.xml` на все версии игры. Строка `1.20.1-47.4.0` делится по первому
дефису. Параллельно запрашивается `promotions_slim.json`, оттуда берутся отметки recommended. Если
он не ответил, список всё равно сохраняется, только без отметок.
Путь к файлу кэша даёт `LauncherPaths::loaderCacheFile()` из [launcherpaths.h](launcherpaths.md).
**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.
Требования сборки: `Qt6::Core` (`QDateTime`, `QHash`, `QSet`) и `Qt6::Network`
(`QNetworkAccessManager`).
Ссылка на установщик собирается по схеме maven:
`<репозиторий>/<версия>/<артефакт>-<версия>-installer.jar`.
## Иерархия и роль
Версии внутри каждой версии игры отсортированы от новых к старым. Числа сравниваются как числа,
поэтому `47.10` новее `47.9`.
Наследует `QObject`: мета-объектная система, два сигнала и владение по родителю. Виртуальных
методов базового класса не переопределяет.
## ensureLoaded(loader, gameVersion, callback, forceRefresh)
## Псевдонимы типов
Логика та же, что у манифеста версий. Отличие в том, что свежесть у Fabric и Quilt считается для
каждой версии игры отдельно, а у Forge и NeoForge для модлоадера целиком. По этому же ключу
объединяются одновременные запросы.
`ModLoaderVersionService::Callback` — `std::function<void(bool ok, const QString &warning)>`.
Как и у сервиса манифеста, `ok == true` с непустым `warning` означает, что данные отдали из
устаревшего кэша.
- Пустая `gameVersion` — сразу `ok` и пустой список.
- При первом обращении к модлоадеру читается кэш, и если он есть, испускается
`versionsChanged(key, gameVersion)`.
- Если сеть не ответила, но какие-то данные по модлоадеру есть, колбэк получит `ok = true` и
`warning`.
- Если ответ не разобрался, хороший кэш не затирается.
## Публичные методы
`versionsChanged(loaderKey, gameVersion)` испускается после каждого успешного обновления.
`refreshingChanged()` — в начале и в конце запроса. `isRefreshing(loader, gameVersion)` отвечает,
идёт ли запрос по этому ключу.
#### explicit ModLoaderVersionService(QObject \*parent = nullptr)
## Формат кэша
Создаёт сервис и его `QNetworkAccessManager`. Кэши читаются лениво, при первом обращении к
конкретному лоадеру. Конструктор помечен `explicit`.
```json
{
"fetchedAt": "...",
"games": {
"1.20.1": { "fetchedAt": "...", "entries": [ { "loaderVersion": "47.4.0", ... } ] }
}
#### 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);
});
```
Верхний `fetchedAt` используется для Forge и NeoForge, `fetchedAt` внутри версии игры — для Fabric и
Quilt.
---
При создании этого документа использовался ИИ.
+137 -57
View File
@@ -1,80 +1,160 @@
# MsaAuthService
`msaauthservice.h`, `msaauthservice.cpp`
## Обзор класса
Вход через аккаунт Microsoft, то есть с лицензионной копией игры. Цепочка такая же, как у
официального лаунчера: OAuth2 → Xbox Live → XSTS → Minecraft Services → проверка лицензии →
профиль. Результат приходит в `AuthResult` из [AuthService](AuthService.md).
`MsaAuthService` — авторизация через учётную запись Microsoft, то есть вход с лицензионной копией
игры. Это та же цепочка, что и в официальном лаунчере: OAuth2 → Xbox Live → XSTS → Minecraft
Services → проверка лицензии.
Окно входа сервис не показывает. Страницу Microsoft открывает
[MicrosoftLoginDialog](../qml/MicrosoftLoginDialog.md), а сервис даёт адрес этой страницы и умеет
узнать адрес возврата. Класс компилируется всегда, но без Qt WebEngine показать страницу нечем, и
[LauncherBackend](LauncherBackend.md) сразу отвечает ошибкой.
Результат отдаётся тем же `AuthResult`, что и [AuthService](AuthService.md) для Ely.by и офлайна,
поэтому запуск игры дальше идёт по общему пути и ничего не знает о способе входа.
## Чьё это приложение
Класс не показывает окно входа сам: страницу Microsoft открывает
[MicrosoftLoginDialog](../qml/MicrosoftLoginDialog.md) на стороне QML, а сервис даёт ему адрес
страницы и разбирает адрес возврата.
Сейчас используется `client_id` официального лаунчера Mojang (`00000000402b5328`). Это старое
приложение Live Connect, поэтому и адреса от `login.live.com`:
## Место в проекте и зависимости
| Константа | Значение |
|-----------|----------|
| `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` | пусто |
Подключает `authservice.h` — ради общей структуры `AuthResult`. Экземпляр создаётся и принадлежит
[LauncherBackend](LauncherBackend.md).
Это временное решение. Чтобы перейти на своё приложение в Azure (с включённым «Allow public client
flows»), меняются эти шесть констант. Что именно подставить, написано в комментарии в начале
`msaauthservice.cpp`. Главное, что легко пропустить: у нового приложения в `RpsTicket` нужна
приставка `d=`.
Требования сборки: `Qt6::Core` (`QJsonObject`, `QString`, `QUrl`) и `Qt6::Network`
(`QNetworkAccessManager`).
`clientId()` отдаёт этот id наружу. Бэкенд подставляет его в `${clientid}`, потому что официальный
лаунчер передаёт туда id приложения, а не случайный токен.
Сам класс собирается всегда и не зависит от Qt WebEngine — от наличия WebEngine зависит только
окно, в котором показывается страница входа. Поэтому в сборке без WebEngine сервис существует, но
воспользоваться им нельзя: показать страницу нечем.
## Окно входа
## Иерархия и роль
`authorizationUrl()` — адрес страницы входа с `client_id`, `response_type=code`, `redirect_uri`,
`scope` и `prompt=select_account`. Последний параметр нужен, чтобы при повторном входе можно было
выбрать другой аккаунт.
Наследует `QObject`: мета-объектная система, сигнал `progress`, владение по родителю. Виртуальных
методов базового класса не переопределяет.
`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-токену. Дальше путь общий:
## Публичные методы
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. Если игра куплена, значит, игрок ещё не выбрал
ник. Если не куплена, значит, лицензии нет.
#### explicit MsaAuthService(QObject \*parent = nullptr)
`userType` = `msa`, `clientToken` = id приложения.
Создаёт сервис и его `QNetworkAccessManager`. Конструктор помечен `explicit`.
Каждый шаг испускает `progress(message)` до отправки запроса. Таймаут на каждый запрос 20 секунд,
повторных попыток нет. Сетевая ошибка и отказ сервера дают разные тексты.
#### static QString clientId()
Refresh-токен Microsoft живёт намного дольше суточного токена Minecraft, поэтому при обычном
запуске окно входа не нужно: бэкенд вызывает `loginWithRefreshToken()`. Если Microsoft не вернул
новый refresh-токен, бэкенд оставляет старый.
Идентификатор приложения лаунчера в Microsoft. Игра ждёт его в `${clientid}`: официальный лаунчер
подставляет туда именно идентификатор приложения, а не случайный токен сессии.
## Колбэки
#### static QUrl authorizationUrl()
Колбэк вызывается один раз в GUI-потоке. Шаги вызываются из колбэков предыдущих. Если удалить
сервис посреди цепочки, она оборвётся вместе с менеджером сети, и колбэк не вызовется.
Адрес страницы входа для встроенного окна браузера. В адресе уже собраны идентификатор клиента,
`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);
});
}
```
---
+159 -78
View File
@@ -1,94 +1,175 @@
# SeasonalBuildService
`seasonalbuildservice.h`, `seasonalbuildservice.cpp`
## Обзор класса
Каталог сезонных сборок, то есть готовых паков, которые команда публикует на своём файловом
сервере. Сервис читает `<baseUrl>/index.json`, хранит копию в
`<root>/cache/seasonal_builds.json` и отвечает из неё 15 минут.
Кроме сборок, которые пользователь собирает сам, лаунчер умеет ставить готовые сезонные сборки с
собственного файлового сервера: набор модов под конкретную версию игры и модлоадер, подготовленный
заранее и выдаваемый целиком.
Экземпляр держит [LauncherBackend](LauncherBackend.md). Он же сводит строки каталога с
установленными сборками и ставит выбранную.
`SeasonalBuildService` — каталог этих сборок: скачивает `index.json` с файлового сервера, кэширует
в папке лаунчера и отдаёт из кэша, пока тот не устарел. Устройство повторяет
[VersionManifestService](VersionManifestService.md) — включая то, что пустой разбор считается
испорченным ответом и хороший кэш им не затирается.
## Адрес сервера
## Место в проекте и зависимости
По умолчанию `https://launcher-builds.galeonworx.com`. Переопределяется ключом `seasonalBaseUrl` в
`settings.json`, например чтобы проверить установку на локальном HTTP-сервере. Бэкенд передаёт
адрес через `setBaseUrl()` при старте и перед каждым `refreshSeasonalCatalog()`.
Экземпляр создаётся и принадлежит [LauncherBackend](LauncherBackend.md). Записи каталога
используются [SeasonalPackDownloader](SeasonalPackDownloader.md) — оттуда берётся адрес архива и
его контрольная сумма.
Если адрес сменился, накопленные данные сбрасываются, а кэш с другим `baseUrl` не читается: ссылки
в нём ведут на старый сервер. Пустой адрес или адрес без хоста — сразу ошибка.
Путь к файлу кэша даёт `LauncherPaths::seasonalCatalogFile()` из
[launcherpaths.h](launcherpaths.md).
## Формат index.json
Требования сборки: `Qt6::Core` (`QDate`, `QDateTime`, `QHash`, `QUrl`) и `Qt6::Network`
(`QNetworkAccessManager`).
Это контракт с теми, кто публикует сборки.
## Иерархия и роль
```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…"
}
}
]
Наследует `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;
}
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()`.
---
При создании этого документа использовался ИИ.
+149 -19
View File
@@ -1,31 +1,161 @@
# SeasonalPackDownloader
`seasonalpackdownloader.h`, `seasonalpackdownloader.cpp`
## Обзор класса
Качает один архив сезонной сборки в файл и проверяет sha256. Пак весит сотни мегабайт, поэтому
пишется потоком через `QSaveFile`, а не собирается в памяти. Битый архив распаковывать поверх
рабочей `.minecraft` нельзя, поэтому файл появляется на диске только после совпадения хеша.
`SeasonalPackDownloader` скачивает один архив сезонной сборки в файл. Задача узкая и отдельная по
двум причинам: пак — это сотни мегабайт, поэтому он пишется потоком, а не держится в памяти; и его
sha256 обязательно сверяется, потому что распаковывать битую загрузку поверх рабочей `.minecraft`
нельзя.
Распаковывает пак [BuildSwitcher](BuildSwitcher.md). Экземпляр держит
[LauncherBackend](LauncherBackend.md), который и вызывает загрузку на шаге Downloading установки
сезонной сборки.
Распаковкой класс не занимается — это делает [BuildSwitcher](BuildSwitcher.md) в отдельном потоке
вместе с остальными операциями над содержимым папки игры.
## download(url, targetPath, sha256, expectedSize, label)
Набор геттеров прогресса повторяет [VersionInstaller](VersionInstaller.md) и
[JavaInstaller](JavaInstaller.md): панель загрузки в интерфейсе читает их одинаково, независимо от
того, кто сейчас работает.
- `targetPath` — `<root>/builds/<id>/.pack-r<ревизия>.zip`. Если файл уже есть, он будет
перезаписан: недокачанный пак с прошлой попытки не должен дожить до новой.
- `expectedSize` из каталога используется для прогресса, пока сервер не сообщил настоящий размер.
- Таймаут — 60 секунд без данных. Повторных попыток нет: если загрузка оборвалась, установку
нужно начать заново.
## Место в проекте и зависимости
Если загрузка уже идёт, сразу приходит `failed`.
Экземпляр создаётся и принадлежит [LauncherBackend](LauncherBackend.md). Адрес архива, его размер
и контрольную сумму даёт запись каталога от [SeasonalBuildService](SeasonalBuildService.md).
Сигналы: `started(label)`, `progressChanged()` (не чаще раза в 100 мс), `finished(path)`,
`failed(label, message)`, `canceled(label)`. Геттеры прогресса такие же, как у других
установщиков.
Требования сборки: `Qt6::Core` (`QCryptographicHash`, `QSaveFile`, `QTimer`) и `Qt6::Network`
(`QNetworkAccessManager`, `QNetworkReply`).
При ошибке и несовпадении хеша файл удаляется. `cancel()` обрывает запрос, и `canceled` приходит
после его завершения.
## Иерархия и роль
Наследует `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);
```
---
+190 -55
View File
@@ -1,75 +1,210 @@
# VersionInstaller
`versioninstaller.h`, `versioninstaller.cpp`
## Обзор класса
Ставит версию игры в `.minecraft`: описание версии, клиентский jar, библиотеки, индекс ресурсов,
сами ресурсы и конфиг логирования. Одновременно ставится одна версия, остальные ждут в очереди.
Всё работает в GUI-потоке на асинхронной сети.
Установить версию Minecraft — значит положить в `.minecraft` пять групп файлов: описание версии,
клиентский jar, библиотеки, индекс ресурсов и сами ресурсы. Последних — десятки тысяч мелких
файлов.
Экземпляр держит [LauncherBackend](LauncherBackend.md). Ещё им пользуется
[ModLoaderInstaller](ModLoaderInstaller.md): перед установкой модлоадера нужно поставить базовую
версию, а после установки докачать библиотеки нового профиля.
`VersionInstaller` делает это фоном, не блокируя интерфейс: складывает всё нужное в очередь
загрузок, качает несколько файлов параллельно, пишет их потоком на диск и по ходу сообщает
прогресс. Одна версия ставится за раз, остальные ждут в очереди.
## Интерфейс
Класс также разворачивает цепочку наследования: если у версии есть `inheritsFrom`, родительская
версия ставится перед ней.
- `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)`.
Подключает [minecraftversion.h](minecraftversion.md) — по разобранной версии он и понимает, что
качать. В конструктор принимает [VersionManifestService](VersionManifestService.md): оттуда
берётся адрес описания версии.
`started` испускается один раз на всю серию установок из очереди. `finished` испускается для
каждой версии.
Экземпляр создаётся и принадлежит [LauncherBackend](LauncherBackend.md). На него же опирается
[ModLoaderInstaller](ModLoaderInstaller.md) — профиль модлоадера ставится поверх установленной
версии игры.
## Как идёт установка
Требования сборки: `Qt6::Core` (`QCryptographicHash`, `QSaveFile`, `QQueue`, `QTimer`) и
`Qt6::Network` (`QNetworkAccessManager`, `QNetworkReply`).
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()`. Если чего-то нет, это ошибка.
## Иерархия и роль
## Уже скачанные файлы
Наследует `QObject`: мета-объектная система, пять сигналов и владение по родителю. Объявлен
виртуальный деструктор — класс владеет незавершёнными загрузками и обязан их закрыть.
Существующий файл считается целым, если его размер совпадает с ожидаемым (или размер неизвестен).
Хеш не пересчитывается: перехеширование сотен мегабайт ресурсов при каждой установке обходится
дороже, чем редкий битый файл. Для будущей кнопки «Проверить файлы» есть флаг `m_verifyExisting`,
сейчас он всегда `false`.
## Публичные структуры
Пропуск файлов идёт пачками по 256 за такт. Иначе установка версии, у которой уже есть все
ресурсы, повесила бы окно на перебор десятков тысяч записей.
### DownloadTask
## Ошибки и отмена
Один файл, который нужно положить в `.minecraft`.
При ошибке все активные загрузки обрываются, недописанные файлы откатываются (`QSaveFile`), а
**очередь очищается**: остальные версии из неё тоже не поставятся. К тексту ошибки добавляются до
трёх накопленных предупреждений.
| Поле | Тип | По умолчанию | Описание |
|------|-----|--------------|----------|
| `kind` | `DownloadTask::Kind` | `ClientJar` | Что это за файл |
| `url` | `QUrl` | — | Откуда качать |
| `path` | `QString` | — | Абсолютный путь назначения |
| `sha1` | `QString` | — | Контрольная сумма; пустая означает «не проверять» |
| `size` | `qint64` | `0` | Ожидаемый размер; `0` — неизвестен |
| `label` | `QString` | — | Что показать в панели прогресса |
| `attempts` | `int` | `0` | Сколько попыток уже сделано |
Отмена обрывает активные запросы и ждёт, пока все они вернутся. Только после этого приходит
`canceled`. Пока отмена не закончилась, `install()` отвечает отказом.
#### Перечисление 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);
```
---
+144 -44
View File
@@ -1,66 +1,166 @@
# VersionManifestService
`versionmanifestservice.h`, `versionmanifestservice.cpp`
## Обзор класса
Каталог версий Mojang. Скачивает `version_manifest_v2.json`, хранит его копию в папке лаунчера и
отвечает из неё, пока копия свежая.
Каталог версий Minecraft — это манифест Mojang: около тысячи записей от альф 2010 года до
свежайших снапшотов. `VersionManifestService` отвечает за него целиком: скачивает манифест,
кэширует в папке лаунчера и отдаёт из кэша, пока тот не устарел.
Экземпляр держит [LauncherBackend](LauncherBackend.md): из каталога он строит список для окна
выбора версии. [VersionInstaller](VersionInstaller.md) берёт из каталога ссылку на описание версии
через `find()`.
Класс нужен двум потребителям: окну выбора версии, которому нужен весь список, и установщику,
которому по идентификатору версии нужна ссылка на её описание.
## RemoteVersionEntry
## Место в проекте и зависимости
| Поле | Что это |
|------|---------|
| `id` | `1.21.8`, `25w33a`, `b1.7.3` |
| `type` | `release`, `snapshot`, `old_beta`, `old_alpha` |
| `url` | адрес `<id>.json` |
| `sha1` | хеш этого json |
| `releaseTime` | |
Экземпляр создаётся и принадлежит [LauncherBackend](LauncherBackend.md). Ссылку на него получает
[VersionInstaller](VersionInstaller.md) — установщик берёт из манифеста адрес описания версии.
Записи без `id` или с адресом не по `https` отбрасываются. Список отсортирован по `releaseTime`,
новые сверху. При одинаковом времени сортировка идёт по `id`.
Путь к файлу кэша даёт `LauncherPaths::versionManifestFile()` из
[launcherpaths.h](launcherpaths.md).
## ensureLoaded(callback, forceRefresh = false)
Требования сборки: `Qt6::Core` (`QDateTime`, `QHash`, `QList`, `QUrl`) и `Qt6::Network`
(`QNetworkAccessManager`).
Через этот метод сервис получает данные; других способов нет.
## Иерархия и роль
1. При первом вызове читается кэш `<root>/cache/version_manifest.json`. Если он разобрался,
сразу испускается `versionsChanged`, и окно показывает список ещё до ответа сети.
2. Если данные моложе 6 часов и `forceRefresh` не задан, колбэк вызывается через
`QTimer::singleShot(0)` с `ok = true`. Сеть не трогается.
3. Иначе колбэк встаёт в очередь. Если запрос уже идёт, второй не отправляется.
Наследует `QObject`: мета-объектная система, два сигнала и владение по родителю. Виртуальных
методов базового класса не переопределяет.
Ответ сети разбирается, кэш перезаписывается через `QSaveFile`, испускается `versionsChanged`, и
все колбэки из очереди получают результат.
## Публичные структуры
Если сеть не ответила, но старые данные есть, колбэк получит `ok = true` и непустой `warning`:
работать можно, просто список, возможно, неполный. Если данных нет совсем, придёт `ok = false`.
Бэкенд показывает `warning` пользователю только во втором случае.
### RemoteVersionEntry
Таймаут запроса 20 секунд.
Одна строка манифеста Mojang.
## Остальное
| Поле | Тип | Описание |
|------|-----|----------|
| `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` | Дата выпуска; по ней список сортируется новыми вперёд |
- `versions()` — весь список.
- `find(id)` — запись по id (через хеш-таблицу, а не перебором).
- `hasData()`, `isRefreshing()`, `fetchedAt()`.
- `refreshingChanged` испускается в начале и в конце запроса. Бэкенд превращает его в
`catalogLoading`.
Категории `type` — те же ключи, по которым окно выбора версии делит каталог на вкладки; версии, не
попавшие ни в одну из четырёх, интерфейс относит к категории «прочие».
## Формат кэша
## Псевдонимы типов
```json
{
"fetchedAt": "2026-10-11T08:00:00Z",
"versions": [
{ "id": "1.21.8", "type": "release", "url": "https://...", "sha1": "...", "releaseTime": "..." }
]
`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); // список из устаревшего кэша
Хранится только то, что нужно лаунчеру, а не весь манифест Mojang.
const auto entry = manifest->find(QStringLiteral("1.21.8"));
if (entry)
startDownload(entry->url);
});
```
---
-57
View File
@@ -1,57 +0,0 @@
# gamedirimport.h
`gamedirimport.h`, `gamedirimport.cpp`
Пространство имён `GameDirImport`: что уже лежит в папке игры, когда лаунчер запускают впервые.
Состояния нет, сеть не нужна, файлы только читаются. Вызывает его
[LauncherBackend](LauncherBackend.md) при первом запуске, решение описано в
[ADR-0001](../adr/0001-import-existing-minecraft.md).
## detect(gameDir)
Возвращает `std::optional<Detected>`: `minecraftVersion`, `resolvedVersionId`, `loader`,
`loaderVersion` и `source` — откуда взята версия (`launcher_profiles`, `versions` или `content`).
`nullopt` — импортировать нечего: в `versions/` нет ни одной установленной версии и нет признаков
того, что в папке играли. Признаки — непустые `mods`, `saves`, `resourcepacks`, `shaderpacks`,
`config` или файлы `options.txt`, `servers.dat`. Служебные файлы других лаунчеров
(`launcher_log.txt`, `webcache2`) не считаются: иначе пустая папка официального лаунчера давала
бы сборку без версии.
Версия выбирается так:
1. профили из `launcher_profiles.json`, у которых `lastVersionId` установлен, по убыванию
`lastUsed` (профиль без даты считается самым старым). `latest-release` и `latest-snapshot` без
`lastVersionId` пропускаются: без манифеста не узнать, во что они разворачивались;
2. установленные версии по убыванию времени правки `<id>/<id>.json`.
Версия с нечитаемым описанием пропускается. Если в `mods/` есть хотя бы один `.jar`, сначала
ищется профиль с `inheritsFrom` (сперва в первом списке, потом во втором), и только потом берётся
что есть. Не нашлось ничего — `source` равен `content`, поля версии пустые.
У профиля с `inheritsFrom` версия игры — это `inheritsFrom`, а `resolvedVersionId` — его `id`,
поэтому запустится ровно найденный профиль, даже если модлоадер не опознан (OptiFine, LabyMod).
У профиля без `inheritsFrom` версия — сам `id`.
## loaderOf(versionJson)
Модлоадер по `libraries[].name` описания профиля:
| Библиотека | `key` | `version` |
|------------|-------|-----------|
| `net.fabricmc:fabric-loader:<v>` | `fabric` | `<v>` |
| `org.quiltmc:quilt-loader:<v>` | `quilt` | `<v>` |
| `net.neoforged:neoforge:<v>` | `neoforge` | `<v>` |
| `net.neoforged:forge:<mc>-<v>` | `neoforge` | `<v>` (NeoForge для 1.20.1) |
| `net.minecraftforge:forge:<mc>-<v>` | `forge` | `<v>` |
Префикс `<mc>-` срезается, хвост `-1.7.10` у старых Forge остаётся: в каталоге
[ModLoaderVersionService](ModLoaderVersionService.md) версии записаны так же, и карточка сборки
найдёт выбранную строку.
Если таких библиотек нет, смотрит на `id`, который дают установщики: `fabric-loader-<v>-<mc>`,
`quilt-loader-<v>-<mc>`, `neoforge-<v>`, `<mc>-forge-<v>`. Не опознано — пустой `key`.
---
При создании этого документа использовался ИИ.
+57 -40
View File
@@ -1,59 +1,76 @@
# JavaLocator
# javalocator.h — JavaLocator
`javalocator.h`, `javalocator.cpp`
## Обзор
Поиск java, уже установленной на машине, и выбор подходящей под версию игры. Скачиванием
занимается [JavaInstaller](JavaInstaller.md), а здесь только смотрим, что есть.
Для запуска Minecraft нужна Java подходящей версии: старым версиям игры — восьмая, новым —
семнадцатая или двадцать первая. Лаунчер умеет скачивать сборки Java сам (см.
[JavaInstaller](JavaInstaller.md)), но сначала стоит посмотреть, что уже есть на машине.
Это пространство имён со свободными функциями. Его используют [GameLauncher](GameLauncher.md) при
запуске, [LauncherBackend](LauncherBackend.md) для списка в настройках и для шага Java в установке
сезонной сборки.
`JavaLocator` отвечает за поиск: собирает все доступные java, определяет их версии и выбирает
подходящую под требование версии игры. Пространство имён используется при подготовке запуска и при
показе списка найденных Java в настройках.
## findAll(gameDir)
## Пространства имён
Список путей к исполняемому `java` (`java.exe` на Windows) без повторов. Порядок такой:
`JavaLocator` группирует четыре функции поиска и выбора. Состояния нет, но результат определения
версии кэшируется внутри реализации.
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`.
## Функции
В каждом каталоге установки проверяются `bin`, `Contents/Home/bin` (так устроены JDK на macOS) и
`jre/bin`.
#### QStringList findAll(const QString &gameDir)
## majorVersion(javaPath)
Все java, которые удалось найти, без дублей и в порядке приоритета. Просматриваются четыре
источника: рантайм самого Minecraft внутри папки игры, переменная `JAVA_HOME`, переменная `PATH` и
стандартные каталоги установки JDK и JRE.
Запускает `java -version` и достаёт мажорную версию из строки `version "..."`. Старая схема
`1.8.0_x` даёт 8. Если процесс не ответил за 5 секунд или вывод не разобрался, возвращается 0.
Параметр `gameDir` — папка игры; из неё берётся первый источник.
Результат кэшируется в статической таблице на всё время работы лаунчера. Если java по тому же пути
обновить, лаунчер узнает об этом только после перезапуска. Кэш не защищён от гонок, поэтому
функцию можно вызывать только из одного потока, и в проекте это GUI-поток.
Результат показывается в диалоге настроек списком «что нашлось в системе».
## select(gameDir, requiredMajor, preferred, &error)
#### int majorVersion(const QString &javaPath)
Выбирает java для запуска.
Мажорная версия java по её пути: 8, 17, 21 и так далее. Возвращает `0`, если запустить
исполняемый файл не удалось — путь неверен, файл не исполняемый или это не java.
- Если `preferred` задан, он идёт первым кандидатом. Это может быть путь к файлу или к домашней
папке JDK (тогда берётся `bin/java`). Если по этому пути ничего нет, функция сразу возвращает
ошибку и других кандидатов не смотрит: пользователь явно указал путь, и молча подменять его
было бы неправильно.
- Дальше добавляется всё из `findAll()`.
- Возвращается первый кандидат с версией не ниже `requiredMajor`. Если таких нет, в `error`
пишется, какая самая новая java нашлась и где.
Результат кэшируется: определение версии требует запуска процесса, а один и тот же путь
проверяется многократно.
Функция возвращает первую подходящую, а не самую новую. Поэтому скачанная лаунчером Java 17
выиграет у системной Java 21, если игре нужна 17.
#### QString windowlessVariant(const QString &javaPath)
## windowlessVariant(javaPath)
Заменяет `java.exe` на `javaw.exe`, чтобы игра не открывала окно консоли. На Unix возвращает вход
без изменений — там разницы нет.
На Windows заменяет `java.exe` на `javaw.exe` из той же папки, если он там есть. Тогда при запуске
игры не появляется консольное окно. На других системах возвращает путь без изменений.
#### 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);
```
---
+135 -76
View File
@@ -1,94 +1,153 @@
# javaruntime.h
# javaruntime.h — JavaRuntime, JavaRuntimeStore и JavaRequirement
`javaruntime.h`, `javaruntime.cpp`
## Обзор
Общие типы для Java: виды сборок, строка каталога, запись об установленной сборке, работа с папкой
`<root>/java` и таблица «какая Java нужна какой версии игры».
`Minecraft_launcher` умеет скачивать Java сам: официальные сборки Mojang — те же, которыми игру
запускает официальный лаунчер, — и сборки Eclipse Temurin в вариантах JDK и JRE. Скачанные сборки
живут в папке лаунчера, по одной подпапке на сборку.
## Виды сборок
Заголовок `javaruntime.h` — словарь этой части проекта. Он даёт перечисление видов сборок, две
структуры (строка каталога и уже установленная сборка), пространство имён для работы с папкой
`<root>/java` и таблицу требований версий игры к Java.
| `JavaRuntimeKind` | Ключ | Что это |
|-------------------|------|---------|
| `Mojang` | `java` | сборки, которые Mojang раздаёт для своего лаунчера |
| `Jdk` | `jdk` | Eclipse Temurin JDK |
| `Jre` | `jre` | Eclipse Temurin JRE |
Заголовок подключают [JavaRuntimeService](JavaRuntimeService.md) (каталог),
[JavaInstaller](JavaInstaller.md) (скачивание и распаковка) и
[LauncherBackend](LauncherBackend.md) (отдача каталога в QML).
`javaKindKey()`, `javaKindFromKey()`, `javaKindTitle()` переводят между перечислением, ключом и
подписью для интерфейса. Ключ хранится в `runtime.json` установленной сборки и используется в QML.
## Типы
## JavaRuntimeEntry
| Имя | Вид | Описание |
|-----|-----|----------|
| `JavaRuntimeKind` | `enum class` | Откуда взялась сборка и что именно в ней лежит |
| `JavaRuntimeEntry` | `struct` | Строка каталога — то, что можно скачать |
| `InstalledJavaRuntime` | `struct` | Сборка, уже распакованная в папке лаунчера |
Строка каталога [JavaRuntimeService](JavaRuntimeService.md), то есть то, что можно скачать.
### JavaRuntimeKind
| Поле | Что это |
|------|---------|
| `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` | |
| Значение | Ключ | Описание |
|----------|------|----------|
| `Mojang` | `java` | Тот же рантайм, которым игру запускает официальный лаунчер |
| `Jdk` | `jdk` | Eclipse Temurin JDK: компилятор и инструменты в комплекте |
| `Jre` | `jre` | Eclipse Temurin JRE: только то, что нужно для запуска |
## InstalledJavaRuntime
Эти же ключи служат именами категорий в окне выбора Java.
Скачанная сборка: `id`, `kind` (ключ), `major`, `version`, `javaPath` (абсолютный путь к
`java`/`java.exe`), `size` (сколько заняла на диске).
### JavaRuntimeEntry
## JavaRuntimeStore
| Поле | Тип | Описание |
|------|-----|----------|
| `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` | Версия с длительной поддержкой; помечается в списке |
Каждая сборка лежит в `<root>/java/<id>/`, а рядом с ней `runtime.json`:
Метод `isValid()` возвращает `true`, когда заполнен `id`, `major` больше нуля и `url` корректен.
```json
{ "id": "...", "kind": "jdk", "major": 21, "version": "21.0.7", "javaPath": "bin/java", "size": 197132288 }
### 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);
}
```
`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 потянет.
---
При создании этого документа использовался ИИ.
+128 -61
View File
@@ -1,77 +1,144 @@
# launcherpaths.h
# launcherpaths.h — LauncherPaths
`launcherpaths.h`, `launcherpaths.cpp`
## Обзор
Все пути к данным лаунчера. Пространство имён `LauncherPaths` из свободных функций, состояния нет:
каждая функция просто собирает строку.
`Minecraft_launcher` хранит собственные данные отдельно от папки игры: настройки, профили, описания
сборок, архивы содержимого `.minecraft`, скачанные сборки Java и кэши каталогов. Пространство имён
`LauncherPaths` — единственное место, где эти пути вычисляются.
Данные лаунчера лежат в папке `BiLauncher` рядом со стандартной папкой игры, а не в системном
каталоге данных приложения. `QStandardPaths` не используется, поэтому имя приложения и организации
из `main.cpp` на эти пути не влияют.
Собственная папка `BiLauncher` лежит рядом со стандартной `.minecraft`, а не в системном
каталоге данных приложения: так все файлы лаунчера остаются там же, где сама игра, и переносятся
вместе с ней.
## Где что лежит
К этому заголовку обращается почти каждый сервис проекта — везде, где нужно прочитать или записать
файл в папке лаунчера.
`containerDir()` — папка, в которой живут и игра, и лаунчер:
## Пространства имён
| ОС | `containerDir()` | папка игры по умолчанию | `rootDir()` |
|----|------------------|-------------------------|-------------|
| Windows | `%USERPROFILE%/AppData/Roaming` | `.minecraft` | `BiLauncher` |
| macOS | `~/Library/Application Support` | `minecraft` (без точки, как у официального лаунчера) | `BiLauncher` |
| Linux | `~` | `.minecraft` | `BiLauncher` |
`LauncherPaths` группирует функции, возвращающие абсолютные пути, и одну функцию создания корневой
папки. Состояния у пространства имён нет: все функции вычисляют путь заново при каждом вызове.
На Linux папка лаунчера получается `~/BiLauncher`, без точки, то есть видимая.
## Функции
## BILAUNCHER_HOME
### Корневые каталоги
`homeOverride()` читает переменную окружения `BILAUNCHER_HOME`. Если в ней абсолютный путь, он
заменяет домашнюю папку (`~` и `%USERPROFILE%` в таблице выше) для всех путей лаунчера, включая
папку игры по умолчанию. Пустое или относительное значение не учитывается. Остальной Qt
(`QDir::homePath()`, `QStandardPaths`) эту переменную не видит.
#### QString containerDir()
Переменная нужна тестам и дымовой проверке на Windows: там `QDir::homePath()` спрашивает профиль
у системы и `USERPROFILE` игнорирует, так что иначе их нельзя увести из настоящего
`%APPDATA%\BiLauncher`. Новых возможностей она не даёт: кто управляет окружением процесса, на
Linux и macOS и так подменит `HOME`. Чтобы случайно оставленную переменную было видно, `main.cpp`
при старте пишет в лог предупреждение с итоговой папкой лаунчера.
Родительская папка, в которой лежит `.minecraft`, а рядом с ней — `BiLauncher`. От неё
отсчитываются и папка игры по умолчанию, и корень данных лаунчера.
Внутри `rootDir()`:
#### QString 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`.
---
+89 -29
View File
@@ -1,36 +1,96 @@
# main.cpp
# main.cpp — точка входа
Точка входа. Объектов предметной области здесь нет: `LauncherBackend` создаёт сам
[Main.qml](../qml/Main.md), а `main()` только готовит приложение и загружает QML.
## Обзор
## По порядку
`Minecraft_launcher` — десктопный лаунчер Minecraft на Qt 6 Quick. Интерфейс написан на QML,
вся работа — авторизация, скачивание версий, установка модлоадеров и Java, запуск игры — лежит в
C++-классе [LauncherBackend](LauncherBackend.md) и его сервисах.
1. **WebEngine.** Если лаунчер собран с Qt WebEngine (`LAUNCHER_HAS_WEBENGINE`),
`QtWebEngineQuick::initialize()` вызывается до создания `QGuiApplication`. После создания
приложения общий контекст OpenGL уже не выставить.
2. **`QGuiApplication`**, а не `QApplication`: виджетов в проекте нет.
3. **Имя и версия.** `applicationName` = `BiLauncher`, `applicationVersion` = `LAUNCHER_VERSION`
(в CMake это `PROJECT_VERSION` плюс `LAUNCHER_VERSION_SUFFIX`, например `1.0.1-dev`; QML читает её
через `Qt.application.version`), организация `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()`.
`main.cpp` — стартовая последовательность приложения: здесь при необходимости инициализируется
Qt WebEngine, создаётся объект приложения, читается каталог переводов, выбирается стиль
Qt Quick Controls, загружается QML-модуль и запускается цикл событий. Файл намеренно короткий:
ни одного объекта предметной области он не создаёт — всё, что нужно, QML заводит сам.
Аргументы командной строки не разбираются. Контекстных свойств нет: QML общается с C++ только
через зарегистрированные типы `LauncherBackend` и `Loc`.
## Настройка приложения 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`.
---
+107 -74
View File
@@ -1,102 +1,135 @@
# minecraftversion.h
# minecraftversion.h — MinecraftVersion и VersionLoader
`minecraftversion.h`, `minecraftversion.cpp`
## Обзор
Чтение описаний версий из `<gameDir>/versions/<id>/<id>.json` и всё, что с ними связано: правила
Mojang, нативные библиотеки под текущую платформу, список установленного, удаление.
Каждая версия Minecraft описывается файлом `versions/<id>/<id>.json` — в нём главный класс,
аргументы запуска, список библиотек, индекс ресурсов и требуемая версия Java. Файлы образуют
цепочку: профиль модлоадера наследуется от версии игры через поле `inheritsFrom`, а та, в свою
очередь, может наследоваться дальше.
Профиль модлоадера обычно содержит только отличия от базовой версии и ссылается на неё через
`inheritsFrom`. `VersionLoader::load()` проходит эту цепочку и сводит её в одну структуру
`MinecraftVersion`, с которой уже работают [GameLauncher](GameLauncher.md) и
[VersionInstaller](VersionInstaller.md).
Заголовок `minecraftversion.h` даёт две вещи: структуры, описывающие версию в разобранном виде, и
пространство имён `VersionLoader` — чтение, разворачивание цепочки наследования и работа с
установленными версиями на диске.
## MinecraftLibrary
Заголовок подключают [GameLauncher](GameLauncher.md) (собирает из версии командную строку),
[VersionInstaller](VersionInstaller.md) (по списку библиотек понимает, что качать) и
[LauncherBackend](LauncherBackend.md) (проверяет комплектность и удаляет версии).
Одна библиотека, к которой уже применены правила.
## Типы
| Поле | Что это |
|------|---------|
| `name` | Maven-координаты, например `org.lwjgl:lwjgl:3.3.1:natives-windows` |
| `path` | путь относительно `libraries/` |
| `url` | откуда качать; может быть пустым |
| `sha1`, `size` | для проверки; могут отсутствовать |
| `native` | распаковывается в `natives`, в classpath не идёт |
| `extractExclude` | префиксы путей внутри jar, которые не распаковываются |
| Имя | Вид | Описание |
|-----|-----|----------|
| `MinecraftLibrary` | `struct` | Одна библиотека из `client.json` с уже разрешёнными правилами |
| `MinecraftVersion` | `struct` | `client.json`, «схлопнутый» по всей цепочке `inheritsFrom` |
## MinecraftVersion
### MinecraftLibrary
| Поле | Что это |
|------|---------|
| `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` |
| Поле | Тип | Описание |
|------|-----|----------|
| `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` | Префиксы путей внутри архива, которые не распаковываются |
`isValid()` возвращает `true`, если заданы `id` и `mainClass`.
### MinecraftVersion
## Как сводится цепочка
| Поле | Тип | Описание |
|------|-----|----------|
| `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` | Версия принимает аргументы размера окна |
`load(gameDir, versionId, features, &error)` идёт от запрошенной версии к родителям по
`inheritsFrom`. Ограничения: не больше 10 звеньев, без циклов. Если файл отсутствует или не
разбирается, это ошибка, и возвращается невалидный объект.
Метод `isValid()` возвращает `true`, когда заполнены и `id`, и `mainClass`: именно этим проверяется
успешность чтения версии.
Дальше поля применяются от корня к потомку:
## Функции
- `mainClass`, `type`, `assets`, `assetIndex`, `javaVersion`, `logging` — потомок перекрывает
родителя;
- `minecraftArguments` (старый формат, одна строка) **заменяет** `gameArgs` целиком;
- `arguments.jvm` и `arguments.game` (новый формат) **дописываются** к тому, что дал родитель.
Пространство имён `VersionLoader`.
Библиотеки собираются в обратном порядке, от потомка к корню. Повторы убираются по ключу
`группа:артефакт[:классификатор]`, без версии. Поэтому если Forge приносит свою версию библиотеки,
она вытесняет ванильную.
#### QStringList installedVersions(const QString &gameDir)
Если среди JVM-аргументов нет `-cp`, значит это старая версия без блока `arguments.jvm`. Тогда
добавляются аргументы, которые официальный лаунчер подставляет сам: `-Djava.library.path`, бренд и
версия лаунчера, `-cp ${classpath}`.
Версии, реально установленные в `<gameDir>/versions`: есть и папка, и файл `<id>.json`. Одной
только папки недостаточно — она остаётся после неудачной установки.
Клиентский jar берётся первый найденный по цепочке, от потомка к корню. Если его нет нигде,
указывается путь у корня. `GameLauncher::missingFiles()` потом скажет, что его не хватает.
#### QStringList dependentsOf(const QString &gameDir, const QString &versionId)
`features` нужен для правил вида `"features": {"has_custom_resolution": true}`. Бэкенд передаёт
`has_custom_resolution`, если в настройках задан размер окна.
Установленные профили, у которых `inheritsFrom` равен `versionId`. Без базовой версии они не
запустятся, поэтому список показывается пользователю перед удалением версии.
## Правила и натив
#### qint64 installedSize(const QString &gameDir, const QString &versionId)
`rulesAllow(rules, features)` реализует алгоритм Mojang: правила проверяются по порядку,
действует последнее совпавшее; пустой список означает «разрешено». Правило совпадает, если подошли
`features`, `os.name`, `os.arch` и регулярное выражение `os.version` (сверяется с
`QSysInfo::productVersion()`).
Размер `<gameDir>/versions/<id>` в байтах; `0`, если папки нет. Используется в предупреждении об
удалении — версия весит десятки мегабайт, и стоит показать, сколько освободится.
Нативные библиотеки встречаются в двух форматах:
#### bool remove(const QString &gameDir, const QString &versionId, QString \*error)
- старый: блок `natives` с классификатором под ОС, `${arch}` заменяется на `32` или `64`;
- новый (LWJGL 3.3+): отдельные библиотеки с классификатором `natives-<ос>[-<арх>]`.
Сносит `<gameDir>/versions/<id>`. Отсутствие папки считается успехом. При неудаче возвращает
`false` и заполняет `error`.
`pickNativesForCurrentArch()` оставляет для каждого артефакта один классификатор. Лучше всего
точный (`nativeClassifier()`, например `natives-macos-arm64`), потом общий для ОС
(`natives-macos`), потом какой попадётся.
Библиотеки и ресурсы в `libraries/` и `assets/` не трогаются: они общие для всех версий.
`osName()` возвращает `windows`, `osx` или `linux`. `osArch()` возвращает архитектуру из
`QSysInfo`, причём `i386` и `i686` сводятся к `x86`.
#### MinecraftVersion load(const QString &gameDir, const QString &versionId, const QSet&lt;QString&gt; &features, QString \*error)
## Установленные версии
Читает версию и разворачивает всю цепочку `inheritsFrom` в один объект. Параметр `features` —
набор включённых возможностей, влияющих на применение правил (например, запрошен ли пользовательский
размер окна).
- `installedVersions(gameDir)` — подпапки `versions/`, в которых есть `<id>.json`, по алфавиту.
- `dependentsOf(gameDir, id)` — установленные профили с `inheritsFrom == id`. Без базовой версии они
не запустятся, поэтому их перечисляют в предупреждении об удалении.
- `installedSize(gameDir, id)` — размер папки в байтах.
- `remove(gameDir, id, &error)` — удаляет `versions/<id>`. Сначала проверяет, что id не пустой,
не начинается с точки и не содержит слэшей, а итоговый путь лежит внутри `versions/`. Если папки
нет, это не ошибка.
При ошибке возвращает объект, у которого `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;
```
---
+72 -29
View File
@@ -1,43 +1,86 @@
# modloader.h
# modloader.h — ModLoader
Перечисление модлоадеров, запись одной версии модлоадера и перевод между перечислением и
строковым ключом. Только заголовок, `.cpp` нет.
## Обзор
## ModLoader
`Minecraft_launcher` умеет ставить четыре модлоадера: Minecraft Forge, Fabric Loader, NeoForge и
Quilt Loader. Заголовок `modloader.h` — общий словарь для всей этой части проекта: перечисление
самих лоадеров, описание одной их сборки и функции перевода между перечислением и строковым
ключом.
`Forge`, `Fabric`, `NeoForge`, `Quilt`.
Лоадеры взаимоисключающи: игра запускается ровно с одним профилем в `<gameDir>/versions`, поэтому в
сборке лаунчера хранится один ключ лоадера, а не набор.
Модлоадеры взаимоисключающие: игра запускается с одним профилем из `versions/`, поэтому у сборки
может быть не больше одного модлоадера. [LoaderRow](../qml/LoaderRow.md) снимает отметку с
остальных, когда пользователь отмечает один.
Заголовок подключают [ModLoaderVersionService](ModLoaderVersionService.md),
[ModLoaderInstaller](ModLoaderInstaller.md) и [LauncherBackend](LauncherBackend.md).
## Ключи и названия
## Типы
| Значение | `loaderKey()` | `loaderTitle()` |
|----------|---------------|-----------------|
| `Forge` | `forge` | Minecraft Forge |
| `Fabric` | `fabric` | Fabric Loader |
| `NeoForge` | `neoforge` | NeoForge |
| `Quilt` | `quilt` | Quilt Loader |
| Имя | Вид | Описание |
|-----|-----|----------|
| `ModLoader` | `enum class` | Модлоадеры, которые лаунчер умеет ставить |
| `LoaderVersionEntry` | `struct` | Одна сборка модлоадера под конкретную версию игры |
Ключ используется в QML, в имени файла кэша (`cache/loader_<ключ>.json`) и в поле `loader` записи
сборки в `customBuilds.json`. Если его поменять, старые сборки и кэши перестанут читаться.
### ModLoader
`loaderFromKey(key)` возвращает `std::nullopt` для любой другой строки, в том числе пустой. В
бэкенде это значит «ванильная сборка».
| Значение | Ключ | Название | Описание |
|----------|------|----------|----------|
| `Forge` | `forge` | Minecraft Forge | Старейший загрузчик; ставится собственным установщиком, который собирает часть файлов на месте |
| `Fabric` | `fabric` | Fabric Loader | Лёгкий загрузчик; профиль версии формируется из метаданных без запуска установщика |
| `NeoForge` | `neoforge` | NeoForge | Ответвление Forge; ставится так же собственным установщиком |
| `Quilt` | `quilt` | Quilt Loader | Ответвление Fabric; ставится так же, как Fabric |
Названия не переводятся: это имена собственные.
Перечисление объявлено как `enum class`, поэтому неявного приведения к целому нет.
## LoaderVersionEntry
### LoaderVersionEntry
| Поле | Что это |
|------|---------|
| `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 |
| Поле | Тип | Описание |
|------|-----|----------|
| `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;
```
---
+53 -40
View File
@@ -1,62 +1,75 @@
# zlibreference.h
# zlibreference.h — ZlibReference
`zlibreference.h`, `zlibreference.cpp`
## Обзор
Обход одной конкретной проблемы на Linux: Forge и NeoForge не ставятся на системах, где вместо
zlib стоит zlib-ng.
`Minecraft_launcher` умеет ставить модлоадеры Forge и NeoForge, а их установщики — это Java-программы,
которые собирают часть jar-файлов прямо на машине пользователя и сверяют sha1 каждого собранного
файла с эталоном из `install_profile.json`.
## В чём проблема
Эталон посчитан на обычном zlib. Дистрибутивы вроде CachyOS и Fedora подставляют вместо него
zlib-ng: сжатие корректное, но побайтово другое — поэтому установка падает на любой версии игры
сообщением «Processor failed, invalid outputs». Сменить Java не выйдет: сборки OpenJDK под Linux
берут `libz.so.1` из системы.
Установщики Forge и NeoForge собирают часть jar прямо на машине пользователя и сверяют sha1
каждого собранного файла с эталоном из `install_profile.json`. Эталон посчитан на обычном zlib.
CachyOS, Fedora и некоторые другие дистрибутивы ставят вместо него zlib-ng в режиме совместимости.
Он сжимает корректно, но результат побайтово другой. Поэтому установка падает на любой версии игры
с сообщением `Processor failed, invalid outputs`.
`ZlibReference` — обход этой проблемы. Рядом с лаунчером лежит собранный обычный zlib, и процессу
установщика он подсовывается через `LD_PRELOAD`. Системный zlib-ng при этом остаётся на месте:
подмена живёт ровно один процесс.
Другая Java не помогает: сборки OpenJDK под Linux берут `libz.so.1` из системы.
Пространство имён используется установщиком модлоадеров при подготовке окружения дочернего
процесса Java.
## Что сделано
## Пространства имён
В репозитории лежат исходники zlib 1.3.1 (каталог `zlib/`). На Linux CMake собирает из них цель
`launcher_zlib_reference` (`libz.so.1`). В дереве сборки она лежит в `<папка бинарника>/zlib/`, при
установке — в `<prefix>/lib/BiLauncher/`. Сам лаунчер с ней не линкуется. Библиотека
подкладывается через `LD_PRELOAD` только процессу установщика, системный zlib-ng не трогается.
Исходники лежат в репозитории, а не скачиваются при сборке, по двум причинам: сборка не зависит от
сети, и версия зафиксирована. От версии зависит побайтовый результат сжатия.
`ZlibReference` группирует три функции: проверку системной библиотеки, поиск собранного эталона и
подготовку окружения процесса. Состояния нет.
## Функции
На Windows и macOS все три функции ничего не делают: `systemIsZlibNg()` возвращает `false`,
`bundledPath()` пустую строку, `applyTo()` — `true`.
#### bool systemIsZlibNg()
### systemIsZlibNg()
Отвечает на вопрос, окажется ли `libz.so.1`, который достанется процессу java, библиотекой zlib-ng.
От ответа зависит, нужна ли подмена вообще: на системе с обычным zlib она бессмысленна.
Открывает `libz.so.1` через `dlopen`, то есть ту же библиотеку, которую загрузчик выдаст процессу
java, и смотрит, есть ли `zlib-ng` в строке `zlibVersion()`. Результат вычисляется один раз за
запуск.
#### QString bundledPath()
### bundledPath()
Путь к собранному рядом эталонному `libz.so.1` или пустая строка, если его нет. Библиотека
собирается целью `launcher_zlib_reference` в `CMakeLists.txt` из исходников каталога `zlib/`,
которые лежат в репозитории, чтобы сборка не зависела от сети, а версия была зафиксирована — от
неё зависит побайтовый результат сжатия. Собирается только на Linux: на Windows и macOS проблемы
подмены системного zlib нет.
Ищет эталонную библиотеку по порядку:
#### bool applyTo(QProcessEnvironment &env, QString *note = nullptr)
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.
Дописывает `LD_PRELOAD` в переданное окружение, если подмена нужна. Изменяет `env` на месте.
Второй вариант рассчитан на `CMAKE_INSTALL_LIBDIR=lib`. Поэтому CI на Ubuntu передаёт его явно,
иначе библиотека легла бы в `lib/x86_64-linux-gnu`.
Возвращает `true`, когда окружение готово, — в том числе в случае, когда подменять нечего:
система с обычным zlib или платформа, где вопрос не стоит. `false` означает, что подмена нужна, но
эталонной библиотеки нет на месте.
### applyTo(env, &note)
Необязательный параметр `note` заполняется строкой, пригодной и для журнала, и для текста ошибки:
она объясняет, была ли подмена применена и почему.
- zlib-ng в системе нет — подменять нечего, возвращает `true`, `note` пуст.
- zlib-ng есть, эталон найден — дописывает его путь в начало `LD_PRELOAD` (чужое содержимое
переменной сохраняется), возвращает `true`, в `note` пишет, что подставлено.
- zlib-ng есть, эталона нет — возвращает `false`, в `note` пишет предупреждение.
## Зависимости
[ModLoaderInstaller](ModLoaderInstaller.md) пишет `note` в журнал установщика. Если `applyTo()`
вернула `false`, а установщик упал с ошибкой контрольных сумм, к ошибке добавляется прямая
подсказка про zlib-ng.
Подключает `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);
```
---
+132 -115
View File
@@ -1,147 +1,164 @@
# BiLauncher: устройство проекта
# Minecraft Launcher — справочник по исходному коду
Лаунчер Minecraft на Qt 6. Интерфейс на QML. Всё остальное, то есть вход в аккаунт, загрузка
версий, установка модлоадеров и Java, смена сборок и запуск игры, написано на C++.
Десктопный лаунчер Minecraft на Qt 6. Интерфейс написан на QML, вся работа — авторизация,
скачивание версий, установка модлоадеров и Java, запуск игры — лежит в C++.
Здесь описано, как устроен код. Как собрать проект и выпустить релиз, рассказано в
[build-and-release.md](build-and-release.md), как устроены и запускаются автотесты — в
[testing.md](testing.md).
Документация разделена на две части: [QML-компоненты](#qml-компоненты) и
[C++-классы](#c-классы).
## Общая схема
## Как устроено приложение
```
QML (Main.qml и диалоги)
│ свойства, вызовы, сигналы
▼
LauncherBackend ──────────────── Loc (Localization)
│ владеет
▼
сервисы: каталоги, установщики, авторизация, BuildSwitcher, GameLauncher
│
▼
сеть, файлы, дочерние процессы
```
Слоёв четыре, и каждый общается только с соседними:
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`.
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++. Он ни от чего не зависит и доступен всем слоям сразу.
Отдельно стоит [Localization](cpp/Localization.md), синглтон `Loc`. Через него идут все тексты
интерфейса: `Loc.t.домен.вид.имя` в QML и `Loc::text("домен.вид.имя")` в C++. Он ни от чего не
зависит, и обращаться к нему можно из любого слоя.
4. **Внешний мир.** Сеть (Mojang, Ely.by, Microsoft, Adoptium, файловый сервер сборок), файловая
система (`.minecraft` и папка лаунчера) и дочерние процессы (java, установщики модлоадеров,
`tar`).
Почти всё работает в GUI-потоке. Параллельность даёт асинхронная сеть, а не потоки. Исключение —
файловые операции над `.minecraft` (это могут быть гигабайты модов и миров). Они вынесены в
отдельный поток в [BuildArchiveWorker](cpp/BuildArchiveWorker.md).
Почти весь код работает в потоке GUI: параллелизм даёт асинхронная сеть, а не потоки. Единственное
исключение — операции над содержимым `.minecraft` (гигабайты модов и миров), вынесенные в
отдельный поток к [BuildArchiveWorker](cpp/BuildArchiveWorker.md).
## Основные понятия
## QML-компоненты
**Профиль** — игрок и способ входа: офлайн, Ely.by или Microsoft.
| Компонент | Описание |
|-----------|----------|
| [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) | Подпись и поле ввода одной колонкой |
**Сборка** — версия игры, модлоадер (или без него), адрес сервера и собственное содержимое
`.minecraft`: моды, конфиги, миры. Активная сборка одна, её файлы разложены в `.minecraft`, у
остальных файлы лежат в архивах в папке лаунчера. Папки `versions`, `libraries`, `assets`,
`runtime` общие для всех сборок. См. [BuildSwitcher](cpp/BuildSwitcher.md).
## C++-классы
**Сезонная сборка** — готовая сборка, которую публикует команда на своём сервере. Ставится одной
кнопкой вместе с версией, модлоадером и Java, обновляется по номеру ревизии. Формат каталога
описан в [SeasonalBuildService](cpp/SeasonalBuildService.md).
### Точка входа
**Папка лаунчера** — `BiLauncher` рядом с `.minecraft`. Что в ней лежит, расписано в
[launcherpaths.h](cpp/launcherpaths.md).
| Файл | Описание |
|------|----------|
| [main.cpp](cpp/main.md) | Инициализация WebEngine, объект приложения, каталог переводов, стиль `Basic`, загрузка QML-модуля |
## 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 во встроенном браузере; только с WebEngine |
| [LoaderRow](qml/LoaderRow.md) | строка модлоадера в карточке сборки |
| [ProgressPanel](qml/ProgressPanel.md) | плашка прогресса |
| [DarkCombo](qml/DarkCombo.md) | выпадающий список в стиле окна |
| [LabelledField](qml/LabelledField.md) | подпись и поле ввода |
| Класс | Описание |
|-------|----------|
| [Localization](cpp/Localization.md) | Синглтон `Loc`: все тексты интерфейса в одном файле `i18n/translations.json`, переключение языка на лету |
| [LauncherBackend](cpp/LauncherBackend.md) | Единственный тип, видимый из QML: 26 свойств, 42 вызываемых метода, состояние лаунчера и порядок работы всех сервисов |
## C++
### Авторизация и запуск
### Вход и фасад
| | |
|---|---|
| [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) | командная строка, нативные библиотеки, процесс игры |
| Класс | Описание |
|-------|----------|
| [AuthService](cpp/AuthService.md) | Вход через Ely.by и офлайн-режим; структура `AuthResult` |
| [MsaAuthService](cpp/MsaAuthService.md) | Вход через аккаунт Microsoft: OAuth2 → Xbox Live → XSTS → Minecraft Services |
| [GameLauncher](cpp/GameLauncher.md) | Сборка командной строки, распаковка нативных библиотек и запуск JVM |
### Версии игры
| | |
|---|---|
| [VersionManifestService](cpp/VersionManifestService.md) | каталог версий Mojang |
| [VersionInstaller](cpp/VersionInstaller.md) | установка версии: jar, библиотеки, ресурсы |
| [minecraftversion.h](cpp/minecraftversion.md) | чтение описаний версий, цепочка `inheritsFrom`, правила Mojang |
| Класс | Описание |
|-------|----------|
| [VersionManifestService](cpp/VersionManifestService.md) | Каталог версий Mojang с кэшем в папке лаунчера |
| [VersionInstaller](cpp/VersionInstaller.md) | Фоновая установка версии: 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 |
| Класс | Описание |
|-------|----------|
| [ModLoaderVersionService](cpp/ModLoaderVersionService.md) | Списки версий Forge, Fabric, NeoForge и Quilt; совместимость заложена в структуру данных |
| [ModLoaderInstaller](cpp/ModLoaderInstaller.md) | Два пути установки под одним фасадом: готовое описание версии либо запуск `installer.jar` |
### Java
| | |
|---|---|
| [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 в системе |
| Класс | Описание |
|-------|----------|
| [JavaRuntimeService](cpp/JavaRuntimeService.md) | Каталог сборок Mojang и Eclipse Temurin под текущую платформу |
| [JavaInstaller](cpp/JavaInstaller.md) | Установка сборки Java: архив Temurin либо дерево файлов Mojang |
### Сборки
### Сборки и их содержимое
| | |
|---|---|
| [BuildSwitcher](cpp/BuildSwitcher.md) | смена активной сборки, раскатка пака, продолжение после падения |
| [BuildArchiveWorker](cpp/BuildArchiveWorker.md) | файловые операции в отдельном потоке |
| [gamedirimport.h](cpp/gamedirimport.md) | сборка по содержимому папки игры при первом запуске |
| [SeasonalBuildService](cpp/SeasonalBuildService.md) | каталог сезонных сборок и его формат |
| [SeasonalPackDownloader](cpp/SeasonalPackDownloader.md) | загрузка архива сезонной сборки |
| [launcherpaths.h](cpp/launcherpaths.md) | все пути к данным лаунчера |
| Класс | Описание |
|-------|----------|
| [BuildSwitcher](cpp/BuildSwitcher.md) | Порядок шагов смены активной сборки и восстановление после прерванной операции |
| [BuildArchiveWorker](cpp/BuildArchiveWorker.md) | Упаковка, очистка, распаковка и раскатка пака в отдельном потоке |
## Прочее в репозитории
### Сезонные сборки
| Путь | Что это |
|------|---------|
| `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 |
| Класс | Описание |
|-------|----------|
| [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
остаётся на месте. Исходники хранятся в репозитории, чтобы сборка не зависела от сети, а версия
была зафиксирована: от неё зависит побайтовый результат сжатия.
---
+148 -64
View File
@@ -1,94 +1,178 @@
# BuildsDialog.qml
# BuildsDialog
Окно пользовательских сборок. Слева список, справа карточка выбранной сборки: имя, сервер, версия
Minecraft, модлоадер. Отсюда сборку ставят, делают активной и удаляют.
## Обзор компонента
Сборка — это версия игры, модлоадер и адрес сервера плюс собственное содержимое `.minecraft`
(моды, конфиги, миры). Активная сборка одна, её файлы разложены в `.minecraft`, остальные лежат в
архивах. Подробнее в [BuildSwitcher](../cpp/BuildSwitcher.md).
`Minecraft_launcher` — десктопный лаунчер Minecraft на Qt Quick. Сборка в нём — это именованный
набор «версия игры + модлоадер + адрес сервера» вместе с собственным содержимым `.minecraft`:
модами, конфигами и мирами. Активная сборка одна, её содержимое лежит в `.minecraft`, остальные
хранятся в архивах и разворачиваются при переключении.
Открывается из [Main](Main.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 с красной рамкой,
привязанный к тому же родителю, что и само окно.
Подвал несёт три кнопки: «Установить», «Сделать активной» и «Закрыть».
## Свойства
| Свойство | Что это |
|----------|---------|
| `backend` (required) | `LauncherBackend` |
| `editIndex` | сборка, открытая в карточке; −1 — ничего не открыто |
| `loading` | карточка сейчас заполняется данными сборки, обработчики полей ничего не записывают |
| Свойство | Тип | По умолчанию | Обязательное | Описание |
|----------|-----|--------------|--------------|----------|
| `backend` | `var` | — | **Да** | Экземпляр `LauncherBackend` — хранилище сборок и исполнитель установки, удаления и переключения. |
| `editIndex` | `int` | `-1` | Нет | Индекс сборки, открытой в карточке справа. Значение `-1` означает, что карточка пуста и выключена. С активной сборкой не связан: активную меняет отдельная кнопка, чтобы случайный клик по списку не запускал архивацию `.minecraft`. |
| `loading` | `bool` | `false` | Нет | Признак того, что карточка сейчас заполняется данными сборки. Пока он выставлен, обработчики полей не должны писать обратно — иначе открытие сборки немедленно перезаписывало бы её. |
`editIndex` не связан с активной сборкой. Щелчок по строке списка только открывает карточку, а
активной сборку делает отдельная кнопка. Иначе случайный щелчок запускал бы архивацию
`.minecraft`.
### Внутренняя панель модлоадеров
## Как устроено окно
Панель `loaderPanel` — это `Column` с четырьмя строками [LoaderRow](LoaderRow.md) и собственным
маленьким API. Лоадеры несовместимы между собой: игра запускается ровно с одним профилем
`versions/<id>`, поэтому отметка одного снимает остальные, а «ничего не отмечено» — это чистая
ваниль.
Модальный `Dialog` 880×600. Пока идёт смена сборки (`backend.switching`), окно закрывается только
кнопкой, а список и карточка выключены.
| Свойство панели | Тип | Описание |
|-----------------|-----|----------|
| `rows` | `var` | Массив из четырёх строк лоадеров в порядке показа: Minecraft Forge (`forge`), Fabric Loader (`fabric`), NeoForge (`neoforge`), Quilt Loader (`quilt`). |
**Слева** (260 px) список сборок. У активной пометка «активна», при наведении появляется корзина.
Ниже строка «+ Новая сборка»: создаёт сборку с первым свободным именем вида «Сборка N» и сразу
открывает её.
Функции панели: `applyBuild(loader, loaderVersion)` раздаёт данные сборки всем строкам;
`selectedRow()` возвращает отмеченную строку или `null`; `keepOnly(row)` очищает все строки,
кроме переданной; `commitSelection()` сохраняет выбор в сборку, записывая ключ лоадера, его версию
и пустой `resolvedVersionId` — профиль появится только после установки, а до неё сборка
запускается на чистой ванили.
**Справа** карточка в `Flickable`, потому что может не поместиться по высоте:
## Сигналы
- название и адрес сервера ([LabelledField](LabelledField.md));
- версия Minecraft. Это не выпадающий список, а поле, которое открывает
[VersionPickerDialog](VersionPickerDialog.md): версий около тысячи, и выбирать их удобнее в окне
с категориями и поиском;
- четыре строки [LoaderRow](LoaderRow.md): Forge, Fabric, NeoForge, Quilt;
- строка комплектности: «можно запускать» или «не хватает N файлов» и первый из них
(`checkInstallation()`).
Собственных сигналов компонент не объявляет: все изменения уходят прямо в бэкенд, и о них
остальной интерфейс узнаёт по его сигналам.
**Внизу** кнопки «Установить» (`installCustomBuild()`), «Сделать активной» (запись в
`activeBuildIndex`) и «Закрыть». Первые две выключены, пока `busy`. «Сделать активной» выключена
ещё и для уже активной сборки. Прогресс установки и смены сборки показывают панели главного окна.
## Методы
## Сохранение по ходу
#### selectBuild(int index) : void
Отдельной кнопки «Сохранить» нет. Каждое поле записывается сразу после правки через
`commit(fields)` → `updateCustomBuild()`. Иначе пришлось бы как-то показывать несохранённые правки
при переключении между сборками в списке.
Открывает сборку с указанным индексом в карточке. Читает данные через `customBuildAt()`,
выставляет `loading` на время заполнения полей, раздаёт лоадеры панели через `applyBuild()` и в
конце обновляет строку состояния.
Чтобы открытие сборки не записывало её саму в себя, на время `selectBuild()` выставляется
`loading`, и `commit()` в это время ничего не делает.
#### askRemove(int index) : void
## Модлоадеры
Спрашивает подтверждение перед удалением: оно необратимо и уносит с собой архив сборки. Запрашивает
`customBuildRemovalInfo()` и наполняет окно подтверждения именем сборки и тремя признаками — есть
ли у неё архив, активна ли она сейчас и последняя ли она. Индекс запоминается в самом окне
подтверждения, потому что к моменту ответа строка списка под курсором может быть уже другой.
`loaderPanel` держит четыре строки и следит, чтобы был отмечен не больше чем один модлоадер: игра
запускается с одним профилем из `versions/`. Ни одной отметки — ванильная сборка.
Окно подтверждения объясняет последствия по этим признакам: вместе со сборкой удалится её архив, и
моды, конфиги и миры восстановить будет нельзя; активная сборка владеет содержимым `.minecraft`,
оно будет очищено, а на его место развернётся следующая сборка; для последней сборки содержимое
`.minecraft` остаётся на месте.
- `applyBuild(loader, loaderVersion)` — раздать данные сборки строкам;
- `selectedRow()` — отмеченная строка или `null`;
- `keepOnly(row)` — снять отметки с остальных (на `userChecked` строки);
- `commitSelection()` — записать `loader`, `loaderVersion` и пустой `resolvedVersionId` (на
`changed` строки). Профиль модлоадера появится только после установки, а до этого сборка
запускается как ванильная.
#### performRemove(int index) : void
Строки получают `gameVersion` привязкой к выбранной версии, поэтому смена версии игры сама
перезапрашивает их списки.
Удаляет сборку через бэкенд и восстанавливает состояние карточки: если сборок не осталось,
сбрасывает `editIndex` в `-1`, иначе открывает соседнюю — ту же позицию или последнюю оставшуюся.
## Удаление
#### commit(var fields) : void
`askRemove(index)` берёт `customBuildRemovalInfo()` и открывает подтверждение. Текст зависит от
трёх признаков:
Сохраняет часть полей сборки: передаёт `QVariantMap` в `updateCustomBuild()` и обновляет строку
состояния. Ничего не делает во время заполнения карточки (`loading`) и при пустом `editIndex` —
это и есть защита от записи при открытии сборки.
- `hasArchive` — вместе со сборкой удалится её архив, моды, конфиги и миры будет не вернуть;
- `active` — содержимое `.minecraft` принадлежит этой сборке, оно будет очищено, и на его место
развернётся следующая;
- `lastOne` — для последней сборки содержимое `.minecraft` остаётся как есть.
Вызывается по завершении правки каждого поля: имени, адреса сервера, версии Minecraft и выбора
модлоадера.
Индекс запоминается в самом окне подтверждения, потому что к моменту ответа под курсором может
оказаться другая строка.
#### refreshStatus() : void
`performRemove(index)` удаляет сборку и открывает соседнюю (ту же позицию или последнюю), а если
сборок не осталось, очищает карточку.
Пересчитывает строку комплектности под карточкой. Спрашивает у бэкенда `checkInstallation()` и
показывает либо сообщение о готовности к запуску, либо число недостающих файлов вместе с первым из
них. При пустом `editIndex` очищает строку.
## Когда окно открывается
#### newBuildName() : string
`onAboutToShow` просит обновить каталог версий и открывает активную сборку (или первую). Через
`Connections` окно слушает `installedVersionsChanged` и обновляет строку комплектности: установка
могла закончиться, пока окно открыто.
Придумывает имя для новой сборки: перебирает «Сборка 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()
}
```
---
+81 -8
View File
@@ -1,15 +1,88 @@
# DarkCombo.qml
# DarkCombo
`ComboBox` в тёмном оформлении окна: фон `#2a2a2a`, зелёная рамка при фокусе, выпадающая панель
высотой до 220 px с полосой прокрутки.
## Обзор компонента
Стрелка взята та же, что у списков профиля и версии на главном окне
(`images/Profile_Box/Asset_23/24.svg`), вместо двойного шеврона стиля `Basic`.
`Minecraft_launcher` — десктопный лаунчер Minecraft на Qt Quick. Всё окно оформлено вручную в
тёмной теме, а стиль Qt Quick Controls принудительно выставлен в `Basic` (`main.cpp`), потому что
нативные стили игнорируют пользовательские `contentItem` и `background`. Из-за этого каждый
стандартный элемент управления, попадающий в интерфейс, приходится переопределять самому.
Новых свойств нет, используется как обычный `ComboBox`. Есть одно ограничение: делегат выводит
`modelData` как текст, поэтому модель должна быть списком строк. `textRole` не поддерживается.
`DarkCombo` — как раз такое переопределение: выпадающий список в общем тёмном стиле окна.
Компонент не добавляет логики, он целиком про внешний вид — фон, рамку, стрелку, делегат строки и
всплывающую панель. К нему обращаются всюду, где нужен выпадающий список внутри диалогов: выбор
версии модлоадера, выбор Java, поля в настройках.
Используется в [LoaderRow](LoaderRow.md) и в диалогах [Main](Main.md).
## Место в проекте и зависимости
Импортирует `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])
}
```
---
+133 -33
View File
@@ -1,48 +1,148 @@
# JavaPickerDialog.qml
# JavaPickerDialog
Окно выбора сборки Java. Устроено как [VersionPickerDialog](VersionPickerDialog.md): слева виды,
справа список с поиском. Отличие в том, что выбранная здесь сборка сразу и скачивается. Версии игры
качает сборка, а Java, которой нет на диске, игре ничем не поможет.
## Обзор компонента
Открывается из диалога настроек в [Main](Main.md).
`Minecraft_launcher` — десктопный лаунчер Minecraft на Qt Quick. Разным версиям игры нужны разные
версии Java, и лаунчер умеет скачивать их сам: официальные сборки Mojang и сборки Temurin в двух
вариантах — JDK и JRE. Держать в голове, какая Java нужна какой версии игры, пользователь не
обязан.
## Интерфейс
`JavaPickerDialog` — окно выбора сборки Java: слева типы сборок, справа сами версии с поиском.
Устроено так же, как [VersionPickerDialog](VersionPickerDialog.md), с одним принципиальным
отличием: выбранное здесь ещё и качается. У версий игры загрузку начинает сама сборка, а сборка
Java, которой нет на диске, запуску ничем не поможет — поэтому кнопка подтверждения при
необходимости сразу ставит выбранную сборку в очередь на скачивание.
| | |
|---|---|
| `backend` (required) | `LauncherBackend` |
| `selectedId` | выбранная сборка |
| `category` | `java` (Mojang), `jdk`, `jre` (Temurin) |
| `filterText`, `installedOnly` | поиск и «только скачанные» |
| `requiredMajor` | минимальная Java для версии игры активной сборки; 0 — версия не выбрана |
| `openFor(runtimeId, requiredMajor)` | открыть |
| `runtimeChosen(runtimeId)` | выбор подтверждён |
Окно также показывает, какая версия Java нужна выбранной версии игры, помечает сборки, которые
для неё слишком старые, и позволяет удалять скачанное прямо из списка.
## Как работает
## Место в проекте и зависимости
`openFor()` сбрасывает поиск, открывает категорию переданной сборки и просит бэкенд обновить
каталог Java. По «Отмене» ничего не меняется.
В начале файла объявлена `pragma ComponentBehavior: Bound`. Импортирует `QtQuick`,
`QtQuick.Controls 2.15` и `QtQuick.Layouts 2.15`.
Под категориями слева написано, какая Java нужна выбранной версии игры, чтобы не держать это в
голове.
Работает с C++-типом `LauncherBackend` (`launcherbackend.h`, `QML_ELEMENT` в модуле
`Minecraft_launcher`), передаваемым снаружи в свойство `backend`. Читает свойства `javaCatalog` и
`javaCatalogLoading`, вызывает `refreshJavaCatalog()`, `installJavaRuntime()` и
`removeJavaRuntime()`.
`javaCatalog` приходит из C++ уже отсортированным: скачанное первым, потом новые версии сверху.
Окно только отбирает строки.
Объявлен в `QML_FILES` модуля (`CMakeLists.txt`); использует ресурс `images/Trash.svg`.
Инстанцируется в диалоге настроек внутри [Main](Main.md) — сборка Java выбирается один раз для
всего лаунчера, а не отдельно для каждой сборки Minecraft.
В строке: версия, пометка LTS, какие версии игры она потянет (`coverage`), размер и галочка у
скачанных. Сборка ниже `requiredMajor` не прячется, а помечается красноватым и подписью «слишком
старая для этой версии». Она может понадобиться другой сборке.
Каталог приходит из C++ уже отсортированным — новые сверху, скачанные первыми, — поэтому окно
только отбирает записи и порядок не трогает. Запись каталога содержит поля `id`, `label`, `kind`
(тип сборки), `major` (мажорная версия Java числом), `installed`, `downloadable`, `lts`, `sizeMb`,
`detail`, `coverage` и `search`.
Кнопка внизу называется «Скачать», если выбранной сборки нет на диске, и «Выбрать», если есть.
`acceptSelection()` испускает `runtimeChosen` и, если сборки нет на диске, вызывает
`installJavaRuntime()`. Ход загрузки показывает панель главного окна.
## Иерархия и роль
## Удаление
Корневой тип — `Dialog`: модальный, 720×480 px, нулевой внутренний отступ, закрывается по Escape и
щелчку мимо, оформление тёмное со скруглением и акцентной рамкой.
У скачанных сборок есть корзина, и удаление происходит **без подтверждения**:
`removeJavaRuntime()` вызывается сразу. Сборка весит около двухсот мегабайт, и без корзины здесь
папка лаунчера росла бы незаметно для пользователя. Если удалили сборку, выбранную в настройках,
бэкенд очистит `javaRuntime`.
Содержимое — `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))
}
```
---
+69 -10
View File
@@ -1,16 +1,75 @@
# LabelledField.qml
# LabelledField
Подпись и под ней поле ввода. Используется в настройках и в карточке сборки.
## Обзор компонента
| | |
|---|---|
| `label` | подпись |
| `placeholder` | текст-подсказка в пустом поле |
| `text` | алиас текста поля |
| `validator` | алиас валидатора поля |
| `editingFinished()` | правка закончена: поле потеряло фокус или нажат Enter |
`Minecraft_launcher` — десктопный лаунчер Minecraft на Qt Quick с полностью самостоятельно
оформленным тёмным интерфейсом. В диалоге настроек и в редакторе сборок много однотипных полей
ввода: подпись сверху, поле под ней. `LabelledField` собирает эту пару в один компонент, чтобы
отступы, цвета и подсветка фокуса не переписывались в каждом месте заново.
Ширина поля равна ширине компонента, высота 32 px.
Компонент нужен там, где пользователь вводит короткое значение: имя профиля, объём памяти, путь,
аргументы запуска.
## Место в проекте и зависимости
Импортирует `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)
}
```
---
+134 -37
View File
@@ -1,52 +1,149 @@
# LoaderRow.qml
# LoaderRow
Одна строка модлоадера в карточке [BuildsDialog](BuildsDialog.md): чекбокс, а если он отмечен,
выпадающий список версий под выбранную версию Minecraft.
## Обзор компонента
Совместимость здесь не проверяется. `backend.loaderVersions()` отдаёт список уже под конкретную
версию игры, и несовместимых строк в нём нет. Если список пустой, значит, модлоадер эту версию не
поддерживает, и вместо пустого выпадающего списка показывается текст.
`Minecraft_launcher` — десктопный лаунчер Minecraft на Qt Quick. Кроме чистой игры он умеет
ставить модлоадеры: Forge, Fabric, NeoForge и Quilt. В карточке сборки лоадер выбирается
чекбоксом, а под ним — конкретная версия лоадера.
## Интерфейс
`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()` |
Совместимость компонент не проверяет и проверять не должен: бэкенд отдаёт список, уже собранный
под конкретную версию игры, поэтому несовместимой строки в нём не бывает. Пустой список — это и
есть отсутствие поддержки.
Пока версия игры не выбрана, чекбокс выключен.
## Место в проекте и зависимости
## applyEntries(list)
Импортирует `QtQuick` и `QtQuick.Controls 2.15`.
Единственное место, где меняются `entries` и `selectedVersion`. Через него проходят все три пути:
ответ из кэша, ответ сети и заполнение из сборки. Если выбранной версии в новом списке нет, она
заменяется первой строкой, а это самая новая версия для этой версии игры. Если при этом значение
изменилось и строка не в режиме `applying`, испускается `changed()`.
Использует компонент [DarkCombo](DarkCombo.md) из того же QML-модуля — выпадающий список версий в
тёмном стиле окна.
Раньше проверку делал только путь из сети, и версия модлоадера, выбранная под прошлую версию игры,
доживала до сохранения сборки.
Работает с 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)`. Строка сравнивает пару со своей и игнорирует чужие
ответы: от другого модлоадера или от прошлой версии игры.
## Иерархия и роль
Под пустым списком написано «загрузка версий…», пока идёт запрос
(`loaderVersionsLoading()`), и «не поддерживает версию N» после.
Корневой тип — `Column` с расстоянием 4 px. Внутри три потомка, видимость которых
взаимоисключающая по нижней части:
`applyBuild()` берёт версию из сборки как пожелание. Если под текущую версию игры её нет,
`applyEntries()` заменит её на самую новую.
- `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()
}
```
---
+176 -101
View File
@@ -1,136 +1,211 @@
# Main.qml
# Main
Главное окно, оно же точка входа: `main.cpp` загружает его через
`engine.loadFromModule("BiLauncher", "Main")`. Корень — `Window` 1280×720.
## Обзор компонента
Что в нём есть:
`Minecraft_launcher` — десктопный лаунчер Minecraft на Qt Quick. `Main.qml` — его главное и
единственное настоящее окно: точка входа приложения, которую загружает `main.cpp` вызовом
`engine.loadFromModule("Minecraft_launcher", "Main")`.
- единственный экземпляр `LauncherBackend` и все обработчики его сигналов;
- экран запуска: фон, кнопка игры по центру, список профилей, кнопка активной сборки, кнопки папки
модов, настроек и сезонных сборок;
- плашка сообщений и две панели прогресса ([ProgressPanel](ProgressPanel.md));
- диалоги, которые не вынесены в отдельные файлы: новый профиль, редактирование профиля, код
двухфакторной аутентификации, настройки запуска.
Окно совмещает четыре роли. Оно держит единственный экземпляр `LauncherBackend` — весь остальной
интерфейс получает его от главного окна. Оно рисует сам экран запуска: фоновая картинка, большая
кнопка игры по центру, выпадающий список профилей, кнопка активной сборки, кнопки папки модов,
настроек и сезонных сборок. Оно показывает обратную связь — всплывающую плашку сообщений и две
панели хода долгих операций. И наконец, оно объявляет диалоги, которые не вынесены в отдельные
файлы: создание и редактирование профиля, ввод кода двухфакторной аутентификации и настройки
запуска.
Отдельными файлами сделаны [BuildsDialog](BuildsDialog.md),
[SeasonalBuildsDialog](SeasonalBuildsDialog.md), [JavaPickerDialog](JavaPickerDialog.md) и
[MicrosoftLoginDialog](MicrosoftLoginDialog.md). Окно передаёт им бэкенд через свойство `backend`.
## Место в проекте и зависимости
Стиль Qt Quick Controls — `Basic` (выставлен в `main.cpp`), и почти у каждого контрола здесь своё
оформление.
Импортирует `QtQuick`, `QtQuick.Layouts 2.15`, `QtQuick.Controls 2.15` и сам QML-модуль проекта
`Minecraft_launcher`, из которого приходит тип `LauncherBackend`.
## Раскладка
Инстанцирует четыре компонента модуля: [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`; поэтому в этом файле почти каждый
элемент управления переопределяет своё оформление вручную.
В левом верхнем углу фона стоит подпись `launcherVersion` — «Версия: 1.0.1-dev». Текст из
`app.label.version`, номер из `Qt.application.version`, то есть из `LAUNCHER_VERSION`
(см. [main.cpp](../cpp/main.md)).
Использует ресурсы из `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`. Список профилей выключен, пока `busy`;
кнопка активной сборки — пока `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.
## Методы
Окно выбора 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
из соседнего поля остаётся запасным вариантом.
- папка — `openMinecraftFolder()` (открывает `mods`);
- настройки — `settingsDialog.load()`;
- сезонные сборки — `seasonalDialog.openCatalog()`;
- активная сборка — `buildsDialog.open()`.
### Тексты
Все тексты окна берутся из `Loc.t`, литералов в разметке нет.
Все подписи, сообщения и подсказки окна берутся из синглтона `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).
---
+105 -30
View File
@@ -1,45 +1,120 @@
# MicrosoftLoginDialog.qml
# MicrosoftLoginDialog
Окно входа в аккаунт Microsoft. Работает так же, как официальный лаунчер: страница входа
открывается внутри приложения в `WebEngineView`, а лаунчер ждёт, когда браузер перейдёт на
`redirect_uri` с кодом в адресе.
## Обзор компонента
Файл попадает в QML-модуль, только если CMake нашёл `Qt6::WebEngineQuick`. Поэтому
[Main](Main.md) не объявляет окно в разметке, а создаёт его через `Qt.createComponent()` при первом
входе (`openMicrosoftLogin()`).
`Minecraft_launcher` — десктопный лаунчер Minecraft на Qt Quick. Он поддерживает несколько
способов входа: офлайн-профиль, сервер Ely.by и учётную запись Microsoft. Последний путь требует
показать пользователю настоящую страницу входа Microsoft и дождаться, пока браузер уйдёт на
`redirect_uri` с кодом авторизации в адресе — ровно так же поступает официальный лаунчер.
## Интерфейс
`MicrosoftLoginDialog` — окно с этой страницей. Внутри него живёт `WebEngineView`; диалог следит
за сменой адреса, отдаёт перехваченный код бэкенду и закрывается. Собственной логики разбора
адреса у него нет — она в C++, чтобы правила совпадения совпадали с теми, по которым сервис сам
строит `redirect_uri`.
| | |
|---|---|
| `backend` (required) | `LauncherBackend` |
| `openAt(url)` | открыть страницу входа |
| `failed(message)` | вход не удался. Окно ничего не знает о плашке сообщений главного окна и сообщает о неудаче сигналом |
## Место в проекте и зависимости
## Как работает
Импортирует `QtQuick`, `QtQuick.Controls 2.15` и `QtWebEngine`. В начале файла объявлена
`pragma ComponentBehavior: Bound`.
На каждое `urlChanged` вызывается `handleUrl()`. Он передаёт адрес в
`backend.inspectMicrosoftRedirect()`. Разбор сделан в C++, чтобы правила совпадали с тем, как
[MsaAuthService](../cpp/MsaAuthService.md) строит `redirect_uri`.
Работает с C++-типом `LauncherBackend` (`launcherbackend.h`, `QML_ELEMENT`), который передаётся
снаружи в свойство `backend`. Вызывает у него `inspectMicrosoftRedirect()`,
`finishMicrosoftLogin()` и `cancelMicrosoftLogin()`.
Когда адрес совпал:
**Особенность сборки.** Это единственный QML-файл проекта, который попадает в модуль условно.
`CMakeLists.txt` ищет `Qt6WebEngineQuick` через `find_package(... QUIET)`; модуль объявлен
необязательным сознательно — он ставится отдельной галочкой в установщике Qt и тянет за собой
WebChannel с Positioning, которых в типовой установке нет. Если модуль найден, файл добавляется в
`QML_FILES` и определяется макрос `LAUNCHER_HAS_WEBENGINE`; если нет — лаунчер собирается и
работает как прежде, только без входа через Microsoft. В QML это различие видно через свойство
`backend.microsoftAvailable`, и интерфейс не должен предлагать этот путь, когда оно ложно.
- окно закрывается, `codeTaken` выставляется в `true`;
- если есть код, вызывается `backend.finishMicrosoftLogin(code)`;
- если кода нет, вызывается `backend.cancelMicrosoftLogin()` и испускается `failed` с текстом от
Microsoft или общим «вход не завершён».
Инстанцируется динамически из [Main](Main.md) — главное окно создаёт диалог по требованию, потому
что при сборке без WebEngine самого типа в модуле не существует.
`codeTaken` нужен потому, что `WebEngineView` может сообщить об одном и том же адресе несколько
раз, а отдавать код дважды нельзя.
## Иерархия и роль
Кнопка «Отмена» выставляет `codeTaken`, закрывает окно и вызывает `cancelMicrosoftLogin()`. Щелчок
мимо окна и Esc его не закрывают.
Корневой тип — `Dialog` из Qt Quick Controls: модальный, 560×680 px, по центру родителя, с нулевым
внутренним отступом и `closePolicy: Popup.NoAutoClose` — окно нельзя закрыть щелчком мимо или
клавишей Escape, выход только через кнопку отмены или успешный вход.
## Куки
Оформление задано вручную: тёмный фон со скруглением и акцентной рамкой, заголовок с
разделительной линией, подвал с кнопкой «Отмена».
Профиль браузера создаётся через `WebEngineProfilePrototype` без `storageName`, то есть только в
памяти. Куки живут, пока работает лаунчер, и в общий браузер не попадают. Выбрать другой аккаунт
в пределах одного запуска позволяет `prompt=select_account` в адресе входа.
Содержимое — `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)
```
---
+81 -15
View File
@@ -1,23 +1,89 @@
# ProgressPanel.qml
# ProgressPanel
Плашка прогресса долгой операции, 320×72, в левом нижнем углу главного окна. Там она не закрывает
кнопку запуска, сообщения по центру и кнопки папки и настроек.
## Обзор компонента
В [Main](Main.md) их две: загрузка (версия, модлоадер, Java, пак) и смена сборки.
`Minecraft_launcher` — десктопный лаунчер Minecraft на Qt Quick. Почти каждое действие в нём
долгое: скачивание версии игры, установка Java, распаковка и архивация папки `.minecraft` при
смене сборки, загрузка сезонной сборки. Лаунчер не блокирует окно на это время, поэтому ход
операции нужно показывать неотрывно от остального интерфейса.
## Интерфейс
`ProgressPanel` — та самая плашка прогресса. Она размещается в левом нижнем углу главного окна,
где не перекрывает кнопку запуска, сообщение по центру и кнопки папки и настроек. Компонент
только отображает переданное состояние; сам он ничего не считает и ни за чем не следит.
| | |
|---|---|
| `title` | заголовок, жирным |
| `status` | вторая строка, если нет `detail` |
| `fraction` | от 0 до 1. −1 — общий объём неизвестен, вместо процентов «…» |
| `detail` | вторая строка вместо `status`: мегабайты у загрузки, путь у архивации. Длинный текст обрезается посередине |
| `cancellable` | показывать ли крестик; по умолчанию `true` |
| `cancelRequested()` | нажат крестик |
## Место в проекте и зависимости
Панель только показывает данные. Видимость, значения и реакцию на отмену задаёт главное окно: у
загрузки крестик вызывает `backend.cancelDownload()`, у смены сборки крестика нет.
Импортирует `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()
}
```
---
+114 -36
View File
@@ -1,56 +1,134 @@
# SeasonalBuildsDialog.qml
# SeasonalBuildsDialog
Каталог сезонных сборок с сервера лаунчера. Таблица показывает всё, что нужно, чтобы решить,
ставить сборку или нет. Остальное делает одна кнопка: создаёт запись сборки, ставит версию игры,
модлоадер и Java и раскладывает файлы. Как это устроено, описано в
[LauncherBackend](../cpp/LauncherBackend.md#сезонные-сборки).
## Обзор компонента
Открывается из [Main](Main.md) кнопкой в правом нижнем углу (`openCatalog()`).
`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 несёт описание и размер выбранной сборки (они длинные и в таблицу не
помещаются, а решение принимается именно по ним) и две кнопки — обновления списка и установки.
## Свойства
| | |
|---|---|
| `backend` (required) | `LauncherBackend` |
| `selectedId` | выбранная строка, по `id` сборки |
| `columns` | описание колонок: ключ, заголовок, ширина, выравнивание |
| Свойство | Тип | По умолчанию | Обязательное | Описание |
|----------|-----|--------------|--------------|----------|
| `backend` | `var` | — | **Да** | Экземпляр `LauncherBackend` — источник каталога сезонных сборок и исполнитель установки. |
| `selectedId` | `string` | `""` | Нет | Идентификатор выбранной строки. Хранится по `id`, а не по индексу: список обновляется под руками, и индекс после обновления указывал бы на другую сборку. |
| `columns` | `var` (только чтение) | список из семи колонок | Нет | Описание таблицы: массив объектов с полями `key` (поле записи), `title` (заголовок), `width` (ширина в пикселях) и `align` (выравнивание). Колонки: название, версия, загрузчик, число модов, начало и конец сезона, статус. Ширины собраны в одном месте, потому что их повторяют и шапка, и делегат строки. |
| `entries` | `var` (только чтение) | `backend.seasonalCatalog` | Нет | Строки каталога напрямую из бэкенда. Отбора и сортировки в окне нет. |
| `selectedEntry` | `var` (только чтение) | вычисляется | Нет | Полная запись выбранной сборки или `null`, если ничего не выбрано. От неё зависят подвал и доступность кнопки установки. |
Выбранная строка хранится по `id`, а не по индексу: список может обновиться, пока окно открыто,
и индекс начал бы указывать на другую сборку.
## Сигналы
## Таблица
Собственных сигналов компонент не объявляет. Результат работы окна виден через состояние бэкенда:
установка меняет список сборок и запускает загрузку, за ходом которой следит главное окно.
Колонки: название, версия, модлоадер с версией, число модов, начало и конец сезона, статус
(«не установлена», «установлена», «доступно обновление»). Ширины заданы в одном месте, `columns`,
и их используют и шапка, и строки.
## Методы
Таблица собрана из `Row` с колонками фиксированной ширины, а не из `TableView`. В проекте нет ни
одной модели на `QAbstractItemModel`, строки приходят из C++ готовыми `QVariantMap`, и заводить
модель ради семи колонок не стоит.
#### openCatalog() : void
Строки приходят уже отсортированными и сведёнными с локальными записями: окно ничего не считает.
Открывает окно. Обновление каталога происходит само в обработчике `onAboutToShow`, который просит
у бэкенда `refreshSeasonalCatalog(false)` — без принудительного обхода кэша: свежий кэш отвечает
без сети, поэтому вызов при каждом открытии ничего не стоит.
Пустой список и ошибка сервера выглядят одинаково пусто, поэтому причина пишется прямо на месте
строк: «загрузка…», «сборок пока нет» или текст `seasonalCatalogError` красным.
#### formatMb(bytes) : string
Под таблицей описание выбранной сборки, ревизия, размер архива и установленная ревизия, если она
есть. Описания длинные и в таблицу не помещаются, а решение принимают как раз по ним.
Переводит размер в байтах в строку с мегабайтами и одним знаком после запятой. Для нулевого или
отсутствующего значения возвращает пустую строку, чтобы размер просто не попал в строку подвала.
## Кнопки
#### cellText(entry, string key) : string
- **Обновить список** — `refreshSeasonalCatalog(true)`, мимо кэша. Выключена, пока идёт загрузка.
- **Установить / Обновить** — `installSeasonalBuild(id)`. Подпись зависит от `updateAvailable`.
Выключена, пока лаунчер чем-то занят (`busy`): установка занимает и панель загрузки, и
`.minecraft` целиком.
Возвращает текст ячейки для записи и ключа колонки. Для всех колонок это значение одноимённого
поля записи, приведённое к строке; исключение — колонка загрузчика, где название и версия
склеиваются в одну подпись, а при пустой версии остаётся только название.
Двойной щелчок по строке делает то же, что кнопка установки.
#### installSelected() : void
Каталог обновляется при каждом открытии окна (`refreshSeasonalCatalog(false)`). Свежий кэш отвечает
без сети.
Ставит выбранную сборку. Ничего не делает, если строка не выбрана или лаунчер занят другой
операцией: установка занимает и панель загрузки, и `.minecraft` целиком, поэтому вторую начинать
нельзя. Иначе вызывает `installSeasonalBuild()` у бэкенда.
Окно закрывается по Esc и щелчку мимо него и во время установки: отменить установку можно только
на панели загрузки главного окна, которую модальное окно перекрывает. Установка при закрытии окна
продолжается.
Вызывается кнопкой установки и двойным щелчком по строке.
## Взаимодействие с другими компонентами
**Со стороны родителя.** [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()
}
```
---
+139 -40
View File
@@ -1,58 +1,157 @@
# VersionPickerDialog.qml
# VersionPickerDialog
Окно выбора версии Minecraft. Слева категории, справа список версий выбранной категории с поиском.
## Обзор компонента
Раньше на этом месте был выпадающий список на тысячу версий, и найти в нём, например, бету 1.7
можно было только набрав точный номер. Категории делят каталог на части разумного размера, а поиск
работает внутри выбранной.
`Minecraft_launcher` — десктопный лаунчер Minecraft на Qt Quick. Каталог версий Mojang — это около
тысячи записей: релизы, снапшоты, старые беты и альфы. Раньше выбор версии был обычным выпадающим
списком на всю эту тысячу, и найти в нём, скажем, бету 1.7 можно было только поиском по точному
номеру.
Используется в [BuildsDialog](BuildsDialog.md) для поля версии игры.
`VersionPickerDialog` заменил тот список отдельным окном: слева категории, справа сами версии с
поиском сверху. Категории делят каталог на обозримые части, а поиск работает внутри выбранной.
Помимо выбора окно умеет удалять уже скачанные версии — с предупреждением о последствиях, потому
что версия весит десятки мегабайт, а поверх неё могут стоять профили модлоадеров.
## Интерфейс
Окно открывается из карточки сборки, когда пользователь выбирает, на какой версии Minecraft
собирается играть.
| | |
|---|---|
| `backend` (required) | `LauncherBackend` |
| `selectedId` | выбранная версия |
| `category` | `release`, `snapshot`, `old_beta`, `old_alpha` или `other` |
| `filterText` | строка поиска |
| `installedOnly` | показывать только скачанные |
| `openFor(versionId)` | открыть с этой версией |
| `versionChosen(versionId)` | выбор подтверждён |
## Место в проекте и зависимости
Сигнал называется не `accepted`, потому что такой сигнал у `Dialog` уже есть, и переопределить его
нельзя.
В начале файла объявлена `pragma ComponentBehavior: Bound`. Импортирует `QtQuick`,
`QtQuick.Controls 2.15` и `QtQuick.Layouts 2.15`.
## Как работает
Работает с C++-типом `LauncherBackend` (`launcherbackend.h`, `QML_ELEMENT` в модуле
`Minecraft_launcher`), передаваемым снаружи в свойство `backend`. Читает у него свойства
`versionCatalog` и `catalogLoading`, вызывает `refreshVersionCatalog()`, `versionRemovalInfo()` и
`removeVersion()`.
`openFor()` сбрасывает поиск, переключается на категорию переданной версии (а не всегда на
релизы), просит бэкенд обновить каталог (`refreshVersionCatalog()`) и прокручивает список к
выбранной строке. Если версии нет в каталоге, окно открывается на релизах. По «Отмене» ничего не
меняется: результат уходит наружу только через `versionChosen`.
Объявлен в `QML_FILES` модуля (`CMakeLists.txt`); использует ресурс `images/Trash.svg` из
`RESOURCES` того же модуля. Инстанцируется в [BuildsDialog](BuildsDialog.md).
`versionCatalog` приходит из C++ уже отсортированным, новые сверху. `visibleEntries` только
отбирает строки по категории, флажку «только скачанные» и подстроке в поле `search`, а порядок не
меняет. В «Прочие» попадают профили модлоадеров и всё, чего нет в манифесте Mojang.
Каталог приходит из C++ уже отсортированным (новые сверху), поэтому окно только отбирает записи и
порядок не трогает. Каждая запись каталога — объект с полями `id` (идентификатор версии), `label`
(подпись строки), `category` (ключ категории), `installed` (скачана ли) и `search`
(предвычисленная строка для поиска в нижнем регистре).
С клавиатуры: фокус сразу в поиске, стрелки вверх и вниз двигают выбор, Enter подтверждает, Esc
закрывает. Мышью выбор подтверждается двойным щелчком или кнопкой.
## Иерархия и роль
Если список пуст, на его месте написано почему: каталог ещё грузится, каталог не загрузился или по
запросу ничего нет.
Корневой тип — `Dialog`: модальный, 720×480 px, нулевой внутренний отступ, закрывается по Escape и
щелчку мимо. Оформление задано вручную — тёмный фон со скруглением и акцентной рамкой.
## Удаление версии
Содержимое — `RowLayout` из двух частей, разделённых вертикальной линией: колонка категорий
шириной 150 px (`Repeater` по `categories`) и область версий. В области версий сверху поле поиска,
внизу флажок «Только установленные», между ними `ListView` со строками версий. Строка показывает
подпись, галочку для установленной версии и корзину удаления; галочка рядом со строкой —
единственное место, где видно, что именно скачано.
У скачанных версий есть корзина. Версия занимает от десятков мегабайт, а это окно — единственное
место, где видно, что именно скачано.
Отдельно объявлен вложенный `Dialog` подтверждения удаления шириной 420 px с красной рамкой. Он
привязан к тому же родителю, что и само окно, и центрируется в нём вручную, чтобы не оказаться
внутри области выбора.
`askRemove(versionId)` берёт `versionRemovalInfo()` и открывает подтверждение. В нём указаны
размер, профили модлоадеров, которые без этой версии не запустятся (`dependents`), и сборки,
которые на неё ссылаются (`builds`). id запоминается в окне подтверждения: к моменту ответа под
курсором может оказаться другая строка.
Пустое состояние списка объясняется текстом по центру, который различает три случая: каталог ещё
грузится, каталог пуст (нет соединения) и по фильтру ничего не найдено.
`performRemove(versionId)` вызывает `removeVersion()`. Если удалили выбранный профиль модлоадера,
он пропадает из каталога совсем (он был в нём только потому, что установлен), и выбор
сбрасывается.
## Свойства
| Свойство | Тип | По умолчанию | Обязательное | Описание |
|----------|-----|--------------|--------------|----------|
| `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)
}
```
---
-171
View File
@@ -1,171 +0,0 @@
# Автотесты
Тесты лежат в `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` | переключение сборок туда и обратно, продолжение после обрыва, пак |
| unit | `tst_gamedirimport` | выбор версии по `launcher_profiles.json` и `versions/`, модлоадер по библиотекам и id, папка не меняется |
| 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`). Такие данные выносите в фикстуры или собирайте по шагам.
---
При создании этого документа использовался ИИ.
-244
View File
@@ -1,244 +0,0 @@
#include "gamedirimport.h"
#include "minecraftversion.h"
#include <QDateTime>
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QJsonArray>
#include <QJsonDocument>
#include <QList>
#include <algorithm>
#include <utility>
namespace {
// Признаки того, что в папке играли: это принадлежит сборке. Служебные файлы
// чужих лаунчеров (launcher_log.txt, webcache2 и т. п.) сюда не входят, иначе
// пустая папка официального лаунчера давала бы сборку без версии.
const QStringList kContentMarkers = {QStringLiteral("mods"),
QStringLiteral("saves"),
QStringLiteral("resourcepacks"),
QStringLiteral("shaderpacks"),
QStringLiteral("config"),
QStringLiteral("options.txt"),
QStringLiteral("servers.dat")};
struct Candidate
{
QString id;
QJsonObject json;
bool inherits = false;
};
QJsonObject readObject(const QString &path)
{
QFile file(path);
if (!file.open(QIODevice::ReadOnly))
return {};
return QJsonDocument::fromJson(file.readAll()).object();
}
QString versionJsonPath(const QString &gameDir, const QString &id)
{
return gameDir + QStringLiteral("/versions/") + id + u'/' + id + QStringLiteral(".json");
}
bool hasContent(const QString &gameDir)
{
for (const QString &name : kContentMarkers) {
const QFileInfo info(gameDir + u'/' + name);
if (info.isFile())
return true;
if (info.isDir() && !QDir(info.filePath()).isEmpty())
return true;
}
return false;
}
bool hasMods(const QString &gameDir)
{
return !QDir(gameDir + QStringLiteral("/mods"))
.entryList({QStringLiteral("*.jar")}, QDir::Files)
.isEmpty();
}
QDateTime parseLastUsed(const QString &text)
{
QDateTime when = QDateTime::fromString(text, Qt::ISODateWithMs);
if (!when.isValid())
when = QDateTime::fromString(text, Qt::ISODate);
return when;
}
// Версии из launcher_profiles.json, которые есть в versions/, свежие сверху.
// Профили latest-release/latest-snapshot без lastVersionId пропускаются: без
// манифеста не узнать, во что они разворачивались.
QStringList profileVersions(const QString &gameDir, const QStringList &installed)
{
const QJsonObject profiles =
readObject(gameDir + QStringLiteral("/launcher_profiles.json"))
.value(QStringLiteral("profiles"))
.toObject();
struct Used
{
QString id;
QDateTime when;
};
QList<Used> used;
for (auto it = profiles.constBegin(); it != profiles.constEnd(); ++it) {
const QJsonObject profile = it.value().toObject();
const QString id = profile.value(QStringLiteral("lastVersionId")).toString();
if (id.isEmpty() || !installed.contains(id))
continue;
used.append({id, parseLastUsed(profile.value(QStringLiteral("lastUsed")).toString())});
}
// Профиль без даты считается самым старым.
std::stable_sort(used.begin(), used.end(), [](const Used &a, const Used &b) {
if (a.when.isValid() != b.when.isValid())
return a.when.isValid();
return a.when > b.when;
});
QStringList ids;
for (const Used &u : used) {
if (!ids.contains(u.id))
ids << u.id;
}
return ids;
}
// Установленные версии по времени правки описания, свежие сверху.
QStringList versionsByMtime(const QString &gameDir, QStringList installed)
{
std::stable_sort(installed.begin(), installed.end(), [&](const QString &a, const QString &b) {
return QFileInfo(versionJsonPath(gameDir, a)).lastModified()
> QFileInfo(versionJsonPath(gameDir, b)).lastModified();
});
return installed;
}
QList<Candidate> load(const QString &gameDir, const QStringList &ids)
{
QList<Candidate> out;
for (const QString &id : ids) {
const QJsonObject json = readObject(versionJsonPath(gameDir, id));
// Битое описание не запустится — такую версию не предлагаем.
if (json.isEmpty())
continue;
out.append({id, json, !json.value(QStringLiteral("inheritsFrom")).toString().isEmpty()});
}
return out;
}
// "1.20.1-47.2.0" → "47.2.0". Хвост "-1.7.10" у старых Forge остаётся: в
// каталоге модлоадеров версии записаны так же.
QString stripGamePrefix(const QString &version, const QString &game)
{
if (!game.isEmpty() && version.startsWith(game + u'-'))
return version.mid(game.size() + 1);
return version;
}
}
namespace GameDirImport {
LoaderInfo loaderOf(const QJsonObject &versionJson)
{
const QString game = versionJson.value(QStringLiteral("inheritsFrom")).toString();
for (const QJsonValue &value : versionJson.value(QStringLiteral("libraries")).toArray()) {
// group:artifact:version[:classifier]
const QStringList parts = value.toObject().value(QStringLiteral("name")).toString().split(u':');
if (parts.size() < 3)
continue;
const QString coord = parts[0] + u':' + parts[1];
const QString version = parts[2];
if (coord == QLatin1String("net.fabricmc:fabric-loader"))
return {QStringLiteral("fabric"), version};
if (coord == QLatin1String("org.quiltmc:quilt-loader"))
return {QStringLiteral("quilt"), version};
if (coord == QLatin1String("net.neoforged:neoforge"))
return {QStringLiteral("neoforge"), version};
// NeoForge для 1.20.1 публиковался под старым именем артефакта.
if (coord == QLatin1String("net.neoforged:forge"))
return {QStringLiteral("neoforge"), stripGamePrefix(version, game)};
if (coord == QLatin1String("net.minecraftforge:forge"))
return {QStringLiteral("forge"), stripGamePrefix(version, game)};
}
// Библиотек модлоадера в описании может не быть (их вынесли в родителя или
// описание урезали руками) — тогда смотрим на имя, которое дают установщики.
const QString id = versionJson.value(QStringLiteral("id")).toString();
for (const auto &[key, prefix] : {std::pair{QStringLiteral("fabric"), QStringLiteral("fabric-loader-")},
std::pair{QStringLiteral("quilt"), QStringLiteral("quilt-loader-")}}) {
const QString suffix = u'-' + game;
if (!game.isEmpty() && id.startsWith(prefix) && id.endsWith(suffix))
return {key, id.mid(prefix.size(), id.size() - prefix.size() - suffix.size())};
}
if (id.startsWith(QLatin1String("neoforge-")))
return {QStringLiteral("neoforge"), id.mid(9)};
const QString forgeInfix = game + QStringLiteral("-forge-");
if (!game.isEmpty() && id.startsWith(forgeInfix))
return {QStringLiteral("forge"), id.mid(forgeInfix.size())};
return {};
}
std::optional<Detected> detect(const QString &gameDir)
{
if (gameDir.isEmpty() || !QFileInfo(gameDir).isDir())
return std::nullopt;
const QStringList installed = VersionLoader::installedVersions(gameDir);
const bool content = hasContent(gameDir);
if (installed.isEmpty() && !content)
return std::nullopt;
const QList<Candidate> fromProfiles = load(gameDir, profileVersions(gameDir, installed));
const QList<Candidate> fromVersions = load(gameDir, versionsByMtime(gameDir, installed));
// С модами в папке ванильная версия почти наверняка не та, с которой играли:
// сначала ищем профиль модлоадера, и только потом берём что есть.
const Candidate *chosen = nullptr;
QString source;
auto pick = [&](const QList<Candidate> &list, const QString &from, bool needInherits) {
if (chosen)
return;
for (const Candidate &c : list) {
if (!needInherits || c.inherits) {
chosen = &c;
source = from;
return;
}
}
};
if (hasMods(gameDir)) {
pick(fromProfiles, QStringLiteral("launcher_profiles"), true);
pick(fromVersions, QStringLiteral("versions"), true);
}
pick(fromProfiles, QStringLiteral("launcher_profiles"), false);
pick(fromVersions, QStringLiteral("versions"), false);
Detected result;
if (!chosen) {
result.source = QStringLiteral("content");
return result;
}
result.source = source;
if (chosen->inherits) {
result.minecraftVersion = chosen->json.value(QStringLiteral("inheritsFrom")).toString();
result.resolvedVersionId = chosen->id;
const LoaderInfo loader = loaderOf(chosen->json);
result.loader = loader.key;
result.loaderVersion = loader.version;
} else {
result.minecraftVersion = chosen->id;
}
return result;
}
}
-38
View File
@@ -1,38 +0,0 @@
#pragma once
#include <QJsonObject>
#include <QString>
#include <optional>
// Что уже разложено в папке игры, когда лаунчер запускают впервые. Только
// читает файлы: .minecraft не меняется ни при каком исходе.
namespace GameDirImport {
// Модлоадер, найденный в описании профиля versions/<id>/<id>.json.
struct LoaderInfo
{
QString key; // "" | forge | fabric | neoforge | quilt
QString version; // в том же виде, что и в каталоге ModLoaderVersionService
};
// Сборка, которую стоит завести под содержимое папки игры.
struct Detected
{
QString minecraftVersion; // inheritsFrom профиля, а без него — сам id
QString resolvedVersionId; // id профиля, если он наследует другую версию
QString loader;
QString loaderVersion;
// Откуда взята версия: "launcher_profiles", "versions" или "content" —
// версии не нашлось, но моды или миры есть. Для тестов и отладки.
QString source;
};
// Модлоадер по библиотекам профиля, а если по ним не понять — по его id.
// Незнакомый профиль (OptiFine, LabyMod) даёт пустой key.
LoaderInfo loaderOf(const QJsonObject &versionJson);
// nullopt — в папке нет ни установленных версий, ни модов, миров и настроек игры.
std::optional<Detected> detect(const QString &gameDir);
}
+1 -6
View File
@@ -8,7 +8,6 @@
#include <QFileInfo>
#include <QHash>
#include <QProcess>
#include <QRegularExpression>
#include <QtCore/private/qzipreader_p.h>
namespace {
@@ -47,11 +46,7 @@ bool isNativeLibraryFile(const QString &fileName)
for (const QString &suffix : suffixes)
if (fileName.endsWith(suffix, Qt::CaseInsensitive))
return true;
// Версионированные 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();
return fileName.contains(QStringLiteral(".so.")); // libfoo.so.1
}
} // namespace
+1 -2
View File
@@ -1,6 +1,5 @@
#pragma once
#include "launcherversion.h"
#include "minecraftversion.h"
#include <QObject>
@@ -35,7 +34,7 @@ struct LaunchOptions
QString authlibInjectorApi = QStringLiteral("ely.by");
QString launcherName = QStringLiteral("BiLauncher");
QString launcherVersion = QStringLiteral(LAUNCHER_VERSION);
QString launcherVersion = QStringLiteral("1.0");
};
class GameLauncher : public QObject
+20 -32
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.",
@@ -31,10 +31,6 @@
}
},
"strings": {
"app.label.version": {
"ru": "Версия: %1",
"en": "Version: %1"
},
"app.title": {
"ru": "BiLauncher",
"en": "BiLauncher"
@@ -44,7 +40,7 @@
"en": "Open mods folder"
},
"app.tooltip.seasonal": {
"ru": "Сезонные сборки от команды Galeon'а",
"ru": "Сезонные сборки от команды Мельника",
"en": "Seasonal builds from Galeon team"
},
"app.tooltip.settings": {
@@ -160,8 +156,8 @@
"en": "The Microsoft account owns the game but has no game profile yet. Sign in to the official launcher once and pick a nickname."
},
"auth.msa.error.noWebEngine": {
"ru": "Эта сборка лаунчера собрана без Qt WebEngine",
"en": "This launcher build was made without Qt WebEngine"
"ru": "Эта сборка лаунчера собрана без Qt WebEngine — показать окно входа Microsoft нечем",
"en": "This launcher build was made without Qt WebEngine — there is nothing to show the Microsoft sign-in window with"
},
"auth.msa.error.noXboxProfile": {
"ru": "К аккаунту Microsoft не привязан профиль Xbox. Создайте его на xbox.com и повторите вход.",
@@ -200,8 +196,8 @@
"en": "The Microsoft session has expired — sign in to the account again (%1)"
},
"auth.msa.error.windowUnavailable": {
"ru": "Окно входа Microsoft недоступно",
"en": "The Microsoft sign-in window is unavailable"
"ru": "Окно входа Microsoft недоступно: сборка без Qt WebEngine",
"en": "The Microsoft sign-in window is unavailable: this build has no Qt WebEngine"
},
"auth.msa.error.xboxNoToken": {
"ru": "Xbox Live не вернул токен",
@@ -292,8 +288,8 @@
"en": "Pick a version"
},
"build.error.closeGameFilesMoving": {
"ru": "Файлы игры в процессе переноса, сначала закройте игру",
"en": "Game files are being moved, close the game first"
"ru": "Сначала закройте игру — её файлы сейчас переносятся",
"en": "Close the game first — its files are being moved right now"
},
"build.error.filesIncomplete": {
"ru": "Файлы сборки установлены не полностью: %1\nПовторите установку сборки.",
@@ -332,8 +328,8 @@
"en": "The build was not switched: %1"
},
"build.error.switchFailedDirty": {
"ru": "Сборка не переключена: %1\n Повторите переключение.",
"en": "The build was not switched: %1\n Switch again."
"ru": "Сборка не переключена: %1\nСодержимое .minecraft осталось разобранным — повторите переключение.",
"en": "The build was not switched: %1\nThe contents of .minecraft are left half-applied — switch again."
},
"build.error.versionNotInstalled": {
"ru": "Версия «%1» из сборки «%2» не установлена в %3",
@@ -344,8 +340,8 @@
"en": "Build \"%1\" will be removed and cannot be recovered."
},
"build.hint.removingLast": {
"ru": "Последняя сборка, файлы из корня игры не удаляются.",
"en": "Last build. The contents of .minecraft will stay."
"ru": "Это последняя сборка. Содержимое .minecraft останется на месте.",
"en": "This is the last build. The contents of .minecraft will stay where they are."
},
"build.label.active": {
"ru": "активна",
@@ -364,8 +360,8 @@
"en": "Server address"
},
"build.placeholder.name": {
"ru": "Введите наименование",
"en": "Write a name"
"ru": "как её видно в списке",
"en": "as it appears in the list"
},
"build.placeholder.serverAddress": {
"ru": "host:port (необязательно)",
@@ -383,21 +379,13 @@
"ru": "Активна сборка «%1»",
"en": "Build \"%1\" is active"
},
"build.status.imported": {
"ru": "В папке игры уже были файлы Minecraft, под них создана сборка «%1». Проверьте её версию и модлоадер в окне сборок.",
"en": "The game folder already had Minecraft files, so the build \"%1\" was created for them. Check its version and mod loader in the builds window."
},
"build.status.named": {
"ru": "Сборка «%1»: %2",
"en": "Build \"%1\": %2"
},
"build.status.ready": {
"ru": "Готова к запуску",
"en": "Ready to launch"
},
"build.title.fromGameDir": {
"ru": "Сборка из папки игры",
"en": "Build from the game folder"
"ru": "Всё на месте, сборку можно запускать",
"en": "Everything is in place, the build can be launched"
},
"build.title.numbered": {
"ru": "Сборка %1",
@@ -408,8 +396,8 @@
"en": "Remove the build?"
},
"build.warning.archiveGoesToo": {
"ru": "Внимание, при удалении так же очищаются пользовательские сохранённые архивы.",
"en": "Attention, after removing from the list all saved files will be deleted."
"ru": "Вместе с ней удалится её архив: моды, конфиги и миры этой сборки восстановить будет нельзя.",
"en": "Its archive goes with it: the mods, configs and worlds of this build cannot be recovered."
},
"build.warning.archiveNotRemoved": {
"ru": "Сборка «%1» удалена, но её архив в %2 стереть не удалось",
@@ -1376,8 +1364,8 @@
"en": "%1 — downloading"
},
"settings.status.javaRuntimeNone": {
"ru": "не выбрана",
"en": "not selected"
"ru": "не выбрана — искать в системе",
"en": "not selected — search the system"
},
"settings.status.resolvedGameDir": {
"ru": "Папка игры: %1",
+1
View File
@@ -0,0 +1 @@
IDI_ICON1 ICON "icon.ico"
+5 -57
View File
@@ -15,11 +15,9 @@
#include "msaauthservice.h"
#include "modloaderversionservice.h"
#include "buildswitcher.h"
#include "gamedirimport.h"
#include "seasonalbuildservice.h"
#include "seasonalpackdownloader.h"
#include <QCoreApplication>
#include <QDate>
#include <QDateTime>
#include <QDesktopServices>
@@ -98,20 +96,11 @@ LauncherBackend::LauncherBackend(QObject *parent)
if (!LauncherPaths::ensureRootExists(&storageError))
m_storageIssues << Loc::text("storage.error.launcherDirFailed").arg(storageError);
// Первый запуск — ни настроек, ни списка сборок, ни старого versions.json.
// Смотреть надо до loadData(): битый файл она отводит в .bak, а
// ensureDataFiles() создаёт пустые. Пустой customBuilds.json — это уже
// решение пользователя «сборок нет», и сборку из папки игры мы не заводим.
const bool firstRun = !QFile::exists(LauncherPaths::settingsFile())
&& !QFile::exists(LauncherPaths::customBuildsFile())
&& !QFile::exists(LauncherPaths::legacyCustomBuildsFile());
loadSettings();
// Порядок важен: миграция должна успеть до того, как ensureDataFiles()
// создаст пустой customBuilds.json поверх старых данных.
migrateLegacyBuildsFile();
loadData();
const QString importedBuild = firstRun && m_builds.isEmpty() ? importGameDir() : QString();
ensureDataFiles();
// Прошлое переключение оборвалось на середине. Доигрываем его сами, а не
@@ -380,10 +369,6 @@ LauncherBackend::LauncherBackend(QObject *parent)
});
// Интерфейс подключается к сигналам уже после конструктора — сообщаем отложенно.
// Ошибки хранилища идут следом: плашка одна, и важнее показать их.
if (!importedBuild.isEmpty()) {
QTimer::singleShot(0, this, [this, importedBuild] { emit gameDirImported(importedBuild); });
}
if (!m_storageIssues.isEmpty()) {
QTimer::singleShot(0, this, [this] {
emit launchError(m_storageIssues.join(u'\n'));
@@ -448,10 +433,7 @@ void LauncherBackend::addCustomBuild(const QString &name,
m_builds.append(build);
saveCustomBuilds();
emit customBuildsChanged();
// Без версии игры ставить нечего: launchVersionIdOf() вернул бы имя сборки,
// и установщик пошёл бы искать в манифесте «Сборка 1».
if (!minecraftVersion.trimmed().isEmpty())
maybeInstall(minecraftVersion.trimmed());
maybeInstall(launchVersionIdOf(m_builds.last()));
}
void LauncherBackend::updateProfile(int index,
@@ -1481,8 +1463,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 = QCoreApplication::applicationName();
options.launcherVersion = QCoreApplication::applicationVersion();
options.launcherName = m_settings.value(QStringLiteral("launcherName")).toString();
options.launcherVersion = m_settings.value(QStringLiteral("launcherVersion")).toString();
const QString extra = m_settings.value(QStringLiteral("jvmArgs")).toString().trimmed();
if (!extra.isEmpty())
@@ -2497,36 +2479,6 @@ bool LauncherBackend::migrateLegacyBuildsFile()
return true;
}
// Сборка описывает то, что уже разложено в .minecraft, поэтому BuildSwitcher не
// нужен: активная сборка без архива — штатное состояние. Через addCustomBuild()
// не идём, чтобы при старте не началась установка.
QString LauncherBackend::importGameDir()
{
const std::optional<GameDirImport::Detected> found = GameDirImport::detect(gameDir());
if (!found)
return {};
CustomBuild build;
build.id = m_nextBuildId++;
build.minecraftVersion = found->minecraftVersion;
build.resolvedVersionId = found->resolvedVersionId;
build.loader = found->loader;
build.loaderVersion = found->loaderVersion;
if (build.minecraftVersion.isEmpty()) {
build.name = Loc::text("build.title.fromGameDir");
} else if (const auto loader = loaderFromKey(build.loader)) {
build.name = loaderTitle(*loader) + u' ' + build.minecraftVersion;
} else {
build.name = QStringLiteral("Minecraft ") + build.minecraftVersion;
}
m_builds.append(build);
m_settings.insert(QStringLiteral("activeBuildId"), build.id);
saveSettings();
saveCustomBuilds();
return build.name;
}
// Файлы могли не появиться при первом запуске, быть удалены или отложены в .bak —
// создаём заново, чтобы папка лаунчера всегда была укомплектована.
void LauncherBackend::ensureDataFiles()
@@ -2591,6 +2543,8 @@ void LauncherBackend::loadSettings()
{QStringLiteral("fullscreen"), false},
// Язык интерфейса: "system" — по системной локали.
{QStringLiteral("language"), QStringLiteral("system")},
{QStringLiteral("launcherName"), QStringLiteral("BiLauncher")},
{QStringLiteral("launcherVersion"), QStringLiteral("1.0")},
{QStringLiteral("activeBuildId"), 0},
// Сервер готовых сборок. В интерфейс не вынесен: менять его
// приходится только при отладке против локального сервера.
@@ -2599,12 +2553,6 @@ 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()
-6
View File
@@ -232,9 +232,6 @@ signals:
// Сезонная сборка установлена и активна — можно запускать игру.
void seasonalInstallFinished(const QString &seasonalId, const QString &buildName);
// Первый запуск: по содержимому папки игры заведена активная сборка.
void gameDirImported(const QString &buildName);
// Сборка Java установлена: окно настроек обновляет подпись, не переоткрываясь.
void javaRuntimeInstalled(const QString &runtimeId);
@@ -316,9 +313,6 @@ private:
// Перекладывает <root>/versions.json в customBuilds.json при первом запуске
// после переименования. true — миграция прошла, читать нужно новый файл.
bool migrateLegacyBuildsFile();
// Первый запуск: заводит активную сборку под то, что уже лежит в папке игры.
// Папку игры только читает. Возвращает имя сборки или пустую строку.
QString importGameDir();
void invalidateCatalog();
void rebuildCatalog() const;
+3 -20
View File
@@ -3,38 +3,21 @@
#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 homeBase() + QStringLiteral("/AppData/Roaming");
return QDir::homePath() + QStringLiteral("/AppData/Roaming");
#elif defined(Q_OS_MACOS)
return homeBase() + QStringLiteral("/Library/Application Support");
return QDir::homePath() + QStringLiteral("/Library/Application Support");
#else // Linux и прочие Unix-системы
return homeBase();
return QDir::homePath();
#endif
}
-8
View File
@@ -4,14 +4,6 @@
namespace LauncherPaths {
// Все пути строятся от домашней папки пользователя (QDir::homePath()). Переменная
// окружения BILAUNCHER_HOME с абсолютным путём подменяет её — только для путей
// лаунчера (папка лаунчера и папка игры по умолчанию), остальной Qt её не видит.
// Нужна тестам и дымовой проверке на Windows: там QDir::homePath() спрашивает
// профиль у системы и HOME/USERPROFILE игнорирует. Пустое или относительное
// значение не учитывается. Возвращает подменённую папку или пустую строку.
QString homeOverride();
QString containerDir();
QString rootDir();
QString defaultMinecraftDir();
-8
View File
@@ -1,9 +1,5 @@
#include "launcherpaths.h"
#include "launcherversion.h"
#include "localization.h"
#include <QDebug>
#include <QDir>
#include <QGuiApplication>
#include <QIcon>
#include <QQmlApplicationEngine>
@@ -37,10 +33,6 @@ 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;
-61
View File
@@ -1,61 +0,0 @@
BiLauncher — сторонние компоненты и их лицензии
================================================
Сам BiLauncher распространяется по Apache License 2.0 (см. файл LICENSE).
В дистрибутив входят следующие сторонние компоненты.
1. Qt 6.11 (The Qt Company Ltd.)
--------------------------------
Модули: Qt Core, Gui, Network, Qml, Quick, Quick Controls 2, Svg, WebEngine,
WebChannel, Positioning и их плагины.
Используются по лицензии GNU Lesser General Public License v3 (LGPLv3).
Библиотеки Qt подключены динамически и лежат в дистрибутиве отдельными файлами:
пользователь вправе заменить их своей сборкой Qt той же версии.
Текст LGPLv3: https://www.gnu.org/licenses/lgpl-3.0.html
Исходный код Qt: https://download.qt.io/official_releases/qt/6.11/
https://code.qt.io/cgit/qt/
Сведения о лицензиях: https://doc.qt.io/qt-6/licensing.html
2. Qt WebEngine / Chromium
--------------------------
Qt WebEngine основан на проекте Chromium и включает множество сторонних
библиотек под лицензиями BSD, MIT, LGPL, MPL и другими. Полный перечень:
https://doc.qt.io/qt-6/qtwebengine-licensing.html
Исходный код Qt WebEngine: https://code.qt.io/cgit/qt/qtwebengine.git/
3. zlib 1.3.1 (только сборка для Linux)
---------------------------------------
Copyright (C) 1995-2024 Jean-loup Gailly and Mark Adler
This software is provided 'as-is', without any express or implied
warranty. In no event will the authors be held liable for any damages
arising from the use of this software.
Permission is granted to anyone to use this software for any purpose,
including commercial applications, and to alter it and redistribute it
freely, subject to the following restrictions:
1. The origin of this software must not be misrepresented; you must not
claim that you wrote the original software. If you use this software
in a product, an acknowledgment in the product documentation would be
appreciated but is not required.
2. Altered source versions must be plainly marked as such, and must not be
misrepresented as being the original software.
3. This notice may not be removed or altered from any source distribution.
Jean-loup Gailly Mark Adler
jloup@gzip.org madler@alumni.caltech.edu
4. Inno Setup (только установщик для Windows)
---------------------------------------------
Установщик для Windows собран с помощью Inno Setup.
Copyright (C) 1997-2026 Jordan Russell, Martijn Laan. https://jrsoftware.org/
Лицензия: https://jrsoftware.org/files/is/license.txt
-65
View File
@@ -1,65 +0,0 @@
#ifndef AppVersion
#error Передайте версию: /DAppVersion=X.Y.Z
#endif
#ifndef SourceDir
#error Передайте префикс установки: /DSourceDir=...
#endif
#ifndef OutputDir
#define OutputDir "."
#endif
#if Pos("-", AppVersion) > 0
#define NumericVersion Copy(AppVersion, 1, Pos("-", AppVersion) - 1)
#else
#define NumericVersion AppVersion
#endif
[Setup]
AppId={{6E0B5C1D-8F2A-4C7E-9B3D-2A1F4E5C7B90}
AppName=BiLauncher
AppVersion={#AppVersion}
AppVerName=BiLauncher {#AppVersion}
AppPublisher=GaleonDev
AppPublisherURL=https://projects-live.galeonworx.com/galeon/BiLauncher
AppSupportURL=https://projects-live.galeonworx.com/galeon/BiLauncher/issues
VersionInfoVersion={#NumericVersion}
VersionInfoTextVersion={#AppVersion}
VersionInfoProductTextVersion={#AppVersion}
DefaultDirName={autopf}\BiLauncher
DefaultGroupName=BiLauncher
DisableProgramGroupPage=yes
LicenseFile={#SourceDir}\share\doc\BiLauncher\LICENSE
OutputDir={#OutputDir}
OutputBaseFilename=BiLauncher-{#AppVersion}-win64-setup
SetupIconFile=..\..\icons\icon.ico
UninstallDisplayIcon={app}\bin\BiLauncher.exe
Compression=lzma2/ultra64
SolidCompression=yes
LZMANumBlockThreads=4
ArchitecturesAllowed=x64compatible
ArchitecturesInstallIn64BitMode=x64compatible
PrivilegesRequiredOverridesAllowed=dialog
WizardStyle=modern
[Languages]
Name: "russian"; MessagesFile: "compiler:Languages\Russian.isl"
Name: "english"; MessagesFile: "compiler:Default.isl"
[Tasks]
Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"
[Files]
Source: "{#SourceDir}\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs; Excludes: "vc_redist.x64.exe"
#ifexist SourceDir + "\bin\vc_redist.x64.exe"
Source: "{#SourceDir}\bin\vc_redist.x64.exe"; DestDir: "{tmp}"; Flags: deleteafterinstall
#endif
[Icons]
Name: "{autoprograms}\BiLauncher"; Filename: "{app}\bin\BiLauncher.exe"
Name: "{autodesktop}\BiLauncher"; Filename: "{app}\bin\BiLauncher.exe"; Tasks: desktopicon
[Run]
#ifexist SourceDir + "\bin\vc_redist.x64.exe"
Filename: "{tmp}\vc_redist.x64.exe"; Parameters: "/install /quiet /norestart"; StatusMsg: "Установка Microsoft Visual C++ Runtime..."; Flags: waituntilterminated
#endif
Filename: "{app}\bin\BiLauncher.exe"; Description: "{cm:LaunchProgram,BiLauncher}"; Flags: nowait postinstall skipifsilent
-126
View File
@@ -1,126 +0,0 @@
# Автотесты 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}" "${LAUNCHER_GENERATED_DIR}")
add_dependencies(bilauncher_testlib launcher_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
@@ -1,2 +0,0 @@
# LauncherBackend целиком: всё, что вызывает QML, с настоящими сервисами под ним.
bilauncher_add_test(tst_launcherbackend LABEL backend TIMEOUT 300 SOURCES tst_launcherbackend.cpp)
-809
View File
@@ -1,809 +0,0 @@
// Интеграционные тесты 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);
}
// Папка игры, в которой до лаунчера играли с Fabric. Возвращает её состав.
static QStringList populateGameDir(const QString &dir)
{
QDir(dir).removeRecursively();
const QString fabric = QStringLiteral("fabric-loader-0.15.0-1.20.1");
Fixtures::write(dir + "/versions/1.20.1/1.20.1.json", R"({"id": "1.20.1", "type": "release"})");
Fixtures::write(dir + "/versions/" + fabric + "/" + fabric + ".json",
Fixtures::read("versions/" + fabric + ".json"));
Fixtures::writeJson(dir + "/launcher_profiles.json",
{{"profiles", QJsonObject{{"fabric", QJsonObject{
{"type", "custom"}, {"lastVersionId", fabric},
{"lastUsed", "2026-03-01T10:00:00.000Z"}}}}}});
Fixtures::write(dir + "/mods/sodium.jar", "jar");
Fixtures::write(dir + "/saves/World/level.dat", "world");
Fixtures::write(dir + "/options.txt", "fov:90");
return Fixtures::listTree(dir);
}
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 firstRunImportsGameDir()
{
// Настоящий первый запуск: ни одного файла лаунчера, папка игры по умолчанию.
QFile::remove(LauncherPaths::settingsFile());
const QString game = LauncherPaths::defaultMinecraftDir();
const QStringList before = populateGameDir(game);
const QByteArray profilesBefore = Fixtures::readFile(game + "/launcher_profiles.json");
{
auto backend = make();
QSignalSpy imported(backend.get(), &LauncherBackend::gameDirImported);
QSignalSpy errors(backend.get(), &LauncherBackend::launchError);
QVERIFY(imported.wait(2000)); // сообщение откладывается до подключения интерфейса
QCOMPARE(imported.constFirst().constFirst().toString(), QStringLiteral("Fabric Loader 1.20.1"));
QCOMPARE(backend->customBuildNames(), QStringList{"Fabric Loader 1.20.1"});
QCOMPARE(backend->activeBuildIndex(), 0);
const QVariantMap build = backend->customBuildAt(0);
QCOMPARE(build.value("minecraftVersion").toString(), QStringLiteral("1.20.1"));
QCOMPARE(build.value("loader").toString(), QStringLiteral("fabric"));
QCOMPARE(build.value("loaderVersion").toString(), QStringLiteral("0.15.0"));
QCOMPARE(build.value("launchVersionId").toString(), QStringLiteral("fabric-loader-0.15.0-1.20.1"));
const int id = QJsonDocument::fromJson(Fixtures::readFile(LauncherPaths::customBuildsFile()))
.array().at(0).toObject().value("id").toInt();
QCOMPARE(id, 1);
QCOMPARE(backend->settings().value("activeBuildId").toInt(), id);
QCOMPARE(Fixtures::readJsonFile(LauncherPaths::settingsFile()).value("activeBuildId").toInt(), id);
// Ничего не качается, ничего не переключается, папка игры та же.
QVERIFY(!backend->downloading());
QVERIFY(!backend->switching());
QVERIFY2(errors.isEmpty(), qPrintable(strings(errors).join('\n')));
}
QCOMPARE(Fixtures::listTree(game), before);
QCOMPARE(Fixtures::readFile(game + "/launcher_profiles.json"), profilesBefore);
QVERIFY(!QFile::exists(LauncherPaths::buildStorageDir() + "/index.json"));
// Второй запуск — уже не первый: новой сборки нет.
{
auto backend = make();
QSignalSpy imported(backend.get(), &LauncherBackend::gameDirImported);
QTest::qWait(50);
QVERIFY(imported.isEmpty());
QCOMPARE(backend->customBuildNames(), QStringList{"Fabric Loader 1.20.1"});
}
QDir(game).removeRecursively();
}
void importOnlyOnFirstRun()
{
// Настройки уже есть (init() их пишет), а списка сборок нет — это не первый запуск.
populateGameDir(m_game);
{
auto backend = make();
QSignalSpy imported(backend.get(), &LauncherBackend::gameDirImported);
QTest::qWait(50);
QVERIFY(imported.isEmpty());
QVERIFY(backend->customBuildNames().isEmpty());
}
// Пустой список сборок — пользователь сам всё удалил; воскрешать не надо.
TestEnv::resetLauncherRoot();
QVERIFY(LauncherPaths::ensureRootExists());
const QString game = LauncherPaths::defaultMinecraftDir();
populateGameDir(game);
Fixtures::write(LauncherPaths::customBuildsFile(), "[]");
{
auto backend = make();
QTest::qWait(50);
QVERIFY(backend->customBuildNames().isEmpty());
}
QDir(game).removeRecursively();
}
void firstRunWithEmptyGameDirCreatesNothing()
{
QFile::remove(LauncherPaths::settingsFile());
QDir(LauncherPaths::defaultMinecraftDir()).removeRecursively();
auto backend = make();
QSignalSpy imported(backend.get(), &LauncherBackend::gameDirImported);
QTest::qWait(50);
QVERIFY(imported.isEmpty());
QCOMPARE(backend->activeBuildIndex(), -1);
}
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
@@ -1,417 +0,0 @@
#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
@@ -1,136 +0,0 @@
#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
@@ -1,194 +0,0 @@
#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
@@ -1,64 +0,0 @@
#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
@@ -1,204 +0,0 @@
#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
@@ -1,60 +0,0 @@
#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
@@ -1,39 +0,0 @@
#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
@@ -1,115 +0,0 @@
#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
@@ -1,55 +0,0 @@
#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
@@ -1,138 +0,0 @@
// Подставная «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
@@ -1,9 +0,0 @@
[
{"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
@@ -1,18 +0,0 @@
<?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
@@ -1,2 +0,0 @@
{"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"}}
@@ -1,11 +0,0 @@
<?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
@@ -1,16 +0,0 @@
<?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
@@ -1,4 +0,0 @@
[
{"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
@@ -1,16 +0,0 @@
{
"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
@@ -1,240 +0,0 @@
{
"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"
}
}
]
}
Loaded 100 of 141 files, more files were not shown because too many files have changed in this diff. Show more